joitain suomennoksia

This commit is contained in:
2026-03-11 17:12:00 +00:00
parent ae946a5e64
commit d756803942
4 changed files with 188 additions and 128 deletions

View File

@@ -1,5 +1,5 @@
import { Home, List, MapPin, Settings } from 'lucide-react'; import { Home, List, MapPin, Settings } from "lucide-react";
import { clsx } from 'clsx'; import { clsx } from "clsx";
interface BottomNavProps { interface BottomNavProps {
currentTab: string; currentTab: string;
@@ -8,10 +8,10 @@ interface BottomNavProps {
export function BottomNav({ currentTab, onChange }: BottomNavProps) { export function BottomNav({ currentTab, onChange }: BottomNavProps) {
const tabs = [ const tabs = [
{ id: 'record', icon: Home, label: 'Record' }, { id: "record", icon: Home, label: "Tallenna" },
{ id: 'spottings', icon: List, label: 'Spottings' }, { id: "spottings", icon: List, label: "Havainnot" },
{ id: 'places', icon: MapPin, label: 'Places' }, { id: "places", icon: MapPin, label: "Paikat" },
{ id: 'settings', icon: Settings, label: 'Settings' }, { id: "settings", icon: Settings, label: "Asetukset" },
]; ];
return ( return (
@@ -25,8 +25,10 @@ export function BottomNav({ currentTab, onChange }: BottomNavProps) {
key={tab.id} key={tab.id}
onClick={() => onChange(tab.id)} onClick={() => onChange(tab.id)}
className={clsx( className={clsx(
'flex flex-col items-center justify-center w-full h-full space-y-1 transition-colors', "flex flex-col items-center justify-center w-full h-full space-y-1 transition-colors",
isActive ? 'text-emerald-600 dark:text-emerald-500' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-300' isActive
? "text-emerald-600 dark:text-emerald-500"
: "text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-300",
)} )}
> >
<Icon className="w-6 h-6" strokeWidth={isActive ? 2.5 : 2} /> <Icon className="w-6 h-6" strokeWidth={isActive ? 2.5 : 2} />

View File

@@ -1,51 +1,60 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from "react";
import { useLiveQuery } from 'dexie-react-hooks'; import { useLiveQuery } from "dexie-react-hooks";
import { Bird, db, CustomPlace } from '../database'; import { Bird, db, CustomPlace } from "../database";
import { haversineDistance, reverseGeocode } from '../utils'; import { haversineDistance, reverseGeocode } from "../utils";
import { useAppContext } from '../AppContext'; import { useAppContext } from "../AppContext";
import { Search, MapPin, CheckCircle, Loader2 } from 'lucide-react'; import { Search, MapPin, CheckCircle, Loader2 } from "lucide-react";
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from "motion/react";
export function RecordScreen() { export function RecordScreen() {
const { language } = useAppContext(); const { language } = useAppContext();
const [query, setQuery] = useState(''); const [query, setQuery] = useState("");
const [selectedBird, setSelectedBird] = useState<Bird | null>(null); const [selectedBird, setSelectedBird] = useState<Bird | null>(null);
const [isRecording, setIsRecording] = useState(false); const [isRecording, setIsRecording] = useState(false);
const [successMsg, setSuccessMsg] = useState(''); const [successMsg, setSuccessMsg] = useState("");
const [locationStatus, setLocationStatus] = useState<'idle' | 'fetching' | 'error'>('idle'); const [locationStatus, setLocationStatus] = useState<
"idle" | "fetching" | "error"
>("idle");
const searchResults = useLiveQuery( const searchResults = useLiveQuery(() => {
() => { if (!query) return [];
if (!query) return []; const lowerQuery = query.toLowerCase();
const lowerQuery = query.toLowerCase(); return db.birds
return db.birds.filter(b => b.searchIndex.includes(lowerQuery)).limit(10).toArray(); .filter((b) => b.searchIndex.includes(lowerQuery))
}, .limit(10)
[query] .toArray();
); }, [query]);
const handleRecord = async () => { const handleRecord = async () => {
if (!selectedBird) return; if (!selectedBird) return;
setIsRecording(true); setIsRecording(true);
setLocationStatus('fetching'); setLocationStatus("fetching");
try { try {
const position = await new Promise<GeolocationPosition>((resolve, reject) => { const position = await new Promise<GeolocationPosition>(
navigator.geolocation.getCurrentPosition(resolve, reject, { (resolve, reject) => {
enableHighAccuracy: true, navigator.geolocation.getCurrentPosition(resolve, reject, {
timeout: 10000, enableHighAccuracy: true,
maximumAge: 0 timeout: 10000,
}); maximumAge: 0,
}); });
},
);
const { latitude, longitude } = position.coords; const { latitude, longitude } = position.coords;
setLocationStatus('idle'); setLocationStatus("idle");
// Silent Place Match // Silent Place Match
const places = await db.customPlaces.toArray(); const places = await db.customPlaces.toArray();
let matchedPlaceId: number | undefined; let matchedPlaceId: number | undefined;
for (const place of places) { for (const place of places) {
const dist = haversineDistance(latitude, longitude, place.latitude, place.longitude); const dist = haversineDistance(
latitude,
longitude,
place.latitude,
place.longitude,
);
if (dist <= place.radiusMeters) { if (dist <= place.radiusMeters) {
matchedPlaceId = place.id; matchedPlaceId = place.id;
break; break;
@@ -57,14 +66,16 @@ export function RecordScreen() {
timestamp: Date.now(), timestamp: Date.now(),
latitude, latitude,
longitude, longitude,
customPlaceId: matchedPlaceId customPlaceId: matchedPlaceId,
}); });
setSuccessMsg(`Recorded ${language === 'fi' ? selectedBird.finnishName : selectedBird.englishName}!`); setSuccessMsg(
`Recorded ${language === "fi" ? selectedBird.finnishName : selectedBird.englishName}!`,
);
setSelectedBird(null); setSelectedBird(null);
setQuery(''); setQuery("");
setTimeout(() => setSuccessMsg(''), 3000); setTimeout(() => setSuccessMsg(""), 3000);
// Async Reverse Geocode // Async Reverse Geocode
reverseGeocode(latitude, longitude).then(async (name) => { reverseGeocode(latitude, longitude).then(async (name) => {
@@ -72,11 +83,12 @@ export function RecordScreen() {
await db.spottings.update(spottingId, { reverseGeocodedName: name }); await db.spottings.update(spottingId, { reverseGeocodedName: name });
} }
}); });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
setLocationStatus('error'); setLocationStatus("error");
alert('Failed to get location. Please ensure location services are enabled.'); alert(
"Failed to get location. Please ensure location services are enabled.",
);
} finally { } finally {
setIsRecording(false); setIsRecording(false);
} }
@@ -84,7 +96,9 @@ export function RecordScreen() {
return ( 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 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> <h1 className="text-2xl font-semibold mb-6 text-zinc-900 dark:text-zinc-100">
Tallenna havainto
</h1>
<div className="relative mb-6"> <div className="relative mb-6">
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none"> <div className="absolute inset-y-0 left-3 flex items-center pointer-events-none">
@@ -97,29 +111,38 @@ export function RecordScreen() {
setQuery(e.target.value); setQuery(e.target.value);
if (selectedBird) setSelectedBird(null); if (selectedBird) setSelectedBird(null);
}} }}
placeholder={language === 'fi' ? 'Etsi lintua...' : 'Search for a bird...'} 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" 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 && ( {query &&
<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"> !selectedBird &&
{searchResults.map(bird => ( searchResults &&
<button searchResults.length > 0 && (
key={bird.id} <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">
onClick={() => { {searchResults.map((bird) => (
setSelectedBird(bird); <button
setQuery(language === 'fi' ? bird.finnishName : bird.englishName); key={bird.id}
}} onClick={() => {
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" setSelectedBird(bird);
> setQuery(
<div className="font-medium text-zinc-900 dark:text-zinc-100"> language === "fi" ? bird.finnishName : bird.englishName,
{language === 'fi' ? bird.finnishName : bird.englishName} );
</div> }}
<div className="text-sm text-zinc-500 italic">{bird.scientificName}</div> 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"
</button> >
))} <div className="font-medium text-zinc-900 dark:text-zinc-100">
</div> {language === "fi" ? bird.finnishName : bird.englishName}
)} </div>
<div className="text-sm text-zinc-500 italic">
{bird.scientificName}
</div>
</button>
))}
</div>
)}
</div> </div>
<AnimatePresence> <AnimatePresence>
@@ -137,9 +160,13 @@ export function RecordScreen() {
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
/> />
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100"> <h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
{language === 'fi' ? selectedBird.finnishName : selectedBird.englishName} {language === "fi"
? selectedBird.finnishName
: selectedBird.englishName}
</h2> </h2>
<p className="text-zinc-500 italic mb-6">{selectedBird.scientificName}</p> <p className="text-zinc-500 italic mb-6">
{selectedBird.scientificName}
</p>
<button <button
onClick={handleRecord} onClick={handleRecord}
@@ -149,12 +176,16 @@ export function RecordScreen() {
{isRecording ? ( {isRecording ? (
<> <>
<Loader2 className="w-6 h-6 animate-spin" /> <Loader2 className="w-6 h-6 animate-spin" />
<span>{locationStatus === 'fetching' ? 'Getting GPS...' : 'Saving...'}</span> <span>
{locationStatus === "fetching"
? "Getting GPS..."
: "Saving..."}
</span>
</> </>
) : ( ) : (
<> <>
<MapPin className="w-6 h-6" /> <MapPin className="w-6 h-6" />
<span>Record Now</span> <span>Tallenna nyt</span>
</> </>
)} )}
</button> </button>

View File

@@ -1,11 +1,11 @@
import { useState } from 'react'; import { useState } from "react";
import { useAppContext } from '../AppContext'; import { useAppContext } from "../AppContext";
import { db } from '../database'; import { db } from "../database";
import { Download, Upload, Globe, Moon, Sun, Monitor } from 'lucide-react'; import { Download, Upload, Globe, Moon, Sun, Monitor } from "lucide-react";
export function SettingsScreen() { export function SettingsScreen() {
const { language, setLanguage, themeMode, setThemeMode } = useAppContext(); const { language, setLanguage, themeMode, setThemeMode } = useAppContext();
const [backupStatus, setBackupStatus] = useState(''); const [backupStatus, setBackupStatus] = useState("");
const handleExport = async () => { const handleExport = async () => {
try { try {
@@ -16,24 +16,26 @@ export function SettingsScreen() {
version: 1, version: 1,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
spottings, spottings,
places places,
}; };
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' }); const blob = new Blob([JSON.stringify(backup, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = `birdspotter_backup_${new Date().toISOString().split('T')[0]}.json`; a.download = `birdspotter_backup_${new Date().toISOString().split("T")[0]}.json`;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
setBackupStatus('Export successful!'); setBackupStatus("Export successful!");
setTimeout(() => setBackupStatus(''), 3000); setTimeout(() => setBackupStatus(""), 3000);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
setBackupStatus('Export failed.'); setBackupStatus("Export failed.");
} }
}; };
@@ -54,27 +56,31 @@ export function SettingsScreen() {
await db.customPlaces.bulkAdd(backup.places); await db.customPlaces.bulkAdd(backup.places);
} }
setBackupStatus('Import successful!'); setBackupStatus("Import successful!");
setTimeout(() => setBackupStatus(''), 3000); setTimeout(() => setBackupStatus(""), 3000);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
setBackupStatus('Import failed. Invalid file format.'); setBackupStatus("Import failed. Invalid file format.");
} }
}; };
return ( 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 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> <h1 className="text-2xl font-semibold text-zinc-900 dark:text-zinc-100 mb-6">
Asetukset
</h1>
<div className="space-y-6"> <div className="space-y-6">
{/* Appearance */} {/* Appearance */}
<section> <section>
<h2 className="text-sm font-semibold text-zinc-500 uppercase tracking-wider mb-3">Appearance</h2> <h2 className="text-sm font-semibold text-zinc-500 uppercase tracking-wider mb-3">
Ulkoasu
</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="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 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"> <div className="flex items-center text-zinc-900 dark:text-zinc-100">
<Globe className="w-5 h-5 mr-3 text-zinc-400" /> <Globe className="w-5 h-5 mr-3 text-zinc-400" />
<span className="font-medium">Language</span> <span className="font-medium">Kieli</span>
</div> </div>
<select <select
value={language} value={language}
@@ -88,10 +94,14 @@ export function SettingsScreen() {
<div className="flex items-center justify-between p-4"> <div className="flex items-center justify-between p-4">
<div className="flex items-center text-zinc-900 dark:text-zinc-100"> <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 === "dark" ? (
themeMode === 'light' ? <Sun className="w-5 h-5 mr-3 text-zinc-400" /> : <Moon className="w-5 h-5 mr-3 text-zinc-400" />
<Monitor className="w-5 h-5 mr-3 text-zinc-400" />} ) : themeMode === "light" ? (
<span className="font-medium">Theme</span> <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">Teema</span>
</div> </div>
<select <select
value={themeMode} value={themeMode}
@@ -108,10 +118,13 @@ export function SettingsScreen() {
{/* Backup & Restore */} {/* Backup & Restore */}
<section> <section>
<h2 className="text-sm font-semibold text-zinc-500 uppercase tracking-wider mb-3">Backup & Restore</h2> <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"> <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"> <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. Export your spottings and custom places to a JSON file, or restore
from a previous backup.
</p> </p>
<div className="flex space-x-3"> <div className="flex space-x-3">
@@ -146,7 +159,9 @@ export function SettingsScreen() {
{/* About */} {/* About */}
<section> <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"> <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> <h3 className="font-semibold text-zinc-900 dark:text-zinc-100">
BirdSpotter Web
</h3>
<p className="text-xs text-zinc-500 mt-1"> <p className="text-xs text-zinc-500 mt-1">
A Progressive Web App version of the offline-first Flutter design. A Progressive Web App version of the offline-first Flutter design.
Uses IndexedDB for local storage and browser Geolocation. Uses IndexedDB for local storage and browser Geolocation.

View File

@@ -1,34 +1,40 @@
import { useState } from 'react'; import { useState } from "react";
import { useLiveQuery } from 'dexie-react-hooks'; import { useLiveQuery } from "dexie-react-hooks";
import { db } from '../database'; import { db } from "../database";
import { useAppContext } from '../AppContext'; import { useAppContext } from "../AppContext";
import { format } from 'date-fns'; import { format } from "date-fns";
import { MapPin, Calendar, Filter } from 'lucide-react'; import { MapPin, Calendar, Filter } from "lucide-react";
import { motion } from 'motion/react'; import { motion } from "motion/react";
export function SpottingsScreen() { export function SpottingsScreen() {
const { language } = useAppContext(); const { language } = useAppContext();
const [filterText, setFilterText] = useState(''); const [filterText, setFilterText] = useState("");
const spottings = useLiveQuery(async () => { const spottings = useLiveQuery(async () => {
const allSpottings = await db.spottings.orderBy('timestamp').reverse().toArray(); const allSpottings = await db.spottings
.orderBy("timestamp")
.reverse()
.toArray();
const birds = await db.birds.toArray(); const birds = await db.birds.toArray();
const places = await db.customPlaces.toArray(); const places = await db.customPlaces.toArray();
const enriched = allSpottings.map(s => { const enriched = allSpottings.map((s) => {
const bird = birds.find(b => b.id === s.birdId); const bird = birds.find((b) => b.id === s.birdId);
const place = places.find(p => p.id === s.customPlaceId); const place = places.find((p) => p.id === s.customPlaceId);
return { ...s, bird, place }; return { ...s, bird, place };
}); });
if (!filterText) return enriched; if (!filterText) return enriched;
const lowerFilter = filterText.toLowerCase(); const lowerFilter = filterText.toLowerCase();
return enriched.filter(s => { return enriched.filter((s) => {
const birdName = language === 'fi' ? s.bird?.finnishName : s.bird?.englishName; const birdName =
language === "fi" ? s.bird?.finnishName : s.bird?.englishName;
const matchName = birdName?.toLowerCase().includes(lowerFilter); const matchName = birdName?.toLowerCase().includes(lowerFilter);
const matchPlace = s.place?.name.toLowerCase().includes(lowerFilter); const matchPlace = s.place?.name.toLowerCase().includes(lowerFilter);
const matchGeocode = s.reverseGeocodedName?.toLowerCase().includes(lowerFilter); const matchGeocode = s.reverseGeocodedName
?.toLowerCase()
.includes(lowerFilter);
return matchName || matchPlace || matchGeocode; return matchName || matchPlace || matchGeocode;
}); });
}, [language, filterText]); }, [language, filterText]);
@@ -36,7 +42,9 @@ export function SpottingsScreen() {
return ( 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 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"> <div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-semibold text-zinc-900 dark:text-zinc-100">Spottings</h1> <h1 className="text-2xl font-semibold text-zinc-900 dark:text-zinc-100">
Havainnot
</h1>
</div> </div>
<div className="relative mb-6"> <div className="relative mb-6">
@@ -71,7 +79,9 @@ export function SpottingsScreen() {
)} )}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-zinc-900 dark:text-zinc-100 truncate"> <h3 className="text-base font-semibold text-zinc-900 dark:text-zinc-100 truncate">
{language === 'fi' ? spotting.bird?.finnishName : spotting.bird?.englishName} {language === "fi"
? spotting.bird?.finnishName
: spotting.bird?.englishName}
</h3> </h3>
<p className="text-xs text-zinc-500 italic truncate mb-1"> <p className="text-xs text-zinc-500 italic truncate mb-1">
{spotting.bird?.scientificName} {spotting.bird?.scientificName}
@@ -80,13 +90,15 @@ export function SpottingsScreen() {
<div className="flex items-center text-xs text-zinc-600 dark:text-zinc-400 mt-2 space-x-3"> <div className="flex items-center text-xs text-zinc-600 dark:text-zinc-400 mt-2 space-x-3">
<div className="flex items-center"> <div className="flex items-center">
<Calendar className="w-3.5 h-3.5 mr-1" /> <Calendar className="w-3.5 h-3.5 mr-1" />
{format(new Date(spotting.timestamp), 'MMM d, HH:mm')} {format(new Date(spotting.timestamp), "MMM d, HH:mm")}
</div> </div>
{(spotting.place || spotting.reverseGeocodedName) && ( {(spotting.place || spotting.reverseGeocodedName) && (
<div className="flex items-center truncate"> <div className="flex items-center truncate">
<MapPin className="w-3.5 h-3.5 mr-1 flex-shrink-0" /> <MapPin className="w-3.5 h-3.5 mr-1 flex-shrink-0" />
<span className="truncate"> <span className="truncate">
{spotting.place?.name || spotting.reverseGeocodedName || 'Unknown Location'} {spotting.place?.name ||
spotting.reverseGeocodedName ||
"Unknown Location"}
</span> </span>
</div> </div>
)} )}
@@ -97,7 +109,7 @@ export function SpottingsScreen() {
{spottings?.length === 0 && ( {spottings?.length === 0 && (
<div className="text-center py-12 text-zinc-500"> <div className="text-center py-12 text-zinc-500">
No spottings found. Go record some birds! Ei vielä havaintoja.
</div> </div>
)} )}
</div> </div>