Same reasoning as the selected-rows export: ExportADIFFiltered was called with includeAppFields=false, so a filtered export lost the award references. A second entry, next to the standard one.
289 lines
12 KiB
TypeScript
289 lines
12 KiB
TypeScript
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2, MapPin } from 'lucide-react';
|
|
import { useI18n } from '@/lib/i18n';
|
|
|
|
export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
|
|
|
|
type Props = {
|
|
menu: QSOMenuState;
|
|
onClose: () => void;
|
|
onUpdateFromCty?: (ids: number[]) => void;
|
|
onUpdateFromQRZ?: (ids: number[]) => void;
|
|
onUpdateFromClublog?: (ids: number[]) => void;
|
|
// Only passed once the offline US county database has been downloaded —
|
|
// an entry that can only ever answer "no database" is not worth a line here.
|
|
onUpdateCountyFromULS?: (ids: number[]) => void;
|
|
onSendTo?: (service: string, ids: number[]) => void;
|
|
onSendRecording?: (ids: number[]) => void;
|
|
onSendEQSL?: (ids: number[]) => void;
|
|
onBulkEdit?: (ids: number[]) => void;
|
|
onExportSelected?: (ids: number[]) => void;
|
|
// Same rows, but keeping the APP_OPSLOG_* tags — award references above all.
|
|
onExportSelectedFull?: (ids: number[]) => void;
|
|
onExportSelectedFields?: (ids: number[]) => void;
|
|
onExportFiltered?: () => void;
|
|
onExportFilteredFull?: () => void;
|
|
onExportCabrilloSelected?: (ids: number[]) => void;
|
|
onExportCabrilloFiltered?: () => void;
|
|
onDelete?: (ids: number[]) => void;
|
|
};
|
|
|
|
const UPLOAD_TARGETS: { service: string; name: string }[] = [
|
|
{ service: 'qrz', name: 'QRZ.com' },
|
|
{ service: 'clublog', name: 'Club Log' },
|
|
{ service: 'hrdlog', name: 'HRDLog.net' },
|
|
{ service: 'eqsl', name: 'eQSL.cc' },
|
|
{ service: 'lotw', name: 'LoTW' },
|
|
{ service: 'hamlog', name: 'HAMLOG.online' },
|
|
];
|
|
|
|
// Lightweight right-click menu for the QSO grids. AG Grid's native context
|
|
// menu is an Enterprise feature, so this is a plain floating menu driven by
|
|
// onCellContextMenu. Stays open until the user clicks outside, presses Escape,
|
|
// or picks a command. (We deliberately do NOT close on scroll/resize: the QSO
|
|
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
|
|
// which used to dismiss the menu the instant it appeared.)
|
|
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onUpdateCountyFromULS, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFull, onExportSelectedFields, onExportFiltered, onExportFilteredFull, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
|
const { t } = useI18n();
|
|
const boxRef = useRef<HTMLDivElement>(null);
|
|
// Starts at the cursor; the layout effect corrects it once the real size is
|
|
// known. Rendering at the cursor first avoids a visible jump for the common
|
|
// case where it already fits.
|
|
const [pos, setPos] = useState({ x: 0, y: 0 });
|
|
useEffect(() => { if (menu) setPos({ x: menu.x, y: menu.y }); }, [menu]);
|
|
useEffect(() => {
|
|
if (!menu) return;
|
|
const close = () => onClose();
|
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
|
window.addEventListener('mousedown', close);
|
|
window.addEventListener('keydown', onKey);
|
|
return () => {
|
|
window.removeEventListener('mousedown', close);
|
|
window.removeEventListener('keydown', onKey);
|
|
};
|
|
}, [menu, onClose]);
|
|
|
|
// Position AFTER measuring.
|
|
//
|
|
// The menu used to be clamped against a guessed height — 230 px when the
|
|
// upload submenu was present, 110 otherwise. It is far taller than that with
|
|
// a selection, so right-clicking near the bottom of the window cut half the
|
|
// entries off. Its height also depends on which actions the caller passes, so
|
|
// no constant can be right for long.
|
|
//
|
|
// Measured instead: if it does not fit below the cursor it is placed above,
|
|
// and failing that pinned to the top with its own scrollbar.
|
|
useLayoutEffect(() => {
|
|
if (!menu || !boxRef.current) return;
|
|
const r = boxRef.current.getBoundingClientRect();
|
|
const pad = 6;
|
|
let x = menu.x;
|
|
let y = menu.y;
|
|
if (x + r.width > window.innerWidth - pad) {
|
|
x = Math.max(pad, window.innerWidth - r.width - pad);
|
|
}
|
|
if (y + r.height > window.innerHeight - pad) {
|
|
// Above the cursor, if there is room there.
|
|
y = menu.y - r.height >= pad ? menu.y - r.height : Math.max(pad, window.innerHeight - r.height - pad);
|
|
}
|
|
setPos({ x, y });
|
|
}, [menu, onSendTo]);
|
|
|
|
if (!menu) return null;
|
|
const n = menu.ids.length;
|
|
|
|
return (
|
|
<div
|
|
ref={boxRef}
|
|
className="fixed z-[200] min-w-[240px] max-h-[85vh] overflow-y-auto rounded-md border border-border bg-popover shadow-lg py-1 text-sm"
|
|
style={{ left: pos.x, top: pos.y }}
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="px-3 py-1 text-[11px] uppercase tracking-wider text-muted-foreground">
|
|
{t('qctx.selected', { n })}
|
|
</div>
|
|
{/* Conditional like every other entry. These two were drawn
|
|
unconditionally, and the grid passes them through an arrow that calls
|
|
an optional handler — so in a grid that wires neither (the QSL
|
|
Manager) both appeared and did precisely nothing when clicked. */}
|
|
{onUpdateFromCty && (
|
|
<button
|
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
|
onClick={() => { onUpdateFromCty(menu.ids); onClose(); }}
|
|
>
|
|
<Globe2 className="size-4 text-primary" />
|
|
<span>{t('qctx.fixCountry')}</span>
|
|
</button>
|
|
)}
|
|
{onUpdateFromQRZ && (
|
|
<button
|
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
|
onClick={() => { onUpdateFromQRZ(menu.ids); onClose(); }}
|
|
>
|
|
<RefreshCw className="size-4 text-info" />
|
|
<span>{t('qctx.updateQrz')}</span>
|
|
</button>
|
|
)}
|
|
{onUpdateFromClublog && (
|
|
<button
|
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
|
onClick={() => { onUpdateFromClublog(menu.ids); onClose(); }}
|
|
>
|
|
<BadgeCheck className="size-4 text-info" />
|
|
<span>{t('qctx.updateClublog')}</span>
|
|
</button>
|
|
)}
|
|
{onUpdateCountyFromULS && (
|
|
<button
|
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
|
onClick={() => { onUpdateCountyFromULS(menu.ids); onClose(); }}
|
|
title={t('qctx.updateUlsCountyTitle')}
|
|
>
|
|
<MapPin className="size-4 text-info" />
|
|
<span>{t('qctx.updateUlsCounty')}</span>
|
|
</button>
|
|
)}
|
|
|
|
{(onSendRecording || onSendEQSL) && (
|
|
<>
|
|
<div className="my-1 border-t border-border" />
|
|
{onSendEQSL && (
|
|
<button
|
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
|
onClick={() => { onSendEQSL(menu.ids); onClose(); }}
|
|
>
|
|
<Mail className="size-4 text-warning" />
|
|
<span>{t('qctx.sendQslEmail')}</span>
|
|
</button>
|
|
)}
|
|
{onSendRecording && (
|
|
<button
|
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
|
onClick={() => { onSendRecording(menu.ids); onClose(); }}
|
|
>
|
|
<Mail className="size-4 text-danger" />
|
|
<span>{t('qctx.sendRecording')}</span>
|
|
</button>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{onBulkEdit && (
|
|
<>
|
|
<div className="my-1 border-t border-border" />
|
|
<button
|
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
|
onClick={() => { onBulkEdit(menu.ids); onClose(); }}
|
|
>
|
|
<PencilLine className="size-4 text-info" />
|
|
<span>{t('qctx.bulkEdit', { n })}</span>
|
|
</button>
|
|
</>
|
|
)}
|
|
|
|
{(onExportSelected || onExportFiltered) && (
|
|
<>
|
|
<div className="my-1 border-t border-border" />
|
|
{onExportSelected && (
|
|
<button
|
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
|
onClick={() => { onExportSelected(menu.ids); onClose(); }}
|
|
>
|
|
<FileDown className="size-4 text-info" />
|
|
<span>{t('qctx.exportSelectedAdif', { n })}</span>
|
|
</button>
|
|
)}
|
|
{/* The same rows WITH the OpsLog fields.
|
|
The export above is standard ADIF — what another logger should be
|
|
given — and it drops every APP_ tag, award references included. A
|
|
backup, or a move to another OpsLog, needs them: RDA@KR-04 lives in
|
|
APP_OPSLOG_AWARDREFS and nowhere else. */}
|
|
{onExportSelectedFull && (
|
|
<button
|
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
|
onClick={() => { onExportSelectedFull(menu.ids); onClose(); }}
|
|
>
|
|
<FileDown className="size-4 text-info" />
|
|
<span>{t('qctx.exportSelectedAdifFull', { n })}</span>
|
|
</button>
|
|
)}
|
|
{onExportSelectedFields && (
|
|
<button
|
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
|
onClick={() => { onExportSelectedFields(menu.ids); onClose(); }}
|
|
>
|
|
<FileDown className="size-4 text-info" />
|
|
<span>{t('qctx.exportSelectedFields', { n })}</span>
|
|
</button>
|
|
)}
|
|
{onExportFiltered && (
|
|
<button
|
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
|
onClick={() => { onExportFiltered(); onClose(); }}
|
|
>
|
|
<FileDown className="size-4 text-info" />
|
|
<span>{t('qctx.exportFilteredAdif')}</span>
|
|
</button>
|
|
)}
|
|
{onExportFilteredFull && (
|
|
<button
|
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
|
onClick={() => { onExportFilteredFull(); onClose(); }}
|
|
>
|
|
<FileDown className="size-4 text-info" />
|
|
<span>{t('qctx.exportFilteredAdifFull')}</span>
|
|
</button>
|
|
)}
|
|
{onExportCabrilloSelected && (
|
|
<button
|
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
|
onClick={() => { onExportCabrilloSelected(menu.ids); onClose(); }}
|
|
>
|
|
<FileDown className="size-4 text-warning" />
|
|
<span>{t('qctx.exportSelectedCabrillo', { n })}</span>
|
|
</button>
|
|
)}
|
|
{onExportCabrilloFiltered && (
|
|
<button
|
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
|
onClick={() => { onExportCabrilloFiltered(); onClose(); }}
|
|
>
|
|
<FileDown className="size-4 text-warning" />
|
|
<span>{t('qctx.exportFilteredCabrillo')}</span>
|
|
</button>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{onSendTo && (
|
|
<>
|
|
<div className="my-1 border-t border-border" />
|
|
{UPLOAD_TARGETS.map((target) => (
|
|
<button
|
|
key={target.service}
|
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
|
onClick={() => { onSendTo(target.service, menu.ids); onClose(); }}
|
|
>
|
|
<Upload className="size-4 text-success" />
|
|
<span>{t('qctx.sendTo', { name: target.name })}</span>
|
|
</button>
|
|
))}
|
|
</>
|
|
)}
|
|
|
|
{onDelete && (
|
|
<>
|
|
<div className="my-1 border-t border-border" />
|
|
<button
|
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-danger hover:bg-danger-muted"
|
|
onClick={() => { onDelete(menu.ids); onClose(); }}
|
|
>
|
|
<Trash2 className="size-4" />
|
|
<span>{t('qctx.delete', { n })}</span>
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|