Iirolta saatu
This commit is contained in:
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# GEMINI_API_KEY: Required for Gemini AI API calls.
|
||||||
|
# AI Studio automatically injects this at runtime from user secrets.
|
||||||
|
# Users configure this via the Secrets panel in the AI Studio UI.
|
||||||
|
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
||||||
|
|
||||||
|
# APP_URL: The URL where this applet is hosted.
|
||||||
|
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
||||||
|
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
||||||
|
APP_URL="MY_APP_URL"
|
||||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
20
README.md
Normal file
20
README.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<div align="center">
|
||||||
|
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
# Run and deploy your AI Studio app
|
||||||
|
|
||||||
|
This contains everything you need to run your app locally.
|
||||||
|
|
||||||
|
View your app in AI Studio: https://ai.studio/apps/2809ec1d-8f16-485c-aa73-0e12baed3c48
|
||||||
|
|
||||||
|
## Run Locally
|
||||||
|
|
||||||
|
**Prerequisites:** Node.js
|
||||||
|
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
`npm install`
|
||||||
|
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||||
|
3. Run the app:
|
||||||
|
`npm run dev`
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>My Google AI Studio App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
7
metadata.json
Normal file
7
metadata.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"name": "BirdSpotter",
|
||||||
|
"description": "An offline-first bird spotting application with local database and GPS matching.",
|
||||||
|
"requestFramePermissions": [
|
||||||
|
"geolocation"
|
||||||
|
]
|
||||||
|
}
|
||||||
5279
package-lock.json
generated
Normal file
5279
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
package.json
Normal file
40
package.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "react-example",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --port=3000 --host=0.0.0.0",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"clean": "rm -rf dist",
|
||||||
|
"lint": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@google/genai": "^1.29.0",
|
||||||
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"better-sqlite3": "^12.4.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"dexie": "^4.3.0",
|
||||||
|
"dexie-react-hooks": "^4.2.0",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"lucide-react": "^0.546.0",
|
||||||
|
"motion": "^12.23.24",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"vite": "^6.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/node": "^22.14.0",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"tailwindcss": "^4.1.14",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "~5.8.2",
|
||||||
|
"vite": "^6.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/App.tsx
Normal file
43
src/App.tsx
Normal 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
63
src/AppContext.tsx
Normal 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;
|
||||||
|
}
|
||||||
40
src/components/BottomNav.tsx
Normal file
40
src/components/BottomNav.tsx
Normal 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
153
src/database.ts
Normal 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
19
src/index.css
Normal 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
10
src/main.tsx
Normal 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>,
|
||||||
|
);
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/utils.ts
Normal file
33
src/utils.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
tsconfig.json
Normal file
26
tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"useDefineForClassFields": false,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"allowJs": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
24
vite.config.ts
Normal file
24
vite.config.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
import {defineConfig, loadEnv} from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig(({mode}) => {
|
||||||
|
const env = loadEnv(mode, '.', '');
|
||||||
|
return {
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
define: {
|
||||||
|
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, '.'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||||
|
// Do not modifyâfile watching is disabled to prevent flickering during agent edits.
|
||||||
|
hmr: process.env.DISABLE_HMR !== 'true',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user