From 4c9d072c70c47cf94345b7a8d9f0858597fcf034 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Mon, 18 May 2026 13:42:27 -0500 Subject: [PATCH] feat(dashboard): inline Quick log form on the dashboard hero Replaces the placeholder Quick log card (which only linked to /new-contact) with a real inline form: callsign with debounced QRZ lookup, frequency with auto-band derivation, mode-aware RST defaults, station picker when multiple are configured, and a Save button that posts to /api/contacts. Matches the new-design/dashboard.html mockup. Saving refreshes the contacts table and stat cards on the parent page, then refocuses the callsign input for rapid sequential logging. Also bumps the README stack blurb to Next.js 16 / React 19 / TS 5.8 and notes the Drizzle Kit + raw-pg runtime split. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 6 +- src/app/dashboard/page.tsx | 51 +--- src/components/QuickLogCard.tsx | 417 ++++++++++++++++++++++++++++++++ 3 files changed, 427 insertions(+), 47 deletions(-) create mode 100644 src/components/QuickLogCard.tsx 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. -

- -
- Press L on any page to jump straight in. -
-
-
- Recent -
-
- {contacts.slice(0, 4).map((contact) => ( - - ))} -
-
-
+ { + 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)} + +
+ +
+
+ +
+ 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} + +
+
+ + handleFreqChange(e.target.value)} + placeholder="14.205" + required + /> +
+
+ + +
+
+ + +
+
+ +
+
+ + setRstSent(e.target.value)} + className="text-center" + /> +
+
+ + setRstReceived(e.target.value)} + className="text-center" + /> +
+
+ + {hasMultipleStations ? ( +
+ + +
+ ) : station ? ( +
+ Logging as + {station.callsign} + · {station.station_name} +
+ ) : stations.length === 0 ? ( +
+ + Add a station + before logging. +
+ ) : null} + + {error ? ( +
+ + {error} +
+ ) : null} + + +
+
+ ); +}