add *
No files matched your search
@@ -0,0 +1,22 @@
|
||||
# Utilise une image Node légère
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copie les fichiers de dépendances
|
||||
COPY package.json .
|
||||
|
||||
# Installe tout
|
||||
RUN npm install
|
||||
|
||||
# Copie le reste du code
|
||||
COPY . .
|
||||
|
||||
# Construit l'application React (crée le dossier dist/)
|
||||
RUN npm run build
|
||||
|
||||
# Expose le port
|
||||
EXPOSE 3002
|
||||
|
||||
# Lance le serveur
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>LoL KDA Generator</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "lol-kda-app",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"lucide-react": "^0.292.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<g fill="none" fillRule="evenodd">
|
||||
<g fill="#9AA4AF" fillRule="nonzero">
|
||||
<g>
|
||||
<path d="M19 3l-4 4H7v8l-4 4V3h16z" opacity=".2" transform="translate(-2164.000000, -827.000000) translate(2164.000000, 827.000000)"/>
|
||||
<path d="M21 5l-4 4H9v8l-4 4V5h16z" transform="translate(-2164.000000, -827.000000) translate(2164.000000, 827.000000) translate(13.000000, 13.000000) rotate(-180.000000) translate(-13.000000, -13.000000)"/>
|
||||
<path d="M10 10H14V14H10z" opacity=".2" transform="translate(-2164.000000, -827.000000) translate(2164.000000, 827.000000)"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 748 B |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<g fill="none" fillRule="evenodd">
|
||||
<g fill="#9AA4AF" fillRule="nonzero">
|
||||
<g>
|
||||
<path d="M5.14 2c1.58 1.21 5.58 5.023 6.976 9.953s0 10.047 0 10.047c-2.749-3.164-5.893-5.2-6.18-5.382l-.02-.013C5.45 13.814 3 8.79 3 8.79c3.536.867 4.93 4.279 4.93 4.279C7.558 8.698 5.14 2 5.14 2zm14.976 5.907s-1.243 2.471-1.814 4.604c-.235.878-.285 2.2-.29 3.058v.282c.003.347.01.568.01.568s-1.738 2.397-3.38 3.678c.088-1.601.062-3.435-.208-5.334.928-2.023 2.846-5.454 5.682-6.856zm-2.124-5.331s-2.325 3.052-2.836 6.029c-.11.636-.201 1.194-.284 1.695-.379.584-.73 1.166-1.05 1.733-.033-.125-.06-.25-.095-.375-.302-1.07-.704-2.095-1.16-3.08.053-.146.103-.29.17-.438 0 0 1.814-3.78 5.255-5.564z" transform="translate(-2164.000000, -763.000000) translate(2164.000000, 763.000000)"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 928 B |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<g fill="none" fillRule="evenodd">
|
||||
<g fill="#9AA4AF" fillRule="nonzero">
|
||||
<g>
|
||||
<path d="M15 3l-4 4H7v4l-4 4V3h12z" opacity=".2" transform="translate(-2164.000000, -795.000000) translate(2164.000000, 795.000000)"/>
|
||||
<path d="M21 9l-4 4h-4v4l-4 4V9h12z" opacity=".2" transform="translate(-2164.000000, -795.000000) translate(2164.000000, 795.000000) translate(15.000000, 15.000000) rotate(-180.000000) translate(-15.000000, -15.000000)"/>
|
||||
<path d="M18 3L21 3 21 6 6 21 3 21 3 18z" transform="translate(-2164.000000, -795.000000) translate(2164.000000, 795.000000)"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 764 B |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<g fill="none" fillRule="evenodd">
|
||||
<g fill="#9AA4AF" fillRule="nonzero">
|
||||
<g>
|
||||
<path d="M19 3l-4 4H7v8l-4 4V3h16z" transform="translate(-2164.000000, -731.000000) translate(2164.000000, 731.000000)"/>
|
||||
<path d="M21 5l-4 4H9v8l-4 4V5h16z" opacity=".2" transform="translate(-2164.000000, -731.000000) translate(2164.000000, 731.000000) translate(13.000000, 13.000000) rotate(-180.000000) translate(-13.000000, -13.000000)"/>
|
||||
<path d="M10 10H14V14H10z" opacity=".2" transform="translate(-2164.000000, -731.000000) translate(2164.000000, 731.000000)"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 748 B |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<g fill="none" fillRule="evenodd">
|
||||
<g fill="#9AA4AF" fillRule="nonzero">
|
||||
<g>
|
||||
<path d="M12.833 10.833L14.5 17.53v.804L12.833 20h-1.666L9.5 18.333v-.804l1.667-6.696h1.666zM7 7.5L9.5 10l-1.667 4.167-2.5-2.5L6.167 10h-2.5L2 7.5h5zm15 0L20.333 10h-2.5l.834 1.667-2.5 2.5L14.5 10 17 7.5h5zM13.743 5l.757.833v.834l-1.667 2.5h-1.666L9.5 6.667v-.834L10.257 5h3.486z" transform="translate(-2164.000000, -859.000000) translate(2164.000000, 859.000000)"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 614 B |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 134 KiB |
@@ -0,0 +1,180 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import axios from 'axios';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
const PORT = 3002;
|
||||
|
||||
// --- VOTRE CLÉ API ---
|
||||
const RIOT_API_KEY = "RGAPI-xxx-xxx-xxx-xxx-xxx";
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.static(path.join(__dirname, 'dist')));
|
||||
|
||||
// --- ROUTAGE ---
|
||||
const getRoutingValue = (region) => {
|
||||
const map = {
|
||||
'EUW': 'euw1', 'EUNE': 'eun1',
|
||||
'NA': 'na1', 'BR': 'br1', 'LAN': 'la1', 'LAS': 'la2',
|
||||
'KR': 'kr', 'JP': 'jp1', 'OCE': 'oc1',
|
||||
'TR': 'tr1', 'RU': 'ru', 'PH': 'ph2', 'SG': 'sg2', 'TH': 'th2', 'TW': 'tw2', 'VN': 'vn2'
|
||||
};
|
||||
return map[region.toUpperCase()] || 'euw1';
|
||||
};
|
||||
|
||||
const getClusterValue = (region) => {
|
||||
if(['NA', 'BR', 'LAN', 'LAS'].includes(region)) return 'americas';
|
||||
if(['KR', 'JP'].includes(region)) return 'asia';
|
||||
if(['PH', 'SG', 'TH', 'TW', 'VN'].includes(region)) return 'sea';
|
||||
return 'europe';
|
||||
};
|
||||
|
||||
// --- NOUVEAU GET RANK (VIA PUUID DIRECTEMENT) ---
|
||||
const getRank = async (puuid, region) => {
|
||||
const route = getRoutingValue(region);
|
||||
|
||||
// URL utilisant DIRECTEMENT le PUUID (Autorisé par votre clé)
|
||||
const url = `https://${route}.api.riotgames.com/lol/league/v4/entries/by-puuid/${puuid}`;
|
||||
|
||||
try {
|
||||
const res = await axios.get(url, { headers: { "X-Riot-Token": RIOT_API_KEY } });
|
||||
|
||||
// On cherche la SoloQ
|
||||
const soloQ = res.data.find(l => l.queueType === 'RANKED_SOLO_5x5');
|
||||
|
||||
if (soloQ) {
|
||||
console.log(`[DEBUG] Rank trouvé pour ${puuid.substring(0,5)}... : ${soloQ.tier} ${soloQ.rank}`);
|
||||
return { tier: soloQ.tier, rank: soloQ.rank };
|
||||
}
|
||||
return { tier: 'UNRANKED', rank: '' };
|
||||
|
||||
} catch (e) {
|
||||
console.error(`[ERROR] Echec Rank sur ${route}: ${e.response?.status} - ${e.response?.statusText}`);
|
||||
return { tier: 'UNRANKED', rank: '' };
|
||||
}
|
||||
};
|
||||
|
||||
// --- HELPERS MATCHS ---
|
||||
const findDuoData = (myPart, participants) => {
|
||||
const myRole = myPart.teamPosition;
|
||||
let targetRole = 'JUNGLE';
|
||||
if (myRole === 'UTILITY') targetRole = 'BOTTOM';
|
||||
if (myRole === 'BOTTOM') targetRole = 'UTILITY';
|
||||
if (myRole === 'JUNGLE') targetRole = 'MIDDLE';
|
||||
if (myRole === 'MIDDLE') targetRole = 'JUNGLE';
|
||||
if (myRole === 'TOP') targetRole = 'JUNGLE';
|
||||
|
||||
const duo = participants.find(p => p.teamId === myPart.teamId && p.teamPosition === targetRole && p.puuid !== myPart.puuid);
|
||||
return duo ? { champion: duo.championName, role: duo.teamPosition } : null;
|
||||
};
|
||||
|
||||
const findOpponentPart = (role, myTeamId, participants) => {
|
||||
if (!role) return null;
|
||||
return participants.find(p => p.teamId !== myTeamId && p.teamPosition === role);
|
||||
};
|
||||
|
||||
// --- API ENDPOINTS ---
|
||||
app.get('/api/versions', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get('https://ddragon.leagueoflegends.com/api/versions.json');
|
||||
res.json(response.data);
|
||||
} catch (error) { res.json(['14.1.1']); }
|
||||
});
|
||||
|
||||
app.get('/api/image', async (req, res) => {
|
||||
const imageUrl = req.query.url;
|
||||
if (!imageUrl) return res.status(400).send('URL manquante');
|
||||
try {
|
||||
const response = await axios.get(imageUrl, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 5000,
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' }
|
||||
});
|
||||
const contentType = imageUrl.endsWith('.svg') ? 'image/svg+xml' : 'image/png';
|
||||
res.set('Content-Type', contentType);
|
||||
res.send(response.data);
|
||||
} catch (error) { res.status(404).send('Image introuvable'); }
|
||||
});
|
||||
|
||||
app.get('/api/riot/:gameName/:tagLine/:region', async (req, res) => {
|
||||
const { gameName, tagLine, region } = req.params;
|
||||
const cluster = getClusterValue(region);
|
||||
|
||||
console.log(`\n=== RECHERCHE: ${gameName}#${tagLine} ===`);
|
||||
|
||||
try {
|
||||
// 1. Account (PUUID)
|
||||
const accUrl = `https://${cluster}.api.riotgames.com/riot/account/v1/accounts/by-riot-id/${encodeURIComponent(gameName)}/${encodeURIComponent(tagLine)}`;
|
||||
const accRes = await axios.get(accUrl, { headers: { "X-Riot-Token": RIOT_API_KEY } });
|
||||
const puuid = accRes.data.puuid;
|
||||
|
||||
// 2. MON RANG (Appel unique optimisé)
|
||||
const myRankData = await getRank(puuid, region);
|
||||
|
||||
// 3. MATCH HISTORY
|
||||
const matUrl = `https://${cluster}.api.riotgames.com/lol/match/v5/matches/by-puuid/${puuid}/ids?start=0&count=5`;
|
||||
const matRes = await axios.get(matUrl, { headers: { "X-Riot-Token": RIOT_API_KEY } });
|
||||
|
||||
// 4. DETAILS DES MATCHS
|
||||
const details = await Promise.all(matRes.data.map(id =>
|
||||
axios.get(`https://${cluster}.api.riotgames.com/lol/match/v5/matches/${id}`, { headers: { "X-Riot-Token": RIOT_API_KEY } }).catch(e=>null)
|
||||
));
|
||||
|
||||
const formatted = await Promise.all(details.filter(r => r && r.data).map(async (r, idx) => {
|
||||
const info = r.data.info;
|
||||
const p = info.participants.find(part => part.puuid === puuid);
|
||||
if (!p) return null;
|
||||
|
||||
const csMin = (info.gameDuration / 60 > 0) ? ((p.totalMinionsKilled + p.neutralMinionsKilled) / (info.gameDuration / 60)).toFixed(1) : "0.0";
|
||||
|
||||
const duoData = findDuoData(p, info.participants);
|
||||
const oppPart = findOpponentPart(p.teamPosition, p.teamId, info.participants);
|
||||
const oppDuoPart = duoData ? findOpponentPart(duoData.role, p.teamId, info.participants) : null;
|
||||
|
||||
// 5. RANG ADVERSAIRE
|
||||
// On limite les appels pour ne pas surcharger la clé (Ex: seulement le premier match a le rang auto, ou avec un délai)
|
||||
let oppRankData = { tier: 'UNRANKED', rank: '' };
|
||||
if (oppPart) {
|
||||
// Petit délai pour la sécurité (Rate Limit)
|
||||
await new Promise(r => setTimeout(r, 100 * idx));
|
||||
oppRankData = await getRank(oppPart.puuid, region);
|
||||
}
|
||||
|
||||
return {
|
||||
id: info.gameId,
|
||||
champion: p.championName,
|
||||
role: p.teamPosition,
|
||||
kills: p.kills, deaths: p.deaths, assists: p.assists,
|
||||
win: p.win,
|
||||
csMin: csMin,
|
||||
|
||||
myRankTier: myRankData.tier,
|
||||
myRankDiv: myRankData.rank,
|
||||
|
||||
oppRankTier: oppRankData.tier,
|
||||
oppRankDiv: oppRankData.rank,
|
||||
|
||||
duoChampion: duoData ? duoData.champion : null,
|
||||
duoRole: duoData ? duoData.role : null,
|
||||
oppChampion: oppPart ? oppPart.championName : null,
|
||||
oppRole: oppPart ? oppPart.teamPosition : null,
|
||||
oppDuoChampion: oppDuoPart ? oppDuoPart.championName : null,
|
||||
oppDuoRole: oppDuoPart ? oppDuoPart.teamPosition : null
|
||||
};
|
||||
}));
|
||||
|
||||
res.json({ matches: formatted.filter(m => m !== null) });
|
||||
|
||||
} catch (error) {
|
||||
console.error("❌ ERREUR:", error.response?.data || error.message);
|
||||
res.status(500).json({ error: "Erreur API Riot" });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('*', (req, res) => { res.sendFile(path.join(__dirname, 'dist', 'index.html')); });
|
||||
app.listen(PORT, '0.0.0.0', () => console.log(`✅ Serveur prêt sur ${PORT}`));
|
||||
@@ -0,0 +1,487 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Download, Search, Loader2, AlertCircle, X } from 'lucide-react';
|
||||
|
||||
export default function KDAOverlayGenerator() {
|
||||
const bannerRef = useRef(null);
|
||||
const [ddragonVersion, setDdragonVersion] = useState('14.1.1');
|
||||
|
||||
// --- ÉTATS INPUTS ---
|
||||
const [gameName, setGameName] = useState('');
|
||||
const [tagLine, setTagLine] = useState('');
|
||||
const [region, setRegion] = useState('EUW');
|
||||
|
||||
// --- HISTORIQUE ---
|
||||
const [recentSearches, setRecentSearches] = useState([]);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [matches, setMatches] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
// --- DONNÉES MANUELLES ---
|
||||
const [manualData, setManualData] = useState({
|
||||
champion: 'Jinx', role: 'BOTTOM',
|
||||
kills: '13', deaths: '8', assists: '7', kda: '2.5', csMin: '8.5',
|
||||
rankTier: 'UNRANKED', rankDiv: 'IV',
|
||||
oppRankTier: 'UNRANKED', oppRankDiv: 'I',
|
||||
teammate: 'Thresh', teammateRole: 'UTILITY',
|
||||
oppChampion: 'Caitlyn', oppRole: 'BOTTOM',
|
||||
oppTeammate: 'Lux', oppTeammateRole: 'UTILITY',
|
||||
victory: true
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/versions').then(r => r.json()).then(v => { if(v?.length) setDdragonVersion(v[0]); }).catch(console.error);
|
||||
const savedHistory = localStorage.getItem('lol_kda_history');
|
||||
if (savedHistory) setRecentSearches(JSON.parse(savedHistory));
|
||||
}, []);
|
||||
|
||||
// --- HELPERS ---
|
||||
const getChampionIcon = (name) => {
|
||||
if(!name) return '';
|
||||
let clean = name.replace(/['\s.]/g, '');
|
||||
if(clean === 'Wukong') clean = 'MonkeyKing';
|
||||
if(clean === 'RenataGlasc') clean = 'Renata';
|
||||
const riotUrl = `https://ddragon.leagueoflegends.com/cdn/${ddragonVersion}/img/champion/${clean}.png`;
|
||||
return `/api/image?url=${encodeURIComponent(riotUrl)}`;
|
||||
};
|
||||
|
||||
const getRoleIcon = (role) => {
|
||||
if (!role) return '';
|
||||
const map = { 'TOP':'TOP', 'JUNGLE':'JUNGLE', 'MIDDLE':'MIDDLE', 'MID':'MIDDLE', 'BOTTOM':'BOTTOM', 'UTILITY':'UTILITY', 'SUPPORT':'UTILITY' };
|
||||
const fileName = map[role] || 'UTILITY';
|
||||
return `/img/${fileName}.svg`;
|
||||
};
|
||||
|
||||
const getRankIcon = (tier) => {
|
||||
if (!tier || tier === 'UNRANKED') return '';
|
||||
return `/img/emblem-${tier.toLowerCase()}.png`;
|
||||
};
|
||||
|
||||
const calculateKDA = (k, d, a) => {
|
||||
const dNum = parseInt(d) || 0;
|
||||
if (dNum === 0) return 'Perfect';
|
||||
return ((parseInt(k) + parseInt(a)) / dNum).toFixed(1);
|
||||
};
|
||||
|
||||
// --- LOGIQUE HISTORIQUE ---
|
||||
const saveToHistory = (name, tag) => {
|
||||
const newItem = { name, tag, region };
|
||||
const filtered = recentSearches.filter(item => !(item.name.toLowerCase() === name.toLowerCase() && item.tag.toLowerCase() === tag.toLowerCase()));
|
||||
const newHistory = [newItem, ...filtered].slice(0, 5);
|
||||
setRecentSearches(newHistory);
|
||||
localStorage.setItem('lol_kda_history', JSON.stringify(newHistory));
|
||||
};
|
||||
|
||||
const loadFromHistory = (item) => {
|
||||
setGameName(item.name);
|
||||
setTagLine(item.tag);
|
||||
setShowHistory(false);
|
||||
};
|
||||
|
||||
const deleteFromHistory = (e, index) => {
|
||||
e.stopPropagation();
|
||||
const newHistory = [...recentSearches];
|
||||
newHistory.splice(index, 1);
|
||||
setRecentSearches(newHistory);
|
||||
localStorage.setItem('lol_kda_history', JSON.stringify(newHistory));
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter') fetchMatches();
|
||||
};
|
||||
|
||||
// --- API ---
|
||||
const fetchMatches = async () => {
|
||||
if (!gameName.trim() || !tagLine.trim()) return setError('Remplissez Nom et Tag');
|
||||
const cleanTag = tagLine.replace('#', '');
|
||||
|
||||
setLoading(true); setError(''); setMatches([]); setShowHistory(false);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/riot/${encodeURIComponent(gameName)}/${encodeURIComponent(cleanTag)}/${region}`);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Erreur inconnue');
|
||||
setMatches(data.matches.map((m, i) => ({ id: i, ...m, victory: m.win })));
|
||||
saveToHistory(gameName, cleanTag);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectMatch = (match) => {
|
||||
setManualData(prev => {
|
||||
const myTier = (match.myRankTier && match.myRankTier !== 'undefined') ? match.myRankTier : prev.rankTier;
|
||||
const myDiv = (match.myRankDiv && match.myRankDiv !== 'undefined') ? match.myRankDiv : prev.rankDiv;
|
||||
|
||||
const oppTier = (match.oppRankTier && match.oppRankTier !== 'undefined') ? match.oppRankTier : prev.oppRankTier;
|
||||
const oppDiv = (match.oppRankDiv && match.oppRankDiv !== 'undefined') ? match.oppRankDiv : prev.oppRankDiv;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
champion: match.champion, role: match.role,
|
||||
kills: match.kills, deaths: match.deaths, assists: match.assists,
|
||||
kda: calculateKDA(match.kills, match.deaths, match.assists),
|
||||
victory: match.victory,
|
||||
csMin: match.csMin,
|
||||
rankTier: myTier, rankDiv: myDiv,
|
||||
oppRankTier: oppTier, oppRankDiv: oppDiv,
|
||||
teammate: match.duoChampion, teammateRole: match.duoRole,
|
||||
oppChampion: match.oppChampion, oppRole: match.oppRole,
|
||||
oppTeammate: match.oppDuoChampion, oppTeammateRole: match.oppDuoRole
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleManualChange = (field, value) => {
|
||||
setManualData(prev => {
|
||||
const updated = { ...prev, [field]: value };
|
||||
if (['kills','deaths','assists'].includes(field)) {
|
||||
updated.kda = calculateKDA(
|
||||
field === 'kills' ? value : prev.kills,
|
||||
field === 'deaths' ? value : prev.deaths,
|
||||
field === 'assists' ? value : prev.assists
|
||||
);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
// --- DESSIN PNG ---
|
||||
const downloadBanner = async () => {
|
||||
if (!bannerRef.current) return;
|
||||
setIsGenerating(true);
|
||||
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const height = 170;
|
||||
const exportWidth = 700;
|
||||
|
||||
canvas.width = exportWidth * 2; canvas.height = height * 2;
|
||||
ctx.scale(2, 2);
|
||||
|
||||
// Fond
|
||||
const gradient = ctx.createLinearGradient(0, 0, exportWidth, 0);
|
||||
if (manualData.victory) { gradient.addColorStop(0, '#312e81'); gradient.addColorStop(1, '#1e3a8a'); }
|
||||
else { gradient.addColorStop(0, '#7f1d1d'); gradient.addColorStop(1, '#881337'); }
|
||||
ctx.fillStyle = gradient; ctx.fillRect(0, 0, exportWidth, height);
|
||||
ctx.fillStyle = manualData.victory ? '#5383E8' : '#E84057'; ctx.fillRect(0, 0, 8, height);
|
||||
|
||||
const loadImage = (src) => new Promise((resolve) => {
|
||||
if(!src) return resolve(null);
|
||||
const img = new Image(); img.crossOrigin = 'anonymous';
|
||||
img.onload = () => resolve(img); img.onerror = () => resolve(null); img.src = src;
|
||||
});
|
||||
|
||||
// Fonction Dessin RANG (Texte + Gros + Centré)
|
||||
const drawRankBadge = async (tier, div, x, y, align) => {
|
||||
if (!tier || tier === 'UNRANKED') return;
|
||||
const rankImg = await loadImage(getRankIcon(tier));
|
||||
if (rankImg) {
|
||||
const size = 38; // Logo un poil plus gros
|
||||
ctx.drawImage(rankImg, x, y, size, size);
|
||||
|
||||
// Police agrandie
|
||||
ctx.font = 'bold 15px Arial';
|
||||
ctx.fillStyle = '#C8AA6E';
|
||||
ctx.textAlign = align === 'left' ? 'left' : 'right';
|
||||
|
||||
// Espace augmenté entre logo et texte (10px)
|
||||
const textX = align === 'left' ? x + size + 8 : x - 8;
|
||||
const textY = y + 24;
|
||||
ctx.fillText(`${tier} ${div}`, textX, textY);
|
||||
}
|
||||
};
|
||||
|
||||
const drawCluster = async (champ, role, duo, duoRole, startX, isLeft, rTier, rDiv) => {
|
||||
const mSize = 75; const dSize = 55;
|
||||
if(duo) {
|
||||
const duoImg = await loadImage(getChampionIcon(duo));
|
||||
const duoRoleImg = await loadImage(getRoleIcon(duoRole));
|
||||
if(duoImg) {
|
||||
const dX = isLeft ? startX + 45 : startX - 45 - dSize;
|
||||
const dY = 45;
|
||||
ctx.beginPath(); ctx.arc(dX + dSize/2, dY + dSize/2, dSize/2 + 2, 0, Math.PI * 2);
|
||||
ctx.fillStyle = "rgba(0,0,0,0.6)"; ctx.fill();
|
||||
ctx.save(); ctx.beginPath(); ctx.arc(dX + dSize/2, dY + dSize/2, dSize/2, 0, Math.PI * 2); ctx.closePath(); ctx.clip();
|
||||
ctx.drawImage(duoImg, dX, dY, dSize, dSize); ctx.restore();
|
||||
ctx.strokeStyle = isLeft ? '#a78bfa' : '#ef4444'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(dX + dSize/2, dY + dSize/2, dSize/2, 0, Math.PI * 2); ctx.stroke();
|
||||
if (duoRoleImg) {
|
||||
const rSize = 20; const rX = isLeft ? dX + dSize - rSize + 5 : dX - 5; const rY = dY + dSize - rSize + 5;
|
||||
ctx.fillStyle = "#1e1e1e"; ctx.fillRect(rX, rY, rSize, rSize);
|
||||
ctx.strokeStyle = "#555"; ctx.lineWidth = 1; ctx.strokeRect(rX, rY, rSize, rSize);
|
||||
ctx.drawImage(duoRoleImg, rX + 2, rY + 2, rSize - 4, rSize - 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
const myImg = await loadImage(getChampionIcon(champ));
|
||||
const myRoleImg = await loadImage(getRoleIcon(role));
|
||||
if(myImg) {
|
||||
const mX = isLeft ? startX : startX - mSize;
|
||||
const mY = 47;
|
||||
ctx.beginPath(); ctx.arc(mX + mSize/2, mY + mSize/2, mSize/2 + 3, 0, Math.PI * 2); ctx.fillStyle = "#1a1a1a"; ctx.fill();
|
||||
ctx.save(); ctx.beginPath(); ctx.arc(mX + mSize/2, mY + mSize/2, mSize/2, 0, Math.PI * 2); ctx.closePath(); ctx.clip();
|
||||
ctx.drawImage(myImg, mX, mY, mSize, mSize); ctx.restore();
|
||||
ctx.strokeStyle = isLeft ? '#C8AA6E' : '#dc2626'; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(mX + mSize/2, mY + mSize/2, mSize/2, 0, Math.PI * 2); ctx.stroke();
|
||||
if (myRoleImg) {
|
||||
const rSize = 24; const rX = isLeft ? mX : mX + mSize - rSize; const rY = mY + mSize - rSize;
|
||||
ctx.fillStyle = "#111"; ctx.fillRect(rX, rY, rSize, rSize);
|
||||
ctx.strokeStyle = isLeft ? "#C8AA6E" : "#dc2626"; ctx.lineWidth = 1; ctx.strokeRect(rX, rY, rSize, rSize);
|
||||
ctx.drawImage(myRoleImg, rX + 2, rY + 2, rSize - 4, rSize - 4);
|
||||
}
|
||||
|
||||
// DESSIN DU RANG (Ajusté vers le centre)
|
||||
// On ajoute +15px de décalage vers la droite (pour la gauche) et vers la gauche (pour la droite)
|
||||
const offset = 15;
|
||||
if (isLeft) {
|
||||
await drawRankBadge(rTier, rDiv, mX + 10 + offset, mY + mSize + 10, 'left');
|
||||
} else {
|
||||
await drawRankBadge(rTier, rDiv, mX + mSize - 35 - 10 - offset, mY + mSize + 10, 'right');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await drawCluster(manualData.champion, manualData.role, manualData.teammate, manualData.teammateRole, 20, true, manualData.rankTier, manualData.rankDiv);
|
||||
await drawCluster(manualData.oppChampion, manualData.oppRole, manualData.oppTeammate, manualData.oppTeammateRole, exportWidth - 20, false, manualData.oppRankTier, manualData.oppRankDiv);
|
||||
|
||||
const cx = exportWidth / 2; const cy = height / 2;
|
||||
ctx.textAlign = "center"; ctx.font = 'bold 36px Arial';
|
||||
const kdaY = cy - 8;
|
||||
ctx.fillStyle = '#ffffff'; ctx.fillText(manualData.kills, cx - 70, kdaY);
|
||||
ctx.fillStyle = '#666'; ctx.font = '28px Arial'; ctx.fillText('/', cx - 35, kdaY);
|
||||
ctx.fillStyle = '#ff5859'; ctx.font = 'bold 36px Arial'; ctx.fillText(manualData.deaths, cx, kdaY);
|
||||
ctx.fillStyle = '#666'; ctx.font = '28px Arial'; ctx.fillText('/', cx + 35, kdaY);
|
||||
ctx.fillStyle = '#ffffff'; ctx.font = 'bold 36px Arial'; ctx.fillText(manualData.assists, cx + 70, kdaY);
|
||||
|
||||
const statsY = cy + 22;
|
||||
ctx.font = 'bold 16px Arial';
|
||||
ctx.textAlign = "right"; ctx.fillStyle = '#00d1b2'; ctx.fillText(`${manualData.kda} KDA`, cx - 10, statsY);
|
||||
ctx.textAlign = "center"; ctx.fillStyle = '#666'; ctx.fillText("•", cx, statsY);
|
||||
ctx.textAlign = "left"; ctx.fillStyle = '#ccc'; ctx.fillText(`${manualData.csMin} CS/m`, cx + 10, statsY);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.download = `kda-vs-${Date.now()}.png`;
|
||||
link.href = canvas.toDataURL('image/png');
|
||||
link.click();
|
||||
} catch (e) {
|
||||
alert("Erreur : " + e.message);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#111] p-6 font-sans text-white" onClick={() => setShowHistory(false)}>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-8 text-center text-yellow-500 tracking-wider">LoL Montage Tool <span className="text-white text-lg font-normal opacity-50">| VS Mode</span></h1>
|
||||
|
||||
<div className="flex flex-col xl:flex-row gap-8 items-start">
|
||||
|
||||
<div className="w-full xl:w-1/3 space-y-6">
|
||||
|
||||
{/* --- INPUTS --- */}
|
||||
<div className="bg-gray-800 p-4 rounded-xl border border-gray-700 shadow-xl relative z-50">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
value={gameName}
|
||||
onChange={e => setGameName(e.target.value)}
|
||||
onFocus={() => setShowHistory(true)}
|
||||
onClick={(e) => {e.stopPropagation(); setShowHistory(true);}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Nom du Joueur"
|
||||
className="w-full bg-gray-900 p-3 rounded-l-lg border border-gray-600 focus:border-yellow-500 outline-none text-white font-bold"
|
||||
/>
|
||||
{showHistory && recentSearches.length > 0 && (
|
||||
<div className="absolute top-full left-0 w-[150%] bg-gray-800 border border-gray-600 mt-1 rounded-lg shadow-xl overflow-hidden z-50">
|
||||
<div className="p-2 text-[10px] text-gray-400 uppercase tracking-widest bg-gray-900/50 flex justify-between items-center">
|
||||
<span>Récents</span>
|
||||
<button onClick={(e)=>{e.stopPropagation(); setRecentSearches([]); localStorage.removeItem('lol_kda_history');}} className="hover:text-red-400"><X size={12}/></button>
|
||||
</div>
|
||||
{recentSearches.map((item, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
onClick={(e) => { e.stopPropagation(); loadFromHistory(item); }}
|
||||
className="p-3 hover:bg-gray-700 cursor-pointer flex justify-between items-center border-b border-gray-700 last:border-0"
|
||||
>
|
||||
<div>
|
||||
<div className="font-bold text-sm text-white">{item.name}</div>
|
||||
<div className="text-xs text-gray-400">#{item.tag}</div>
|
||||
</div>
|
||||
<button onClick={(e)=>deleteFromHistory(e, idx)} className="text-gray-500 hover:text-red-400 p-1"><X size={14}/></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center bg-gray-900 border-y border-r border-gray-600 rounded-r-lg px-2">
|
||||
<span className="text-gray-500 font-bold mr-1">#</span>
|
||||
<input
|
||||
value={tagLine}
|
||||
onChange={e => setTagLine(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="TAG"
|
||||
className="w-16 bg-transparent outline-none text-white font-bold uppercase"
|
||||
/>
|
||||
</div>
|
||||
<button onClick={fetchMatches} disabled={loading} className="bg-blue-600 hover:bg-blue-500 px-4 rounded-lg transition disabled:opacity-50">
|
||||
{loading ? <Loader2 className="animate-spin"/> : <Search/>}
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="mt-3 text-sm text-red-400 bg-red-900/20 p-2 rounded flex items-center gap-2"><AlertCircle size={16}/> {error}</div>}
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 p-4 rounded-xl border border-gray-700 shadow-xl max-h-[400px] overflow-y-auto custom-scrollbar relative z-0">
|
||||
<h3 className="text-gray-400 text-xs font-bold mb-4 uppercase tracking-wider">Historique</h3>
|
||||
{matches.length === 0 ? <div className="text-gray-500 text-center italic py-8">Aucun match chargé</div> : (
|
||||
<div className="space-y-2">
|
||||
{matches.map(m => (
|
||||
<button key={m.id} onClick={() => selectMatch(m)} className={`w-full flex items-center justify-between p-3 rounded-lg hover:bg-gray-700 transition group border-l-4 ${m.victory ? 'border-blue-500 bg-blue-900/10' : 'border-red-500 bg-red-900/10'}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<img src={getChampionIcon(m.champion)} className="w-10 h-10 rounded-full border border-gray-600"/>
|
||||
<div className="text-left leading-tight"><div className="font-bold text-sm text-gray-200">{m.champion}</div><div className="text-[10px] text-gray-400">{m.role}</div></div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 opacity-60 group-hover:opacity-100 transition">
|
||||
<div className="text-right leading-tight"><div className="text-xs font-bold text-gray-300">{m.oppChampion || '?'}</div><div className="text-[10px] text-red-400">VS</div></div>
|
||||
<img src={getChampionIcon(m.oppChampion)} className="w-8 h-8 rounded-full border border-red-900/50"/>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 p-5 rounded-xl border border-gray-700 shadow-xl relative z-0">
|
||||
<h3 className="text-gray-400 text-xs font-bold mb-4 uppercase tracking-wider">Éditeur Manuel</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 p-3 bg-gray-900/50 rounded-lg border border-gray-700/50">
|
||||
<div><label className="text-[10px] text-blue-400 uppercase font-bold mb-1 block">Moi</label><input className="bg-gray-800 w-full p-2 rounded text-sm border border-gray-600" value={manualData.champion} onChange={e=>handleManualChange('champion', e.target.value)}/></div>
|
||||
<div><label className="text-[10px] text-red-400 uppercase font-bold mb-1 block">Adversaire</label><input className="bg-gray-800 w-full p-2 rounded text-sm border border-gray-600" value={manualData.oppChampion || ''} onChange={e=>handleManualChange('oppChampion', e.target.value)}/></div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 p-3 bg-gray-900/50 rounded-lg border border-gray-700/50">
|
||||
<div>
|
||||
<label className="text-[10px] text-yellow-500 uppercase font-bold mb-1 block">Mon Rang</label>
|
||||
<select className="bg-gray-800 w-full p-1 rounded text-xs" value={manualData.rankTier} onChange={e=>handleManualChange('rankTier', e.target.value)}>
|
||||
<option value="UNRANKED">Unr.</option><option value="IRON">Iron</option><option value="BRONZE">Bronze</option><option value="SILVER">Silver</option><option value="GOLD">Gold</option><option value="PLATINUM">Plat</option><option value="EMERALD">Emer</option><option value="DIAMOND">Dia</option><option value="MASTER">Mas</option><option value="GRANDMASTER">GM</option><option value="CHALLENGER">Chall</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-yellow-500 uppercase font-bold mb-1 block">Division</label>
|
||||
<select className="bg-gray-800 w-full p-1 rounded text-xs" value={manualData.rankDiv} onChange={e=>handleManualChange('rankDiv', e.target.value)}>
|
||||
<option value="I">I</option><option value="II">II</option><option value="III">III</option><option value="IV">IV</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 p-3 bg-red-900/20 rounded-lg border border-red-700/50">
|
||||
<div>
|
||||
<label className="text-[10px] text-red-400 uppercase font-bold mb-1 block">Rang Adv.</label>
|
||||
<select className="bg-gray-800 w-full p-1 rounded text-xs" value={manualData.oppRankTier} onChange={e=>handleManualChange('oppRankTier', e.target.value)}>
|
||||
<option value="UNRANKED">Unr.</option><option value="IRON">Iron</option><option value="BRONZE">Bronze</option><option value="SILVER">Silver</option><option value="GOLD">Gold</option><option value="PLATINUM">Plat</option><option value="EMERALD">Emer</option><option value="DIAMOND">Dia</option><option value="MASTER">Mas</option><option value="GRANDMASTER">GM</option><option value="CHALLENGER">Chall</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-red-400 uppercase font-bold mb-1 block">Div. Adv.</label>
|
||||
<select className="bg-gray-800 w-full p-1 rounded text-xs" value={manualData.oppRankDiv} onChange={e=>handleManualChange('oppRankDiv', e.target.value)}>
|
||||
<option value="I">I</option><option value="II">II</option><option value="III">III</option><option value="IV">IV</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className="col-span-1"><label className="text-[10px] text-gray-500 uppercase block mb-1">Kills</label><input type="number" className="bg-gray-800 w-full p-2 rounded text-center font-bold" value={manualData.kills} onChange={e=>handleManualChange('kills', e.target.value)}/></div>
|
||||
<div className="col-span-1"><label className="text-[10px] text-gray-500 uppercase block mb-1">Deaths</label><input type="number" className="bg-gray-800 w-full p-2 rounded text-center font-bold text-red-400" value={manualData.deaths} onChange={e=>handleManualChange('deaths', e.target.value)}/></div>
|
||||
<div className="col-span-1"><label className="text-[10px] text-gray-500 uppercase block mb-1">Assists</label><input type="number" className="bg-gray-800 w-full p-2 rounded text-center font-bold" value={manualData.assists} onChange={e=>handleManualChange('assists', e.target.value)}/></div>
|
||||
<div className="col-span-1"><label className="text-[10px] text-gray-500 uppercase block mb-1">CS/m</label><input type="text" className="bg-gray-800 w-full p-2 rounded text-center text-sm" value={manualData.csMin} onChange={e=>handleManualChange('csMin', e.target.value)}/></div>
|
||||
</div>
|
||||
<select className="w-full bg-gray-800 p-3 rounded-lg border border-gray-600 text-sm font-bold" value={manualData.victory?'victory':'defeat'} onChange={e => handleManualChange('victory', e.target.value === 'victory')}>
|
||||
<option value="victory">🔵 Victoire</option>
|
||||
<option value="defeat">🔴 Défaite</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full xl:w-2/3 xl:sticky xl:top-8 relative z-0">
|
||||
<div className="bg-gray-800 p-8 rounded-xl border border-gray-700 shadow-2xl flex flex-col items-center">
|
||||
<h2 className="text-gray-400 text-sm font-bold mb-6 uppercase tracking-widest flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span> Aperçu en temps réel
|
||||
</h2>
|
||||
|
||||
<div
|
||||
ref={bannerRef}
|
||||
className="relative w-[700px] h-[170px] rounded-lg overflow-hidden shadow-2xl transition-all select-none origin-top scale-75 md:scale-100"
|
||||
style={{ background: manualData.victory ? 'linear-gradient(90deg, #312e81 0%, #1e3a8a 100%)' : 'linear-gradient(90deg, #7f1d1d 0%, #881337 100%)' }}
|
||||
>
|
||||
<div className={`absolute left-0 top-0 bottom-0 w-[8px] ${manualData.victory ? 'bg-[#5383E8]' : 'bg-[#E84057]'}`}></div>
|
||||
|
||||
<div className="absolute left-[20px] top-0 h-full w-[150px]">
|
||||
{manualData.teammate && (
|
||||
<>
|
||||
<img src={getChampionIcon(manualData.teammate)} className="absolute top-[45px] left-[45px] w-[55px] h-[55px] rounded-full border-2 border-purple-400 opacity-80 z-0 bg-black object-cover shadow-lg"/>
|
||||
{manualData.teammateRole && <div className="absolute top-[83px] left-[85px] w-[20px] h-[20px] bg-[#1e1e1e] border border-[#555] z-10 flex items-center justify-center shadow"><img src={getRoleIcon(manualData.teammateRole)} className="w-[16px] h-[16px]"/></div>}
|
||||
</>
|
||||
)}
|
||||
<img src={getChampionIcon(manualData.champion)} className="absolute top-[47px] left-0 w-[75px] h-[75px] rounded-full border-[3px] border-[#C8AA6E] z-20 bg-black object-cover shadow-xl"/>
|
||||
{manualData.role && <div className="absolute top-[98px] left-[0px] w-[24px] h-[24px] bg-[#111] border border-[#C8AA6E] z-30 flex items-center justify-center shadow-md"><img src={getRoleIcon(manualData.role)} className="w-[20px] h-[20px]"/></div>}
|
||||
|
||||
{/* RANG HTML - GAUCHE (Décalé vers la droite) */}
|
||||
{manualData.rankTier && manualData.rankTier !== 'UNRANKED' && (
|
||||
<div className="absolute top-[135px] left-[20px] flex items-center gap-1">
|
||||
<img src={getRankIcon(manualData.rankTier)} className="w-[30px] h-[30px]"/>
|
||||
<span className="text-[12px] font-bold text-[#C8AA6E] uppercase">{manualData.rankTier} {manualData.rankDiv}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center z-30 pointer-events-none">
|
||||
<div className="flex items-center gap-3 text-4xl font-bold font-sans mb-1 drop-shadow-lg" style={{marginTop: '-10px'}}>
|
||||
<span>{manualData.kills}</span><span className="text-gray-500 text-3xl">/</span><span className="text-[#ff5859]">{manualData.deaths}</span><span className="text-gray-500 text-3xl">/</span><span>{manualData.assists}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-base font-medium drop-shadow-md">
|
||||
<span className="text-[#00d1b2] font-bold">{manualData.kda} KDA</span><span className="text-gray-500">•</span><span className="text-gray-300">{manualData.csMin} CS/m</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute right-[20px] top-0 h-full w-[150px]">
|
||||
{manualData.oppTeammate && (
|
||||
<>
|
||||
<img src={getChampionIcon(manualData.oppTeammate)} className="absolute top-[45px] right-[45px] w-[55px] h-[55px] rounded-full border-2 border-red-800 opacity-80 z-0 bg-black object-cover shadow-lg"/>
|
||||
{manualData.oppTeammateRole && <div className="absolute top-[83px] right-[85px] w-[20px] h-[20px] bg-[#1e1e1e] border border-[#555] z-10 flex items-center justify-center shadow"><img src={getRoleIcon(manualData.oppTeammateRole)} className="w-[16px] h-[16px]"/></div>}
|
||||
</>
|
||||
)}
|
||||
{manualData.oppChampion && (
|
||||
<>
|
||||
<img src={getChampionIcon(manualData.oppChampion)} className="absolute top-[47px] right-0 w-[75px] h-[75px] rounded-full border-[3px] border-red-600 z-20 bg-black object-cover shadow-xl"/>
|
||||
{manualData.oppRole && <div className="absolute top-[98px] right-[51px] w-[24px] h-[24px] bg-[#111] border border-red-600 z-30 flex items-center justify-center shadow-md"><img src={getRoleIcon(manualData.oppRole)} className="w-[20px] h-[20px]"/></div>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* RANG HTML - DROITE (Décalé vers la gauche) */}
|
||||
{manualData.oppRankTier && manualData.oppRankTier !== 'UNRANKED' && (
|
||||
<div className="absolute top-[135px] right-[20px] flex items-center justify-end gap-1">
|
||||
<span className="text-[12px] font-bold text-[#C8AA6E] uppercase">{manualData.oppRankTier} {manualData.oppRankDiv}</span>
|
||||
<img src={getRankIcon(manualData.oppRankTier)} className="w-[30px] h-[30px]"/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex flex-col items-center gap-2">
|
||||
<button onClick={downloadBanner} disabled={isGenerating} className="bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-500 hover:to-emerald-500 text-white px-10 py-4 rounded-xl shadow-lg font-bold text-lg flex items-center gap-3 transform hover:-translate-y-1 transition disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{isGenerating ? <Loader2 className="animate-spin"/> : <Download size={24}/>} {isGenerating ? "Génération..." : "Télécharger l'image"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3002'
|
||||
}
|
||||
}
|
||||
})
|
||||