mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
231 lines
11 KiB
TypeScript
231 lines
11 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { sanitizeClubName } from '../../utils/url';
|
|
import { TeamLogo } from '../common/TeamLogo';
|
|
|
|
export interface StandingRow {
|
|
position?: number;
|
|
pos?: number;
|
|
rank?: number;
|
|
team?: any;
|
|
team_id?: string | number;
|
|
team_logo_url?: string;
|
|
club?: string;
|
|
points?: number | string;
|
|
pts?: number | string;
|
|
played?: number | string;
|
|
matches?: number | string;
|
|
wins?: number | string;
|
|
win?: number | string;
|
|
draws?: number | string;
|
|
draw?: number | string;
|
|
losses?: number | string;
|
|
loss?: number | string;
|
|
score?: string;
|
|
}
|
|
|
|
|
|
function deriveTeamIdFromLogoUrl(url?: string): string | undefined {
|
|
try {
|
|
const u = String(url || '');
|
|
if (!u) return undefined;
|
|
const m = u.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/);
|
|
return m ? m[0].toLowerCase() : undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
type TeamOverrides = { by_id?: Record<string, { name?: string; logo_url?: string }>; by_name?: Record<string, string> };
|
|
|
|
const StandingsCard: React.FC<{ rows: StandingRow[]; onRowClick?: (row: StandingRow, index: number) => void; variant?: 'logos' | 'plain' }>= ({ rows, onRowClick, variant = 'logos' }) => {
|
|
const { t } = useTranslation();
|
|
const safe = Array.isArray(rows) ? rows : [];
|
|
const [sortKey, setSortKey] = useState<'rank' | 'team' | 'played' | 'wins' | 'draws' | 'losses' | 'score' | 'points' | null>(null);
|
|
const [sortOrder, setSortOrder] = useState<'desc' | 'asc' | null>(null);
|
|
const [overrides, setOverrides] = useState<TeamOverrides>({});
|
|
const toNumber = (v: any): number => {
|
|
if (typeof v === 'number') return v;
|
|
const n = parseFloat(String(v ?? '').replace(/[^0-9\-\.]/g, ''));
|
|
return isNaN(n) ? 0 : n;
|
|
};
|
|
const scoreDiff = (s: any): number => {
|
|
const str = String(s ?? '').trim();
|
|
const m = str.match(/(-?\d+)\s*[:\-]\s*(-?\d+)/);
|
|
if (m) return Number(m[1]) - Number(m[2]);
|
|
return toNumber(str);
|
|
};
|
|
const normalize = (s: string) => String(s || '')
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-')
|
|
.replace(/[.,!;:()\[\]{}]/g, ' ')
|
|
.replace(/[\s,]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.toLowerCase();
|
|
|
|
const byIdMap = useMemo(() => (overrides?.by_id || {}) as Record<string, { name?: string; logo_url?: string }>, [overrides]);
|
|
const overridesNameIndex = useMemo(() => {
|
|
const idx: Record<string, { id: string; name: string }> = {};
|
|
try {
|
|
for (const [id, v] of Object.entries(byIdMap)) {
|
|
const nm = String((v as any)?.name || '').trim();
|
|
if (!nm) continue;
|
|
const key = normalize(nm);
|
|
if (!key) continue;
|
|
idx[key] = { id, name: nm };
|
|
}
|
|
} catch {}
|
|
return idx;
|
|
}, [byIdMap]);
|
|
|
|
const pickName = (teamId?: string | number, original?: string, logoUrl?: string): string => {
|
|
const id = String(teamId || '') || deriveTeamIdFromLogoUrl(logoUrl) || '';
|
|
const v = id ? (byIdMap[id]?.name || '') : '';
|
|
if (v && v.trim().length > 0) return v;
|
|
if (original) {
|
|
const n = normalize(original);
|
|
let hit = overridesNameIndex[n];
|
|
if (!hit) {
|
|
for (const [k, val] of Object.entries(overridesNameIndex)) {
|
|
if (!k) continue;
|
|
if (n.endsWith(k) || k.endsWith(n)) { hit = val as any; break; }
|
|
}
|
|
}
|
|
if (hit?.name) return hit.name;
|
|
}
|
|
return original || '';
|
|
};
|
|
|
|
useEffect(() => {
|
|
let mounted = true;
|
|
(async () => {
|
|
const now = Date.now();
|
|
let data: any = null;
|
|
try {
|
|
const res = await fetch(`/api/v1/public/team-logo-overrides?t=${now}`, { cache: 'no-cache' });
|
|
if (res.ok) data = await res.json();
|
|
} catch {}
|
|
if (!data) {
|
|
try {
|
|
const res2 = await fetch('/cache/prefetch/team_logo_overrides.json', { cache: 'no-cache' });
|
|
if (res2.ok) data = await res2.json();
|
|
} catch {}
|
|
}
|
|
if (mounted) setOverrides(data || { by_id: {}, by_name: {} });
|
|
})();
|
|
return () => { mounted = false; };
|
|
}, []);
|
|
|
|
const getTeam = (r: any): string => sanitizeClubName(pickName(r?.team_id, r?.team?.name ?? r?.team ?? r?.club ?? '', r?.team_logo_url));
|
|
const getRank = (r: any): number => toNumber(r?.rank ?? r?.pos ?? r?.position);
|
|
const toggleSort = (key: 'rank' | 'team' | 'played' | 'wins' | 'draws' | 'losses' | 'score' | 'points') => {
|
|
if (sortKey !== key) { setSortKey(key); setSortOrder('desc'); return; }
|
|
if (sortOrder === 'desc') { setSortOrder('asc'); return; }
|
|
setSortKey(null); setSortOrder(null);
|
|
};
|
|
const sorted = useMemo(() => {
|
|
if (!sortKey || !sortOrder) return safe;
|
|
const arr = [...safe];
|
|
arr.sort((a: any, b: any) => {
|
|
let va: any; let vb: any; let isText = false;
|
|
switch (sortKey) {
|
|
case 'team': va = getTeam(a); vb = getTeam(b); isText = true; break;
|
|
case 'rank': va = getRank(a); vb = getRank(b); break;
|
|
case 'played': va = toNumber(a?.played ?? a?.matches); vb = toNumber(b?.played ?? b?.matches); break;
|
|
case 'wins': va = toNumber(a?.wins ?? a?.win); vb = toNumber(b?.wins ?? b?.win); break;
|
|
case 'draws': va = toNumber(a?.draws ?? a?.draw); vb = toNumber(b?.draws ?? b?.draw); break;
|
|
case 'losses': va = toNumber(a?.losses ?? a?.loss); vb = toNumber(b?.losses ?? b?.loss); break;
|
|
case 'score': va = scoreDiff(a?.score); vb = scoreDiff(b?.score); break;
|
|
case 'points': va = toNumber(a?.points ?? a?.pts); vb = toNumber(b?.points ?? b?.pts); break;
|
|
default: va = 0; vb = 0;
|
|
}
|
|
let res = isText ? String(va).localeCompare(String(vb)) : (va as number) - (vb as number);
|
|
if (sortOrder === 'desc') res = -res;
|
|
if (res === 0) {
|
|
const ra = getRank(a); const rb = getRank(b);
|
|
res = ra - rb;
|
|
}
|
|
return res;
|
|
});
|
|
return arr;
|
|
}, [safe, sortKey, sortOrder]);
|
|
const arrow = (key: 'rank' | 'team' | 'played' | 'wins' | 'draws' | 'losses' | 'score' | 'points') => sortKey === key ? (sortOrder === 'desc' ? ' ▼' : ' ▲') : '';
|
|
return (
|
|
<div className="table-card">
|
|
<div className="standings-table-wrapper" style={{ overflowX: 'auto' }}>
|
|
<table className="standings-table-compact" style={{ width: '100%', borderCollapse: 'separate', borderSpacing: '0 4px' }}>
|
|
<thead>
|
|
<tr style={{ fontSize: '0.75rem', color: 'var(--dark-gray)', textTransform: 'uppercase' }}>
|
|
<th onClick={() => toggleSort('rank')} style={{ padding: '6px 8px', textAlign: 'left', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}># {arrow('rank')}</th>
|
|
<th onClick={() => toggleSort('team')} style={{ padding: '6px 8px', textAlign: 'left', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>{t('homepage.team')}{arrow('team')}</th>
|
|
<th onClick={() => toggleSort('played')} style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>{t('homepage.played')}{arrow('played')}</th>
|
|
<th onClick={() => toggleSort('wins')} style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>{t('homepage.won')}{arrow('wins')}</th>
|
|
<th onClick={() => toggleSort('draws')} style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>{t('homepage.drawn')}{arrow('draws')}</th>
|
|
<th onClick={() => toggleSort('losses')} style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>{t('homepage.lost')}{arrow('losses')}</th>
|
|
<th onClick={() => toggleSort('score')} style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, display: 'none', cursor: 'pointer', userSelect: 'none' }} className="hide-mobile">{t('homepage.goals')}{arrow('score')}</th>
|
|
<th onClick={() => toggleSort('points')} style={{ padding: '6px 8px', textAlign: 'center', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>{t('homepage.points')}{arrow('points')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sorted.slice(0, 8).map((row, idx) => {
|
|
const teamNameRaw = (row as any).team?.name ?? (row as any).team ?? (row as any).club ?? '-';
|
|
const teamName = sanitizeClubName(pickName((row as any).team_id, teamNameRaw, (row as any).team_logo_url));
|
|
const tid = (row as any).team_id || deriveTeamIdFromLogoUrl((row as any).team_logo_url);
|
|
return (
|
|
<tr
|
|
key={idx}
|
|
onClick={() => onRowClick?.(row, idx)}
|
|
style={{
|
|
cursor: onRowClick ? 'pointer' : 'default',
|
|
background: 'var(--card-bg)',
|
|
border: '1px solid var(--card-border)',
|
|
borderRadius: '8px',
|
|
transition: 'all 0.2s ease',
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
(e.currentTarget as HTMLTableRowElement).style.boxShadow = '0 4px 12px rgba(0,0,0,0.08)';
|
|
(e.currentTarget as HTMLTableRowElement).style.borderColor = 'var(--primary)';
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
(e.currentTarget as HTMLTableRowElement).style.boxShadow = 'none';
|
|
(e.currentTarget as HTMLTableRowElement).style.borderColor = 'var(--card-border)';
|
|
}}
|
|
>
|
|
<td style={{ padding: '10px 8px', fontWeight: 700, color: 'var(--secondary)' }}>{row.position ?? row.pos ?? row.rank ?? idx + 1}</td>
|
|
<td style={{ padding: '10px 8px', fontWeight: 600 }}>
|
|
{variant === 'logos' ? (
|
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
|
<TeamLogo
|
|
teamId={tid}
|
|
teamName={teamName}
|
|
facrLogo={(row as any).team_logo_url}
|
|
size="small"
|
|
alt={teamName || t('homepage.team')}
|
|
borderRadius="full"
|
|
/>
|
|
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{teamName}</span>
|
|
</span>
|
|
) : (
|
|
teamName
|
|
)}
|
|
</td>
|
|
<td style={{ padding: '10px 4px', textAlign: 'center' }}>{(row as any).played ?? (row as any).matches ?? '-'}</td>
|
|
<td style={{ padding: '10px 4px', textAlign: 'center' }}>{(row as any).wins ?? (row as any).win ?? '-'}</td>
|
|
<td style={{ padding: '10px 4px', textAlign: 'center' }}>{(row as any).draws ?? (row as any).draw ?? '-'}</td>
|
|
<td style={{ padding: '10px 4px', textAlign: 'center' }}>{(row as any).losses ?? (row as any).loss ?? '-'}</td>
|
|
<td style={{ padding: '10px 4px', textAlign: 'center', display: 'none' }} className="hide-mobile">{(row as any).score ?? '-'}</td>
|
|
<td style={{ padding: '10px 8px', textAlign: 'center', fontWeight: 800 }}>{(row as any).points ?? (row as any).pts ?? '-'}</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default StandingsCard;
|