feat: Complete translation in French
This commit is contained in:
@@ -12,6 +12,9 @@ import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
|
||||
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
||||
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
@@ -117,27 +120,27 @@ function statusFor(p: any): SpotStatusEntry | undefined {
|
||||
// Status column, using the same colours as the per-cell fills (NEW DXCC =
|
||||
// call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when
|
||||
// there's nothing notable to show.
|
||||
function statusBadge(s: SpotStatusEntry | undefined): { text: string; fg: string; bg: string } | null {
|
||||
function statusBadge(t: TFn, s: SpotStatusEntry | undefined): { text: string; fg: string; bg: string } | null {
|
||||
switch (s?.status) {
|
||||
case 'new': return { text: 'NEW DXCC', fg: '#be123c', bg: '#ffe4e6' };
|
||||
case 'new-band': return { text: 'NEW BAND', fg: '#92400e', bg: '#fde68a' };
|
||||
case 'new-mode': return { text: 'NEW MODE', fg: '#854d0e', bg: '#fef08a' };
|
||||
case 'new-slot': return { text: 'NEW SLOT', fg: '#854d0e', bg: '#fef08a' };
|
||||
default: return s?.worked_call ? { text: 'WKD CALL', fg: '#0369a1', bg: '#e0f2fe' } : null;
|
||||
case 'new': return { text: t('clg2.newDxcc'), fg: '#be123c', bg: '#ffe4e6' };
|
||||
case 'new-band': return { text: t('clg2.newBand'), fg: '#92400e', bg: '#fde68a' };
|
||||
case 'new-mode': return { text: t('clg2.newMode'), fg: '#854d0e', bg: '#fef08a' };
|
||||
case 'new-slot': return { text: t('clg2.newSlot'), fg: '#854d0e', bg: '#fef08a' };
|
||||
default: return s?.worked_call ? { text: t('clg2.wkdCall'), fg: '#0369a1', bg: '#e0f2fe' } : null;
|
||||
}
|
||||
}
|
||||
|
||||
const COL_CATALOG: ColEntry[] = [
|
||||
const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
{
|
||||
group: 'Spot', label: 'Time', colId: 'time',
|
||||
headerName: 'Time', field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.time'), colId: 'time',
|
||||
headerName: t('clg2.c.time'), field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
|
||||
defaultVisible: true,
|
||||
sort: 'desc',
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Call', colId: 'call',
|
||||
headerName: 'Call', field: 'dx_call' as any, width: 120,
|
||||
group: 'Spot', label: t('clg2.c.call'), colId: 'call',
|
||||
headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120,
|
||||
defaultVisible: true,
|
||||
cellClass: 'font-mono',
|
||||
// New DXCC entity → fill the whole cell (no padded pill, so calls stay
|
||||
@@ -147,25 +150,25 @@ const COL_CATALOG: ColEntry[] = [
|
||||
: { color: statusFor(p)?.worked_call ? '#0369a1' : '#b8410c', fontWeight: 700 }),
|
||||
tooltipValueGetter: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
return s?.status === 'new' ? `NEW DXCC: ${s?.country ?? ''}` : s?.worked_call ? 'Already worked this call' : undefined;
|
||||
return s?.status === 'new' ? t('clg2.tipNewDxcc', { country: s?.country ?? '' }) : s?.worked_call ? t('clg2.tipWorkedCall') : undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Status', colId: 'status',
|
||||
headerName: 'Status', width: 96, sortable: true,
|
||||
group: 'Spot', label: t('clg2.c.status'), colId: 'status',
|
||||
headerName: t('clg2.c.status'), width: 96, sortable: true,
|
||||
defaultVisible: true,
|
||||
// Spells out the slot status as a text badge so NEW SLOT (and the others)
|
||||
// is obvious at the row level, not just a single coloured cell.
|
||||
valueGetter: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
if (s?.status === 'new') return 'NEW DXCC';
|
||||
if (s?.status === 'new-band') return 'NEW BAND';
|
||||
if (s?.status === 'new-mode') return 'NEW MODE';
|
||||
if (s?.status === 'new-slot') return 'NEW SLOT';
|
||||
return s?.worked_call ? 'WKD CALL' : '';
|
||||
if (s?.status === 'new') return t('clg2.newDxcc');
|
||||
if (s?.status === 'new-band') return t('clg2.newBand');
|
||||
if (s?.status === 'new-mode') return t('clg2.newMode');
|
||||
if (s?.status === 'new-slot') return t('clg2.newSlot');
|
||||
return s?.worked_call ? t('clg2.wkdCall') : '';
|
||||
},
|
||||
cellRenderer: (p: any) => {
|
||||
const b = statusBadge(statusFor(p));
|
||||
const b = statusBadge(t, statusFor(p));
|
||||
if (!b) return <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>;
|
||||
return (
|
||||
<span style={{
|
||||
@@ -176,41 +179,41 @@ const COL_CATALOG: ColEntry[] = [
|
||||
},
|
||||
tooltipValueGetter: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
if (s?.status === 'new') return `NEW DXCC: ${s?.country ?? ''}`;
|
||||
if (s?.status === 'new-band') return 'NEW BAND for this entity';
|
||||
if (s?.status === 'new-slot') return 'NEW SLOT (mode not yet worked on this band)';
|
||||
if (s?.worked_call) return 'Already worked this call';
|
||||
if (s?.status === 'new') return t('clg2.tipNewDxcc', { country: s?.country ?? '' });
|
||||
if (s?.status === 'new-band') return t('clg2.tipNewBand');
|
||||
if (s?.status === 'new-slot') return t('clg2.tipNewSlotBand');
|
||||
if (s?.worked_call) return t('clg2.tipWorkedCall');
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'POTA', colId: 'pota',
|
||||
headerName: 'POTA', field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.pota'), colId: 'pota',
|
||||
headerName: t('clg2.c.pota'), field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
||||
defaultVisible: true,
|
||||
cellStyle: { color: '#166534' },
|
||||
tooltipValueGetter: (p: any) => (p.data?.pota_name ? `POTA — ${p.data.pota_name}` : undefined),
|
||||
tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined),
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Freq', colId: 'freq',
|
||||
headerName: 'Freq', field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.freq'), colId: 'freq',
|
||||
headerName: t('clg2.c.freq'), field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono',
|
||||
defaultVisible: true,
|
||||
valueFormatter: (p) => typeof p.value === 'number' ? p.value.toFixed(1) : '',
|
||||
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Band', colId: 'band',
|
||||
headerName: 'Band', field: 'band' as any, width: 75,
|
||||
group: 'Spot', label: t('clg2.c.band'), colId: 'band',
|
||||
headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
|
||||
defaultVisible: true,
|
||||
cellClass: 'font-mono',
|
||||
// NEW BAND for this entity → fill the cell (keeps the band text aligned).
|
||||
cellStyle: (p: any) => (statusFor(p)?.status === 'new-band'
|
||||
? { backgroundColor: '#fde68a', color: '#92400e', fontWeight: 700 }
|
||||
: undefined),
|
||||
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? 'NEW BAND for this entity' : undefined),
|
||||
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined),
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Mode', colId: 'mode',
|
||||
headerName: 'Mode', colSpan: undefined, width: 80,
|
||||
group: 'Spot', label: t('clg2.c.mode'), colId: 'mode',
|
||||
headerName: t('clg2.c.mode'), colSpan: undefined, width: 80,
|
||||
defaultVisible: true,
|
||||
cellClass: 'font-mono',
|
||||
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
|
||||
@@ -226,48 +229,48 @@ const COL_CATALOG: ColEntry[] = [
|
||||
cellRenderer: (p: any) => p.value ? p.value : <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>,
|
||||
tooltipValueGetter: (p: any) => {
|
||||
const st = statusFor(p)?.status;
|
||||
if (st === 'new-mode') return 'NEW MODE (this mode never worked on this entity)';
|
||||
if (st === 'new-slot') return 'NEW SLOT (this band+mode not yet worked)';
|
||||
if (st === 'new-mode') return t('clg2.tipNewMode');
|
||||
if (st === 'new-slot') return t('clg2.tipNewSlot');
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Pfx', colId: 'pfx',
|
||||
headerName: 'Pfx', width: 60, cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.pfx'), colId: 'pfx',
|
||||
headerName: t('clg2.c.pfx'), width: 60, cellClass: 'font-mono',
|
||||
valueGetter: (p: any) => fmtPfx(p.data?.dx_call ?? ''),
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
},
|
||||
{
|
||||
group: 'Geo', label: 'CQ Zone', colId: 'cqz',
|
||||
headerName: 'CQZ', field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
group: 'Geo', label: t('clg2.c.cqz'), colId: 'cqz',
|
||||
headerName: t('clg2.h.cqz'), field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
valueFormatter: (p) => p.value ? String(p.value) : '',
|
||||
},
|
||||
{
|
||||
group: 'Geo', label: 'ITU Zone', colId: 'ituz',
|
||||
headerName: 'ITU', field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
group: 'Geo', label: t('clg2.c.ituz'), colId: 'ituz',
|
||||
headerName: t('clg2.h.ituz'), field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
valueFormatter: (p) => p.value ? String(p.value) : '',
|
||||
},
|
||||
{
|
||||
group: 'Geo', label: 'Distance (km)', colId: 'distance_km',
|
||||
headerName: 'Dist km', field: 'distance_km' as any, width: 80, type: 'rightAligned', cellClass: 'font-mono',
|
||||
group: 'Geo', label: t('clg2.c.distance_km'), colId: 'distance_km',
|
||||
headerName: t('clg2.h.distance_km'), field: 'distance_km' as any, width: 80, type: 'rightAligned', cellClass: 'font-mono',
|
||||
valueFormatter: (p) => p.value ? String(p.value) : '',
|
||||
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
||||
},
|
||||
{
|
||||
group: 'Geo', label: 'Short path (°)', colId: 'sp_deg',
|
||||
headerName: 'SP°', field: 'sp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
group: 'Geo', label: t('clg2.c.sp_deg'), colId: 'sp_deg',
|
||||
headerName: t('clg2.h.sp_deg'), field: 'sp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
valueFormatter: (p) => (p.value || p.value === 0) ? String(p.value) : '',
|
||||
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
||||
},
|
||||
{
|
||||
group: 'Geo', label: 'Long path (°)', colId: 'lp_deg',
|
||||
headerName: 'LP°', field: 'lp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
group: 'Geo', label: t('clg2.c.lp_deg'), colId: 'lp_deg',
|
||||
headerName: t('clg2.h.lp_deg'), field: 'lp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
valueFormatter: (p) => (p.value || p.value === 0) ? String(p.value) : '',
|
||||
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Country', colId: 'country',
|
||||
headerName: 'Country', width: 140,
|
||||
group: 'Spot', label: t('clg2.c.country'), colId: 'country',
|
||||
headerName: t('clg2.c.country'), width: 140,
|
||||
defaultVisible: true,
|
||||
valueGetter: (p: any) => p.data?.country ?? p.context?.spotStatus?.[
|
||||
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
||||
@@ -275,8 +278,8 @@ const COL_CATALOG: ColEntry[] = [
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Continent', colId: 'continent',
|
||||
headerName: 'Cont', width: 60, cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.continent'), colId: 'continent',
|
||||
headerName: t('clg2.h.continent'), width: 60, cellClass: 'font-mono',
|
||||
defaultVisible: true,
|
||||
valueGetter: (p: any) => p.data?.continent ?? p.context?.spotStatus?.[
|
||||
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
||||
@@ -284,50 +287,57 @@ const COL_CATALOG: ColEntry[] = [
|
||||
cellStyle: { color: '#7a6b50', fontSize: 10 },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Spotter', colId: 'spotter',
|
||||
headerName: 'Spotter', field: 'spotter' as any, width: 100, cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.spotter'), colId: 'spotter',
|
||||
headerName: t('clg2.c.spotter'), field: 'spotter' as any, width: 100, cellClass: 'font-mono',
|
||||
defaultVisible: true,
|
||||
valueFormatter: (p) => cleanSpotter(p.value ?? ''),
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Source', colId: 'source',
|
||||
headerName: 'Source', field: 'source_name' as any, width: 100,
|
||||
group: 'Spot', label: t('clg2.c.source'), colId: 'source',
|
||||
headerName: t('clg2.c.source'), field: 'source_name' as any, width: 100,
|
||||
defaultVisible: true,
|
||||
cellStyle: { color: '#9a8870', fontSize: 10 },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Locator', colId: 'locator',
|
||||
headerName: 'Loc', field: 'locator' as any, width: 80, cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.locator'), colId: 'locator',
|
||||
headerName: t('clg2.h.locator'), field: 'locator' as any, width: 80, cellClass: 'font-mono',
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Comment', colId: 'comment',
|
||||
headerName: 'Comment', field: 'comment' as any, flex: 1, minWidth: 160,
|
||||
group: 'Spot', label: t('clg2.c.comment'), colId: 'comment',
|
||||
headerName: t('clg2.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160,
|
||||
defaultVisible: true,
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Received at', colId: 'received_at',
|
||||
headerName: 'Received UTC', field: 'received_at' as any, width: 160, cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.received_at'), colId: 'received_at',
|
||||
headerName: t('clg2.h.received_at'), field: 'received_at' as any, width: 160, cellClass: 'font-mono',
|
||||
valueFormatter: (p) => fmtDateTimeUTC(p.value),
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Raw', colId: 'raw',
|
||||
headerName: 'Raw', field: 'raw' as any, width: 300, cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.raw'), colId: 'raw',
|
||||
headerName: t('clg2.c.raw'), field: 'raw' as any, width: 300, cellClass: 'font-mono',
|
||||
},
|
||||
];
|
||||
|
||||
const GROUP_ORDER = ['Spot', 'Geo'];
|
||||
|
||||
const CLG_GRP_KEYS: Record<string, string> = { Spot: 'clg2.grpSpot', Geo: 'clg2.grpGeo' };
|
||||
const groupLabel = (t: TFn, g: string): string => t(CLG_GRP_KEYS[g] ?? g);
|
||||
|
||||
export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
const { t } = useI18n();
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
// Localized column catalog — rebuilt when the language changes.
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
||||
|
||||
const columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => COL_CATALOG.map((c) => {
|
||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||
return { ...rest, hide: !defaultVisible };
|
||||
}), []);
|
||||
}), [COL_CATALOG]);
|
||||
|
||||
const defaultColDef = useMemo<ColDef>(() => ({
|
||||
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
||||
@@ -404,11 +414,11 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
<>
|
||||
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
||||
title="Clear all column filters">
|
||||
<FilterX className="size-3.5" /> Clear filters
|
||||
title={t('clg2.clearFiltersTitle')}>
|
||||
<FilterX className="size-3.5" /> {t('clg2.clearFilters')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
||||
<Columns3 className="size-3.5" /> Columns
|
||||
<Columns3 className="size-3.5" /> {t('clg2.columns')}
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
@@ -437,9 +447,9 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Cluster columns</DialogTitle>
|
||||
<DialogTitle>{t('clg2.pickerTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick the columns you want visible in the Cluster table.
|
||||
{t('clg2.pickerDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[60vh] overflow-y-auto py-2">
|
||||
@@ -449,10 +459,10 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
return (
|
||||
<div key={group} className="rounded-md border border-border p-2.5 mb-2">
|
||||
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{group}</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{groupLabel(t, group)}</span>
|
||||
<div className="flex gap-0.5">
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>all</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>none</button>
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>{t('clg2.all')}</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>{t('clg2.none')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
@@ -471,8 +481,8 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
})}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
|
||||
<Button size="sm" onClick={() => setPickerOpen(false)}>Done</Button>
|
||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>{t('clg2.resetDefaults')}</Button>
|
||||
<Button size="sm" onClick={() => setPickerOpen(false)}>{t('clg2.done')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
Reference in New Issue
Block a user