feat: Complete translation in French

This commit is contained in:
2026-07-05 10:44:40 +02:00
parent 3a6afc28ac
commit 2d742be7df
32 changed files with 1841 additions and 1421 deletions
+85 -75
View File
@@ -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>