Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
428 changes: 428 additions & 0 deletions public/previous-contacts-demo.html

Large diffs are not rendered by default.

50 changes: 50 additions & 0 deletions src/app/api/contacts/previous/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyToken } from '@/lib/auth';
import { Contact } from '@/models/Contact';

export async function GET(request: NextRequest) {
try {
const user = await verifyToken(request);
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const { searchParams } = new URL(request.url);
const callsign = searchParams.get('callsign');
const limit = parseInt(searchParams.get('limit') || '10');

if (!callsign) {
return NextResponse.json({ error: 'Callsign parameter is required' }, { status: 400 });
}

const userId = typeof user.userId === 'string' ? parseInt(user.userId, 10) : user.userId;

const contacts = await Contact.findByCallsignAndUserId(userId, callsign, limit);

// Format the contacts for the response
const formattedContacts = contacts.map(contact => ({
id: contact.id,
datetime: contact.datetime,
band: contact.band,
mode: contact.mode,
frequency: typeof contact.frequency === 'string' ? parseFloat(contact.frequency) : contact.frequency,
rst_sent: contact.rst_sent,
rst_received: contact.rst_received,
name: contact.name,
qth: contact.qth,
notes: contact.notes
}));

return NextResponse.json({
contacts: formattedContacts,
count: formattedContacts.length
});

} catch (error) {
console.error('Error fetching previous contacts:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
76 changes: 76 additions & 0 deletions src/app/new-contact/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,30 @@ import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { ArrowLeft, Loader2, Search, Check, AlertCircle, Radio, Clock } from 'lucide-react';
import Navbar from '@/components/Navbar';
import PreviousContacts from '@/components/PreviousContacts';
import ContactLocationMap from '@/components/ContactLocationMap';


interface Station {
id: number;
callsign: string;
station_name: string;
is_default: boolean;
}

interface PreviousContact {
id: number;
datetime: string;
band: string;
mode: string;
frequency: number | string;
rst_sent?: string;
rst_received?: string;
name?: string;
qth?: string;
notes?: string;
}

export default function NewContactPage() {
const [stations, setStations] = useState<Station[]>([]);
const [stationsLoading, setStationsLoading] = useState(true);
Expand Down Expand Up @@ -56,13 +71,19 @@ export default function NewContactPage() {
country?: string;
error?: string;
} | null>(null);

const [previousContacts, setPreviousContacts] = useState<PreviousContact[]>([]);
const [previousContactsLoading, setPreviousContactsLoading] = useState(false);
const [previousContactsError, setPreviousContactsError] = useState('');

const [currentUser, setCurrentUser] = useState<{
id: number;
email: string;
name: string;
callsign?: string;
grid_locator?: string;
} | null>(null);

const router = useRouter();

const modes = ['SSB', 'CW', 'FM', 'AM', 'RTTY', 'PSK31', 'FT8', 'FT4', 'JT65', 'JT9', 'MFSK', 'OLIVIA', 'CONTESTIA'];
Expand Down Expand Up @@ -182,6 +203,49 @@ export default function NewContactPage() {
return () => window.removeEventListener('keydown', handleKeyDown);
}, [formData.callsign, handleCallsignLookup]);

const fetchPreviousContacts = useCallback(async (callsign: string) => {
if (!callsign.trim()) {
setPreviousContacts([]);
return;
}

setPreviousContactsLoading(true);
setPreviousContactsError('');

try {
const response = await fetch(`/api/contacts/previous?callsign=${encodeURIComponent(callsign)}&limit=10`);

if (response.status === 401) {
// User not authenticated, but don't show error for this
setPreviousContacts([]);
return;
}

const data = await response.json();

if (response.ok) {
setPreviousContacts(data.contacts || []);
} else {
setPreviousContactsError(data.error || 'Failed to fetch previous contacts');
setPreviousContacts([]);
}
} catch {
setPreviousContactsError('Network error while fetching previous contacts');
setPreviousContacts([]);
} finally {
setPreviousContactsLoading(false);
}
}, []);

// Fetch previous contacts when callsign changes
useEffect(() => {
const timeoutId = setTimeout(() => {
fetchPreviousContacts(formData.callsign);
}, 500); // Debounce for 500ms to avoid too many API calls

return () => clearTimeout(timeoutId);
}, [formData.callsign, fetchPreviousContacts]);

const fetchStations = async () => {
try {
setStationsLoading(true);
Expand Down Expand Up @@ -302,6 +366,7 @@ export default function NewContactPage() {
// Clear lookup result when callsign changes
if (name === 'callsign') {
setLookupResult(null);
// Previous contacts will be fetched via useEffect with debounce
}
};

Expand Down Expand Up @@ -586,6 +651,17 @@ export default function NewContactPage() {
)}
</div>

{/* Previous Contacts Section */}
{formData.callsign.trim() && (
<div className="md:col-span-2">
<PreviousContacts
contacts={previousContacts}
loading={previousContactsLoading}
error={previousContactsError}
/>
</div>
)}

<div className="space-y-2">
<Label htmlFor="frequency">Frequency (MHz) *</Label>
<Input
Expand Down
219 changes: 219 additions & 0 deletions src/components/PreviousContacts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import React from 'react';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Clock, Radio, Volume2 } from 'lucide-react';

interface PreviousContact {
id: number;
datetime: string;
band: string;
mode: string;
frequency: number | string;
rst_sent?: string;
rst_received?: string;
name?: string;
qth?: string;
notes?: string;
}

interface PreviousContactsProps {
contacts: PreviousContact[];
loading: boolean;
error?: string;
}

export default function PreviousContacts({ contacts, loading, error }: PreviousContactsProps) {
if (loading) {
return (
<div className="space-y-3 animate-pulse">
<div className="h-4 bg-muted rounded w-40"></div>
<div className="border rounded-md p-4">
<div className="space-y-2">
<div className="h-3 bg-muted rounded w-full"></div>
<div className="h-3 bg-muted rounded w-3/4"></div>
<div className="h-3 bg-muted rounded w-1/2"></div>
</div>
</div>
</div>
);
}

if (error) {
return (
<div className="text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded-md p-3">
Error loading previous contacts: {error}
</div>
);
}

if (contacts.length === 0) {
return (
<div className="text-sm text-muted-foreground bg-muted/30 border border-border rounded-md p-4 text-center">
<Radio className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="font-medium">No previous contacts found</p>
<p className="text-xs mt-1">This will be your first QSO with this station</p>
</div>
);
}

const formatDateTime = (datetime: string) => {
const date = new Date(datetime);
return {
date: date.toLocaleDateString(),
time: date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
};
};

const formatFrequency = (freq: number | string) => {
// Convert to number if it's a string
const frequency = typeof freq === 'string' ? parseFloat(freq) : freq;

// Handle invalid numbers
if (isNaN(frequency) || frequency <= 0) {
return 'Unknown';
}

if (frequency >= 1000) {
return `${(frequency / 1000).toFixed(3)} GHz`;
} else if (frequency >= 1) {
return `${frequency.toFixed(3)} MHz`;
} else {
return `${(frequency * 1000).toFixed(0)} kHz`;
}
};

return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-foreground flex items-center">
<Clock className="h-4 w-4 mr-1" />
Previous Contacts ({contacts.length})
</h4>
{contacts.length === 10 && (
<Badge variant="secondary" className="text-xs">
Showing latest 10
</Badge>
)}
</div>

{/* Mobile-friendly card layout */}
<div className="md:hidden space-y-2">
{contacts.map((contact) => {
const { date, time } = formatDateTime(contact.datetime);
return (
<div key={contact.id} className="border border-border rounded-md p-3 bg-card">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center space-x-2">
<Badge variant="outline" className="text-xs">
{contact.band}
</Badge>
<Badge variant="secondary" className="text-xs">
{contact.mode}
</Badge>
</div>
<div className="text-xs text-muted-foreground text-right">
<div>{date}</div>
<div>{time}</div>
</div>
</div>

<div className="text-sm space-y-1">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Freq:</span>
<span className="font-mono">{formatFrequency(contact.frequency)}</span>
</div>

{(contact.rst_sent || contact.rst_received) && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground flex items-center">
<Volume2 className="h-3 w-3 mr-1" />
RST:
</span>
<span className="font-mono text-xs">
{contact.rst_sent && `Sent: ${contact.rst_sent}`}
{contact.rst_sent && contact.rst_received && ' | '}
{contact.rst_received && `Rcvd: ${contact.rst_received}`}
</span>
</div>
)}

{contact.name && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Name:</span>
<span>{contact.name}</span>
</div>
)}

{contact.qth && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground">QTH:</span>
<span className="truncate ml-2">{contact.qth}</span>
</div>
)}
</div>
</div>
);
})}
</div>

{/* Desktop table layout */}
<div className="hidden md:block border border-border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-24">Date</TableHead>
<TableHead className="w-20">Time</TableHead>
<TableHead className="w-16">Band</TableHead>
<TableHead className="w-16">Mode</TableHead>
<TableHead className="w-24">Frequency</TableHead>
<TableHead className="w-20">RST S/R</TableHead>
<TableHead>Name / QTH</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{contacts.map((contact) => {
const { date, time } = formatDateTime(contact.datetime);
return (
<TableRow key={contact.id}>
<TableCell className="text-xs">{date}</TableCell>
<TableCell className="text-xs font-mono">{time}</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{contact.band}
</Badge>
</TableCell>
<TableCell>
<Badge variant="secondary" className="text-xs">
{contact.mode}
</Badge>
</TableCell>
<TableCell className="text-xs font-mono">
{formatFrequency(contact.frequency)}
</TableCell>
<TableCell className="text-xs font-mono">
{contact.rst_sent && contact.rst_received
? `${contact.rst_sent}/${contact.rst_received}`
: contact.rst_sent || contact.rst_received || '-'
}
</TableCell>
<TableCell className="text-xs">
<div className="space-y-0.5">
{contact.name && (
<div className="font-medium">{contact.name}</div>
)}
{contact.qth && (
<div className="text-muted-foreground truncate">
{contact.qth}
</div>
)}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
);
}
Loading