diff --git a/README.md b/README.md index 2c23524..692ade8 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,9 @@ A modern, web-based amateur radio logging application built with Next.js and Pos ## Technology Stack -- **Frontend**: Next.js 15 with TypeScript +- **Frontend**: Next.js 16, React 19, TypeScript 5.8 - **Backend**: Next.js API Routes -- **Database**: PostgreSQL with native SQL +- **Database**: PostgreSQL (raw `pg` at runtime); schema-as-code via Drizzle Kit (`drizzle/schema.ts`) - **Authentication**: JWT-based authentication - **Styling**: Tailwind CSS - **Deployment**: Vercel-ready @@ -29,7 +29,7 @@ A modern, web-based amateur radio logging application built with Next.js and Pos ### Prerequisites -- Node.js 18+ +- Node.js 20.9+ (required by Next.js 16) - PostgreSQL 13+ database - Docker and Docker Compose (recommended) - Git diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index b903c7f..16a9247 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -12,6 +12,7 @@ import DXpeditionWidget from '@/components/DXpeditionWidget'; import LotwSyncIndicator from '@/components/LotwSyncIndicator'; import QRZSyncIndicator from '@/components/QRZSyncIndicator'; import Pagination from '@/components/Pagination'; +import QuickLogCard from '@/components/QuickLogCard'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Chip } from '@/components/ui/chip'; @@ -375,50 +376,12 @@ export default function DashboardPage() { - - - Quick log - - - Live - - - - Need more fields? Open the full new-contact form for callsign - lookup, RST defaults by mode, station picker, and notes. - - - - - Log a QSO - - - - Press L on any page to jump straight in. - - - - Recent - - - {contacts.slice(0, 4).map((contact) => ( - handleContactClick(contact)} - className="flex justify-between items-center px-3 py-2 rounded-[10px] bg-bg-1 border border-line text-left hover:border-line-hi transition-colors cursor-pointer" - > - - {contact.callsign} - - - {contact.band} · {contact.mode} · {formatUtc(contact.datetime)} - - - ))} - - - + { + fetchContacts(1, pagination.limit); + fetchStats(); + }} + /> {/* Band activity */} diff --git a/src/components/QuickLogCard.tsx b/src/components/QuickLogCard.tsx new file mode 100644 index 0000000..bdd2997 --- /dev/null +++ b/src/components/QuickLogCard.tsx @@ -0,0 +1,417 @@ +'use client'; + +import { useState, useEffect, useCallback, useRef } from 'react'; +import Link from 'next/link'; +import { Loader2, Check, AlertCircle } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { Chip } from '@/components/ui/chip'; +import { Dot } from '@/components/ui/dot'; +import { Input } from '@/components/ui/input'; +import { Kbd } from '@/components/ui/kbd'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { cn } from '@/lib/utils'; + +interface Station { + id: number; + callsign: string; + station_name: string; + is_default: boolean; +} + +interface LookupResult { + found: boolean; + name?: string; + qth?: string; + grid_locator?: string; + latitude?: number; + longitude?: number; + country?: string; + error?: string; +} + +const MODES = ['SSB', 'CW', 'FT8', 'FT4', 'RTTY', 'PSK31', 'AM', 'FM'] as const; +const BANDS = ['160M', '80M', '60M', '40M', '30M', '20M', '17M', '15M', '12M', '10M', '6M', '2M', '1.25M', '70CM'] as const; + +function freqToBand(freq: number): string { + if (freq >= 1.8 && freq <= 2.0) return '160M'; + if (freq >= 3.5 && freq <= 4.0) return '80M'; + if (freq >= 5.33 && freq <= 5.408) return '60M'; + if (freq >= 7.0 && freq <= 7.3) return '40M'; + if (freq >= 10.1 && freq <= 10.15) return '30M'; + if (freq >= 14.0 && freq <= 14.35) return '20M'; + if (freq >= 18.068 && freq <= 18.168) return '17M'; + if (freq >= 21.0 && freq <= 21.45) return '15M'; + if (freq >= 24.89 && freq <= 24.99) return '12M'; + if (freq >= 28.0 && freq <= 29.7) return '10M'; + if (freq >= 50.0 && freq <= 54.0) return '6M'; + if (freq >= 144.0 && freq <= 148.0) return '2M'; + if (freq >= 219.0 && freq <= 225.0) return '1.25M'; + if (freq >= 420.0 && freq <= 450.0) return '70CM'; + return ''; +} + +function defaultRstForMode(mode: string): string { + if (mode === 'CW') return '599'; + if (['FT8', 'FT4', 'PSK31', 'RTTY', 'MFSK', 'OLIVIA', 'CONTESTIA'].includes(mode)) return '-10'; + return '59'; +} + +function formatUtcClock(d: Date): string { + const hh = String(d.getUTCHours()).padStart(2, '0'); + const mm = String(d.getUTCMinutes()).padStart(2, '0'); + return `${hh}:${mm} UTC`; +} + +interface QuickLogCardProps { + onSaved?: () => void; +} + +export default function QuickLogCard({ onSaved }: QuickLogCardProps) { + const [callsign, setCallsign] = useState(''); + const [frequency, setFrequency] = useState(''); + const [mode, setMode] = useState('SSB'); + const [band, setBand] = useState(''); + const [rstSent, setRstSent] = useState('59'); + const [rstReceived, setRstReceived] = useState('59'); + + const [stations, setStations] = useState([]); + const [selectedStationId, setSelectedStationId] = useState(''); + + const [lookupResult, setLookupResult] = useState(null); + const [lookupLoading, setLookupLoading] = useState(false); + + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(''); + const [savedFlash, setSavedFlash] = useState(false); + + const [utcNow, setUtcNow] = useState(() => new Date()); + const callsignRef = useRef(null); + + useEffect(() => { + const id = setInterval(() => setUtcNow(new Date()), 1000); + return () => clearInterval(id); + }, []); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const response = await fetch('/api/stations'); + if (!response.ok) return; + const data = await response.json(); + if (cancelled) return; + const list: Station[] = data.stations || []; + setStations(list); + const def = list.find((s) => s.is_default) ?? list[0]; + if (def) setSelectedStationId(def.id.toString()); + } catch { + /* noop */ + } + })(); + return () => { + cancelled = true; + }; + }, []); + + // Debounced callsign lookup + useEffect(() => { + const trimmed = callsign.trim(); + if (!trimmed) { + setLookupResult(null); + return; + } + const id = setTimeout(async () => { + setLookupLoading(true); + try { + const response = await fetch('/api/lookup/callsign', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ callsign: trimmed }), + }); + const data = await response.json(); + if (response.ok) { + setLookupResult(data); + } else { + setLookupResult({ found: false, error: data.error || 'Lookup failed' }); + } + } catch { + setLookupResult({ found: false, error: 'Network error during lookup' }); + } finally { + setLookupLoading(false); + } + }, 350); + return () => clearTimeout(id); + }, [callsign]); + + const handleFreqChange = (value: string) => { + setFrequency(value); + const f = parseFloat(value); + if (Number.isFinite(f)) { + const derived = freqToBand(f); + if (derived) setBand(derived); + } + }; + + const handleModeChange = useCallback((value: string) => { + setMode(value); + const rst = defaultRstForMode(value); + setRstSent(rst); + setRstReceived(rst); + }, []); + + const resetForm = () => { + setCallsign(''); + setLookupResult(null); + // keep frequency/mode/band/rst — most ops stay on the same band + callsignRef.current?.focus(); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + const trimmedCall = callsign.trim().toUpperCase(); + if (!trimmedCall) { + setError('Callsign is required'); + return; + } + const freq = parseFloat(frequency); + if (!Number.isFinite(freq)) { + setError('Frequency is required'); + return; + } + const resolvedBand = band || freqToBand(freq); + if (!resolvedBand) { + setError('Frequency is outside amateur radio bands'); + return; + } + + setIsSaving(true); + try { + const response = await fetch('/api/contacts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + callsign: trimmedCall, + frequency: freq, + mode, + band: resolvedBand, + datetime: new Date().toISOString(), + rst_sent: rstSent, + rst_received: rstReceived, + name: lookupResult?.found ? lookupResult.name : undefined, + qth: lookupResult?.found ? lookupResult.qth : undefined, + grid_locator: lookupResult?.found ? lookupResult.grid_locator : undefined, + latitude: lookupResult?.found ? lookupResult.latitude : undefined, + longitude: lookupResult?.found ? lookupResult.longitude : undefined, + station_id: selectedStationId ? Number.parseInt(selectedStationId, 10) : undefined, + }), + }); + const data = await response.json(); + if (!response.ok) { + setError(data.error || 'Failed to save QSO'); + return; + } + setSavedFlash(true); + setTimeout(() => setSavedFlash(false), 1800); + resetForm(); + onSaved?.(); + } catch { + setError('Network error. Please try again.'); + } finally { + setIsSaving(false); + } + }; + + const station = stations.find((s) => s.id.toString() === selectedStationId); + const hasMultipleStations = stations.length > 1; + + return ( + + + Quick log + + + Live · {formatUtcClock(utcNow)} + + + + + + Callsign + + setCallsign(e.target.value.toUpperCase())} + placeholder="W1AW" + autoComplete="off" + spellCheck={false} + required + /> + {lookupLoading ? ( + + ) : null} + + + + {lookupResult?.found ? ( + + + {lookupResult.name ?? callsign} + {lookupResult.qth ? · {lookupResult.qth} : null} + + + {[lookupResult.grid_locator, lookupResult.country].filter(Boolean).join(' · ') || '—'} + + + ) : lookupResult && !lookupResult.found && callsign.trim() ? ( + + + {lookupResult.error || 'Callsign not found'} + + ) : null} + + + + Freq (MHz) + handleFreqChange(e.target.value)} + placeholder="14.205" + required + /> + + + Mode + + + + + + {MODES.map((m) => ( + {m} + ))} + + + + + Band + + + + + + {BANDS.map((b) => ( + {b.toLowerCase()} + ))} + + + + + + + + RST sent + setRstSent(e.target.value)} + className="text-center" + /> + + + RST rcvd + setRstReceived(e.target.value)} + className="text-center" + /> + + + + {hasMultipleStations ? ( + + Station + + + + + + {stations.map((s) => ( + + {s.station_name} + {s.callsign} + + ))} + + + + ) : station ? ( + + Logging as + {station.callsign} + · {station.station_name} + + ) : stations.length === 0 ? ( + + + Add a station + before logging. + + ) : null} + + {error ? ( + + + {error} + + ) : null} + + + {isSaving ? ( + <> + + Saving… + > + ) : savedFlash ? ( + <> + + Saved + > + ) : ( + <> + + Save QSO + ⏎ + > + )} + + + + ); +}
- Need more fields? Open the full new-contact form for callsign - lookup, RST defaults by mode, station picker, and notes. -