diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b16f8a7 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/index.html b/index.html new file mode 100644 index 0000000..c1d756f --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + LoL KDA Generator + + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..3a9325a --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/img/BOTTOM.svg b/public/img/BOTTOM.svg new file mode 100644 index 0000000..d3d50f0 --- /dev/null +++ b/public/img/BOTTOM.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/img/JUNGLE.svg b/public/img/JUNGLE.svg new file mode 100644 index 0000000..65ce3c0 --- /dev/null +++ b/public/img/JUNGLE.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/img/MIDDLE.svg b/public/img/MIDDLE.svg new file mode 100644 index 0000000..683f455 --- /dev/null +++ b/public/img/MIDDLE.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/img/TOP.svg b/public/img/TOP.svg new file mode 100644 index 0000000..201c090 --- /dev/null +++ b/public/img/TOP.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/img/UTILITY.svg b/public/img/UTILITY.svg new file mode 100644 index 0000000..cf8f5a9 --- /dev/null +++ b/public/img/UTILITY.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/img/emblem-bronze.png b/public/img/emblem-bronze.png new file mode 100644 index 0000000..104c493 Binary files /dev/null and b/public/img/emblem-bronze.png differ diff --git a/public/img/emblem-challenger.png b/public/img/emblem-challenger.png new file mode 100644 index 0000000..fc62435 Binary files /dev/null and b/public/img/emblem-challenger.png differ diff --git a/public/img/emblem-diamond.png b/public/img/emblem-diamond.png new file mode 100644 index 0000000..d78cb14 Binary files /dev/null and b/public/img/emblem-diamond.png differ diff --git a/public/img/emblem-emerald.png b/public/img/emblem-emerald.png new file mode 100644 index 0000000..a2e6505 Binary files /dev/null and b/public/img/emblem-emerald.png differ diff --git a/public/img/emblem-gold.png b/public/img/emblem-gold.png new file mode 100644 index 0000000..cb41686 Binary files /dev/null and b/public/img/emblem-gold.png differ diff --git a/public/img/emblem-grandmaster.png b/public/img/emblem-grandmaster.png new file mode 100644 index 0000000..ad696aa Binary files /dev/null and b/public/img/emblem-grandmaster.png differ diff --git a/public/img/emblem-iron.png b/public/img/emblem-iron.png new file mode 100644 index 0000000..133aecd Binary files /dev/null and b/public/img/emblem-iron.png differ diff --git a/public/img/emblem-master.png b/public/img/emblem-master.png new file mode 100644 index 0000000..a769db6 Binary files /dev/null and b/public/img/emblem-master.png differ diff --git a/public/img/emblem-platinum.png b/public/img/emblem-platinum.png new file mode 100644 index 0000000..440ce98 Binary files /dev/null and b/public/img/emblem-platinum.png differ diff --git a/public/img/emblem-silver.png b/public/img/emblem-silver.png new file mode 100644 index 0000000..860e0f4 Binary files /dev/null and b/public/img/emblem-silver.png differ diff --git a/server.js b/server.js new file mode 100644 index 0000000..09083d7 --- /dev/null +++ b/server.js @@ -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}`)); diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..0f9b8d2 --- /dev/null +++ b/src/App.jsx @@ -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 ( +
setShowHistory(false)}> +
+

LoL Montage Tool | VS Mode

+ +
+ +
+ + {/* --- INPUTS --- */} +
+
+
+ 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 && ( +
+
+ Récents + +
+ {recentSearches.map((item, idx) => ( +
{ 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" + > +
+
{item.name}
+
#{item.tag}
+
+ +
+ ))} +
+ )} +
+
+ # + setTagLine(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="TAG" + className="w-16 bg-transparent outline-none text-white font-bold uppercase" + /> +
+ +
+ {error &&
{error}
} +
+ +
+

Historique

+ {matches.length === 0 ?
Aucun match chargé
: ( +
+ {matches.map(m => ( + + ))} +
+ )} +
+ +
+

Éditeur Manuel

+
+
+
handleManualChange('champion', e.target.value)}/>
+
handleManualChange('oppChampion', e.target.value)}/>
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
handleManualChange('kills', e.target.value)}/>
+
handleManualChange('deaths', e.target.value)}/>
+
handleManualChange('assists', e.target.value)}/>
+
handleManualChange('csMin', e.target.value)}/>
+
+ +
+
+
+ +
+
+

+ Aperçu en temps réel +

+ +
+
+ +
+ {manualData.teammate && ( + <> + + {manualData.teammateRole &&
} + + )} + + {manualData.role &&
} + + {/* RANG HTML - GAUCHE (Décalé vers la droite) */} + {manualData.rankTier && manualData.rankTier !== 'UNRANKED' && ( +
+ + {manualData.rankTier} {manualData.rankDiv} +
+ )} +
+ +
+
+ {manualData.kills}/{manualData.deaths}/{manualData.assists} +
+
+ {manualData.kda} KDA•{manualData.csMin} CS/m +
+
+ +
+ {manualData.oppTeammate && ( + <> + + {manualData.oppTeammateRole &&
} + + )} + {manualData.oppChampion && ( + <> + + {manualData.oppRole &&
} + + )} + + {/* RANG HTML - DROITE (Décalé vers la gauche) */} + {manualData.oppRankTier && manualData.oppRankTier !== 'UNRANKED' && ( +
+ {manualData.oppRankTier} {manualData.oppRankDiv} + +
+ )} +
+
+ +
+ +
+
+
+ +
+
+
+ ); +} diff --git a/src/main.jsx b/src/main.jsx new file mode 100644 index 0000000..51a8c58 --- /dev/null +++ b/src/main.jsx @@ -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( + + + , +) diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..228498f --- /dev/null +++ b/vite.config.js @@ -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' + } + } +})