Iirolta saatu
This commit is contained in:
152
src/screens/PlacesScreen.tsx
Normal file
152
src/screens/PlacesScreen.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useState } from 'react';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { db } from '../database';
|
||||
import { MapPin, Plus, Trash2, Loader2 } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
export function PlacesScreen() {
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [radius, setRadius] = useState(500);
|
||||
const [locationStatus, setLocationStatus] = useState<'idle' | 'fetching' | 'error'>('idle');
|
||||
|
||||
const places = useLiveQuery(() => db.customPlaces.toArray());
|
||||
|
||||
const handleAddPlace = async () => {
|
||||
if (!name.trim()) return;
|
||||
setLocationStatus('fetching');
|
||||
|
||||
try {
|
||||
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
maximumAge: 0
|
||||
});
|
||||
});
|
||||
|
||||
const { latitude, longitude } = position.coords;
|
||||
|
||||
await db.customPlaces.add({
|
||||
name: name.trim(),
|
||||
latitude,
|
||||
longitude,
|
||||
radiusMeters: radius
|
||||
});
|
||||
|
||||
setName('');
|
||||
setRadius(500);
|
||||
setIsAdding(false);
|
||||
setLocationStatus('idle');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setLocationStatus('error');
|
||||
alert('Failed to get location. Please ensure location services are enabled.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (confirm('Are you sure you want to delete this place?')) {
|
||||
await db.customPlaces.delete(id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-zinc-50 dark:bg-zinc-950 px-4 pt-6 pb-24 overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-semibold text-zinc-900 dark:text-zinc-100">Custom Places</h1>
|
||||
<button
|
||||
onClick={() => setIsAdding(!isAdding)}
|
||||
className="p-2 bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400 rounded-full hover:bg-emerald-200 dark:hover:bg-emerald-800/50 transition-colors"
|
||||
>
|
||||
<Plus className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isAdding && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="bg-white dark:bg-zinc-900 p-5 rounded-2xl shadow-sm border border-zinc-200 dark:border-zinc-800 mb-6"
|
||||
>
|
||||
<h2 className="text-lg font-medium text-zinc-900 dark:text-zinc-100 mb-4">Add New Place</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Summer Cottage"
|
||||
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-800 rounded-xl focus:ring-2 focus:ring-emerald-500 outline-none dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1">
|
||||
Radius: {radius} meters
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="50"
|
||||
max="5000"
|
||||
step="50"
|
||||
value={radius}
|
||||
onChange={(e) => setRadius(Number(e.target.value))}
|
||||
className="w-full accent-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleAddPlace}
|
||||
disabled={!name.trim() || locationStatus === 'fetching'}
|
||||
className="w-full flex justify-center items-center py-3 bg-emerald-600 hover:bg-emerald-700 disabled:bg-emerald-400 text-white rounded-xl font-medium transition-colors"
|
||||
>
|
||||
{locationStatus === 'fetching' ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
||||
Getting GPS...
|
||||
</>
|
||||
) : (
|
||||
'Save Current Location'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{places?.map((place) => (
|
||||
<div
|
||||
key={place.id}
|
||||
className="flex items-center justify-between bg-white dark:bg-zinc-900 p-4 rounded-2xl shadow-sm border border-zinc-200 dark:border-zinc-800"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="w-10 h-10 rounded-full bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center mr-4">
|
||||
<MapPin className="w-5 h-5 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-zinc-900 dark:text-zinc-100">{place.name}</h3>
|
||||
<p className="text-sm text-zinc-500">Radius: {place.radiusMeters}m</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => place.id && handleDelete(place.id)}
|
||||
className="p-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-full transition-colors"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{places?.length === 0 && !isAdding && (
|
||||
<div className="text-center py-12 text-zinc-500">
|
||||
No custom places yet. Add your summer cottage or local park!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
180
src/screens/RecordScreen.tsx
Normal file
180
src/screens/RecordScreen.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { Bird, db, CustomPlace } from '../database';
|
||||
import { haversineDistance, reverseGeocode } from '../utils';
|
||||
import { useAppContext } from '../AppContext';
|
||||
import { Search, MapPin, CheckCircle, Loader2 } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
|
||||
export function RecordScreen() {
|
||||
const { language } = useAppContext();
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedBird, setSelectedBird] = useState<Bird | null>(null);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [successMsg, setSuccessMsg] = useState('');
|
||||
const [locationStatus, setLocationStatus] = useState<'idle' | 'fetching' | 'error'>('idle');
|
||||
|
||||
const searchResults = useLiveQuery(
|
||||
() => {
|
||||
if (!query) return [];
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return db.birds.filter(b => b.searchIndex.includes(lowerQuery)).limit(10).toArray();
|
||||
},
|
||||
[query]
|
||||
);
|
||||
|
||||
const handleRecord = async () => {
|
||||
if (!selectedBird) return;
|
||||
setIsRecording(true);
|
||||
setLocationStatus('fetching');
|
||||
|
||||
try {
|
||||
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
maximumAge: 0
|
||||
});
|
||||
});
|
||||
|
||||
const { latitude, longitude } = position.coords;
|
||||
setLocationStatus('idle');
|
||||
|
||||
// Silent Place Match
|
||||
const places = await db.customPlaces.toArray();
|
||||
let matchedPlaceId: number | undefined;
|
||||
|
||||
for (const place of places) {
|
||||
const dist = haversineDistance(latitude, longitude, place.latitude, place.longitude);
|
||||
if (dist <= place.radiusMeters) {
|
||||
matchedPlaceId = place.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const spottingId = await db.spottings.add({
|
||||
birdId: selectedBird.id,
|
||||
timestamp: Date.now(),
|
||||
latitude,
|
||||
longitude,
|
||||
customPlaceId: matchedPlaceId
|
||||
});
|
||||
|
||||
setSuccessMsg(`Recorded ${language === 'fi' ? selectedBird.finnishName : selectedBird.englishName}!`);
|
||||
setSelectedBird(null);
|
||||
setQuery('');
|
||||
|
||||
setTimeout(() => setSuccessMsg(''), 3000);
|
||||
|
||||
// Async Reverse Geocode
|
||||
reverseGeocode(latitude, longitude).then(async (name) => {
|
||||
if (name) {
|
||||
await db.spottings.update(spottingId, { reverseGeocodedName: name });
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setLocationStatus('error');
|
||||
alert('Failed to get location. Please ensure location services are enabled.');
|
||||
} finally {
|
||||
setIsRecording(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-zinc-50 dark:bg-zinc-950 px-4 pt-6 pb-24 overflow-y-auto">
|
||||
<h1 className="text-2xl font-semibold mb-6 text-zinc-900 dark:text-zinc-100">Record Spotting</h1>
|
||||
|
||||
<div className="relative mb-6">
|
||||
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none">
|
||||
<Search className="w-5 h-5 text-zinc-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
if (selectedBird) setSelectedBird(null);
|
||||
}}
|
||||
placeholder={language === 'fi' ? 'Etsi lintua...' : 'Search for a bird...'}
|
||||
className="w-full pl-10 pr-4 py-3 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-xl shadow-sm focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none transition-all dark:text-white"
|
||||
/>
|
||||
|
||||
{query && !selectedBird && searchResults && searchResults.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-xl shadow-lg z-10 max-h-60 overflow-y-auto">
|
||||
{searchResults.map(bird => (
|
||||
<button
|
||||
key={bird.id}
|
||||
onClick={() => {
|
||||
setSelectedBird(bird);
|
||||
setQuery(language === 'fi' ? bird.finnishName : bird.englishName);
|
||||
}}
|
||||
className="w-full text-left px-4 py-3 hover:bg-zinc-50 dark:hover:bg-zinc-800 border-b border-zinc-100 dark:border-zinc-800 last:border-0"
|
||||
>
|
||||
<div className="font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{language === 'fi' ? bird.finnishName : bird.englishName}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-500 italic">{bird.scientificName}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{selectedBird && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="flex flex-col items-center bg-white dark:bg-zinc-900 p-6 rounded-2xl shadow-sm border border-zinc-200 dark:border-zinc-800"
|
||||
>
|
||||
<img
|
||||
src={selectedBird.photoUrl}
|
||||
alt={selectedBird.englishName}
|
||||
className="w-48 h-48 object-cover rounded-full shadow-md mb-4"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{language === 'fi' ? selectedBird.finnishName : selectedBird.englishName}
|
||||
</h2>
|
||||
<p className="text-zinc-500 italic mb-6">{selectedBird.scientificName}</p>
|
||||
|
||||
<button
|
||||
onClick={handleRecord}
|
||||
disabled={isRecording}
|
||||
className="w-full flex items-center justify-center space-x-2 bg-emerald-600 hover:bg-emerald-700 disabled:bg-emerald-400 text-white py-4 rounded-xl font-semibold text-lg transition-colors shadow-sm"
|
||||
>
|
||||
{isRecording ? (
|
||||
<>
|
||||
<Loader2 className="w-6 h-6 animate-spin" />
|
||||
<span>{locationStatus === 'fetching' ? 'Getting GPS...' : 'Saving...'}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MapPin className="w-6 h-6" />
|
||||
<span>Record Now</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{successMsg && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 50 }}
|
||||
className="fixed bottom-20 left-4 right-4 bg-zinc-900 text-white px-4 py-3 rounded-xl shadow-lg flex items-center space-x-3"
|
||||
>
|
||||
<CheckCircle className="w-5 h-5 text-emerald-400" />
|
||||
<span className="font-medium">{successMsg}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
159
src/screens/SettingsScreen.tsx
Normal file
159
src/screens/SettingsScreen.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useState } from 'react';
|
||||
import { useAppContext } from '../AppContext';
|
||||
import { db } from '../database';
|
||||
import { Download, Upload, Globe, Moon, Sun, Monitor } from 'lucide-react';
|
||||
|
||||
export function SettingsScreen() {
|
||||
const { language, setLanguage, themeMode, setThemeMode } = useAppContext();
|
||||
const [backupStatus, setBackupStatus] = useState('');
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const spottings = await db.spottings.toArray();
|
||||
const places = await db.customPlaces.toArray();
|
||||
|
||||
const backup = {
|
||||
version: 1,
|
||||
timestamp: new Date().toISOString(),
|
||||
spottings,
|
||||
places
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `birdspotter_backup_${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
setBackupStatus('Export successful!');
|
||||
setTimeout(() => setBackupStatus(''), 3000);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setBackupStatus('Export failed.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const backup = JSON.parse(text);
|
||||
|
||||
if (backup.spottings) {
|
||||
await db.spottings.clear();
|
||||
await db.spottings.bulkAdd(backup.spottings);
|
||||
}
|
||||
if (backup.places) {
|
||||
await db.customPlaces.clear();
|
||||
await db.customPlaces.bulkAdd(backup.places);
|
||||
}
|
||||
|
||||
setBackupStatus('Import successful!');
|
||||
setTimeout(() => setBackupStatus(''), 3000);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setBackupStatus('Import failed. Invalid file format.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-zinc-50 dark:bg-zinc-950 px-4 pt-6 pb-24 overflow-y-auto">
|
||||
<h1 className="text-2xl font-semibold text-zinc-900 dark:text-zinc-100 mb-6">Settings</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Appearance */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-zinc-500 uppercase tracking-wider mb-3">Appearance</h2>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-sm border border-zinc-200 dark:border-zinc-800 overflow-hidden">
|
||||
<div className="flex items-center justify-between p-4 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<div className="flex items-center text-zinc-900 dark:text-zinc-100">
|
||||
<Globe className="w-5 h-5 mr-3 text-zinc-400" />
|
||||
<span className="font-medium">Language</span>
|
||||
</div>
|
||||
<select
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
className="bg-zinc-50 dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-800 rounded-lg px-3 py-1.5 text-sm outline-none dark:text-white"
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="fi">Suomi</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center text-zinc-900 dark:text-zinc-100">
|
||||
{themeMode === 'dark' ? <Moon className="w-5 h-5 mr-3 text-zinc-400" /> :
|
||||
themeMode === 'light' ? <Sun className="w-5 h-5 mr-3 text-zinc-400" /> :
|
||||
<Monitor className="w-5 h-5 mr-3 text-zinc-400" />}
|
||||
<span className="font-medium">Theme</span>
|
||||
</div>
|
||||
<select
|
||||
value={themeMode}
|
||||
onChange={(e) => setThemeMode(e.target.value)}
|
||||
className="bg-zinc-50 dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-800 rounded-lg px-3 py-1.5 text-sm outline-none dark:text-white"
|
||||
>
|
||||
<option value="system">System</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Backup & Restore */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-zinc-500 uppercase tracking-wider mb-3">Backup & Restore</h2>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-sm border border-zinc-200 dark:border-zinc-800 overflow-hidden p-4 space-y-4">
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Export your spottings and custom places to a JSON file, or restore from a previous backup.
|
||||
</p>
|
||||
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="flex-1 flex items-center justify-center py-2.5 bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700 text-zinc-900 dark:text-zinc-100 rounded-xl font-medium transition-colors text-sm"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export
|
||||
</button>
|
||||
|
||||
<label className="flex-1 flex items-center justify-center py-2.5 bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700 text-zinc-900 dark:text-zinc-100 rounded-xl font-medium transition-colors text-sm cursor-pointer">
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Import
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleImport}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{backupStatus && (
|
||||
<p className="text-sm text-emerald-600 dark:text-emerald-400 text-center font-medium">
|
||||
{backupStatus}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* About */}
|
||||
<section>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-sm border border-zinc-200 dark:border-zinc-800 p-4 text-center">
|
||||
<h3 className="font-semibold text-zinc-900 dark:text-zinc-100">BirdSpotter Web</h3>
|
||||
<p className="text-xs text-zinc-500 mt-1">
|
||||
A Progressive Web App version of the offline-first Flutter design.
|
||||
Uses IndexedDB for local storage and browser Geolocation.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
src/screens/SpottingsScreen.tsx
Normal file
106
src/screens/SpottingsScreen.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useState } from 'react';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { db } from '../database';
|
||||
import { useAppContext } from '../AppContext';
|
||||
import { format } from 'date-fns';
|
||||
import { MapPin, Calendar, Filter } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
export function SpottingsScreen() {
|
||||
const { language } = useAppContext();
|
||||
const [filterText, setFilterText] = useState('');
|
||||
|
||||
const spottings = useLiveQuery(async () => {
|
||||
const allSpottings = await db.spottings.orderBy('timestamp').reverse().toArray();
|
||||
const birds = await db.birds.toArray();
|
||||
const places = await db.customPlaces.toArray();
|
||||
|
||||
const enriched = allSpottings.map(s => {
|
||||
const bird = birds.find(b => b.id === s.birdId);
|
||||
const place = places.find(p => p.id === s.customPlaceId);
|
||||
return { ...s, bird, place };
|
||||
});
|
||||
|
||||
if (!filterText) return enriched;
|
||||
|
||||
const lowerFilter = filterText.toLowerCase();
|
||||
return enriched.filter(s => {
|
||||
const birdName = language === 'fi' ? s.bird?.finnishName : s.bird?.englishName;
|
||||
const matchName = birdName?.toLowerCase().includes(lowerFilter);
|
||||
const matchPlace = s.place?.name.toLowerCase().includes(lowerFilter);
|
||||
const matchGeocode = s.reverseGeocodedName?.toLowerCase().includes(lowerFilter);
|
||||
return matchName || matchPlace || matchGeocode;
|
||||
});
|
||||
}, [language, filterText]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-zinc-50 dark:bg-zinc-950 px-4 pt-6 pb-24 overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-semibold text-zinc-900 dark:text-zinc-100">Spottings</h1>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-6">
|
||||
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none">
|
||||
<Filter className="w-5 h-5 text-zinc-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={filterText}
|
||||
onChange={(e) => setFilterText(e.target.value)}
|
||||
placeholder="Filter by bird or location..."
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-xl shadow-sm focus:ring-2 focus:ring-emerald-500 outline-none transition-all dark:text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{spottings?.map((spotting, idx) => (
|
||||
<motion.div
|
||||
key={spotting.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: idx * 0.05 }}
|
||||
className="flex items-center bg-white dark:bg-zinc-900 p-4 rounded-2xl shadow-sm border border-zinc-200 dark:border-zinc-800"
|
||||
>
|
||||
{spotting.bird && (
|
||||
<img
|
||||
src={spotting.bird.photoUrl}
|
||||
alt={spotting.bird.englishName}
|
||||
className="w-16 h-16 rounded-xl object-cover shadow-sm mr-4"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-zinc-900 dark:text-zinc-100 truncate">
|
||||
{language === 'fi' ? spotting.bird?.finnishName : spotting.bird?.englishName}
|
||||
</h3>
|
||||
<p className="text-xs text-zinc-500 italic truncate mb-1">
|
||||
{spotting.bird?.scientificName}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center text-xs text-zinc-600 dark:text-zinc-400 mt-2 space-x-3">
|
||||
<div className="flex items-center">
|
||||
<Calendar className="w-3.5 h-3.5 mr-1" />
|
||||
{format(new Date(spotting.timestamp), 'MMM d, HH:mm')}
|
||||
</div>
|
||||
{(spotting.place || spotting.reverseGeocodedName) && (
|
||||
<div className="flex items-center truncate">
|
||||
<MapPin className="w-3.5 h-3.5 mr-1 flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
{spotting.place?.name || spotting.reverseGeocodedName || 'Unknown Location'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{spottings?.length === 0 && (
|
||||
<div className="text-center py-12 text-zinc-500">
|
||||
No spottings found. Go record some birds!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user