Iirolta saatu

This commit is contained in:
2026-02-26 18:07:16 +02:00
commit 9fd3acd5d7
20 changed files with 6384 additions and 0 deletions

43
src/App.tsx Normal file
View File

@@ -0,0 +1,43 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect } from 'react';
import { AppProvider } from './AppContext';
import { BottomNav } from './components/BottomNav';
import { RecordScreen } from './screens/RecordScreen';
import { SpottingsScreen } from './screens/SpottingsScreen';
import { PlacesScreen } from './screens/PlacesScreen';
import { SettingsScreen } from './screens/SettingsScreen';
import { initializeDatabase } from './database';
export default function App() {
const [currentTab, setCurrentTab] = useState('record');
const [isInitialized, setIsInitialized] = useState(false);
useEffect(() => {
initializeDatabase().then(() => setIsInitialized(true));
}, []);
if (!isInitialized) {
return (
<div className="flex items-center justify-center h-screen bg-zinc-50 dark:bg-zinc-950">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-emerald-500"></div>
</div>
);
}
return (
<AppProvider>
<div className="h-screen w-full bg-zinc-50 dark:bg-zinc-950 overflow-hidden font-sans text-zinc-900 dark:text-zinc-100">
{currentTab === 'record' && <RecordScreen />}
{currentTab === 'spottings' && <SpottingsScreen />}
{currentTab === 'places' && <PlacesScreen />}
{currentTab === 'settings' && <SettingsScreen />}
<BottomNav currentTab={currentTab} onChange={setCurrentTab} />
</div>
</AppProvider>
);
}

63
src/AppContext.tsx Normal file
View File

@@ -0,0 +1,63 @@
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { db } from './database';
import { useLiveQuery } from 'dexie-react-hooks';
interface AppContextType {
language: string;
setLanguage: (lang: string) => void;
themeMode: string;
setThemeMode: (mode: string) => void;
}
const AppContext = createContext<AppContextType | undefined>(undefined);
export function AppProvider({ children }: { children: ReactNode }) {
const settings = useLiveQuery(() => db.settings.toArray());
const [language, setLanguageState] = useState('fi');
const [themeMode, setThemeModeState] = useState('system');
useEffect(() => {
if (settings) {
const lang = settings.find(s => s.key === 'language')?.value;
const theme = settings.find(s => s.key === 'themeMode')?.value;
if (lang) setLanguageState(lang);
if (theme) setThemeModeState(theme);
}
}, [settings]);
const setLanguage = async (lang: string) => {
await db.settings.put({ key: 'language', value: lang });
setLanguageState(lang);
};
const setThemeMode = async (mode: string) => {
await db.settings.put({ key: 'themeMode', value: mode });
setThemeModeState(mode);
};
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
if (themeMode === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
root.classList.add(systemTheme);
} else {
root.classList.add(themeMode);
}
}, [themeMode]);
return (
<AppContext.Provider value={{ language, setLanguage, themeMode, setThemeMode }}>
{children}
</AppContext.Provider>
);
}
export function useAppContext() {
const context = useContext(AppContext);
if (context === undefined) {
throw new Error('useAppContext must be used within an AppProvider');
}
return context;
}

View File

@@ -0,0 +1,40 @@
import { Home, List, MapPin, Settings } from 'lucide-react';
import { clsx } from 'clsx';
interface BottomNavProps {
currentTab: string;
onChange: (tab: string) => void;
}
export function BottomNav({ currentTab, onChange }: BottomNavProps) {
const tabs = [
{ id: 'record', icon: Home, label: 'Record' },
{ id: 'spottings', icon: List, label: 'Spottings' },
{ id: 'places', icon: MapPin, label: 'Places' },
{ id: 'settings', icon: Settings, label: 'Settings' },
];
return (
<div className="fixed bottom-0 left-0 right-0 bg-white dark:bg-zinc-900 border-t border-zinc-200 dark:border-zinc-800 pb-safe">
<div className="flex justify-around items-center h-16">
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = currentTab === tab.id;
return (
<button
key={tab.id}
onClick={() => onChange(tab.id)}
className={clsx(
'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'
)}
>
<Icon className="w-6 h-6" strokeWidth={isActive ? 2.5 : 2} />
<span className="text-[10px] font-medium">{tab.label}</span>
</button>
);
})}
</div>
</div>
);
}

153
src/database.ts Normal file
View File

@@ -0,0 +1,153 @@
import Dexie, { type Table } from 'dexie';
export interface Bird {
id: number;
scientificName: string;
englishName: string;
finnishName: string;
photoUrl: string;
searchIndex: string;
}
export interface Spotting {
id?: number;
birdId: number;
timestamp: number;
latitude: number;
longitude: number;
reverseGeocodedName?: string;
customPlaceId?: number;
notes?: string;
}
export interface CustomPlace {
id?: number;
name: string;
latitude: number;
longitude: number;
radiusMeters: number;
}
export interface AppSettings {
key: string;
value: string;
}
export class BirdSpotterDB extends Dexie {
birds!: Table<Bird>;
spottings!: Table<Spotting>;
customPlaces!: Table<CustomPlace>;
settings!: Table<AppSettings>;
constructor() {
super('BirdSpotterDB');
this.version(1).stores({
birds: 'id, scientificName, englishName, finnishName, searchIndex',
spottings: '++id, birdId, timestamp, customPlaceId',
customPlaces: '++id, name',
settings: 'key'
});
}
}
export const db = new BirdSpotterDB();
export const MOCK_BIRDS: Bird[] = [
{
id: 1,
scientificName: 'Parus major',
englishName: 'Great Tit',
finnishName: 'Talitiainen',
photoUrl: 'https://images.unsplash.com/photo-1579895315518-2495b682613c?auto=format&fit=crop&q=80&w=600',
searchIndex: 'talitiainen great tit parus major'
},
{
id: 2,
scientificName: 'Anas platyrhynchos',
englishName: 'Mallard',
finnishName: 'Sinisorsa',
photoUrl: 'https://images.unsplash.com/photo-1555841769-735978415f30?auto=format&fit=crop&q=80&w=600',
searchIndex: 'sinisorsa mallard anas platyrhynchos'
},
{
id: 3,
scientificName: 'Erithacus rubecula',
englishName: 'European Robin',
finnishName: 'Punarinta',
photoUrl: 'https://images.unsplash.com/photo-1612444530582-fc66183b16f7?auto=format&fit=crop&q=80&w=600',
searchIndex: 'punarinta european robin erithacus rubecula'
},
{
id: 4,
scientificName: 'Turdus merula',
englishName: 'Common Blackbird',
finnishName: 'Mustarastas',
photoUrl: 'https://images.unsplash.com/photo-1601506521937-0121a7fc2a6b?auto=format&fit=crop&q=80&w=600',
searchIndex: 'mustarastas common blackbird turdus merula'
},
{
id: 5,
scientificName: 'Cyanistes caeruleus',
englishName: 'Blue Tit',
finnishName: 'Sinitiainen',
photoUrl: 'https://images.unsplash.com/photo-1588656811776-3511740d1f7d?auto=format&fit=crop&q=80&w=600',
searchIndex: 'sinitiainen blue tit cyanistes caeruleus'
},
{
id: 6,
scientificName: 'Fringilla coelebs',
englishName: 'Chaffinch',
finnishName: 'Peippo',
photoUrl: 'https://images.unsplash.com/photo-1615456889410-64251c68e747?auto=format&fit=crop&q=80&w=600',
searchIndex: 'peippo chaffinch fringilla coelebs'
},
{
id: 7,
scientificName: 'Pica pica',
englishName: 'Eurasian Magpie',
finnishName: 'Harakka',
photoUrl: 'https://images.unsplash.com/photo-1582200230230-101740941910?auto=format&fit=crop&q=80&w=600',
searchIndex: 'harakka eurasian magpie pica pica'
},
{
id: 8,
scientificName: 'Corvus cornix',
englishName: 'Hooded Crow',
finnishName: 'Varis',
photoUrl: 'https://images.unsplash.com/photo-1599388173468-228712165b4c?auto=format&fit=crop&q=80&w=600',
searchIndex: 'varis hooded crow corvus cornix'
},
{
id: 9,
scientificName: 'Motacilla alba',
englishName: 'White Wagtail',
finnishName: 'Västäräkki',
photoUrl: 'https://images.unsplash.com/photo-1595166068270-2a0752b04f32?auto=format&fit=crop&q=80&w=600',
searchIndex: 'västäräkki white wagtail motacilla alba'
},
{
id: 10,
scientificName: 'Garrulus glandarius',
englishName: 'Eurasian Jay',
finnishName: 'Närhi',
photoUrl: 'https://images.unsplash.com/photo-1598463870624-9457fb1e5c2d?auto=format&fit=crop&q=80&w=600',
searchIndex: 'närhi eurasian jay garrulus glandarius'
}
];
export async function initializeDatabase() {
const count = await db.birds.count();
if (count === 0) {
await db.birds.bulkAdd(MOCK_BIRDS);
}
const langCount = await db.settings.where('key').equals('language').count();
if (langCount === 0) {
await db.settings.add({ key: 'language', value: 'fi' });
}
const themeCount = await db.settings.where('key').equals('themeMode').count();
if (themeCount === 0) {
await db.settings.add({ key: 'themeMode', value: 'system' });
}
}

19
src/index.css Normal file
View File

@@ -0,0 +1,19 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
}
@layer utilities {
.pb-safe {
padding-bottom: env(safe-area-inset-bottom);
}
}
html, body {
height: 100%;
width: 100%;
overflow: hidden;
overscroll-behavior-y: none;
}

10
src/main.tsx Normal file
View File

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

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

33
src/utils.ts Normal file
View File

@@ -0,0 +1,33 @@
export function haversineDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371e3; // Earth radius in meters
const φ1 = (lat1 * Math.PI) / 180;
const φ2 = (lat2 * Math.PI) / 180;
const Δφ = ((lat2 - lat1) * Math.PI) / 180;
const Δλ = ((lon2 - lon1) * 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; // Distance in meters
}
export async function reverseGeocode(lat: number, lon: number): Promise<string | undefined> {
try {
const response = await fetch(
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lon}&zoom=14&addressdetails=1`,
{
headers: {
'Accept-Language': 'en,fi'
}
}
);
if (!response.ok) return undefined;
const data = await response.json();
return data.display_name || data.name || undefined;
} catch (error) {
console.error('Geocoding failed:', error);
return undefined;
}
}