LoLStats: Champion Tier List pro Game Mode (ARAM, Arena, URF, etc.)

Neue op.gg Champion API (lol-api-champion.op.gg) fuer Tier-Daten.
30-Min In-Memory Cache pro Mode+Region. Neuer Bereich unterhalb
der Match History mit Mode-Tabs, Champion-Grid, Tier-Badges und
Filter. Arena-Mode zeigt Avg Placement statt KDA.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-09 00:36:08 +01:00
parent 9093f12259
commit 905f156d47
5 changed files with 397 additions and 3 deletions

View file

@ -9,6 +9,13 @@ interface LeagueStat { game_type: string; win: number | null; lose: number | nul
interface LadderRank { rank: number | null; total: number | null; }
interface ChampionStat { champion_name: string; id: number; play: number; win: number; lose: number; kill: number; death: number; assist: number; }
interface MostChampions { game_type: string; play: number; win: number; lose: number; champion_stats: ChampionStat[]; }
interface GameModeInfo { key: string; label: string; }
interface ChampionTierEntry {
champion_id: number; champion_name: string; tier: number; rank: number;
win_rate: number | null; pick_rate: number; ban_rate: number | null;
kda: number | null; play: number;
average_placement?: number; first_place_rate?: number;
}
interface SummonerProfile {
game_name: string; tagline: string; level: number; profile_image_url: string;
@ -114,15 +121,42 @@ export default function LolstatsTab({ data }: { data: any }) {
const [renewing, setRenewing] = useState(false);
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
// Tier list state
const [tierMode, setTierMode] = useState('aram');
const [tierRegion, setTierRegion] = useState('EUW');
const [tierList, setTierList] = useState<ChampionTierEntry[]>([]);
const [tierLoading, setTierLoading] = useState(false);
const [tierError, setTierError] = useState<string | null>(null);
const [gameModes, setGameModes] = useState<GameModeInfo[]>([]);
const [tierSearch, setTierSearch] = useState('');
const [showAllTiers, setShowAllTiers] = useState(false);
const searchRef = useRef<HTMLInputElement>(null);
const currentSearchRef = useRef<{ gameName: string; tagLine: string; region: string } | null>(null);
// Load regions
// Load regions + modes
useEffect(() => {
fetch('/api/lolstats/regions').then(r => r.json()).then(setRegions).catch(() => {});
fetch('/api/lolstats/recent').then(r => r.json()).then(setRecentSearches).catch(() => {});
fetch('/api/lolstats/modes').then(r => r.json()).then(setGameModes).catch(() => {});
}, []);
// Load tier list when mode/region changes
useEffect(() => {
if (!tierMode) return;
setTierLoading(true);
setTierError(null);
setShowAllTiers(false);
fetch(`/api/lolstats/tierlist?mode=${tierMode}&region=${tierRegion}`)
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then(data => setTierList(data.champions ?? []))
.catch(e => setTierError(e.message))
.finally(() => setTierLoading(false));
}, [tierMode, tierRegion]);
// SSE data
useEffect(() => {
if (!data) return;
@ -538,6 +572,101 @@ export default function LolstatsTab({ data }: { data: any }) {
<p>Gib einen Summoner Name#Tag ein und wähle die Region</p>
</div>
)}
{/* Champion Tier List — always visible */}
<div className="lol-tier-section">
<div className="lol-section-title">Champion Tier List</div>
<div className="lol-tier-controls">
<div className="lol-tier-modes">
{gameModes.map(m => (
<button
key={m.key}
className={`lol-tier-mode-btn ${tierMode === m.key ? 'active' : ''}`}
onClick={() => setTierMode(m.key)}
>
{m.label}
</button>
))}
</div>
<select
className="lol-search-region"
value={tierRegion}
onChange={e => setTierRegion(e.target.value)}
>
{regions.map(r => <option key={r.code} value={r.code}>{r.code}</option>)}
</select>
<input
className="lol-tier-filter"
placeholder="Filter champion..."
value={tierSearch}
onChange={e => setTierSearch(e.target.value)}
/>
</div>
{tierLoading && (
<div className="lol-loading">
<div className="lol-spinner" />
Lade Tier List...
</div>
)}
{tierError && <div className="lol-error">{tierError}</div>}
{!tierLoading && !tierError && tierList.length > 0 && (
<>
<div className="lol-tier-table">
<div className="lol-tier-header">
<span className="lol-tier-col-rank">#</span>
<span className="lol-tier-col-champ">Champion</span>
<span className="lol-tier-col-tier">Tier</span>
<span className="lol-tier-col-wr">{tierMode === 'arena' ? 'Win' : 'Win %'}</span>
<span className="lol-tier-col-pr">Pick %</span>
{tierMode !== 'arena' && <span className="lol-tier-col-br">Ban %</span>}
<span className="lol-tier-col-kda">{tierMode === 'arena' ? 'Avg Place' : 'KDA'}</span>
</div>
{tierList
.filter(c => !tierSearch || c.champion_name.toLowerCase().includes(tierSearch.toLowerCase()))
.slice(0, showAllTiers ? undefined : 50)
.map(c => {
const tierLabels = ['OP', '1', '2', '3', '4', '5'];
return (
<div key={c.champion_id} className={`lol-tier-row tier-${c.tier}`}>
<span className="lol-tier-col-rank">{c.rank}</span>
<span className="lol-tier-col-champ">
<img src={champImg(c.champion_name)} alt={c.champion_name} />
{c.champion_name}
</span>
<span className={`lol-tier-col-tier tier-badge-${c.tier}`}>
{tierLabels[c.tier] ?? c.tier}
</span>
<span className="lol-tier-col-wr">
{c.win_rate != null ? `${(c.win_rate * 100).toFixed(1)}%` : '-'}
</span>
<span className="lol-tier-col-pr">
{(c.pick_rate * 100).toFixed(1)}%
</span>
{tierMode !== 'arena' && (
<span className="lol-tier-col-br">
{c.ban_rate != null ? `${(c.ban_rate * 100).toFixed(1)}%` : '-'}
</span>
)}
<span className="lol-tier-col-kda">
{tierMode === 'arena'
? (c.average_placement?.toFixed(1) ?? '-')
: (c.kda?.toFixed(2) ?? '-')}
</span>
</div>
);
})}
</div>
{!showAllTiers && tierList.length > 50 && (
<button className="lol-load-more" onClick={() => setShowAllTiers(true)}>
Alle {tierList.length} Champions anzeigen
</button>
)}
</>
)}
</div>
</div>
);
}

View file

@ -490,6 +490,133 @@
}
.lol-load-more:hover { border-color: var(--accent); color: var(--text-normal); }
/*
Champion Tier List
*/
.lol-tier-section {
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--bg-tertiary);
}
.lol-tier-controls {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 12px;
flex-wrap: wrap;
}
.lol-tier-modes {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.lol-tier-mode-btn {
padding: 6px 14px;
border: 1px solid var(--bg-tertiary);
border-radius: 16px;
background: var(--bg-secondary);
color: var(--text-muted);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.lol-tier-mode-btn:hover { border-color: var(--accent); color: var(--text-normal); }
.lol-tier-mode-btn.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.lol-tier-filter {
padding: 6px 12px;
border: 1px solid var(--bg-tertiary);
border-radius: 8px;
background: var(--bg-secondary);
color: var(--text-normal);
font-size: 12px;
outline: none;
width: 140px;
margin-left: auto;
}
.lol-tier-filter:focus { border-color: var(--accent); }
.lol-tier-filter::placeholder { color: var(--text-faint); }
/* Tier Table */
.lol-tier-table {
display: flex;
flex-direction: column;
gap: 2px;
}
.lol-tier-header {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
font-size: 10px;
font-weight: 600;
color: var(--text-faint);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.lol-tier-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 6px;
background: var(--bg-secondary);
font-size: 13px;
color: var(--text-muted);
transition: background 0.15s;
}
.lol-tier-row:hover { background: var(--bg-tertiary); }
/* Tier row accent borders */
.lol-tier-row.tier-0 { border-left: 3px solid #f4c874; }
.lol-tier-row.tier-1 { border-left: 3px solid #d4a017; }
.lol-tier-row.tier-2 { border-left: 3px solid #576cce; }
.lol-tier-row.tier-3 { border-left: 3px solid #28b29e; }
.lol-tier-row.tier-4 { border-left: 3px solid var(--bg-tertiary); }
.lol-tier-row.tier-5 { border-left: 3px solid var(--bg-tertiary); opacity: 0.7; }
/* Column widths */
.lol-tier-col-rank { width: 32px; text-align: center; font-weight: 600; flex-shrink: 0; }
.lol-tier-col-champ {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
font-weight: 500;
color: var(--text-normal);
}
.lol-tier-col-champ img {
width: 28px; height: 28px;
border-radius: 50%;
flex-shrink: 0;
}
.lol-tier-col-tier { width: 36px; text-align: center; font-weight: 700; font-size: 12px; flex-shrink: 0; }
.lol-tier-col-wr { width: 60px; text-align: center; flex-shrink: 0; }
.lol-tier-col-pr { width: 60px; text-align: center; flex-shrink: 0; }
.lol-tier-col-br { width: 60px; text-align: center; flex-shrink: 0; }
.lol-tier-col-kda { width: 50px; text-align: center; flex-shrink: 0; }
/* Tier badge colors */
.tier-badge-0 { color: #f4c874; }
.tier-badge-1 { color: #d4a017; }
.tier-badge-2 { color: #576cce; }
.tier-badge-3 { color: #28b29e; }
.tier-badge-4 { color: var(--text-faint); }
.tier-badge-5 { color: var(--text-faint); }
/* ── Responsive ── */
@media (max-width: 640px) {
.lol-search { flex-wrap: wrap; }
@ -498,4 +625,8 @@
.lol-match-meta { margin-left: 0; text-align: left; }
.lol-match-items { flex-wrap: wrap; }
.lol-profile { flex-wrap: wrap; }
.lol-tier-controls { flex-direction: column; align-items: stretch; }
.lol-tier-filter { width: 100%; margin-left: 0; }
.lol-tier-col-br { display: none; }
.lol-tier-col-kda { display: none; }
}