joitain suomennoksia
This commit is contained in:
@@ -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} />
|
||||||
|
|||||||
@@ -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,8 +96,10 @@ 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">
|
||||||
<Search className="w-5 h-5 text-zinc-400" />
|
<Search className="w-5 h-5 text-zinc-400" />
|
||||||
@@ -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>
|
||||||
@@ -130,16 +153,20 @@ export function RecordScreen() {
|
|||||||
exit={{ opacity: 0, y: -10 }}
|
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"
|
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
|
<img
|
||||||
src={selectedBird.photoUrl}
|
src={selectedBird.photoUrl}
|
||||||
alt={selectedBird.englishName}
|
alt={selectedBird.englishName}
|
||||||
className="w-48 h-48 object-cover rounded-full shadow-md mb-4"
|
className="w-48 h-48 object-cover rounded-full shadow-md mb-4"
|
||||||
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>
|
||||||
|
|||||||
@@ -1,39 +1,41 @@
|
|||||||
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 {
|
||||||
const spottings = await db.spottings.toArray();
|
const spottings = await db.spottings.toArray();
|
||||||
const places = await db.customPlaces.toArray();
|
const places = await db.customPlaces.toArray();
|
||||||
|
|
||||||
const backup = {
|
const backup = {
|
||||||
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}
|
||||||
@@ -85,13 +91,17 @@ export function SettingsScreen() {
|
|||||||
<option value="fi">Suomi</option>
|
<option value="fi">Suomi</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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,12 +118,15 @@ 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">
|
||||||
<button
|
<button
|
||||||
onClick={handleExport}
|
onClick={handleExport}
|
||||||
@@ -122,7 +135,7 @@ export function SettingsScreen() {
|
|||||||
<Download className="w-4 h-4 mr-2" />
|
<Download className="w-4 h-4 mr-2" />
|
||||||
Export
|
Export
|
||||||
</button>
|
</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">
|
<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" />
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
Import
|
Import
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -62,31 +70,35 @@ export function SpottingsScreen() {
|
|||||||
className="flex items-center bg-white dark:bg-zinc-900 p-4 rounded-2xl shadow-sm border border-zinc-200 dark:border-zinc-800"
|
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 && (
|
{spotting.bird && (
|
||||||
<img
|
<img
|
||||||
src={spotting.bird.photoUrl}
|
src={spotting.bird.photoUrl}
|
||||||
alt={spotting.bird.englishName}
|
alt={spotting.bird.englishName}
|
||||||
className="w-16 h-16 rounded-xl object-cover shadow-sm mr-4"
|
className="w-16 h-16 rounded-xl object-cover shadow-sm mr-4"
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<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}
|
||||||
</p>
|
</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 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user