mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-05 03:02:56 +00:00
dev day #89
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { assetUrl, sanitizeClubName } from '../../utils/url';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { sanitizeClubName } from '../../utils/url';
|
||||
import { TeamLogo } from '../common/TeamLogo';
|
||||
|
||||
export interface StandingRow {
|
||||
position?: number;
|
||||
@@ -22,30 +23,154 @@ export interface StandingRow {
|
||||
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 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 style={{ padding: '6px 8px', textAlign: 'left', fontWeight: 600 }}>#</th>
|
||||
<th style={{ padding: '6px 8px', textAlign: 'left', fontWeight: 600 }}>Tým</th>
|
||||
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600 }}>Z</th>
|
||||
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600 }}>V</th>
|
||||
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600 }}>R</th>
|
||||
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600 }}>P</th>
|
||||
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, display: 'none' }} className="hide-mobile">Skóre</th>
|
||||
<th style={{ padding: '6px 8px', textAlign: 'center', fontWeight: 600 }}>Body</th>
|
||||
<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ým{arrow('team')}</th>
|
||||
<th onClick={() => toggleSort('played')} style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>Z{arrow('played')}</th>
|
||||
<th onClick={() => toggleSort('wins')} style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>V{arrow('wins')}</th>
|
||||
<th onClick={() => toggleSort('draws')} style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>R{arrow('draws')}</th>
|
||||
<th onClick={() => toggleSort('losses')} style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>P{arrow('losses')}</th>
|
||||
<th onClick={() => toggleSort('score')} style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, display: 'none', cursor: 'pointer', userSelect: 'none' }} className="hide-mobile">Skóre{arrow('score')}</th>
|
||||
<th onClick={() => toggleSort('points')} style={{ padding: '6px 8px', textAlign: 'center', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>Body{arrow('points')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{safe.slice(0, 8).map((row, idx) => {
|
||||
{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(teamNameRaw);
|
||||
const logo = (row as any).team_logo_url;
|
||||
const logoSrc = logo ? (assetUrl(logo) || logo) : null;
|
||||
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}
|
||||
@@ -70,25 +195,18 @@ const StandingsCard: React.FC<{ rows: StandingRow[]; onRowClick?: (row: Standing
|
||||
<td style={{ padding: '10px 8px', fontWeight: 600 }}>
|
||||
{variant === 'logos' ? (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||
{logoSrc ? (
|
||||
<img
|
||||
src={logoSrc as string}
|
||||
alt={teamName || 'Tým'}
|
||||
loading="lazy"
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: '50%',
|
||||
objectFit: 'cover',
|
||||
background: 'var(--bg-soft)',
|
||||
border: '1px solid var(--card-border)',
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<TeamLogo
|
||||
teamId={tid}
|
||||
teamName={teamName}
|
||||
facrLogo={(row as any).team_logo_url}
|
||||
size="small"
|
||||
alt={teamName || 'Tým'}
|
||||
borderRadius="full"
|
||||
/>
|
||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{teamName}</span>
|
||||
</span>
|
||||
) : (
|
||||
teamNameRaw
|
||||
teamName
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '10px 4px', textAlign: 'center' }}>{(row as any).played ?? (row as any).matches ?? '-'}</td>
|
||||
|
||||
Reference in New Issue
Block a user