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
270 changes: 169 additions & 101 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useState, useEffect, useCallback, useMemo } from 'react';
import { useState, useEffect, useCallback, useMemo, type ReactNode } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Loader2, Plus, Search } from 'lucide-react';
Expand Down Expand Up @@ -129,6 +129,17 @@ function formatUtc(date: string) {
return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')} UTC`;
}

function MobileCardRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex justify-between items-center gap-3 py-1 text-[14px]">
<span className="text-[11px] uppercase tracking-[0.08em] font-semibold text-fg-2">
{label}
</span>
<span className="text-fg text-right">{children}</span>
</div>
);
}

export default function DashboardPage() {
const [contacts, setContacts] = useState<Contact[]>([]);
const [loading, setLoading] = useState(true);
Expand Down Expand Up @@ -290,7 +301,7 @@ export default function DashboardPage() {
<div className="min-h-screen">
<Navbar />

<main className="max-w-[1400px] mx-auto px-6 lg:px-8 py-8">
<main className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
<PageHeader
title={
<>
Expand Down Expand Up @@ -343,19 +354,16 @@ export default function DashboardPage() {
</div>

{/* Map + Quick Log */}
<div
className="grid gap-5 mb-6"
style={{ gridTemplateColumns: 'minmax(0, 1.55fr) minmax(0, 1fr)' }}
>
<div className="grid gap-5 mb-6 grid-cols-1 lg:[grid-template-columns:minmax(0,1.55fr)_minmax(0,1fr)]">
<Card className="overflow-hidden p-0">
{error && (
<div className="bg-bad/10 border-b border-bad/20 text-bad px-4 py-3 text-sm">
{error}
</div>
)}
<div className="relative h-[460px]">
<DynamicContactMap contacts={contacts} user={user} height="460px" />
<div className="absolute top-4 left-4 flex gap-2 z-[400]">
<div className="relative h-[320px] sm:h-[460px]">
<DynamicContactMap contacts={contacts} user={user} height="100%" />
<div className="absolute top-4 left-4 flex flex-wrap gap-2 z-[400]">
<Chip>
<Dot tone="ok" live />
Worldmap
Expand All @@ -367,7 +375,7 @@ export default function DashboardPage() {
</div>
</Card>

<Card className="p-7 flex flex-col gap-5">
<Card className="p-4 sm:p-7 flex flex-col gap-5">
<div className="flex items-center justify-between">
<h2 className="text-[17px] font-semibold">Quick log</h2>
<Chip variant="accent" size="sm">
Expand Down Expand Up @@ -428,7 +436,7 @@ export default function DashboardPage() {
onChange={setBandRange}
/>
</div>
<div className="flex gap-1.5 p-3 bg-bg-1 border border-line rounded-[12px]">
<div className="grid grid-cols-4 sm:grid-cols-6 md:flex gap-1.5 p-2.5 sm:p-3 bg-bg-1 border border-line rounded-[12px]">
{BAND_ORDER.map((band) => {
const count = bandActivity[band] ?? 0;
const filled = activityBars(count);
Expand All @@ -437,7 +445,7 @@ export default function DashboardPage() {
<div
key={band}
className={[
'flex-1 text-center px-2 py-3 rounded-[8px] border font-mono text-[13px] transition-colors cursor-default',
'md:flex-1 text-center px-2 py-3 rounded-[8px] border font-mono text-[13px] transition-colors cursor-default',
isActive
? 'border-accent bg-accent-soft text-accent-hi'
: 'border-transparent bg-white/[0.015] text-fg-2 hover:bg-white/[0.04] hover:text-fg',
Expand All @@ -463,19 +471,16 @@ export default function DashboardPage() {
</Card>

{/* Log + DXpeditions */}
<div
className="grid gap-5"
style={{ gridTemplateColumns: 'minmax(0, 1fr) 340px' }}
>
<div className="grid gap-5 grid-cols-1 lg:[grid-template-columns:minmax(0,1fr)_340px]">
<Card className="overflow-hidden p-0">
<div className="flex items-center gap-3 px-5 py-4 border-b border-line">
<div className="flex-1 flex items-center gap-2.5 px-3.5 py-2 bg-bg-1 border border-line-hi rounded-[10px]">
<Search className="h-4 w-4 text-fg-2" />
<div className="flex flex-wrap items-center gap-2 sm:gap-3 px-3 sm:px-5 py-3 sm:py-4 border-b border-line">
<div className="flex-1 min-w-0 basis-full sm:basis-auto flex items-center gap-2.5 px-3.5 py-2 bg-bg-1 border border-line-hi rounded-[10px]">
<Search className="h-4 w-4 text-fg-2 shrink-0" />
<input
value={tableSearch}
onChange={(e) => setTableSearch(e.target.value)}
placeholder="Search callsign, name, grid, frequency…"
className="flex-1 bg-transparent border-0 outline-none text-fg text-[15px] placeholder:text-fg-3"
className="flex-1 min-w-0 bg-transparent border-0 outline-none text-fg text-[15px] placeholder:text-fg-3"
/>
<Kbd>/</Kbd>
</div>
Expand All @@ -496,105 +501,168 @@ export default function DashboardPage() {
</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Callsign</TableHead>
<TableHead>When</TableHead>
<TableHead>Band / Mode</TableHead>
<TableHead>Freq</TableHead>
<TableHead>RST</TableHead>
<TableHead>Operator</TableHead>
<TableHead>QSL</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<>
{/* Desktop: classic table */}
<div className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead>Callsign</TableHead>
<TableHead>When</TableHead>
<TableHead>Band / Mode</TableHead>
<TableHead>Freq</TableHead>
<TableHead>RST</TableHead>
<TableHead>Operator</TableHead>
<TableHead>QSL</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-10">
<div className="flex items-center justify-center gap-2 text-fg-2">
<Loader2 className="h-4 w-4 animate-spin" />
Loading contacts…
</div>
</TableCell>
</TableRow>
) : filteredContacts.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-10 text-fg-2">
No contacts match those filters.
</TableCell>
</TableRow>
) : (
filteredContacts.map((contact) => (
<TableRow
key={contact.id}
className="cursor-pointer"
onClick={() => handleContactClick(contact)}
>
<TableCell>
<span className="font-mono font-semibold text-fg text-[16px]">
{contact.callsign}
</span>
</TableCell>
<TableCell>
{formatUtc(contact.datetime)}
<br />
<span className="text-[13px] text-fg-2">
{formatRelativeTime(contact.datetime)}
</span>
</TableCell>
<TableCell>
<div className="flex gap-1.5 flex-wrap">
<Chip size="sm">{contact.band}</Chip>
<Chip size="sm">{contact.mode}</Chip>
</div>
</TableCell>
<TableCell>
<span className="font-mono text-fg-1">
{contact.frequency}
</span>
</TableCell>
<TableCell>
<span className="font-mono text-fg-1">
{contact.rst_sent ?? '-'} / {contact.rst_received ?? '-'}
</span>
</TableCell>
<TableCell>
{contact.name ?? '—'}
{contact.qth ? (
<span className="text-[13px] text-fg-2"> · {contact.qth}</span>
) : null}
</TableCell>
<TableCell>
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
{qslChip(contact)}
<span className="hidden xl:flex items-center gap-1.5">
<LotwSyncIndicator
lotwQslSent={contact.lotw_qsl_sent}
lotwQslRcvd={contact.lotw_qsl_rcvd}
qslLotw={contact.qsl_lotw}
qslLotwDate={contact.qsl_lotw_date}
lotwMatchStatus={contact.lotw_match_status}
contactId={contact.id}
stationId={contact.station_id}
onStatusChange={() => fetchContacts()}
size="sm"
/>
<QRZSyncIndicator
qrzQslSent={contact.qrz_qsl_sent}
qrzQslSentDate={contact.qrz_qsl_sent_date}
qrzQslRcvd={contact.qrz_qsl_rcvd}
qrzQslRcvdDate={contact.qrz_qsl_rcvd_date}
size="sm"
/>
</span>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>

{/* Mobile: stacked QSO cards */}
<div className="md:hidden flex flex-col gap-2.5 p-3">
{loading ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-10">
<div className="flex items-center justify-center gap-2 text-fg-2">
<Loader2 className="h-4 w-4 animate-spin" />
Loading contacts…
</div>
</TableCell>
</TableRow>
<div className="flex items-center justify-center gap-2 py-10 text-fg-2">
<Loader2 className="h-4 w-4 animate-spin" />
Loading contacts…
</div>
) : filteredContacts.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-10 text-fg-2">
No contacts match those filters.
</TableCell>
</TableRow>
<div className="text-center py-10 text-fg-2">
No contacts match those filters.
</div>
) : (
filteredContacts.map((contact) => (
<TableRow
<button
key={contact.id}
className="cursor-pointer"
type="button"
onClick={() => handleContactClick(contact)}
className="rounded-xl border border-line bg-bg-1 p-3.5 text-left cursor-pointer hover:border-line-hi transition-colors"
>
<TableCell>
<span className="font-mono font-semibold text-fg text-[16px]">
<div className="pb-2.5 mb-2 border-b border-line flex justify-between items-center gap-3">
<span className="font-mono font-semibold text-fg text-lg">
{contact.callsign}
</span>
</TableCell>
<TableCell>
{formatUtc(contact.datetime)}
<br />
<span className="text-[13px] text-fg-2">
{formatRelativeTime(contact.datetime)}
{qslChip(contact)}
</div>
<MobileCardRow label="When">
<span>
{formatUtc(contact.datetime)}{' '}
<span className="text-fg-2">· {formatRelativeTime(contact.datetime)}</span>
</span>
</TableCell>
<TableCell>
<div className="flex gap-1.5 flex-wrap">
</MobileCardRow>
<MobileCardRow label="Band / Mode">
<span className="flex gap-1.5 flex-wrap justify-end">
<Chip size="sm">{contact.band}</Chip>
<Chip size="sm">{contact.mode}</Chip>
</div>
</TableCell>
<TableCell>
<span className="font-mono text-fg-1">
{contact.frequency}
</span>
</TableCell>
<TableCell>
</MobileCardRow>
<MobileCardRow label="Freq">
<span className="font-mono text-fg-1">{contact.frequency}</span>
</MobileCardRow>
<MobileCardRow label="RST">
<span className="font-mono text-fg-1">
{contact.rst_sent ?? '-'} / {contact.rst_received ?? '-'}
</span>
</TableCell>
<TableCell>
{contact.name ?? '—'}
{contact.qth ? (
<span className="text-[13px] text-fg-2"> · {contact.qth}</span>
) : null}
</TableCell>
<TableCell>
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
{qslChip(contact)}
<span className="hidden xl:flex items-center gap-1.5">
<LotwSyncIndicator
lotwQslSent={contact.lotw_qsl_sent}
lotwQslRcvd={contact.lotw_qsl_rcvd}
qslLotw={contact.qsl_lotw}
qslLotwDate={contact.qsl_lotw_date}
lotwMatchStatus={contact.lotw_match_status}
contactId={contact.id}
stationId={contact.station_id}
onStatusChange={() => fetchContacts()}
size="sm"
/>
<QRZSyncIndicator
qrzQslSent={contact.qrz_qsl_sent}
qrzQslSentDate={contact.qrz_qsl_sent_date}
qrzQslRcvd={contact.qrz_qsl_rcvd}
qrzQslRcvdDate={contact.qrz_qsl_rcvd_date}
size="sm"
/>
</span>
</div>
</TableCell>
</TableRow>
</MobileCardRow>
<MobileCardRow label="Operator">
<span className="text-right">
{contact.name ?? '—'}
{contact.qth ? (
<span className="text-[13px] text-fg-2"> · {contact.qth}</span>
) : null}
</span>
</MobileCardRow>
</button>
))
)}
</TableBody>
</Table>
</div>
</>
)}

{pagination.pages > 1 && (
Expand Down
Loading
Loading