Initial Commit

This commit is contained in:
2025-11-16 16:00:51 +00:00
commit ba324b370e
52 changed files with 8014 additions and 0 deletions

104
src/App.tsx Normal file
View File

@@ -0,0 +1,104 @@
import { ClerkProvider } from '@clerk/clerk-react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
// Import Leaflet CSS
import 'leaflet/dist/leaflet.css';
// Layout
import { Layout } from './components/layout';
// Auth components
import { ProtectedRoute, SignInPage, SignUpPage } from './components/auth';
// Pages
import {
HomePage,
MapPage,
ReportFormPage,
EventsPage,
AnalyticsPage
} from './pages';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 1,
},
},
});
const clerkPubKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY;
if (!clerkPubKey) {
throw new Error('Missing Clerk Publishable Key');
}
function App() {
return (
<ClerkProvider publishableKey={clerkPubKey}>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
{/* Public routes */}
<Route path="/sign-in/*" element={<SignInPage />} />
<Route path="/sign-up/*" element={<SignUpPage />} />
{/* Routes with layout */}
<Route
path="/"
element={
<Layout>
<HomePage />
</Layout>
}
/>
<Route
path="/map"
element={
<Layout>
<MapPage />
</Layout>
}
/>
{/* Protected routes */}
<Route
path="/reports/new"
element={
<ProtectedRoute>
<Layout>
<ReportFormPage />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/events"
element={
<ProtectedRoute>
<Layout>
<EventsPage />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/analytics"
element={
<ProtectedRoute>
<Layout>
<AnalyticsPage />
</Layout>
</ProtectedRoute>
}
/>
</Routes>
</BrowserRouter>
</QueryClientProvider>
</ClerkProvider>
);
}
export default App;

139
src/README.md Normal file
View File

@@ -0,0 +1,139 @@
# Beach Litter Mapping Platform - Source Code
This directory contains the frontend source code for the Beach Litter Mapping Platform.
## Project Structure
```
src/
├── components/ # React components organized by feature
├── pages/ # Page-level components for routing
├── services/ # API clients and external service integrations
├── hooks/ # Custom React hooks
├── utils/ # Utility functions and helpers
├── types/ # TypeScript type definitions
├── App.tsx # Main application component
├── main.tsx # Application entry point
└── index.css # Global styles with Tailwind CSS
```
## Technology Stack
### Core
- **React 19** - UI library
- **TypeScript** - Type safety
- **Vite** - Build tool and dev server
### Routing & State
- **React Router** - Client-side routing
- **React Query (@tanstack/react-query)** - Server state management
### Authentication
- **Clerk** - User authentication and management
### Mapping
- **Leaflet** - Interactive maps
- **OpenStreetMap** - Free map tiles
- **Leaflet.heat** - Heat map visualization
- **Leaflet.markercluster** - Marker clustering
### Styling
- **Tailwind CSS v4** - Utility-first CSS framework
### Data Visualization
- **Recharts** - Charts and graphs
### Utilities
- **date-fns** - Date manipulation
## Path Aliases
The project uses TypeScript path aliases for cleaner imports:
```typescript
import { Report } from '@/types';
import { apiClient } from '@/services/api';
import { useGeolocation } from '@/hooks';
import { isValidIrishCoordinates } from '@/utils';
```
Available aliases:
- `@/*` → `src/*`
- `@/components/*` → `src/components/*`
- `@/pages/*` → `src/pages/*`
- `@/services/*` → `src/services/*`
- `@/hooks/*` → `src/hooks/*`
- `@/utils/*` → `src/utils/*`
- `@/types/*` → `src/types/*`
## Environment Variables
Required environment variables (see `.env.local`):
```env
# Clerk Authentication
VITE_CLERK_PUBLISHABLE_KEY=your_publishable_key
# API Configuration
VITE_API_BASE_URL=http://localhost:3000/api
```
## Development
```bash
# Install dependencies
npm install
# Start development server
npm run dev
# Build for production
npm run build
# Preview production build
npm run preview
# Lint code
npm run lint
```
## Key Files
### `main.tsx`
Application entry point that renders the root component.
### `App.tsx`
Main application component that sets up:
- Clerk authentication provider
- React Query client
- React Router
- Global layout
### `types/index.ts`
Central type definitions for:
- Domain models (Report, Event, User, etc.)
- DTOs (Data Transfer Objects)
- Enums (LitterType, QuantityLevel, etc.)
### `services/api.ts`
Base API client with methods for HTTP requests.
### `hooks/useGeolocation.ts`
Custom hook for accessing device geolocation.
### `utils/index.ts`
Utility functions for:
- Coordinate validation
- Distance calculations
- File validation
- Date formatting
## Next Steps
The following components and features will be implemented in subsequent tasks:
1. Map interface with Leaflet
2. Report submission form
3. Analytics dashboard
4. Event management
5. User profile
6. Verification system

1
src/assets/react.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="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

13
src/components/README.md Normal file
View File

@@ -0,0 +1,13 @@
# Components Directory
This directory contains all React components for the Beach Litter Mapping Platform.
## Structure
Components will be organized by feature:
- `map/` - Map-related components (MapInterface, markers, clusters, etc.)
- `reports/` - Report-related components (ReportForm, ReportDetail, etc.)
- `events/` - Event management components
- `analytics/` - Analytics and dashboard components
- `user/` - User profile and authentication components
- `common/` - Shared/reusable components (buttons, modals, forms, etc.)

View File

@@ -0,0 +1,36 @@
import { useAuth } from '@clerk/clerk-react';
import { Navigate, useLocation } from 'react-router-dom';
import { ReactNode } from 'react';
interface ProtectedRouteProps {
children: ReactNode;
redirectTo?: string;
}
/**
* Protected route wrapper that requires authentication
* Redirects to sign-in page if user is not authenticated
*/
export function ProtectedRoute({
children,
redirectTo = '/sign-in'
}: ProtectedRouteProps) {
const { isLoaded, isSignedIn } = useAuth();
const location = useLocation();
// Wait for Clerk to load
if (!isLoaded) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
// Redirect to sign-in if not authenticated
if (!isSignedIn) {
return <Navigate to={redirectTo} state={{ from: location }} replace />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,43 @@
import { SignIn } from '@clerk/clerk-react';
import { useNavigate, useLocation } from 'react-router-dom';
/**
* Sign-in page with Clerk authentication
*/
export function SignInPage() {
const navigate = useNavigate();
const location = useLocation();
// Get the redirect path from location state, default to home
const from = (location.state as any)?.from?.pathname || '/';
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Sign in to Beach Litter Mapping
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Join the community in tracking and cleaning coastal pollution
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
<SignIn
routing="path"
path="/sign-in"
signUpUrl="/sign-up"
afterSignInUrl={from}
appearance={{
elements: {
rootBox: 'w-full',
card: 'shadow-none',
},
}}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { SignUp } from '@clerk/clerk-react';
/**
* Sign-up page with Clerk authentication
*/
export function SignUpPage() {
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Create your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Start reporting and tracking beach litter in your area
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
<SignUp
routing="path"
path="/sign-up"
signInUrl="/sign-in"
afterSignUpUrl="/"
appearance={{
elements: {
rootBox: 'w-full',
card: 'shadow-none',
},
}}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,58 @@
import { useUser, useClerk } from '@clerk/clerk-react';
import { useNavigate } from 'react-router-dom';
/**
* User profile display component showing user info and sign-out option
*/
export function UserProfile() {
const { user, isLoaded } = useUser();
const { signOut } = useClerk();
const navigate = useNavigate();
const handleSignOut = async () => {
await signOut();
navigate('/');
};
if (!isLoaded) {
return (
<div className="flex items-center space-x-3">
<div className="animate-pulse">
<div className="h-10 w-10 bg-gray-300 rounded-full"></div>
</div>
</div>
);
}
if (!user) {
return null;
}
return (
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-3">
{user.imageUrl && (
<img
src={user.imageUrl}
alt={user.fullName || user.username || 'User'}
className="h-10 w-10 rounded-full object-cover border-2 border-gray-200"
/>
)}
<div className="hidden md:block">
<p className="text-sm font-medium text-gray-900">
{user.fullName || user.username || 'User'}
</p>
<p className="text-xs text-gray-500">
{user.primaryEmailAddress?.emailAddress}
</p>
</div>
</div>
<button
onClick={handleSignOut}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Sign Out
</button>
</div>
);
}

View File

@@ -0,0 +1,4 @@
export { ProtectedRoute } from './ProtectedRoute';
export { UserProfile } from './UserProfile';
export { SignInPage } from './SignInPage';
export { SignUpPage } from './SignUpPage';

8
src/components/index.ts Normal file
View File

@@ -0,0 +1,8 @@
// Auth components
export * from './auth';
// Layout components
export * from './layout';
// Map components
export * from './map';

View File

@@ -0,0 +1,73 @@
import { SignedIn, SignedOut, SignInButton, SignUpButton } from '@clerk/clerk-react';
import { Link } from 'react-router-dom';
import { UserProfile } from '../auth/UserProfile';
/**
* Application header with navigation and authentication controls
*/
export function Header() {
return (
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-4">
<div className="flex items-center">
<Link to="/" className="flex items-center">
<h1 className="text-2xl font-bold text-blue-600">
Beach Litter Mapping
</h1>
</Link>
</div>
<nav className="flex items-center space-x-4">
<SignedIn>
<Link
to="/map"
className="text-gray-700 hover:text-blue-600 px-3 py-2 rounded-md text-sm font-medium"
>
Map
</Link>
<Link
to="/reports/new"
className="text-gray-700 hover:text-blue-600 px-3 py-2 rounded-md text-sm font-medium"
>
Report Litter
</Link>
<Link
to="/events"
className="text-gray-700 hover:text-blue-600 px-3 py-2 rounded-md text-sm font-medium"
>
Events
</Link>
<Link
to="/analytics"
className="text-gray-700 hover:text-blue-600 px-3 py-2 rounded-md text-sm font-medium"
>
Analytics
</Link>
<UserProfile />
</SignedIn>
<SignedOut>
<Link
to="/map"
className="text-gray-700 hover:text-blue-600 px-3 py-2 rounded-md text-sm font-medium"
>
View Map
</Link>
<SignInButton mode="modal">
<button className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Sign In
</button>
</SignInButton>
<SignUpButton mode="modal">
<button className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Sign Up
</button>
</SignUpButton>
</SignedOut>
</nav>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,18 @@
import { ReactNode } from 'react';
import { Header } from './Header';
interface LayoutProps {
children: ReactNode;
}
/**
* Main layout wrapper with header
*/
export function Layout({ children }: LayoutProps) {
return (
<div className="min-h-screen bg-gray-50">
<Header />
<main>{children}</main>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { Header } from './Header';
export { Layout } from './Layout';

View File

@@ -0,0 +1,217 @@
import { useState } from 'react';
import type { FilterState, LitterType, QuantityLevel } from '@/types';
interface MapFiltersProps {
filters: FilterState;
onFiltersChange: (filters: FilterState) => void;
onToggleClusters: () => void;
showClusters: boolean;
}
export function MapFilters({
filters,
onFiltersChange,
onToggleClusters,
showClusters,
}: MapFiltersProps) {
const [isExpanded, setIsExpanded] = useState(false);
const litterTypes: LitterType[] = ['plastic', 'metal', 'glass', 'organic', 'hazardous', 'other'];
const quantities: QuantityLevel[] = ['minimal', 'moderate', 'significant', 'severe'];
const handleLitterTypeToggle = (type: LitterType) => {
const currentTypes = filters.litterTypes || [];
const newTypes = currentTypes.includes(type)
? currentTypes.filter(t => t !== type)
: [...currentTypes, type];
onFiltersChange({
...filters,
litterTypes: newTypes.length > 0 ? newTypes : undefined,
});
};
const handleQuantityToggle = (quantity: QuantityLevel) => {
const currentQuantities = filters.quantities || [];
const newQuantities = currentQuantities.includes(quantity)
? currentQuantities.filter(q => q !== quantity)
: [...currentQuantities, quantity];
onFiltersChange({
...filters,
quantities: newQuantities.length > 0 ? newQuantities : undefined,
});
};
const handleDateRangeChange = (field: 'start' | 'end', value: string) => {
if (!value) {
if (field === 'start' && !filters.dateRange?.end) {
onFiltersChange({ ...filters, dateRange: undefined });
} else if (field === 'end' && !filters.dateRange?.start) {
onFiltersChange({ ...filters, dateRange: undefined });
} else {
onFiltersChange({
...filters,
dateRange: {
start: field === 'start' ? new Date() : filters.dateRange!.start,
end: field === 'end' ? new Date() : filters.dateRange!.end,
[field]: undefined,
} as any,
});
}
return;
}
const date = new Date(value);
onFiltersChange({
...filters,
dateRange: {
start: field === 'start' ? date : filters.dateRange?.start || date,
end: field === 'end' ? date : filters.dateRange?.end || date,
},
});
};
const handleClearFilters = () => {
onFiltersChange({});
};
const hasActiveFilters =
(filters.litterTypes && filters.litterTypes.length > 0) ||
(filters.quantities && filters.quantities.length > 0) ||
filters.dateRange;
return (
<div className="absolute top-4 left-4 z-[1000] bg-white rounded-lg shadow-lg max-w-sm">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<h3 className="font-semibold text-gray-900">Filters</h3>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-gray-500 hover:text-gray-700"
aria-label={isExpanded ? 'Collapse filters' : 'Expand filters'}
>
<svg
className={`w-5 h-5 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
{/* Filter Content */}
{isExpanded && (
<div className="p-4 space-y-4 max-h-[70vh] overflow-y-auto">
{/* Cluster Toggle */}
<div className="flex items-center justify-between">
<label htmlFor="cluster-toggle" className="text-sm font-medium text-gray-700">
Cluster Markers
</label>
<button
id="cluster-toggle"
onClick={onToggleClusters}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
showClusters ? 'bg-blue-600' : 'bg-gray-200'
}`}
role="switch"
aria-checked={showClusters}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
showClusters ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{/* Date Range */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Date Range
</label>
<div className="space-y-2">
<input
type="date"
value={filters.dateRange?.start ? filters.dateRange.start.toISOString().split('T')[0] : ''}
onChange={(e) => handleDateRangeChange('start', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Start date"
/>
<input
type="date"
value={filters.dateRange?.end ? filters.dateRange.end.toISOString().split('T')[0] : ''}
onChange={(e) => handleDateRangeChange('end', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="End date"
/>
</div>
</div>
{/* Litter Type */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Litter Type
</label>
<div className="space-y-2">
{litterTypes.map((type) => (
<label key={type} className="flex items-center">
<input
type="checkbox"
checked={filters.litterTypes?.includes(type) || false}
onChange={() => handleLitterTypeToggle(type)}
className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-gray-700 capitalize">{type}</span>
</label>
))}
</div>
</div>
{/* Quantity Level */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Quantity Level
</label>
<div className="space-y-2">
{quantities.map((quantity) => (
<label key={quantity} className="flex items-center">
<input
type="checkbox"
checked={filters.quantities?.includes(quantity) || false}
onChange={() => handleQuantityToggle(quantity)}
className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-gray-700 capitalize">{quantity}</span>
</label>
))}
</div>
</div>
{/* Clear Filters Button */}
{hasActiveFilters && (
<button
onClick={handleClearFilters}
className="w-full px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
>
Clear All Filters
</button>
)}
</div>
)}
{/* Active Filters Badge */}
{!isExpanded && hasActiveFilters && (
<div className="px-4 py-2 text-xs text-blue-600 font-medium">
{[
filters.litterTypes?.length && `${filters.litterTypes.length} type(s)`,
filters.quantities?.length && `${filters.quantities.length} quantity`,
filters.dateRange && 'date range',
].filter(Boolean).join(', ')}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,404 @@
import { useEffect, useRef } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import 'leaflet.markercluster/dist/MarkerCluster.css';
import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
import 'leaflet.markercluster';
import type { Report, CleanupEvent, FilterState } from '@/types';
// Fix for default marker icons in Leaflet with Vite
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
import markerIcon from 'leaflet/dist/images/marker-icon.png';
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({
iconUrl: markerIcon,
iconRetinaUrl: markerIcon2x,
shadowUrl: markerShadow,
});
interface MapViewProps {
reports: Report[];
events?: CleanupEvent[];
filters?: FilterState;
onMarkerClick?: (reportId: string) => void;
onMapClick?: (lat: number, lng: number) => void;
showHeatmap?: boolean;
showClusters?: boolean;
center?: [number, number];
zoom?: number;
}
export function MapView({
reports,
events = [],
filters,
onMarkerClick,
onMapClick,
showClusters = true,
center = [53.3498, -6.2603], // Dublin, Ireland default
zoom = 7,
}: MapViewProps) {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<L.Map | null>(null);
const markersLayerRef = useRef<L.LayerGroup | null>(null);
const clusterGroupRef = useRef<L.MarkerClusterGroup | null>(null);
// Initialize map
useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return;
// Create map instance
const map = L.map(mapRef.current).setView(center, zoom);
// Add OpenStreetMap tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19,
}).addTo(map);
// Handle map clicks
if (onMapClick) {
map.on('click', (e: L.LeafletMouseEvent) => {
onMapClick(e.latlng.lat, e.latlng.lng);
});
}
// Create markers layer
markersLayerRef.current = L.layerGroup();
// Create cluster group with custom configuration
clusterGroupRef.current = L.markerClusterGroup({
maxClusterRadius: 50, // Cluster markers within 50 pixels
spiderfyOnMaxZoom: true,
showCoverageOnHover: true,
zoomToBoundsOnClick: true,
iconCreateFunction: (cluster) => {
const count = cluster.getChildCount();
let sizeClass = 'marker-cluster-small';
if (count > 10) {
sizeClass = 'marker-cluster-large';
} else if (count > 5) {
sizeClass = 'marker-cluster-medium';
}
return L.divIcon({
html: `<div><span>${count}</span></div>`,
className: `marker-cluster ${sizeClass}`,
iconSize: L.point(40, 40),
});
},
});
mapInstanceRef.current = map;
return () => {
map.remove();
mapInstanceRef.current = null;
markersLayerRef.current = null;
clusterGroupRef.current = null;
};
}, []);
// Filter reports based on filter state
const getFilteredReports = (): Report[] => {
if (!filters) return reports;
return reports.filter((report) => {
// Filter by date range
if (filters.dateRange) {
const reportDate = new Date(report.createdAt);
if (filters.dateRange.start && reportDate < filters.dateRange.start) {
return false;
}
if (filters.dateRange.end && reportDate > filters.dateRange.end) {
return false;
}
}
// Filter by litter type
if (filters.litterTypes && filters.litterTypes.length > 0) {
if (!filters.litterTypes.includes(report.litterType)) {
return false;
}
}
// Filter by quantity
if (filters.quantities && filters.quantities.length > 0) {
if (!filters.quantities.includes(report.quantity)) {
return false;
}
}
// Filter by verification status
if (filters.verificationStatus && filters.verificationStatus.length > 0) {
if (!filters.verificationStatus.includes(report.verificationStatus)) {
return false;
}
}
return true;
});
};
// Update markers when reports, filters, or clustering changes
useEffect(() => {
if (!mapInstanceRef.current || !markersLayerRef.current || !clusterGroupRef.current) return;
const map = mapInstanceRef.current;
const markersLayer = markersLayerRef.current;
const clusterGroup = clusterGroupRef.current;
// Remove existing layers from map
if (map.hasLayer(markersLayer)) {
map.removeLayer(markersLayer);
}
if (map.hasLayer(clusterGroup)) {
map.removeLayer(clusterGroup);
}
// Clear existing markers
markersLayer.clearLayers();
clusterGroup.clearLayers();
// Get filtered reports
const filteredReports = getFilteredReports();
// Add report markers
filteredReports.forEach((report) => {
const icon = createCustomMarkerIcon(report.litterType, report.quantity);
const marker = L.marker([report.latitude, report.longitude], { icon });
// Add popup with report details
marker.bindPopup(`
<div class="p-2">
<h3 class="font-bold text-sm mb-1">${getLitterTypeLabel(report.litterType)}</h3>
<p class="text-xs text-gray-600">Quantity: ${report.quantity}</p>
<p class="text-xs text-gray-600">Status: ${report.verificationStatus}</p>
<p class="text-xs text-gray-600">Date: ${new Date(report.createdAt).toLocaleDateString()}</p>
${report.description ? `<p class="text-xs mt-1">${report.description}</p>` : ''}
</div>
`);
// Handle marker click
if (onMarkerClick) {
marker.on('click', () => {
onMarkerClick(report.id);
});
}
// Add to appropriate layer
if (showClusters) {
clusterGroup.addLayer(marker);
} else {
markersLayer.addLayer(marker);
}
});
// Add event markers (always to regular layer, not clustered)
events.forEach((event) => {
const icon = createEventMarkerIcon(event.status);
const marker = L.marker([event.latitude, event.longitude], { icon });
marker.bindPopup(`
<div class="p-2">
<h3 class="font-bold text-sm mb-1">${event.title}</h3>
<p class="text-xs text-gray-600">${event.locationName}</p>
<p class="text-xs text-gray-600">Participants: ${event.participantCount}</p>
<p class="text-xs text-gray-600">Status: ${event.status}</p>
</div>
`);
markersLayer.addLayer(marker);
});
// Add appropriate layer to map
if (showClusters) {
clusterGroup.addTo(map);
}
markersLayer.addTo(map);
}, [reports, events, filters, showClusters, onMarkerClick]);
// Handle geolocation
const handleGeolocation = () => {
if (!navigator.geolocation) {
alert('Geolocation is not supported by your browser');
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
const coords: [number, number] = [
position.coords.latitude,
position.coords.longitude,
];
if (mapInstanceRef.current) {
mapInstanceRef.current.setView(coords, 13);
// Add user location marker
L.marker(coords, {
icon: L.divIcon({
className: 'user-location-marker',
html: '<div class="w-4 h-4 bg-blue-500 rounded-full border-2 border-white shadow-lg"></div>',
iconSize: [16, 16],
}),
}).addTo(mapInstanceRef.current);
}
},
(error) => {
alert(`Error getting location: ${error.message}`);
},
{
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0,
}
);
};
return (
<div className="relative w-full h-full">
<div ref={mapRef} className="w-full h-full rounded-lg" />
{/* Geolocation button */}
<button
onClick={handleGeolocation}
className="absolute top-4 right-4 z-[1000] bg-white hover:bg-gray-50 text-gray-700 p-3 rounded-lg shadow-lg transition-colors"
title="Center map on your location"
aria-label="Center map on your location"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</button>
</div>
);
}
// Helper function to create custom marker icons based on litter type
function createCustomMarkerIcon(litterType: string, quantity: string): L.DivIcon {
const colors = {
plastic: '#3B82F6', // blue
metal: '#6B7280', // gray
glass: '#10B981', // green
organic: '#84CC16', // lime
hazardous: '#EF4444', // red
other: '#8B5CF6', // purple
};
const sizes = {
minimal: 24,
moderate: 28,
significant: 32,
severe: 36,
};
const color = colors[litterType as keyof typeof colors] || colors.other;
const size = sizes[quantity as keyof typeof sizes] || sizes.moderate;
return L.divIcon({
className: 'custom-marker',
html: `
<div style="
width: ${size}px;
height: ${size}px;
background-color: ${color};
border: 2px solid white;
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
">
<div style="
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
transform: rotate(45deg);
color: white;
font-size: ${size * 0.4}px;
font-weight: bold;
">
${getLitterTypeIcon(litterType)}
</div>
</div>
`,
iconSize: [size, size],
iconAnchor: [size / 2, size],
});
}
// Helper function to create event marker icons
function createEventMarkerIcon(status: string): L.DivIcon {
const colors = {
upcoming: '#F59E0B', // amber
completed: '#10B981', // green
cancelled: '#6B7280', // gray
};
const color = colors[status as keyof typeof colors] || colors.upcoming;
return L.divIcon({
className: 'event-marker',
html: `
<div style="
width: 32px;
height: 32px;
background-color: ${color};
border: 3px solid white;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 16px;
font-weight: bold;
">
📅
</div>
`,
iconSize: [32, 32],
iconAnchor: [16, 16],
});
}
// Helper function to get litter type icon
function getLitterTypeIcon(litterType: string): string {
const icons: Record<string, string> = {
plastic: '♻',
metal: '⚙',
glass: '◆',
organic: '🌿',
hazardous: '⚠',
other: '●',
};
const icon = icons[litterType];
return icon !== undefined ? icon : '●';
}
// Helper function to get litter type label
function getLitterTypeLabel(litterType: string): string {
const label = litterType.charAt(0).toUpperCase() + litterType.slice(1);
return label;
}

View File

@@ -0,0 +1,80 @@
# Map Components
## MapView
Interactive map component built with Leaflet.js for displaying litter reports and cleanup events.
### Features
- **OpenStreetMap Integration**: Uses free OpenStreetMap tiles
- **Custom Markers**: Color-coded markers for different litter types with size based on quantity
- **Geolocation**: Button to center map on user's current location
- **Interactive Popups**: Click markers to view report details
- **Event Markers**: Display cleanup events with distinct styling
- **Zoom & Pan Controls**: Standard Leaflet map controls
### Usage
```tsx
import { MapView } from '@/components/map';
function MyMapPage() {
const reports = [/* array of Report objects */];
const events = [/* array of CleanupEvent objects */];
const handleMarkerClick = (reportId: string) => {
console.log('Report clicked:', reportId);
};
return (
<MapView
reports={reports}
events={events}
onMarkerClick={handleMarkerClick}
center={[53.3498, -6.2603]} // Optional: default center
zoom={7} // Optional: default zoom level
/>
);
}
```
### Props
- `reports` (Report[]): Array of litter reports to display
- `events` (CleanupEvent[]): Array of cleanup events to display (optional)
- `filters` (FilterState): Filter state for reports (optional, for future use)
- `onMarkerClick` ((reportId: string) => void): Callback when a report marker is clicked (optional)
- `onMapClick` ((lat: number, lng: number) => void): Callback when the map is clicked (optional)
- `showHeatmap` (boolean): Toggle heat map overlay (optional, for future use)
- `showClusters` (boolean): Toggle marker clustering (optional, for future use)
- `center` ([number, number]): Initial map center coordinates (default: Dublin, Ireland)
- `zoom` (number): Initial zoom level (default: 7)
### Marker Colors
- **Plastic**: Blue (#3B82F6)
- **Metal**: Gray (#6B7280)
- **Glass**: Green (#10B981)
- **Organic**: Lime (#84CC16)
- **Hazardous**: Red (#EF4444)
- **Other**: Purple (#8B5CF6)
### Marker Sizes (by quantity)
- **Minimal**: 24px
- **Moderate**: 28px
- **Significant**: 32px
- **Severe**: 36px
### Event Marker Colors
- **Upcoming**: Amber (#F59E0B)
- **Completed**: Green (#10B981)
- **Cancelled**: Gray (#6B7280)
### Requirements Satisfied
- ✅ 2.1: Display litter reports as visual markers on interactive map
- ✅ 2.2: Click markers to display full report details
- ✅ 2.3: Map zoom functionality from country to street level
- ✅ 8.2: Access device GPS to center map on user location

View File

@@ -0,0 +1,2 @@
export { MapView } from './MapView';
export { MapFilters } from './MapFilters';

4
src/hooks/index.ts Normal file
View File

@@ -0,0 +1,4 @@
// Custom React hooks for the Beach Litter Mapping Platform
export { default as useGeolocation } from './useGeolocation';
export { useAuth } from './useAuth';

20
src/hooks/useAuth.ts Normal file
View File

@@ -0,0 +1,20 @@
import { useUser, useAuth as useClerkAuth } from '@clerk/clerk-react';
/**
* Custom hook for authentication state and user data
* Wraps Clerk's useAuth and useUser hooks for easier access
*/
export function useAuth() {
const { isLoaded: authLoaded, isSignedIn, userId } = useClerkAuth();
const { user, isLoaded: userLoaded } = useUser();
const isLoaded = authLoaded && userLoaded;
return {
isLoaded,
isSignedIn,
userId,
user,
isAuthenticated: isSignedIn && !!user,
};
}

View File

@@ -0,0 +1,62 @@
import { useState, useEffect } from 'react';
import type { Coordinates } from '@/types';
interface GeolocationState {
coordinates: Coordinates | null;
error: string | null;
loading: boolean;
}
/**
* Custom hook to get user's current geolocation
*/
export default function useGeolocation() {
const [state, setState] = useState<GeolocationState>({
coordinates: null,
error: null,
loading: false,
});
const getCurrentPosition = () => {
if (!navigator.geolocation) {
setState({
coordinates: null,
error: 'Geolocation is not supported by your browser',
loading: false,
});
return;
}
setState(prev => ({ ...prev, loading: true, error: null }));
navigator.geolocation.getCurrentPosition(
(position) => {
setState({
coordinates: {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
},
error: null,
loading: false,
});
},
(error) => {
setState({
coordinates: null,
error: error.message,
loading: false,
});
},
{
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0,
}
);
};
return {
...state,
getCurrentPosition,
};
}

80
src/index.css Normal file
View File

@@ -0,0 +1,80 @@
@import "tailwindcss";
/* Leaflet CSS overrides */
.leaflet-container {
font-family: inherit;
}
/* Ensure map markers are clickable */
.leaflet-marker-icon {
cursor: pointer;
}
/* Custom marker cluster styles */
.marker-cluster {
background-clip: padding-box;
border-radius: 50%;
}
.marker-cluster div {
width: 30px;
height: 30px;
margin-left: 5px;
margin-top: 5px;
text-align: center;
border-radius: 50%;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
}
.marker-cluster-small {
background-color: rgba(59, 130, 246, 0.6);
}
.marker-cluster-small div {
background-color: rgba(59, 130, 246, 0.8);
color: white;
font-size: 12px;
}
.marker-cluster-medium {
background-color: rgba(249, 115, 22, 0.6);
}
.marker-cluster-medium div {
background-color: rgba(249, 115, 22, 0.8);
color: white;
font-size: 13px;
}
.marker-cluster-large {
background-color: rgba(239, 68, 68, 0.6);
}
.marker-cluster-large div {
background-color: rgba(239, 68, 68, 0.8);
color: white;
font-size: 14px;
}
/* User location marker animation */
.user-location-marker {
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.2);
opacity: 0.7;
}
100% {
transform: scale(1);
opacity: 1;
}
}

16
src/main.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('Root element not found');
}
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>
);

View File

@@ -0,0 +1,15 @@
/**
* Analytics page - placeholder for task 10-12
*/
export function AnalyticsPage() {
return (
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Analytics Dashboard</h2>
<div className="bg-white rounded-lg shadow p-8">
<p className="text-gray-500">
Analytics dashboard will be implemented in tasks 10-12
</p>
</div>
</div>
);
}

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

@@ -0,0 +1,15 @@
/**
* Events page - placeholder for task 13-14
*/
export function EventsPage() {
return (
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Clean-up Events</h2>
<div className="bg-white rounded-lg shadow p-8">
<p className="text-gray-500">
Events management will be implemented in tasks 13-14
</p>
</div>
</div>
);
}

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

@@ -0,0 +1,90 @@
import { SignedIn, SignedOut } from '@clerk/clerk-react';
import { Link } from 'react-router-dom';
/**
* Home page with welcome message and call-to-action
*/
export function HomePage() {
return (
<div className="max-w-7xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
<div className="text-center">
<h2 className="text-4xl font-extrabold text-gray-900 sm:text-5xl">
Track and Clean Ireland's Beaches
</h2>
<p className="mt-4 text-xl text-gray-600 max-w-3xl mx-auto">
Join our community in mapping beach litter, organizing clean-up events,
and protecting our coastal environment through data-driven action.
</p>
<SignedOut>
<div className="mt-8 flex justify-center space-x-4">
<Link
to="/sign-up"
className="px-8 py-3 border border-transparent text-base font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 md:py-4 md:text-lg md:px-10"
>
Get Started
</Link>
<Link
to="/map"
className="px-8 py-3 border border-gray-300 text-base font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 md:py-4 md:text-lg md:px-10"
>
View Map
</Link>
</div>
</SignedOut>
<SignedIn>
<div className="mt-8 flex justify-center space-x-4">
<Link
to="/reports/new"
className="px-8 py-3 border border-transparent text-base font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 md:py-4 md:text-lg md:px-10"
>
Report Litter
</Link>
<Link
to="/map"
className="px-8 py-3 border border-gray-300 text-base font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 md:py-4 md:text-lg md:px-10"
>
View Map
</Link>
</div>
</SignedIn>
</div>
<div className="mt-16 grid grid-cols-1 gap-8 md:grid-cols-3">
<div className="bg-white p-6 rounded-lg shadow">
<div className="text-blue-600 text-3xl mb-4">📍</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Report Litter
</h3>
<p className="text-gray-600">
Document beach litter with photos and location data to raise awareness
and track pollution patterns.
</p>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<div className="text-blue-600 text-3xl mb-4">🗺️</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Interactive Maps
</h3>
<p className="text-gray-600">
Visualize litter hotspots, view reports from your area, and identify
areas that need attention.
</p>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<div className="text-blue-600 text-3xl mb-4">🤝</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Organize Events
</h3>
<p className="text-gray-600">
Create and join clean-up events, coordinate with volunteers, and make
a real impact on coastal health.
</p>
</div>
</div>
</div>
);
}

165
src/pages/MapPage.tsx Normal file
View File

@@ -0,0 +1,165 @@
import { useState } from 'react';
import { MapView, MapFilters } from '@/components/map';
import type { Report, FilterState } from '@/types';
/**
* Map page - Interactive map displaying litter reports and cleanup events
*/
export function MapPage() {
const [selectedReportId, setSelectedReportId] = useState<string | null>(null);
const [filters, setFilters] = useState<FilterState>({});
const [showClusters, setShowClusters] = useState(true);
// Sample data for demonstration (will be replaced with API data in later tasks)
const sampleReports: Report[] = [
{
id: '1',
userId: 'user1',
latitude: 53.3498,
longitude: -6.2603,
locationSource: 'gps',
photoUrls: ['https://example.com/photo1.jpg'],
litterType: 'plastic',
quantity: 'moderate',
description: 'Plastic bottles and bags near the shore',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
submittedAt: new Date('2024-01-15'),
verificationStatus: 'verified',
},
{
id: '2',
userId: 'user2',
latitude: 53.2707,
longitude: -9.0568,
locationSource: 'gps',
photoUrls: ['https://example.com/photo2.jpg'],
litterType: 'glass',
quantity: 'minimal',
description: 'Broken glass bottles',
createdAt: new Date('2024-01-16'),
updatedAt: new Date('2024-01-16'),
submittedAt: new Date('2024-01-16'),
verificationStatus: 'pending',
},
{
id: '3',
userId: 'user3',
latitude: 51.8969,
longitude: -8.4863,
locationSource: 'manual',
photoUrls: ['https://example.com/photo3.jpg'],
litterType: 'metal',
quantity: 'significant',
description: 'Metal cans and containers',
createdAt: new Date('2024-01-17'),
updatedAt: new Date('2024-01-17'),
submittedAt: new Date('2024-01-17'),
verificationStatus: 'verified',
},
{
id: '4',
userId: 'user4',
latitude: 53.3520,
longitude: -6.2650,
locationSource: 'gps',
photoUrls: ['https://example.com/photo4.jpg'],
litterType: 'plastic',
quantity: 'severe',
description: 'Large amount of plastic waste',
createdAt: new Date('2024-01-18'),
updatedAt: new Date('2024-01-18'),
submittedAt: new Date('2024-01-18'),
verificationStatus: 'verified',
},
{
id: '5',
userId: 'user5',
latitude: 53.3450,
longitude: -6.2580,
locationSource: 'gps',
photoUrls: ['https://example.com/photo5.jpg'],
litterType: 'hazardous',
quantity: 'moderate',
description: 'Chemical containers',
createdAt: new Date('2024-01-19'),
updatedAt: new Date('2024-01-19'),
submittedAt: new Date('2024-01-19'),
verificationStatus: 'disputed',
},
{
id: '6',
userId: 'user6',
latitude: 53.3480,
longitude: -6.2620,
locationSource: 'manual',
photoUrls: ['https://example.com/photo6.jpg'],
litterType: 'organic',
quantity: 'minimal',
description: 'Seaweed and organic debris',
createdAt: new Date('2024-01-20'),
updatedAt: new Date('2024-01-20'),
submittedAt: new Date('2024-01-20'),
verificationStatus: 'verified',
},
];
const handleMarkerClick = (reportId: string) => {
setSelectedReportId(reportId);
console.log('Report clicked:', reportId);
// In later tasks, this will open a modal with full report details
};
const handleMapClick = (lat: number, lng: number) => {
console.log('Map clicked at:', lat, lng);
// In later tasks, this can be used for manual location selection
};
const handleToggleClusters = () => {
setShowClusters(!showClusters);
};
return (
<div className="h-screen flex flex-col">
<div className="bg-white border-b border-gray-200 px-4 py-4 sm:px-6 lg:px-8">
<h2 className="text-2xl font-bold text-gray-900">Interactive Map</h2>
<p className="mt-1 text-sm text-gray-600">
View litter reports and cleanup events across Ireland
</p>
</div>
<div className="flex-1 p-4">
<div className="h-full bg-white rounded-lg shadow-lg overflow-hidden relative">
<MapView
reports={sampleReports}
filters={filters}
showClusters={showClusters}
onMarkerClick={handleMarkerClick}
onMapClick={handleMapClick}
/>
<MapFilters
filters={filters}
onFiltersChange={setFilters}
onToggleClusters={handleToggleClusters}
showClusters={showClusters}
/>
</div>
</div>
{selectedReportId && (
<div className="fixed bottom-4 left-4 bg-white p-4 rounded-lg shadow-lg z-[1000]">
<p className="text-sm text-gray-600">
Selected Report ID: {selectedReportId}
</p>
<button
onClick={() => setSelectedReportId(null)}
className="mt-2 text-xs text-blue-600 hover:text-blue-800"
>
Close
</button>
</div>
)}
</div>
);
}

13
src/pages/README.md Normal file
View File

@@ -0,0 +1,13 @@
# Pages Directory
This directory contains page-level components that represent different routes in the application.
## Planned Pages
- `HomePage.tsx` - Landing page with map view
- `ReportPage.tsx` - Report submission page
- `AnalyticsPage.tsx` - Analytics dashboard
- `EventsPage.tsx` - Event listing and management
- `EventDetailPage.tsx` - Individual event details
- `ProfilePage.tsx` - User profile page
- `NotFoundPage.tsx` - 404 error page

View File

@@ -0,0 +1,15 @@
/**
* Report form page - placeholder for task 6-9
*/
export function ReportFormPage() {
return (
<div className="max-w-3xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Report Litter</h2>
<div className="bg-white rounded-lg shadow p-8">
<p className="text-gray-500">
Report form will be implemented in tasks 6-9
</p>
</div>
</div>
);
}

5
src/pages/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export { HomePage } from './HomePage';
export { MapPage } from './MapPage';
export { ReportFormPage } from './ReportFormPage';
export { EventsPage } from './EventsPage';
export { AnalyticsPage } from './AnalyticsPage';

13
src/services/README.md Normal file
View File

@@ -0,0 +1,13 @@
# Services Directory
This directory contains service modules for API communication and external integrations.
## Planned Services
- `api.ts` - Base API client (already created)
- `reportService.ts` - Report-related API calls
- `eventService.ts` - Event-related API calls
- `analyticsService.ts` - Analytics API calls
- `userService.ts` - User profile API calls
- `exifService.ts` - EXIF metadata extraction
- `externalApiService.ts` - Weather, tides, water quality APIs

66
src/services/api.ts Normal file
View File

@@ -0,0 +1,66 @@
// API service configuration and base client
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api';
export interface ApiError {
code: string;
message: string;
details?: any;
timestamp: string;
}
export class ApiClient {
private baseUrl: string;
constructor(baseUrl: string = API_BASE_URL) {
this.baseUrl = baseUrl;
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
if (!response.ok) {
const error: ApiError = await response.json();
throw new Error(error.message || 'An error occurred');
}
return response.json();
}
async get<T>(endpoint: string, options?: RequestInit): Promise<T> {
return this.request<T>(endpoint, { ...options, method: 'GET' });
}
async post<T>(endpoint: string, data?: any, options?: RequestInit): Promise<T> {
return this.request<T>(endpoint, {
...options,
method: 'POST',
body: JSON.stringify(data),
});
}
async patch<T>(endpoint: string, data?: any, options?: RequestInit): Promise<T> {
return this.request<T>(endpoint, {
...options,
method: 'PATCH',
body: JSON.stringify(data),
});
}
async delete<T>(endpoint: string, options?: RequestInit): Promise<T> {
return this.request<T>(endpoint, { ...options, method: 'DELETE' });
}
}
export const apiClient = new ApiClient();

121
src/types/index.ts Normal file
View File

@@ -0,0 +1,121 @@
// Core type definitions for the Beach Litter Mapping Platform
export type LitterType = 'plastic' | 'metal' | 'glass' | 'organic' | 'hazardous' | 'other';
export type QuantityLevel = 'minimal' | 'moderate' | 'significant' | 'severe';
export type LocationSource = 'exif' | 'gps' | 'manual';
export type VerificationStatus = 'pending' | 'verified' | 'disputed';
export type EventStatus = 'upcoming' | 'completed' | 'cancelled';
export interface Coordinates {
latitude: number;
longitude: number;
}
export interface User {
id: string;
clerkId: string;
email: string;
username: string;
firstName?: string;
lastName?: string;
avatarUrl?: string;
impactScore: number;
createdAt: Date;
updatedAt: Date;
}
export interface Report {
id: string;
userId: string;
latitude: number;
longitude: number;
locationSource: LocationSource;
photoUrls: string[];
photoTimestamp?: Date;
litterType: LitterType;
quantity: QuantityLevel;
description?: string;
createdAt: Date;
updatedAt: Date;
submittedAt: Date;
verificationStatus: VerificationStatus;
}
export interface CleanupEvent {
id: string;
organizerId: string;
title: string;
description: string;
latitude: number;
longitude: number;
locationName: string;
scheduledDate: Date;
duration: number; // in minutes
status: EventStatus;
participantCount: number;
litterCollected?: number; // in kilograms
photos?: string[];
createdAt: Date;
updatedAt: Date;
}
export interface EnvironmentalConcern {
id: string;
reportId: string;
concernType: string;
severity: 'low' | 'medium' | 'high' | 'critical';
description: string;
createdAt: Date;
}
export interface Hotspot {
id: string;
latitude: number;
longitude: number;
radius: number; // in meters
reportCount: number;
severityScore: number;
lastReportDate: Date;
calculatedAt: Date;
}
// DTOs (Data Transfer Objects)
export interface CreateReportDTO {
photos: File[];
latitude?: number;
longitude?: number;
locationSource: LocationSource;
litterType: LitterType;
quantity: QuantityLevel;
description?: string;
environmentalConcerns?: {
type: string;
severity: 'low' | 'medium' | 'high' | 'critical';
description: string;
}[];
}
export interface ReportResponseDTO extends Report {
user: {
id: string;
username: string;
avatarUrl?: string;
};
verificationCount: number;
disputeCount: number;
environmentalConcerns: EnvironmentalConcern[];
}
export interface FilterState {
dateRange?: {
start: Date;
end: Date;
};
litterTypes?: LitterType[];
quantities?: QuantityLevel[];
verificationStatus?: VerificationStatus[];
}

71
src/utils/index.ts Normal file
View File

@@ -0,0 +1,71 @@
// Utility functions for the Beach Litter Mapping Platform
/**
* Validates if coordinates are within Ireland's geographical bounds
*/
export function isValidIrishCoordinates(lat: number, lng: number): boolean {
// Ireland approximate bounds
const MIN_LAT = 51.4;
const MAX_LAT = 55.4;
const MIN_LNG = -10.5;
const MAX_LNG = -5.5;
return lat >= MIN_LAT && lat <= MAX_LAT && lng >= MIN_LNG && lng <= MAX_LNG;
}
/**
* Formats a date to ISO 8601 format
*/
export function formatDateISO(date: Date): string {
return date.toISOString();
}
/**
* Calculates distance between two coordinates in meters using Haversine formula
*/
export function calculateDistance(
lat1: number,
lng1: number,
lat2: number,
lng2: number
): number {
const R = 6371e3; // Earth's radius in meters
const φ1 = (lat1 * Math.PI) / 180;
const φ2 = (lat2 * Math.PI) / 180;
const Δφ = ((lat2 - lat1) * Math.PI) / 180;
const Δλ = ((lng2 - lng1) * Math.PI) / 180;
const a =
Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
/**
* Validates file type for photo uploads
*/
export function isValidImageFile(file: File): boolean {
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
return validTypes.includes(file.type);
}
/**
* Validates file size (max 5MB)
*/
export function isValidFileSize(file: File, maxSizeMB: number = 5): boolean {
const maxSizeBytes = maxSizeMB * 1024 * 1024;
return file.size <= maxSizeBytes;
}
/**
* Formats bytes to human-readable size
*/
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}

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

@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_CLERK_PUBLISHABLE_KEY: string;
readonly VITE_API_BASE_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}