diff --git a/.github/workflows/vercel-build-check.yml b/.github/workflows/vercel-build-check.yml new file mode 100644 index 0000000..7641f52 --- /dev/null +++ b/.github/workflows/vercel-build-check.yml @@ -0,0 +1,40 @@ +name: Vercel Build Check + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + + - name: Install dependencies + run: | + cd client + npm install + + - name: Build React app + run: | + cd client + npm run build + + - name: Install API dependencies + run: | + cd api + npm install + + - name: Check API files + run: | + ls -la api/ + ls -la api/_lib/ diff --git a/.vercelignore b/.vercelignore new file mode 100644 index 0000000..efe58c7 --- /dev/null +++ b/.vercelignore @@ -0,0 +1,32 @@ +# Ignore server directory (not needed for Vercel deployment) +server/ + +# Node modules +node_modules/ +*/node_modules/ + +# Environment files (set in Vercel dashboard) +.env +.env.local +.env*.local + +# Build outputs +client/build/ +.next/ +dist/ + +# Development files +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# OS files +.DS_Store +Thumbs.db + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo diff --git a/README.md b/README.md index bed533e..e91a4d2 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,36 @@ -# League Stats App +# ShapeSplitter - League of Legends Personality Analyzer -A League of Legends player statistics web application built with React and Node.js. +A League of Legends player statistics and personality analysis web application. Analyze players' personalities based on their gameplay patterns, find compatible duo partners, and chat with an AI-powered digital twin. + +## ๐Ÿš€ Deploy to Vercel (Recommended) + +This project is optimized for Vercel serverless deployment! + +**Quick Deploy:** +1. Push to GitHub +2. Import to Vercel +3. Add `RIOT_API_KEY` environment variable +4. Deploy! ๐ŸŽ‰ + +**๐Ÿ“– Complete Guide:** See [READY_TO_DEPLOY.md](READY_TO_DEPLOY.md) for detailed instructions. + +**๐Ÿ“‹ Step-by-Step:** See [DEPLOYMENT_CHECKLIST.md](DEPLOYMENT_CHECKLIST.md) ## Project Structure -- `client/` - React frontend application -- `server/` - Node.js backend API server -- Root level contains configuration for running both client and server together +- `client/` - React frontend application (TypeScript) +- `api/` - Serverless API functions (deployed to Vercel) + - `_lib/` - Shared modules for API functions +- `server/` - Express server (for local development only, not deployed) + +## โœจ Features + +- ๐Ÿ” **Player Search** - Search any League player by Riot ID +- ๐Ÿง  **Personality Analysis** - Analyze gameplay patterns using Big Five personality traits +- ๐ŸŽญ **Archetype Matching** - Match players to 12 Jungian archetypes +- ๐Ÿ’˜ **Duo Compatibility** - Find perfect duo queue partners +- ๐Ÿค– **AI Chat** - Chat with your digital twin (powered by AWS Bedrock/Claude) +- ๐Ÿ“Š **Champion Mastery** - View top champions and play patterns ## Getting Started @@ -14,6 +38,7 @@ A League of Legends player statistics web application built with React and Node. - Node.js (v14 or higher) - npm +- Riot Games API key from https://developer.riotgames.com/ ### Installation @@ -23,16 +48,33 @@ Install dependencies for all parts of the application: npm run install-all ``` +### Environment Variables + +Create a `.env` file in the root directory (see `.env.example`): + +```bash +RIOT_API_KEY=your_riot_api_key_here + +# Optional - for AI features +BEDROCK_API_KEY=your_bedrock_api_key +AWS_REGION=us-east-1 +``` + ### Development -Start both client and server in development mode: +**Option 1: With Vercel CLI (Recommended)** +```bash +npm install -g vercel +vercel dev +``` +**Option 2: With Express Server** ```bash npm run dev ``` This will start: -- Server on the default port (check server/index.js) +- Server on port 5000 - Client on port 3000 (React development server) ### Building @@ -46,8 +88,11 @@ npm run build ## Available Scripts - `npm run dev` - Start both client and server in development mode -- `npm run server` - Start only the server -- `npm run client` - Start only the client +- `npm run server` - Start only the Express server +- `npm run client` - Start only the React client +- `npm run build` - Build client for production +- `npm run vercel-build` - Build for Vercel deployment +- `npm run install-all` - Install dependencies for all packages - `npm run build` - Build client for production - `npm run install-all` - Install dependencies for root, server, and client diff --git a/api/_lib/leagueDataFetcher.js b/api/_lib/leagueDataFetcher.js new file mode 100644 index 0000000..c48d8d4 --- /dev/null +++ b/api/_lib/leagueDataFetcher.js @@ -0,0 +1,388 @@ +const axios = require('axios'); +const https = require('https'); + +// Create HTTPS agent that ignores SSL certificate errors +const httpsAgent = new https.Agent({ + rejectUnauthorized: false +}); + +class LeagueDataFetcher { + constructor(apiKey, region, routingRegion) { + this.apiKey = apiKey; + this.region = region; + this.routingRegion = routingRegion; + this.headers = { "X-Riot-Token": apiKey }; + this.rateLimitDelay = 1200; // 1200 ms - matches Python script RATE_LIMIT_DELAY + this.ddragonVersion = null; // Cache the Data Dragon version + this.championData = null; // Cache champion data + this.progressCallback = null; // Callback for progress updates + } + + sendProgress(message, details = {}) { + if (this.progressCallback) { + this.progressCallback({ + type: 'progress', + message, + ...details + }); + } + console.log(message, details); + } + + // Data Dragon methods + async getDdragonVersion() { + if (this.ddragonVersion) { + return this.ddragonVersion; + } + + try { + const response = await axios.get('https://ddragon.leagueoflegends.com/api/versions.json', { + timeout: 5000, + httpsAgent: httpsAgent + }); + this.ddragonVersion = response.data[0]; // Latest version + return this.ddragonVersion; + } catch (error) { + console.error('Error fetching Data Dragon version:', error); + // Fallback to a known stable version + this.ddragonVersion = '14.20.1'; + return this.ddragonVersion; + } + } + + async getChampionData() { + if (this.championData) { + return this.championData; + } + + try { + const version = await this.getDdragonVersion(); + const response = await axios.get(`https://ddragon.leagueoflegends.com/cdn/${version}/data/en_US/champion.json`, { + timeout: 10000, + httpsAgent: httpsAgent + }); + this.championData = response.data.data; + return this.championData; + } catch (error) { + console.error('Error fetching champion data:', error); + return {}; + } + } + + getProfileIconUrl(profileIconId) { + if (!this.ddragonVersion) { + return null; + } + return `https://ddragon.leagueoflegends.com/cdn/${this.ddragonVersion}/img/profileicon/${profileIconId}.png`; + } + + async getChampionImageUrl(championId) { + try { + const championData = await this.getChampionData(); + const version = await this.getDdragonVersion(); + + // Find champion by ID + const champion = Object.values(championData).find(champ => parseInt(champ.key) === championId); + + if (champion) { + return `https://ddragon.leagueoflegends.com/cdn/${version}/img/champion/${champion.id}.png`; + } + + return null; + } catch (error) { + console.error('Error getting champion image URL:', error); + return null; + } + } + + async _makeRequest(url, params = null) { + try { + const response = await axios.get(url, { + headers: this.headers, + params: params, + timeout: 10000, + httpsAgent: httpsAgent // Use the SSL-ignoring agent + }); + return response.data; + } catch (error) { + if (error.response?.status === 404) { + return null; + } + console.error(`API Error: ${error.response?.status} - ${error.message}`); + throw error; + } + } + + async _delay() { + return new Promise(resolve => setTimeout(resolve, this.rateLimitDelay)); + } + + async getAccountByRiotId(gameName, tagLine) { + const url = `https://${this.routingRegion}.api.riotgames.com/riot/account/v1/accounts/by-riot-id/${gameName}/${tagLine}`; + return this._makeRequest(url); + } + + async getSummonerByPuuid(puuid) { + const url = `https://${this.region}.api.riotgames.com/lol/summoner/v4/summoners/by-puuid/${puuid}`; + return this._makeRequest(url); + } + + async getLeagueEntries(summonerId) { + const url = `https://${this.region}.api.riotgames.com/lol/league/v4/entries/by-summoner/${summonerId}`; + return this._makeRequest(url); + } + + async getAllChampionMasteries(puuid) { + const url = `https://${this.region}.api.riotgames.com/lol/champion-mastery/v4/champion-masteries/by-puuid/${puuid}`; + return this._makeRequest(url); + } + + async getChampionMasteryScore(puuid) { + const url = `https://${this.region}.api.riotgames.com/lol/champion-mastery/v4/scores/by-puuid/${puuid}`; + return this._makeRequest(url); + } + + async getMatchIds(puuid, count = 20, start = 0) { + const url = `https://${this.routingRegion}.api.riotgames.com/lol/match/v5/matches/by-puuid/${puuid}/ids`; + return this._makeRequest(url, { start, count }); + } + + async getMatchDetails(matchId) { + const url = `https://${this.routingRegion}.api.riotgames.com/lol/match/v5/matches/${matchId}`; + return this._makeRequest(url); + } + + async getCurrentGame(puuid) { + const url = `https://${this.region}.api.riotgames.com/lol/spectator/v5/active-games/by-summoner/${puuid}`; + return this._makeRequest(url); + } + + async getCompletePlayerData(gameName, tagLine, maxFetch = 100, targetAnalyze = 25, includeMatchDetails = true) { + const data = { + metadata: { + fetchedAt: new Date().toISOString(), + gameName, + tagLine, + maxFetch, + targetAnalyze, + region: this.region, + routingRegion: this.routingRegion + } + }; + + try { + // Initialize Data Dragon version + this.sendProgress('๐Ÿ”„ Initializing Data Dragon version...', { step: 1, total: 8 }); + await this.getDdragonVersion(); + + // Get account info + this.sendProgress('๐Ÿ” Looking up account info...', { step: 2, total: 8 }); + const accountData = await this.getAccountByRiotId(gameName, tagLine); + if (!accountData) { + throw new Error('Account not found'); + } + + data.account = accountData; + const puuid = accountData.puuid; + + // Get summoner info (no rate limiting for lightweight calls) + this.sendProgress('โœ… Account found! Fetching summoner data...', { step: 3, total: 8 }); + const summonerData = await this.getSummonerByPuuid(puuid); + data.summoner = summonerData; + + // Add profile icon URL + if (summonerData?.profileIconId) { + data.summoner.profileIconUrl = this.getProfileIconUrl(summonerData.profileIconId); + } + + const summonerId = summonerData?.id; + + // Get ranked data and champion mastery in parallel (no rate limiting for lightweight calls) + this.sendProgress('๐Ÿ“Š Fetching ranked data and champion mastery...', { step: 4, total: 8 }); + const [rankedData, masteryScore, allMasteries] = await Promise.all([ + summonerId ? this.getLeagueEntries(summonerId) : Promise.resolve([]), + this.getChampionMasteryScore(puuid), + this.getAllChampionMasteries(puuid) + ]); + // Handle ranked data + data.ranked = rankedData || []; + + if (masteryScore !== null && allMasteries) { + // Add champion image URLs to mastery data in parallel - optimized + const topMasteries = allMasteries.slice(0, 10); + const championImagePromises = topMasteries.map(mastery => + mastery.championId ? this.getChampionImageUrl(mastery.championId) : Promise.resolve(null) + ); + + const championImageUrls = await Promise.all(championImagePromises); + + // Assign URLs back to mastery objects + topMasteries.forEach((mastery, index) => { + if (championImageUrls[index]) { + mastery.championImageUrl = championImageUrls[index]; + } + }); + + data.championMastery = { + totalScore: masteryScore, + champions: topMasteries // Top 10 champions with image URLs + }; + } + + // Get current game (no rate limiting for lightweight calls) + const currentGame = await this.getCurrentGame(puuid); + data.currentGame = currentGame; + + // Get match history with smart batching (like Python script) + // Apply rate limiting only to heavy API calls + + // Valid queue types for personality analysis + const VALID_QUEUE_IDS = new Set([420, 440, 400, 430, 490]); + + this.sendProgress(`๐Ÿ“Š Fetching up to ${maxFetch} recent games for personality analysis...`, { step: 5, total: 8 }); + + // Fetch match IDs in batches to find enough valid games + let allMatchIds = []; + for (let start = 0; start < maxFetch; start += 100) { + const batchSize = Math.min(100, maxFetch - start); + await this._delay(); // Rate limiting only for match history calls + const batchIds = await this.getMatchIds(puuid, batchSize, start); + if (!batchIds || batchIds.length === 0) { + break; + } + allMatchIds.push(...batchIds); + this.sendProgress(`๐Ÿ“ฅ Fetched ${allMatchIds.length} match IDs so far...`, { + step: 5, + total: 8, + matchIds: allMatchIds.length, + maxFetch + }); + + if (allMatchIds.length >= maxFetch) { + break; + } + } + + data.matchIds = allMatchIds; + + // Get match details with early termination when we have enough valid games + if (includeMatchDetails && allMatchIds && allMatchIds.length > 0) { + data.matches = []; + let validGameCount = 0; + let rankedGames = 0; + let normalGames = 0; + let otherGames = 0; + + this.sendProgress(`โœ… Found ${allMatchIds.length} total matches. Analyzing game details...`, { + step: 6, + total: 8, + totalMatches: allMatchIds.length, + targetAnalyze + }); + this.sendProgress(`โณ Looking for ${targetAnalyze} ranked/normal games...`, { step: 6, total: 8 }); + + for (let i = 0; i < allMatchIds.length; i++) { + if (i % 5 === 0 && i > 0) { + this.sendProgress(`๐Ÿ” Progress: ${i}/${allMatchIds.length} matches analyzed`, { + step: 6, + total: 8, + progress: i, + totalMatches: allMatchIds.length, + validGames: validGameCount, + rankedGames, + normalGames, + otherGames, + targetAnalyze + }); + } + + await this._delay(); // Rate limiting only for match details calls + const matchData = await this.getMatchDetails(allMatchIds[i]); + if (matchData) { + // Check if this is a valid game for analysis + const queueId = matchData.info?.queueId; + const isValidGame = VALID_QUEUE_IDS.has(queueId); + + // Categorize games for detailed reporting + if (queueId === 420 || queueId === 440) { + rankedGames++; + } else if (queueId === 400 || queueId === 430 || queueId === 490) { + normalGames++; + } else { + otherGames++; + } + + // Enhance match data with champion images in parallel - optimized + if (matchData.info && matchData.info.participants) { + const championIds = matchData.info.participants + .map(p => p.championId) + .filter(id => id); + + const championImagePromises = championIds.map(id => this.getChampionImageUrl(id)); + const championImageUrls = await Promise.all(championImagePromises); + + // Create a map for quick lookup + const imageUrlMap = new Map(); + championIds.forEach((id, index) => { + if (championImageUrls[index]) { + imageUrlMap.set(id, championImageUrls[index]); + } + }); + + // Assign URLs to participants + matchData.info.participants.forEach(participant => { + if (participant.championId && imageUrlMap.has(participant.championId)) { + participant.championImageUrl = imageUrlMap.get(participant.championId); + } + }); + } + data.matches.push(matchData); + + // Count valid games and check if we have enough + if (isValidGame) { + validGameCount++; + if (validGameCount >= targetAnalyze) { + this.sendProgress(`๐ŸŽฏ Reached target of ${targetAnalyze} ranked/normal games!`, { + step: 6, + total: 8, + validGames: validGameCount, + rankedGames, + normalGames, + otherGames, + totalAnalyzed: data.matches.length, + targetReached: true + }); + break; + } + } + } + } + + this.sendProgress(`๐Ÿ“ˆ Analysis complete: ${data.matches.length} total matches, ${validGameCount} valid for personality analysis`, { + step: 6, + total: 8, + finalStats: { + totalMatches: data.matches.length, + validGames: validGameCount, + rankedGames, + normalGames, + otherGames + } + }); + } else { + data.matches = []; + } + + // Add Data Dragon version to metadata + data.metadata.ddragonVersion = this.ddragonVersion; + + return data; + + } catch (error) { + console.error('Error fetching player data:', error); + throw error; + } + } +} + +module.exports = LeagueDataFetcher; diff --git a/api/_lib/personalityAnalyzer.js b/api/_lib/personalityAnalyzer.js new file mode 100644 index 0000000..e970f8d --- /dev/null +++ b/api/_lib/personalityAnalyzer.js @@ -0,0 +1,392 @@ +const { Counter } = require('./utils'); + +class PersonalityAnalyzer { + constructor() { + this.VALID_QUEUE_IDS = new Set([420, 440, 400, 430, 490]); + this.QUEUE_NAMES = { + 420: "Ranked Solo/Duo", + 440: "Ranked Flex", + 400: "Normal Draft", + 430: "Normal Blind", + 490: "Normal Quickplay" + }; + + this.JUNGIAN_ARCHETYPES = { + "Innocent": { + "traits": {"Extraversion": 45, "Openness": 50, "Agreeableness": 80, "Conscientiousness": 70, "Emotional Stability": 65}, + "champions": ["Lulu", "Soraka"], + "description": "Optimistic and cautious, avoids risks and helps teammates" + }, + "Orphan": { + "traits": {"Extraversion": 55, "Openness": 55, "Agreeableness": 75, "Conscientiousness": 55, "Emotional Stability": 55}, + "champions": ["Amumu", "Maokai"], + "description": "Balanced team player, seeks connection and reliable performance" + }, + "Hero": { + "traits": {"Extraversion": 90, "Openness": 55, "Agreeableness": 60, "Conscientiousness": 75, "Emotional Stability": 70}, + "champions": ["Garen", "Darius"], + "description": "Fearless frontline warrior, disciplined and victory-driven" + }, + "Caregiver": { + "traits": {"Extraversion": 50, "Openness": 50, "Agreeableness": 95, "Conscientiousness": 65, "Emotional Stability": 65}, + "champions": ["Janna", "Nami"], + "description": "Selfless support main, prioritizes team survival above personal glory" + }, + "Explorer": { + "traits": {"Extraversion": 80, "Openness": 85, "Agreeableness": 50, "Conscientiousness": 50, "Emotional Stability": 55}, + "champions": ["Taliyah", "Bard"], + "description": "Adventurous experimenter, constantly trying new strategies and champions" + }, + "Rebel": { + "traits": {"Extraversion": 95, "Openness": 75, "Agreeableness": 45, "Conscientiousness": 45, "Emotional Stability": 50}, + "champions": ["Yasuo", "Draven"], + "description": "High-risk solo player, ignores meta and plays aggressively" + }, + "Lover": { + "traits": {"Extraversion": 85, "Openness": 65, "Agreeableness": 90, "Conscientiousness": 60, "Emotional Stability": 60}, + "champions": ["Xayah", "Rakan"], + "description": "Passionate duo player, excels with coordination and synergy" + }, + "Creator": { + "traits": {"Extraversion": 50, "Openness": 90, "Agreeableness": 50, "Conscientiousness": 70, "Emotional Stability": 65}, + "champions": ["Viktor", "Heimerdinger"], + "description": "Big-brain strategist, builds innovative win conditions" + }, + "Jester": { + "traits": {"Extraversion": 90, "Openness": 70, "Agreeableness": 50, "Conscientiousness": 45, "Emotional Stability": 50}, + "champions": ["Teemo", "Shaco"], + "description": "Chaotic trickster, plays for entertainment and enemy frustration" + }, + "Sage": { + "traits": {"Extraversion": 60, "Openness": 65, "Agreeableness": 60, "Conscientiousness": 75, "Emotional Stability": 70}, + "champions": ["Ryze", "Orianna"], + "description": "Disciplined perfectionist, masters fundamentals through practice" + }, + "Magician": { + "traits": {"Extraversion": 75, "Openness": 75, "Agreeableness": 60, "Conscientiousness": 75, "Emotional Stability": 70}, + "champions": ["Twisted Fate", "Zed"], + "description": "Versatile playmaker, adapts to carry games from any position" + }, + "Ruler": { + "traits": {"Extraversion": 65, "Openness": 55, "Agreeableness": 70, "Conscientiousness": 80, "Emotional Stability": 75}, + "champions": ["Galio", "Shen"], + "description": "Strategic macro player, controls vision and map objectives" + } + }; + } + + extractPlayerStats(matches, puuid) { + const stats = { + roles: [], visionScores: [], kdas: [], deaths: [], + champions: [], kills: [], assists: [], gameDurations: [], + totalObjectives: 0, firstBloods: 0, killParticipationSum: 0, + queueTypes: [], matchIds: [] + }; + + let validGames = 0; + + for (const match of matches.filter(Boolean)) { + const info = match.info || {}; + const queueId = info.queueId; + + // Filter for ranked/normal games only + if (!this.VALID_QUEUE_IDS.has(queueId)) { + continue; + } + + const playerData = info.participants?.find(p => p.puuid === puuid); + if (!playerData) continue; + + validGames++; + const gameDuration = (info.gameDuration || 1) / 60; + stats.gameDurations.push(gameDuration); + stats.roles.push(playerData.teamPosition || "UNKNOWN"); + stats.champions.push(playerData.championName || "Unknown"); + stats.queueTypes.push(this.QUEUE_NAMES[queueId] || `Queue ${queueId}`); + stats.matchIds.push(match.metadata?.matchId || "Unknown"); + + const kills = playerData.kills || 0; + const deaths = playerData.deaths || 0; + const assists = playerData.assists || 0; + + stats.kills.push(kills); + stats.deaths.push(deaths); + stats.assists.push(assists); + stats.kdas.push((kills + assists) / Math.max(1, deaths)); + stats.visionScores.push(playerData.visionScore || 0); + + stats.totalObjectives += (playerData.dragonKills || 0) + + (playerData.baronKills || 0) + + (playerData.turretKills || 0); + + if (playerData.firstBloodKill) { + stats.firstBloods++; + } + + const teamId = playerData.teamId; + const teamKills = info.participants + ?.filter(p => p.teamId === teamId) + ?.reduce((sum, p) => sum + (p.kills || 0), 0) || 1; + + if (teamKills > 0) { + stats.killParticipationSum += (kills + assists) / teamKills; + } + } + + console.log(`โœ… Analyzed ${validGames} valid ranked/normal games`); + return { stats, validGames }; + } + + calculateFeatures(stats) { + const numGames = stats.kdas.length; + if (numGames === 0) return {}; + + const avgKills = this.mean(stats.kills); + const avgDeaths = this.mean(stats.deaths); + const avgAssists = this.mean(stats.assists); + const avgDuration = this.mean(stats.gameDurations); + const killParticipation = (stats.killParticipationSum / numGames) * 100; + + // Aggression Index: (#kills + kill participation) / (deaths + assists) + const aggressionIndex = (avgKills + (killParticipation / 100)) / Math.max(1, avgDeaths + avgAssists); + + const roleCounts = this.countArray(stats.roles); + const primaryRole = roleCounts.length > 0 ? roleCounts[0][0] : "UNKNOWN"; + + return { + primaryRole, + visionPerMin: this.mean(stats.visionScores) / avgDuration, + aggressionIndex, + kdaVariance: this.standardDeviation(stats.kdas), + deathVariance: this.standardDeviation(stats.deaths), + objectiveFocus: stats.totalObjectives / numGames, + championDiversity: new Set(stats.champions).size / numGames, + killParticipation, + firstBloodRate: stats.firstBloods / numGames, + assistRatio: avgAssists / Math.max(1, avgKills), + avgDeaths, + avgKills, + avgAssists, + avgKda: this.mean(stats.kdas), + }; + } + + mapToBigFive(features) { + const traits = { + "Openness": 50.0, + "Conscientiousness": 50.0, + "Extraversion": 50.0, + "Agreeableness": 50.0, + "Emotional Stability": 50.0 + }; + + const calculations = { + "Openness": [], + "Conscientiousness": [], + "Extraversion": [], + "Agreeableness": [], + "Emotional Stability": [] + }; + + // Openness: Linked to champion diversity and creative/roaming roles (Mid) + const diversityBonus = (features.championDiversity || 0) * 40; + traits["Openness"] += diversityBonus; + calculations["Openness"].push(`Champion diversity: +${diversityBonus.toFixed(1)} (${(features.championDiversity || 0).toFixed(2)} ร— 40)`); + + if (features.primaryRole === "MIDDLE") { + traits["Openness"] += 15; + calculations["Openness"].push("Mid role: +15.0"); + } else if (["BOTTOM", "UTILITY"].includes(features.primaryRole)) { + traits["Openness"] -= 10; + calculations["Openness"].push(`${features.primaryRole} role: -10.0`); + } + + // Conscientiousness: Linked to consistency (low variance) and precision roles (ADC) + // REBALANCED: Reduced penalties and added KDA bonus to balance + const deathPenalty = Math.max(0, (features.avgDeaths || 5) - 3) * 2.0; // Only penalize deaths above 3 + traits["Conscientiousness"] -= deathPenalty; + calculations["Conscientiousness"].push(`Avg deaths penalty: -${deathPenalty.toFixed(1)} (max(0, ${(features.avgDeaths || 0).toFixed(2)} - 3) ร— 2.0)`); + + const kdaVarPenalty = (features.kdaVariance || 0) * 2.5; // Reduced from 4 + traits["Conscientiousness"] -= kdaVarPenalty; + calculations["Conscientiousness"].push(`KDA variance penalty: -${kdaVarPenalty.toFixed(1)} (${(features.kdaVariance || 0).toFixed(2)} ร— 2.5)`); + + const deathVarPenalty = (features.deathVariance || 0) * 2; // Reduced from 3 + traits["Conscientiousness"] -= deathVarPenalty; + calculations["Conscientiousness"].push(`Death variance penalty: -${deathVarPenalty.toFixed(1)} (${(features.deathVariance || 0).toFixed(2)} ร— 2)`); + + // Add KDA bonus to reward good play + const kdaBonus = Math.min(20, (features.avgKda || 0) * 5); // Cap at +20 + traits["Conscientiousness"] += kdaBonus; + calculations["Conscientiousness"].push(`KDA bonus: +${kdaBonus.toFixed(1)} (min(20, ${(features.avgKda || 0).toFixed(2)} ร— 5))`); + + if (features.primaryRole === "BOTTOM") { + traits["Conscientiousness"] += 15; // Reduced from 20 since we added KDA bonus + calculations["Conscientiousness"].push("ADC role: +15.0"); + } + + // Extraversion: Linked to aggression, roaming, and high-action roles (Jungle/Mid) + const kpBonus = (features.killParticipation || 0) * 0.4; + traits["Extraversion"] += kpBonus; + calculations["Extraversion"].push(`Kill participation: +${kpBonus.toFixed(1)} (${(features.killParticipation || 0).toFixed(1)}% ร— 0.4)`); + + const aggroBonus = (features.aggressionIndex || 0) * 20; + traits["Extraversion"] += aggroBonus; + calculations["Extraversion"].push(`Aggression index: +${aggroBonus.toFixed(1)} (${(features.aggressionIndex || 0).toFixed(2)} ร— 20)`); + + const fbBonus = (features.firstBloodRate || 0) * 25; + traits["Extraversion"] += fbBonus; + calculations["Extraversion"].push(`First blood rate: +${fbBonus.toFixed(1)} (${(features.firstBloodRate || 0).toFixed(2)} ร— 25)`); + + if (["JUNGLE", "MIDDLE"].includes(features.primaryRole)) { + traits["Extraversion"] += 15; + calculations["Extraversion"].push(`${features.primaryRole} role: +15.0`); + } else if (features.primaryRole === "TOP") { + traits["Extraversion"] -= 15; + calculations["Extraversion"].push("Top role: -15.0"); + } + + // Agreeableness: Linked to supportive actions (assists, vision) and roles (Support, Jungle) + // REBALANCED: Reduced multipliers to avoid everyone having high Agreeableness + const assistBonus = Math.min(25, (features.assistRatio || 0) * 15); // Reduced from 20, capped at 25 + traits["Agreeableness"] += assistBonus; + calculations["Agreeableness"].push(`Assist ratio: +${assistBonus.toFixed(1)} (${(features.assistRatio || 0).toFixed(2)} ร— 15, max 25)`); + + const visionBonus = Math.min(15, (features.visionPerMin || 0) * 7); // Reduced from 10, capped at 15 + traits["Agreeableness"] += visionBonus; + calculations["Agreeableness"].push(`Vision/min: +${visionBonus.toFixed(1)} (${(features.visionPerMin || 0).toFixed(2)} ร— 7, max 15)`); + + // Penalize selfish play (low assist ratio) + if ((features.assistRatio || 0) < 0.5) { + const selfishPenalty = (0.5 - (features.assistRatio || 0)) * 30; + traits["Agreeableness"] -= selfishPenalty; + calculations["Agreeableness"].push(`Low assist penalty: -${selfishPenalty.toFixed(1)}`); + } + + if (features.primaryRole === "UTILITY") { + traits["Agreeableness"] += 25; + calculations["Agreeableness"].push("Support role: +25.0"); + } else if (features.primaryRole === "JUNGLE") { + traits["Agreeableness"] += 10; // Reduced from 15 + calculations["Agreeableness"].push("Jungle role: +10.0"); + } else if (features.primaryRole === "MIDDLE") { + traits["Agreeableness"] -= 10; // Reduced penalty + calculations["Agreeableness"].push("Mid role: -10.0"); + } else if (features.primaryRole === "TOP") { + traits["Agreeableness"] -= 5; + calculations["Agreeableness"].push("Top role: -5.0"); + } + + // Emotional Stability: Linked to low variance (consistency) and objective control + // REBALANCED: Reduced variance penalties and boosted objective bonus + const kdaStabPenalty = (features.kdaVariance || 0) * 3; // Reduced from 5 + traits["Emotional Stability"] -= kdaStabPenalty; + calculations["Emotional Stability"].push(`KDA variance penalty: -${kdaStabPenalty.toFixed(1)} (${(features.kdaVariance || 0).toFixed(2)} ร— 3)`); + + const deathStabPenalty = (features.deathVariance || 0) * 2.5; // Reduced from 4 + traits["Emotional Stability"] -= deathStabPenalty; + calculations["Emotional Stability"].push(`Death variance penalty: -${deathStabPenalty.toFixed(1)} (${(features.deathVariance || 0).toFixed(2)} ร— 2.5)`); + + const objBonus = (features.objectiveFocus || 0) * 8; // Doubled from 4 to 8 + traits["Emotional Stability"] += objBonus; + calculations["Emotional Stability"].push(`Objective focus: +${objBonus.toFixed(1)} (${(features.objectiveFocus || 0).toFixed(2)} ร— 8)`); + + // Add KDA consistency bonus (reward high KDA with low variance) + const kdaConsistency = features.avgKda > 2.0 && features.kdaVariance < 2.0; + if (kdaConsistency) { + traits["Emotional Stability"] += 10; + calculations["Emotional Stability"].push("KDA consistency bonus: +10.0"); + } + + if (features.primaryRole === "JUNGLE") { + traits["Emotional Stability"] += 10; + calculations["Emotional Stability"].push("Jungle role: +10.0"); + } + + // Normalize all traits to be within the 0-100 range + for (const trait in traits) { + const original = traits[trait]; + traits[trait] = Math.max(0, Math.min(100, Math.round(traits[trait] * 10) / 10)); + if (original !== traits[trait]) { + calculations[trait].push(`Normalized from ${original.toFixed(1)} to ${traits[trait].toFixed(1)}`); + } + } + + return { traits, calculations }; + } + + matchArchetype(bigFive) { + let bestMatch = null; + let minDistance = Infinity; + + for (const [archetype, data] of Object.entries(this.JUNGIAN_ARCHETYPES)) { + const distance = Math.sqrt( + Object.entries(data.traits).reduce((sum, [trait, value]) => { + return sum + Math.pow((bigFive[trait] || 50) - value, 2); + }, 0) + ); + + if (distance < minDistance) { + minDistance = distance; + bestMatch = archetype; + } + } + + // Convert distance to a more intuitive similarity score + // Max possible distance is sqrt(3 * 100^2) approx 173. We scale this to a 0-100% similarity. + const similarity = Math.max(0, 100 - (minDistance / 173 * 100)); + return { archetype: bestMatch, similarity }; + } + + analyzePersonality(matches, puuid) { + console.log("๐Ÿง  Starting personality analysis..."); + + const { stats, validGames } = this.extractPlayerStats(matches, puuid); + + if (validGames === 0) { + throw new Error("No valid ranked/normal games found for analysis"); + } + + const features = this.calculateFeatures(stats); + const { traits, calculations } = this.mapToBigFive(features); + const { archetype, similarity } = this.matchArchetype(traits); + + return { + stats: { + validGames, + rawStats: stats, + features + }, + personality: { + bigFive: traits, + calculations, + archetype: { + name: archetype, + similarity: Math.round(similarity * 10) / 10, + description: this.JUNGIAN_ARCHETYPES[archetype]?.description || "", + champions: this.JUNGIAN_ARCHETYPES[archetype]?.champions || [] + } + } + }; + } + + // Utility methods + mean(array) { + return array.length > 0 ? array.reduce((a, b) => a + b, 0) / array.length : 0; + } + + standardDeviation(array) { + if (array.length <= 1) return 0; + const mean = this.mean(array); + const variance = array.reduce((sum, value) => sum + Math.pow(value - mean, 2), 0) / (array.length - 1); + return Math.sqrt(variance); + } + + countArray(array) { + const counts = {}; + array.forEach(item => counts[item] = (counts[item] || 0) + 1); + return Object.entries(counts).sort(([,a], [,b]) => b - a); + } +} + +module.exports = PersonalityAnalyzer; diff --git a/api/_lib/prompts.js b/api/_lib/prompts.js new file mode 100644 index 0000000..112bff8 --- /dev/null +++ b/api/_lib/prompts.js @@ -0,0 +1,68 @@ +/** + * AI Prompts for League of Legends Personality Analysis + * + * This file contains all prompts sent to Claude AI models. + * Organized by feature for easy maintenance and experimentation. + */ + +/** + * COMPATIBILITY ANALYSIS PROMPT + * Used in: /api/matchmaking + * Purpose: Analyze duo compatibility between two players + */ +const compatibilityAnalysisPrompt = (player1Context, player2Context) => { + return `You are an expert League of Legends duo compatibility analyst. Analyze the compatibility between these two players and provide insightful, personalized feedback. + +${player1Context} + +${player2Context} + +Based on their personalities, playstyles, and stats, provide a compatibility analysis in the following JSON format: +{ + "score": , + "level": "", + "recommendation": "<2-3 sentence personalized recommendation>", + "strengths": ["", "", ""], + "challenges": ["", ""] +} + +Guidelines: +- Score should reflect overall duo potential (personality + playstyle + skill synergy) +- Recommendation should be warm, encouraging, and specific to their duo dynamic +- Strengths should highlight 3 key synergies (personality, playstyle, or role compatibility) +- Challenges should mention 1-2 areas to work on (be constructive, not negative) +- Use their actual champion preferences, roles, and personality traits in your analysis +- Be conversational but insightful - make it feel personalized +- Reference specific archetypes and traits when relevant + +Respond ONLY with the JSON object, no other text.`; +}; + +/** + * DIGITAL TWIN SYSTEM PROMPT + * Used in: /api/chat + * Purpose: Initialize the chat assistant as the player's digital twin + */ +const digitalTwinSystemPrompt = (playerName, playerContext) => { + return `You are ${playerName || 'this player'}'s League of Legends digital twin AI assistant. You have deep insights into their gameplay, personality, and performance patterns. Be conversational, insightful, and engaging. Use the player's actual data to provide meaningful analysis. + +Player Context: +${playerContext} + +Please respond as their digital twin, speaking knowledgeably about their League gameplay and personality. Keep responses concise but insightful (2-3 sentences typically). Champions should be referred to by their name not their number i.e Chapion 7 should be called Leblanc.`; +}; + +/** + * DIGITAL TWIN ACKNOWLEDGMENT + * Used in: /api/chat + * Purpose: Assistant's acknowledgment message after system prompt + */ +const digitalTwinAcknowledgment = () => { + return "I understand. I'm ready to discuss this player's League of Legends journey, personality insights, and gameplay patterns based on their data."; +}; + +module.exports = { + compatibilityAnalysisPrompt, + digitalTwinSystemPrompt, + digitalTwinAcknowledgment +}; diff --git a/api/_lib/utils.js b/api/_lib/utils.js new file mode 100644 index 0000000..474b93a --- /dev/null +++ b/api/_lib/utils.js @@ -0,0 +1,46 @@ +class Counter { + constructor(iterable = []) { + this.counts = {}; + if (iterable) { + for (const item of iterable) { + this.counts[item] = (this.counts[item] || 0) + 1; + } + } + } + + get(item) { + return this.counts[item] || 0; + } + + set(item, count) { + this.counts[item] = count; + } + + add(item, count = 1) { + this.counts[item] = (this.counts[item] || 0) + count; + } + + mostCommon(n = null) { + const entries = Object.entries(this.counts) + .sort(([,a], [,b]) => b - a); + return n ? entries.slice(0, n) : entries; + } + + keys() { + return Object.keys(this.counts); + } + + values() { + return Object.values(this.counts); + } + + items() { + return Object.entries(this.counts); + } + + total() { + return Object.values(this.counts).reduce((sum, count) => sum + count, 0); + } +} + +module.exports = { Counter }; diff --git a/api/bedrock-test.js b/api/bedrock-test.js new file mode 100644 index 0000000..6e9ed48 --- /dev/null +++ b/api/bedrock-test.js @@ -0,0 +1,48 @@ +// Vercel Serverless Function +const axios = require('axios'); + +module.exports = async (req, res) => { + try { + if (!process.env.BEDROCK_API_KEY || !process.env.AWS_REGION) { + return res.status(200).json({ + configured: false, + message: 'Bedrock not configured. Set BEDROCK_API_KEY and AWS_REGION in environment variables.' + }); + } + + const apiKey = process.env.BEDROCK_API_KEY; + const awsRegion = process.env.AWS_REGION; + const modelId = "anthropic.claude-3-5-sonnet-20240620-v1:0"; + const url = `https://bedrock-runtime.${awsRegion}.amazonaws.com/model/${modelId}/invoke`; + + const payload = { + anthropic_version: "bedrock-2023-05-31", + max_tokens: 50, + messages: [{ "role": "user", "content": "Say hello in one sentence." }], + }; + + const response = await axios.post(url, payload, { + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + timeout: 10000 + }); + + res.status(200).json({ + configured: true, + message: 'Bedrock is working correctly!', + testResponse: response.data.content[0].text, + region: awsRegion + }); + + } catch (error) { + console.error('Bedrock test error:', error); + res.status(200).json({ + configured: false, + message: 'Bedrock configuration error', + error: error.response?.data?.message || error.message + }); + } +}; diff --git a/api/chat.js b/api/chat.js new file mode 100644 index 0000000..cc6d0db --- /dev/null +++ b/api/chat.js @@ -0,0 +1,180 @@ +// Vercel Serverless Function for chat +const axios = require('axios'); +const { digitalTwinSystemPrompt, digitalTwinAcknowledgment } = require('./_lib/prompts'); + +module.exports = async (req, res) => { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + try { + const { message, playerData, chatHistory = [] } = req.body; + + if (!message) { + return res.status(400).json({ error: 'Message is required' }); + } + + if (!process.env.BEDROCK_API_KEY || !process.env.AWS_REGION) { + return res.status(500).json({ + error: 'Bedrock not configured. Please set BEDROCK_API_KEY and AWS_REGION environment variables.' + }); + } + + const apiKey = process.env.BEDROCK_API_KEY; + const awsRegion = process.env.AWS_REGION; + const modelId = "anthropic.claude-3-5-sonnet-20240620-v1:0"; + const url = `https://bedrock-runtime.${awsRegion}.amazonaws.com/model/${modelId}/invoke`; + + const playerContext = buildPlayerContext(playerData); + const messages = []; + + messages.push({ + role: "user", + content: digitalTwinSystemPrompt(playerData?.account?.gameName, playerContext) + }); + + messages.push({ + role: "assistant", + content: digitalTwinAcknowledgment() + }); + + const recentHistory = chatHistory.slice(-10); + recentHistory.forEach(msg => { + messages.push({ + role: msg.isUser ? "user" : "assistant", + content: msg.text + }); + }); + + messages.push({ + role: "user", + content: message + }); + + const payload = { + anthropic_version: "bedrock-2023-05-31", + max_tokens: 300, + messages: messages, + temperature: 0.7 + }; + + const response = await axios.post(url, payload, { + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + timeout: 30000 + }); + + const aiResponse = response.data.content[0].text; + + res.status(200).json({ + success: true, + response: aiResponse, + timestamp: new Date().toISOString() + }); + + } catch (error) { + console.error('Chat error:', error); + + const fallbackResponse = generateFallbackResponse(req.body.message, req.body.playerData); + + res.status(200).json({ + success: true, + response: fallbackResponse, + fallback: true, + error: error.response?.data?.message || error.message + }); + } +}; + +function buildPlayerContext(playerData) { + if (!playerData) return "No player data available."; + + let context = []; + + if (playerData.account) { + context.push(`Player: ${playerData.account.gameName}#${playerData.account.tagLine}`); + } + + if (playerData.summoner?.summonerLevel) { + context.push(`Level: ${playerData.summoner.summonerLevel}`); + } + + if (playerData.ranked?.length > 0) { + const soloRank = playerData.ranked.find(r => r.queueType === 'RANKED_SOLO_5x5'); + if (soloRank) { + const winRate = Math.round((soloRank.wins / (soloRank.wins + soloRank.losses)) * 100); + context.push(`Rank: ${soloRank.tier} ${soloRank.rank} ${soloRank.leaguePoints}LP (${winRate}% WR, ${soloRank.wins}W/${soloRank.losses}L)`); + } + } + + if (playerData.championMastery?.champions?.length > 0) { + const topChamps = playerData.championMastery.champions.slice(0, 3).map(c => + `${c.championName || `Champion ${c.championId}`} (${Math.floor(c.championPoints / 1000)}k pts)` + ).join(', '); + context.push(`Top Champions: ${topChamps}`); + } + + if (playerData.personality && !playerData.personality.error) { + const personality = playerData.personality.personality; + if (personality?.archetype) { + context.push(`Archetype: ${personality.archetype.name} (${personality.archetype.similarity}% match)`); + } + + if (personality?.bigFive) { + const traits = Object.entries(personality.bigFive) + .map(([trait, score]) => `${trait}: ${score}%`) + .join(', '); + context.push(`Personality Traits: ${traits}`); + } + + if (playerData.personality.stats?.features) { + const stats = playerData.personality.stats.features; + if (stats.primaryRole) context.push(`Primary Role: ${stats.primaryRole}`); + if (stats.avgKda) context.push(`Average KDA: ${stats.avgKda.toFixed(2)}`); + if (stats.aggressionIndex) context.push(`Aggression Level: ${(stats.aggressionIndex * 100).toFixed(0)}%`); + } + } + + if (playerData.matches?.length > 0) { + const recentGames = playerData.matches.slice(0, 5); + const wins = recentGames.filter(match => { + const participant = match.info.participants.find(p => p.puuid === playerData.account?.puuid); + return participant?.win; + }).length; + context.push(`Recent Performance: ${wins}W/${recentGames.length - wins}L in last ${recentGames.length} games`); + } + + return context.join('\n'); +} + +function generateFallbackResponse(userMessage, playerData) { + const message = userMessage.toLowerCase(); + const gameName = playerData?.account?.gameName || 'Player'; + + if (message.includes('rank') || message.includes('tier')) { + const soloRank = playerData?.ranked?.find(r => r.queueType === 'RANKED_SOLO_5x5'); + if (soloRank) { + return `${gameName} is currently ${soloRank.tier} ${soloRank.rank} with ${soloRank.leaguePoints} LP. They have a ${Math.round((soloRank.wins / (soloRank.wins + soloRank.losses)) * 100)}% win rate this season!`; + } + return `${gameName} doesn't have a current solo queue ranking, but they're still climbing!`; + } + + if (message.includes('champion') || message.includes('main')) { + const topChampion = playerData?.championMastery?.champions?.[0]; + if (topChampion) { + return `${gameName}'s highest mastery champion is ${topChampion.championName || `Champion ${topChampion.championId}`} with ${topChampion.championPoints?.toLocaleString()} mastery points!`; + } + return `${gameName} plays a variety of champions. Versatility is key!`; + } + + const responses = [ + `That's an interesting question about ${gameName}! I'm having trouble with my advanced analysis right now, but their gameplay shows consistent patterns.`, + `Great question! While I can't access my full personality insights at the moment, ${gameName} clearly has a unique approach to the game.`, + `${gameName} would probably have some interesting thoughts on that! I'm experiencing some technical difficulties with my deeper analysis capabilities.` + ]; + + return responses[Math.floor(Math.random() * responses.length)]; +} diff --git a/api/health.js b/api/health.js new file mode 100644 index 0000000..80f73b0 --- /dev/null +++ b/api/health.js @@ -0,0 +1,7 @@ +// Vercel Serverless Function +module.exports = async (req, res) => { + res.status(200).json({ + status: 'OK', + timestamp: new Date().toISOString() + }); +}; diff --git a/api/index.js b/api/index.js new file mode 100644 index 0000000..e69de29 diff --git a/api/matchmaking.js b/api/matchmaking.js new file mode 100644 index 0000000..cc79afa --- /dev/null +++ b/api/matchmaking.js @@ -0,0 +1,396 @@ +// Vercel Serverless Function for matchmaking +const axios = require('axios'); +const LeagueDataFetcher = require('./_lib/leagueDataFetcher'); +const PersonalityAnalyzer = require('./_lib/personalityAnalyzer'); +const { compatibilityAnalysisPrompt } = require('./_lib/prompts'); + +module.exports = async (req, res) => { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + const { player1Data, player2GameName, player2TagLine, region = 'na1' } = req.body; + + if (!player1Data || !player2GameName || !player2TagLine) { + return res.status(400).json({ + error: 'Missing required fields: player1Data, player2GameName, player2TagLine' + }); + } + + try { + const regionToRoutingMap = { + 'na1': 'americas', + 'br1': 'americas', + 'la1': 'americas', + 'la2': 'americas', + 'kr': 'asia', + 'jp1': 'asia', + 'euw1': 'europe', + 'eun1': 'europe', + 'tr1': 'europe', + 'ru': 'europe', + 'oc1': 'sea', + 'ph2': 'sea', + 'sg2': 'sea', + 'th2': 'sea', + 'tw2': 'sea', + 'vn2': 'sea' + }; + + const routingRegion = regionToRoutingMap[region] || 'americas'; + + const fetcher = new LeagueDataFetcher(process.env.RIOT_API_KEY, region, routingRegion); + const analyzer = new PersonalityAnalyzer(); + + // Fetch player 2 data + const player2Data = await fetcher.getCompletePlayerData( + player2GameName, + player2TagLine, + 100, + 25, + true + ); + + // Analyze both players' personalities + let player1Personality = player1Data.personality; + if (!player1Personality || player1Personality.error) { + try { + player1Personality = analyzer.analyzePersonality(player1Data.matches, player1Data.account?.puuid); + } catch (analysisError) { + player1Personality = { error: 'Analysis failed', message: analysisError.message }; + } + } + + let player2Personality; + try { + player2Personality = analyzer.analyzePersonality(player2Data.matches, player2Data.account?.puuid); + } catch (analysisError) { + player2Personality = { error: 'Analysis failed', message: analysisError.message }; + } + + // Calculate compatibility + const compatibility = await calculateCompatibilityWithAI( + player1Personality, + player2Personality, + player1Data, + player2Data + ); + + res.status(200).json({ + success: true, + player1: { + gameName: player1Data.account?.gameName, + tagLine: player1Data.account?.tagLine, + profileIconUrl: player1Data.summoner?.profileIconUrl, + personality: player1Personality + }, + player2: { + gameName: player2Data.account?.gameName, + tagLine: player2Data.account?.tagLine, + profileIconUrl: player2Data.summoner?.profileIconUrl, + personality: player2Personality, + ranked: player2Data.ranked, + championMastery: player2Data.championMastery + }, + compatibility + }); + + } catch (error) { + console.error('Matchmaking error:', error); + res.status(500).json({ + error: error.message || 'Failed to calculate compatibility', + details: error.response?.data || null + }); + } +}; + +// AI-powered compatibility calculation +async function calculateCompatibilityWithAI(player1Personality, player2Personality, player1Data, player2Data) { + const basicCompatibility = calculateCompatibility(player1Personality, player2Personality, player1Data, player2Data); + + if (!process.env.BEDROCK_API_KEY || !process.env.AWS_REGION || + !player1Personality?.personality || !player2Personality?.personality || + player1Personality.error || player2Personality.error) { + return basicCompatibility; + } + + try { + const apiKey = process.env.BEDROCK_API_KEY; + const awsRegion = process.env.AWS_REGION; + const modelId = "anthropic.claude-3-5-sonnet-20240620-v1:0"; + const url = `https://bedrock-runtime.${awsRegion}.amazonaws.com/model/${modelId}/invoke`; + + const player1Context = buildPlayerCompatibilityContext(player1Data, player1Personality, 'Player 1'); + const player2Context = buildPlayerCompatibilityContext(player2Data, player2Personality, 'Player 2'); + const prompt = compatibilityAnalysisPrompt(player1Context, player2Context); + + const payload = { + anthropic_version: "bedrock-2023-05-31", + max_tokens: 500, + messages: [{ role: "user", content: prompt }], + temperature: 0.7 + }; + + const response = await axios.post(url, payload, { + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + timeout: 30000 + }); + + const aiResponse = response.data.content[0].text; + const aiCompatibility = JSON.parse(aiResponse); + + if (!aiCompatibility.score || !aiCompatibility.level || !aiCompatibility.recommendation) { + throw new Error('Invalid AI response format'); + } + + return { + score: Math.max(0, Math.min(100, Math.round(aiCompatibility.score))), + level: aiCompatibility.level, + recommendation: aiCompatibility.recommendation, + strengths: aiCompatibility.strengths || [], + challenges: aiCompatibility.challenges || [], + aiGenerated: true + }; + + } catch (error) { + console.error('AI compatibility generation failed:', error); + return { + ...basicCompatibility, + aiGenerated: false, + fallback: true + }; + } +} + +function buildPlayerCompatibilityContext(playerData, personality, label) { + let context = [`${label}:`]; + + if (playerData.account) { + context.push(` Name: ${playerData.account.gameName}#${playerData.account.tagLine}`); + } + + if (playerData.summoner?.summonerLevel) { + context.push(` Level: ${playerData.summoner.summonerLevel}`); + } + + if (playerData.ranked?.length > 0) { + const soloRank = playerData.ranked.find(r => r.queueType === 'RANKED_SOLO_5x5'); + if (soloRank) { + const winRate = Math.round((soloRank.wins / (soloRank.wins + soloRank.losses)) * 100); + context.push(` Rank: ${soloRank.tier} ${soloRank.rank} ${soloRank.leaguePoints}LP (${winRate}% WR, ${soloRank.wins}W/${soloRank.losses}L)`); + } + } + + if (playerData.championMastery?.champions?.length > 0) { + const topChamps = playerData.championMastery.champions.slice(0, 3).map(c => + c.championName || `Champion ${c.championId}` + ).join(', '); + context.push(` Main Champions: ${topChamps}`); + } + + if (personality?.personality) { + const p = personality.personality; + + if (p.archetype) { + context.push(` Archetype: ${p.archetype.name} (${p.archetype.similarity}% match)`); + context.push(` Archetype Description: ${p.archetype.description}`); + } + + if (p.bigFive) { + context.push(` Personality Traits:`); + Object.entries(p.bigFive).forEach(([trait, score]) => { + context.push(` - ${trait}: ${score}%`); + }); + } + } + + if (personality?.stats?.features) { + const stats = personality.stats.features; + context.push(` Playstyle:`); + if (stats.primaryRole) context.push(` - Primary Role: ${stats.primaryRole}`); + if (stats.avgKda) context.push(` - Average KDA: ${stats.avgKda.toFixed(2)}`); + if (stats.aggressionIndex !== undefined) { + const aggressionLevel = stats.aggressionIndex > 0.7 ? 'Very Aggressive' : + stats.aggressionIndex > 0.5 ? 'Aggressive' : + stats.aggressionIndex > 0.3 ? 'Balanced' : 'Passive'; + context.push(` - Aggression: ${aggressionLevel} (${(stats.aggressionIndex * 100).toFixed(0)}%)`); + } + if (stats.championDiversity !== undefined) { + context.push(` - Champion Pool: ${(stats.championDiversity * 100).toFixed(0)}% diversity`); + } + if (stats.killParticipation !== undefined) { + context.push(` - Kill Participation: ${(stats.killParticipation * 100).toFixed(0)}%`); + } + } + + return context.join('\n'); +} + +function calculateCompatibility(player1Personality, player2Personality, player1Data, player2Data) { + let score = 50; + let strengths = []; + let challenges = []; + + if (!player1Personality?.personality || !player2Personality?.personality || + player1Personality.error || player2Personality.error) { + + let reasonMessage = 'Not enough data to calculate accurate compatibility'; + if (player1Personality?.error && player2Personality?.error) { + reasonMessage = 'Both players need more ranked/normal games for personality analysis'; + } else if (player1Personality?.error) { + reasonMessage = 'First player needs more ranked/normal games for personality analysis'; + } else if (player2Personality?.error) { + reasonMessage = 'Second player needs more ranked/normal games for personality analysis'; + } + + return { + score: 50, + level: 'Unknown', + reasons: [reasonMessage], + strengths: [], + challenges: [], + recommendation: 'Play more ranked or normal games individually to unlock personality insights!' + }; + } + + const p1Traits = player1Personality.personality.bigFive; + const p2Traits = player2Personality.personality.bigFive; + const p1Stats = player1Personality.stats?.features || {}; + const p2Stats = player2Personality.stats?.features || {}; + + let totalTraitDiff = 0; + Object.keys(p1Traits).forEach(trait => { + const diff = Math.abs(p1Traits[trait] - p2Traits[trait]); + totalTraitDiff += diff; + }); + + const avgTraitDiff = totalTraitDiff / Object.keys(p1Traits).length; + + if (avgTraitDiff < 20) { + score += 15; + strengths.push('Very similar personalities - you think alike!'); + } else if (avgTraitDiff < 40) { + score += 10; + strengths.push('Balanced personality differences - good synergy potential'); + } else { + score -= 5; + challenges.push('Very different personalities - may require extra communication'); + } + + if (p1Traits.Agreeableness > 70 && p2Traits.Agreeableness > 70) { + score += 10; + strengths.push('Both are team-oriented and cooperative'); + } + + const conscDiff = Math.abs(p1Traits.Conscientiousness - p2Traits.Conscientiousness); + if (conscDiff > 30 && conscDiff < 60) { + score += 8; + strengths.push('Good balance of planning and adaptability'); + } + + if (Math.abs(p1Traits.Extraversion - p2Traits.Extraversion) < 30) { + score += 5; + strengths.push('Similar communication styles'); + } + + if (p1Stats.primaryRole && p2Stats.primaryRole) { + if (p1Stats.primaryRole !== p2Stats.primaryRole) { + score += 15; + strengths.push(`Complementary roles: ${p1Stats.primaryRole} + ${p2Stats.primaryRole}`); + } else { + score -= 5; + challenges.push(`Both prefer ${p1Stats.primaryRole} - role flexibility needed`); + } + } + + if (p1Stats.aggressionIndex && p2Stats.aggressionIndex) { + const aggrDiff = Math.abs(p1Stats.aggressionIndex - p2Stats.aggressionIndex); + if (aggrDiff < 0.3) { + score += 8; + strengths.push('Similar aggression levels - good fight coordination'); + } else if (aggrDiff > 0.6) { + score -= 3; + challenges.push('Very different aggression styles - sync your engages'); + } + } + + if (p1Stats.avgKda && p2Stats.avgKda) { + const kdaDiff = Math.abs(p1Stats.avgKda - p2Stats.avgKda); + if (kdaDiff < 0.5) { + score += 5; + strengths.push('Similar skill levels'); + } else if (kdaDiff > 1.5) { + score -= 8; + challenges.push('Different skill levels - mentor/learning opportunity'); + } + } + + if (p1Stats.championDiversity && p2Stats.championDiversity) { + const diversityDiff = Math.abs(p1Stats.championDiversity - p2Stats.championDiversity); + if (diversityDiff < 0.3) { + score += 5; + strengths.push('Similar champion pool flexibility'); + } + } + + const p1Archetype = player1Personality.personality.archetype.name; + const p2Archetype = player2Personality.personality.archetype.name; + + const synergies = { + 'Hero': ['Caregiver', 'Sage', 'Ruler'], + 'Caregiver': ['Hero', 'Innocent', 'Lover'], + 'Sage': ['Hero', 'Creator', 'Magician'], + 'Explorer': ['Rebel', 'Creator', 'Magician'], + 'Rebel': ['Explorer', 'Jester'], + 'Creator': ['Sage', 'Explorer', 'Magician'], + 'Magician': ['Sage', 'Creator', 'Ruler'], + 'Ruler': ['Hero', 'Sage', 'Magician'], + 'Innocent': ['Caregiver', 'Lover'], + 'Lover': ['Caregiver', 'Innocent'], + 'Jester': ['Rebel', 'Explorer'], + 'Orphan': ['Caregiver', 'Hero'] + }; + + if (synergies[p1Archetype]?.includes(p2Archetype)) { + score += 12; + strengths.push(`${p1Archetype} + ${p2Archetype} archetypes work great together!`); + } else if (p1Archetype === p2Archetype) { + score += 5; + strengths.push(`Both ${p1Archetype} types - you understand each other`); + } + + score = Math.max(0, Math.min(100, score)); + + let level; + if (score >= 80) level = 'Excellent'; + else if (score >= 65) level = 'Very Good'; + else if (score >= 50) level = 'Good'; + else if (score >= 35) level = 'Fair'; + else level = 'Challenging'; + + let recommendation; + if (score >= 80) { + recommendation = 'Perfect duo queue partners! Your playstyles complement each other beautifully.'; + } else if (score >= 65) { + recommendation = 'Great compatibility! You should have excellent synergy in most games.'; + } else if (score >= 50) { + recommendation = 'Solid partnership potential. Focus on communication and you\'ll do great!'; + } else if (score >= 35) { + recommendation = 'You can make it work with good communication and understanding each other\'s style.'; + } else { + recommendation = 'Very different styles, but opposites can attract! Be patient and learn from each other.'; + } + + return { + score: Math.round(score), + level, + reasons: [], + strengths, + challenges, + recommendation + }; +} diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..f8c5688 --- /dev/null +++ b/api/package.json @@ -0,0 +1,9 @@ +{ + "name": "shapesplitter-api", + "version": "1.0.0", + "description": "Serverless API functions for ShapeSplitter", + "dependencies": { + "axios": "^1.6.0", + "dotenv": "^16.3.1" + } +} diff --git a/api/regions.js b/api/regions.js new file mode 100644 index 0000000..2166553 --- /dev/null +++ b/api/regions.js @@ -0,0 +1,23 @@ +// Vercel Serverless Function +module.exports = async (req, res) => { + res.status(200).json({ + regions: [ + { value: 'na1', label: 'North America', routing: 'americas' }, + { value: 'euw1', label: 'Europe West', routing: 'europe' }, + { value: 'eun1', label: 'Europe Nordic & East', routing: 'europe' }, + { value: 'kr', label: 'Korea', routing: 'asia' }, + { value: 'jp1', label: 'Japan', routing: 'asia' }, + { value: 'br1', label: 'Brazil', routing: 'americas' }, + { value: 'la1', label: 'Latin America North', routing: 'americas' }, + { value: 'la2', label: 'Latin America South', routing: 'americas' }, + { value: 'oc1', label: 'Oceania', routing: 'sea' }, + { value: 'tr1', label: 'Turkey', routing: 'europe' }, + { value: 'ru', label: 'Russia', routing: 'europe' }, + { value: 'ph2', label: 'Philippines', routing: 'sea' }, + { value: 'sg2', label: 'Singapore', routing: 'sea' }, + { value: 'th2', label: 'Thailand', routing: 'sea' }, + { value: 'tw2', label: 'Taiwan', routing: 'sea' }, + { value: 'vn2', label: 'Vietnam', routing: 'sea' } + ] + }); +}; diff --git a/api/search.js b/api/search.js new file mode 100644 index 0000000..b1ca5b1 --- /dev/null +++ b/api/search.js @@ -0,0 +1,93 @@ +// Vercel Serverless Function for player search +const LeagueDataFetcher = require('./_lib/leagueDataFetcher'); +const PersonalityAnalyzer = require('./_lib/personalityAnalyzer'); + +module.exports = async (req, res) => { + try { + // Extract parameters from URL path + const path = req.url.split('?')[0]; + const pathParts = path.split('/').filter(p => p); + + // Expected format: /api/search/gameName/tagLine + const gameName = decodeURIComponent(pathParts[2] || req.query.gameName || ''); + const tagLine = decodeURIComponent(pathParts[3] || req.query.tagLine || ''); + const region = req.query.region || 'na1'; + + if (!gameName || !tagLine) { + return res.status(400).json({ + error: 'Missing required parameters: gameName and tagLine' + }); + } + + // Map regions to their routing regions + const regionToRoutingMap = { + 'na1': 'americas', + 'br1': 'americas', + 'la1': 'americas', + 'la2': 'americas', + 'kr': 'asia', + 'jp1': 'asia', + 'euw1': 'europe', + 'eun1': 'europe', + 'tr1': 'europe', + 'ru': 'europe', + 'oc1': 'sea', + 'ph2': 'sea', + 'sg2': 'sea', + 'th2': 'sea', + 'tw2': 'sea', + 'vn2': 'sea' + }; + + const routingRegion = regionToRoutingMap[region] || 'americas'; + + if (!process.env.RIOT_API_KEY) { + return res.status(500).json({ error: 'API key not configured' }); + } + + const fetcher = new LeagueDataFetcher( + process.env.RIOT_API_KEY, + region, + routingRegion + ); + + const playerData = await fetcher.getCompletePlayerData( + gameName, + tagLine, + 100, // maxFetch + 25, // targetAnalyze + true + ); + + // Add personality analysis if we have match data + if (playerData.matches && playerData.matches.length > 0) { + try { + const analyzer = new PersonalityAnalyzer(); + const personalityData = analyzer.analyzePersonality(playerData.matches, playerData.account.puuid); + playerData.personality = personalityData; + console.log(`โœ… Personality analysis completed for ${gameName}#${tagLine}`); + } catch (analysisError) { + console.error('Personality analysis failed:', analysisError); + playerData.personality = { error: 'Analysis failed', message: analysisError.message }; + } + } + + res.status(200).json(playerData); + + } catch (error) { + console.error('Search error:', error); + + if (error.response?.status === 404) { + return res.status(404).json({ error: 'Player not found' }); + } + + if (error.response?.status === 403) { + return res.status(403).json({ error: 'Invalid API key or rate limit exceeded' }); + } + + res.status(500).json({ + error: 'Failed to fetch player data', + details: error.message + }); + } +}; diff --git a/api/test-images.js b/api/test-images.js new file mode 100644 index 0000000..71d84bf --- /dev/null +++ b/api/test-images.js @@ -0,0 +1,95 @@ +// Vercel Serverless Function +module.exports = async (req, res) => { + try { + // Use a hardcoded stable version to avoid network issues + const latestVersion = '14.20.1'; + + // Mock response with sample data and Data Dragon image URLs + const mockData = { + metadata: { + fetchedAt: new Date().toISOString(), + gameName: 'SamplePlayer', + tagLine: 'NA1', + ddragonVersion: latestVersion + }, + account: { + gameName: 'SamplePlayer', + tagLine: 'NA1', + puuid: 'sample-puuid' + }, + summoner: { + id: 'sample', + summonerLevel: 150, + profileIconId: 4658, + profileIconUrl: `https://ddragon.leagueoflegends.com/cdn/${latestVersion}/img/profileicon/4658.png` + }, + matches: [ + { + metadata: { matchId: 'sample1' }, + info: { + gameCreation: Date.now() - 3600000, + gameDuration: 1845, + gameMode: 'CLASSIC', + participants: [ + { + puuid: 'sample-puuid', + championId: 157, + championName: 'Yasuo', + championImageUrl: `https://ddragon.leagueoflegends.com/cdn/${latestVersion}/img/champion/Yasuo.png`, + kills: 12, + deaths: 4, + assists: 8, + win: true + } + ] + } + }, + { + metadata: { matchId: 'sample2' }, + info: { + gameCreation: Date.now() - 7200000, + gameDuration: 2156, + gameMode: 'CLASSIC', + participants: [ + { + puuid: 'sample-puuid', + championId: 238, + championName: 'Zed', + championImageUrl: `https://ddragon.leagueoflegends.com/cdn/${latestVersion}/img/champion/Zed.png`, + kills: 15, + deaths: 6, + assists: 10, + win: false + } + ] + } + }, + { + metadata: { matchId: 'sample3' }, + info: { + gameCreation: Date.now() - 10800000, + gameDuration: 1624, + gameMode: 'CLASSIC', + participants: [ + { + puuid: 'sample-puuid', + championId: 103, + championName: 'Ahri', + championImageUrl: `https://ddragon.leagueoflegends.com/cdn/${latestVersion}/img/champion/Ahri.png`, + kills: 8, + deaths: 2, + assists: 15, + win: true + } + ] + } + } + ] + }; + + res.status(200).json(mockData); + } catch (error) { + console.error('Test images error:', error); + res.status(500).json({ error: 'Failed to generate test data' }); + } +}; diff --git a/client/package.json b/client/package.json index 15194d7..2b61e79 100644 --- a/client/package.json +++ b/client/package.json @@ -4,16 +4,19 @@ "private": true, "dependencies": { "@types/node": "^16.18.126", - "@types/react": "^19.2.0", - "@types/react-dom": "^19.2.0", - "react": "^19.2.0", - "react-dom": "^19.2.0", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@types/react-router-dom": "^5.3.3", + "html2canvas": "^1.4.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.15.0", "react-scripts": "5.0.1", "typescript": "^4.9.5" }, "scripts": { "start": "cross-env HTTPS=false NODE_TLS_REJECT_UNAUTHORIZED=0 react-scripts start", - "build": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 react-scripts build" + "build": "react-scripts build" }, "eslintConfig": { "extends": [ @@ -33,6 +36,7 @@ ] }, "devDependencies": { + "@types/html2canvas": "^0.5.35", "cross-env": "^10.1.0" } } diff --git a/client/public/favicon.ico b/client/public/favicon.ico index f0eac51..15c6d9b 100644 Binary files a/client/public/favicon.ico and b/client/public/favicon.ico differ diff --git a/client/public/icon.ico b/client/public/icon.ico deleted file mode 100644 index f0eac51..0000000 Binary files a/client/public/icon.ico and /dev/null differ diff --git a/client/public/index.html b/client/public/index.html index f63dc0b..c8ac6c9 100644 --- a/client/public/index.html +++ b/client/public/index.html @@ -2,9 +2,9 @@ - + - + - Shape Splitter + Shape Split + + diff --git a/client/public/logo.gif b/client/public/logo.gif new file mode 100644 index 0000000..c2140dc Binary files /dev/null and b/client/public/logo.gif differ diff --git a/client/public/logo.png b/client/public/logo.png index 3f389d3..bfb4c78 100644 Binary files a/client/public/logo.png and b/client/public/logo.png differ diff --git a/client/src/App.css b/client/src/App.css index 5d07cf3..dab977c 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -6,38 +6,205 @@ .App { min-height: 100vh; - background: linear-gradient(135deg, #1a2818 0%, #2d3a2a 100%); + background: radial-gradient(circle at center, #1a1a1a 0%, #000000 100%); color: #ffffff; - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + font-family: sans-serif; } -.App-header { - padding: 2rem 0; - background: rgba(0, 0, 0, 0.2); +.menu-button { + position: fixed; + top: 2rem; + right: 2rem; + width: 40px; + height: 40px; + background: transparent; + border: none; + cursor: pointer; + z-index: 1001; + display: flex; + flex-direction: column; + justify-content: space-around; + padding: 8px; + transition: transform 0.3s ease; +} + +.menu-button:hover { + transform: scale(1.1); +} + +.menu-button span { + width: 100%; + height: 2px; + background: #ffffff; + transition: all 0.3s ease; + border-radius: 2px; +} + +/* Menu */ +.menu { + position: fixed; + top: 0; + right: -300px; + width: 300px; + height: 100vh; + background: rgba(255, 255, 255, 0.05); backdrop-filter: blur(10px); - border-bottom: 1px solid rgba(255, 255, 255, 0.1); + border-left: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + flex-direction: column; + justify-content: center; + gap: 2rem; + padding: 2rem; + z-index: 1000; + transition: right 0.3s ease; +} + +.menu.open { + right: 0; +} + +.menu a { + color: #ffffff; + text-decoration: none; + font-size: 1.5rem; + font-family: 'Rajdhani', sans-serif; + padding: 1rem; + transition: all 0.3s ease; + border-left: 3px solid transparent; + border-radius: 4px; +} + +.menu a:hover { + color: #ffffff; + background: rgba(255, 255, 255, 0.1); + border-left-color: #ffffff; + padding-left: 1.5rem; +} +/* Menu Overlay */ +.menu-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100vh; + background: rgba(0, 0, 0, 0.5); + z-index: 999; + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +.tagline { + font-family: 'Rajdhani', sans-serif; + font-size: 1.5rem; + font-weight: 300; + letter-spacing: 0.3em; + color: rgba(255, 255, 255, 0.9); + text-transform: uppercase; + margin-top: -2rem; + animation: fadeIn 1s ease-out forwards 0.5s; + opacity: 0; } -.logo-container { +.tagline::before, +.tagline::after { + content: ''; + display: inline-block; + width: 40px; + height: 1px; + background: rgba(255, 255, 255, 0.5); + vertical-align: middle; + margin: 0 1rem; +} + +.hero-section { + min-height: 100vh; display: flex; + flex-direction: column; + justify-content: flex-start; align-items: center; + gap: 4rem; + padding: 2rem 2rem; + padding-bottom: 8rem; +} + +.title-container { + position: relative; + display: flex; justify-content: center; - gap: 1rem; + align-items: center; + margin-top: 4rem; } -.app-logo { - height: 50px; - width: 50px; - object-fit: contain; + +.hero-logo { + height: 600px; + width: auto; + margin: 0 6rem; + position: relative; + z-index: 1; } -.logo-container h1 { - font-size: 2.5rem; - font-weight: 700; - background: linear-gradient(45deg, #7b8471, #a8b892); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; +@keyframes revealShape { + 0% { + opacity: 0; + transform: translate(300px, -100%); + } + 100% { + opacity: 1; + transform: translate(0, -100%); + } +} + +@keyframes revealSplit { + 0% { + opacity: 0; + transform: translate(-300px, 0); + } + 100% { + opacity: 1; + transform: translate(0, 0); + } +} + +.title-shape { + position: absolute; + top: 45%; + left: 0; + font-size: 4.5rem; + font-family: 'Bebas Neue', sans-serif; + font-weight: bold; + letter-spacing: 0.5em; + color: white; + text-shadow: 0 2px 4px rgba(0,0,0,0.3); + z-index: 0; + opacity: 0; + animation: revealShape 1s ease-out forwards; +} + +.title-split { + position: absolute; + top: 55%; + right: 0; + font-size: 4.5rem; + font-weight: bold; + font-family: 'Bebas Neue', sans-serif; + letter-spacing: 0.5em; + color: white; + text-shadow: 0 2px 4px rgba(0,0,0,0.3); + z-index: 0; + opacity: 0; + animation: revealSplit 1s ease-out forwards 0.2s; +} + +.animated-letter { + display: inline-block; } .main-content { @@ -47,35 +214,37 @@ } .search-container { - background: rgba(255, 255, 255, 0.05); - backdrop-filter: blur(10px); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 16px; - padding: 2rem; - margin-bottom: 2rem; + width: 100%; + max-width: 800px; + padding: 0 2rem; + margin-bottom: 4rem; } .search-form { display: flex; - flex-direction: column; - gap: 1.5rem; align-items: center; + gap: 0.5rem; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + padding: 0.5rem; + width: 100%; + border: 1px solid rgba(255, 255, 255, 0.1); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + transform-origin: center; +} + +.search-form:hover, +.search-form:focus-within { + border-color: #ffffff; + background: rgba(255, 255, 255, 0.08); + box-shadow: 0 0 20px rgba(255, 255, 255, 0.1); } .input-group { display: flex; align-items: center; gap: 0.5rem; - background: rgba(0, 0, 0, 0.3); - border-radius: 12px; - padding: 0.5rem 1rem; - border: 2px solid transparent; - transition: all 0.3s ease; -} - -.input-group:focus-within { - border-color: #7b8471; - box-shadow: 0 0 20px rgba(123, 132, 113, 0.3); + flex: 1; } .search-input { @@ -83,70 +252,92 @@ border: none; outline: none; color: #ffffff; - font-size: 1.2rem; - padding: 0.8rem; - min-width: 150px; + font-size: 1rem; + padding: 0.5rem; + width: 100%; + font-family: 'Rajdhani', sans-serif; + transition: all 0.3s ease; } .search-input::placeholder { + color: rgba(255, 255, 255, 0.4); + transition: all 0.3s ease; +} + +.search-form:hover .search-input::placeholder { color: rgba(255, 255, 255, 0.6); } .tag-input { - min-width: 80px; + width: 80px; } .separator { - color: #8b6f47; - font-size: 1.5rem; - font-weight: bold; + color: rgba(255, 255, 255, 0.6); + font-size: 1.2rem; + font-weight: normal; + transition: all 0.3s ease; +} + +.search-form:hover .separator { + color: rgba(255, 255, 255, 0.8); } .region-select { - background: rgba(0, 0, 0, 0.3); - border: 2px solid rgba(255, 255, 255, 0.2); - border-radius: 12px; + background: transparent; + border: none; color: #ffffff; - padding: 1rem 1.5rem; - font-size: 1rem; + padding: 0.5rem; + font-size: 0.9rem; + font-family: 'Rajdhani', sans-serif; outline: none; cursor: pointer; + width: auto; + margin: 0 0.5rem; transition: all 0.3s ease; } +.search-form:hover .region-select { + background: rgba(255, 255, 255, 0.08); +} + .region-select:focus { - border-color: #7b8471; - box-shadow: 0 0 20px rgba(123, 132, 113, 0.3); + background: rgba(255, 255, 255, 0.1); } .region-select option { - background: #2d3a2a; + background: #000000; color: #ffffff; } .search-button { - background: linear-gradient(45deg, #8b6f47, #a8b892); + background: #ffffff; + color: #000000; border: none; - border-radius: 12px; - color: #1a2818; - font-size: 1.1rem; - font-weight: 600; - padding: 1rem 2.5rem; + padding: 0.5rem 1.5rem; + border-radius: 6px; + font-size: 0.9rem; + font-weight: 500; + font-family: 'Rajdhani', sans-serif; cursor: pointer; - transition: all 0.3s ease; - text-transform: uppercase; - letter-spacing: 1px; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + min-width: 100px; + position: relative; + overflow: hidden; } -.search-button:hover { - transform: translateY(-2px); - box-shadow: 0 10px 30px rgba(139, 111, 71, 0.4); +.search-button:hover:not(:disabled) { + background: #ffffff; + box-shadow: 0 0 15px rgba(255, 255, 255, 0.2); +} + +.search-button:active:not(:disabled) { + transform: scale(0.98); } .search-button:disabled { - opacity: 0.6; + opacity: 0.5; cursor: not-allowed; - transform: none; } .error-message { @@ -161,6 +352,7 @@ .results-container { animation: fadeInUp 0.6s ease; + font-family: 'Rajdhani', sans-serif; } @keyframes fadeInUp { @@ -182,6 +374,22 @@ padding: 2rem; margin-bottom: 2rem; text-align: center; + display: flex; + align-items: center; + justify-content: center; + gap: 2rem; +} + +.profile-icon { + flex-shrink: 0; +} + +.profile-icon-img { + width: 80px; + height: 80px; + border-radius: 50%; + border: 3px solid #8b6f47; + object-fit: cover; } .player-info h2 { @@ -219,6 +427,7 @@ font-size: 1.5rem; margin-bottom: 1.5rem; text-align: center; + font-family: 'Bebas Neue', sans-serif; } .rank-info { @@ -266,29 +475,2450 @@ font-weight: 600; } -@media (max-width: 768px) { - .main-content { - padding: 2rem 1rem; - } - - .search-form { - gap: 1rem; - } - - .input-group { - flex-direction: column; - gap: 0.5rem; + +.champion-info { + color: #a8b892; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.champion-icon { + width: 32px; + height: 32px; + border-radius: 6px; + border: 2px solid #8b6f47; + object-fit: cover; +} + +.kda { + font-family: 'Courier New', monospace; + color: rgba(255, 255, 255, 0.9); +} + +.game-mode { + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.6); +} + +.hero-logo { + height: 600px; + width: auto; + margin: 0 6rem; + position: relative; + z-index: 1; +} + +/* Floating particles container */ +.hero-logo-wrapper { + position: relative; + display: inline-block; +} + +.particle { + position: absolute; + width: 6px; + height: 6px; + background: radial-gradient(circle, rgba(255, 255, 255, 0.9) 0%, transparent 70%); + border-radius: 50%; + pointer-events: none; + animation: float 3s ease-in-out infinite; + box-shadow: 0 0 15px rgba(255, 255, 255, 0.8), 0 0 30px rgba(255, 255, 255, 0.4); + z-index: 10; +} + +@keyframes float { + 0%, 100% { + transform: translate(0, 0) scale(1); + opacity: 0; } - - .search-input { - min-width: 200px; + 10% { + opacity: 1; } - - .stats-grid { - grid-template-columns: 1fr; + 50% { + transform: translate(var(--tx), var(--ty)) scale(1.5); + opacity: 0.8; } - - .logo-container h1 { - font-size: 2rem; + 90% { + opacity: 1; } } + +/* Center-concentrated particles with wider radius */ +.particle:nth-child(1) { --tx: 60px; --ty: -40px; animation-delay: 0s; left: 45%; top: 38%; animation-duration: 3s; } +.particle:nth-child(2) { --tx: -55px; --ty: -38px; animation-delay: 0.2s; left: 55%; top: 39%; animation-duration: 3.2s; } +.particle:nth-child(3) { --tx: 70px; --ty: 40px; animation-delay: 0.4s; left: 42%; top: 61%; animation-duration: 2.8s; } +.particle:nth-child(4) { --tx: -65px; --ty: 38px; animation-delay: 0.6s; left: 58%; top: 60%; animation-duration: 3.1s; } +.particle:nth-child(5) { --tx: 50px; --ty: -45px; animation-delay: 0.8s; left: 50%; top: 35%; animation-duration: 2.9s; } +.particle:nth-child(6) { --tx: -70px; --ty: -30px; animation-delay: 1.0s; left: 48%; top: 63%; animation-duration: 3.3s; } +.particle:nth-child(7) { --tx: 55px; --ty: 43px; animation-delay: 1.2s; left: 52%; top: 40%; animation-duration: 2.7s; } +.particle:nth-child(8) { --tx: -60px; --ty: 45px; animation-delay: 1.4s; left: 46%; top: 59%; animation-duration: 3.4s; } +.particle:nth-child(9) { --tx: 75px; --ty: -35px; animation-delay: 1.6s; left: 53%; top: 43%; animation-duration: 2.6s; } +.particle:nth-child(10) { --tx: -68px; --ty: 34px; animation-delay: 1.8s; left: 47%; top: 58%; animation-duration: 3.5s; } +.particle:nth-child(11) { --tx: 62px; --ty: -33px; animation-delay: 2.0s; left: 51%; top: 41%; animation-duration: 3s; } +.particle:nth-child(12) { --tx: -58px; --ty: 48px; animation-delay: 2.2s; left: 44%; top: 62%; animation-duration: 2.8s; } +.particle:nth-child(13) { --tx: 80px; --ty: -43px; animation-delay: 2.4s; left: 49%; top: 44%; animation-duration: 3.2s; } +.particle:nth-child(14) { --tx: -62px; --ty: 39px; animation-delay: 2.6s; left: 51%; top: 56%; animation-duration: 2.9s; } +.particle:nth-child(15) { --tx: 52px; --ty: -46px; animation-delay: 2.8s; left: 50%; top: 49%; animation-duration: 3.1s; } +.particle:nth-child(16) { --tx: 85px; --ty: -50px; animation-delay: 0.3s; left: 38%; top: 34%; animation-duration: 2.7s; } +.particle:nth-child(17) { --tx: -78px; --ty: -48px; animation-delay: 0.5s; left: 62%; top: 36%; animation-duration: 3.3s; } +.particle:nth-child(18) { --tx: 72px; --ty: 50px; animation-delay: 0.7s; left: 40%; top: 64%; animation-duration: 2.6s; } +.particle:nth-child(19) { --tx: -85px; --ty: 44px; animation-delay: 0.9s; left: 60%; top: 63%; animation-duration: 3.4s; } +.particle:nth-child(20) { --tx: 65px; --ty: -36px; animation-delay: 1.1s; left: 47%; top: 45%; animation-duration: 3s; } +.particle:nth-child(21) { --tx: 90px; --ty: -53px; animation-delay: 0.1s; left: 35%; top: 33%; animation-duration: 2.8s; } +.particle:nth-child(22) { --tx: -82px; --ty: -49px; animation-delay: 0.4s; left: 65%; top: 35%; animation-duration: 3.2s; } +.particle:nth-child(23) { --tx: 78px; --ty: 53px; animation-delay: 0.6s; left: 37%; top: 65%; animation-duration: 2.9s; } +.particle:nth-child(24) { --tx: -90px; --ty: 46px; animation-delay: 0.8s; left: 63%; top: 64%; animation-duration: 3.1s; } +.particle:nth-child(25) { --tx: 58px; --ty: -44px; animation-delay: 1.0s; left: 50%; top: 38%; animation-duration: 2.7s; } +.particle:nth-child(26) { --tx: -72px; --ty: 41px; animation-delay: 1.2s; left: 48%; top: 62%; animation-duration: 3.3s; } +.particle:nth-child(27) { --tx: 55px; --ty: -34px; animation-delay: 1.4s; left: 49%; top: 46%; animation-duration: 2.6s; } +.particle:nth-child(28) { --tx: -68px; --ty: 36px; animation-delay: 1.6s; left: 52%; top: 54%; animation-duration: 3.4s; } +.particle:nth-child(29) { --tx: 95px; --ty: -55px; animation-delay: 0.2s; left: 33%; top: 31%; animation-duration: 3s; } +.particle:nth-child(30) { --tx: -88px; --ty: -51px; animation-delay: 0.5s; left: 67%; top: 34%; animation-duration: 2.8s; } +.particle:nth-child(31) { --tx: 82px; --ty: 55px; animation-delay: 0.7s; left: 36%; top: 66%; animation-duration: 3.2s; } +.particle:nth-child(32) { --tx: -95px; --ty: 49px; animation-delay: 0.9s; left: 64%; top: 65%; animation-duration: 2.9s; } +.particle:nth-child(33) { --tx: 60px; --ty: -39px; animation-delay: 1.3s; left: 50%; top: 43%; animation-duration: 3.1s; } +.particle:nth-child(34) { --tx: -65px; --ty: 35px; animation-delay: 1.5s; left: 50%; top: 57%; animation-duration: 2.7s; } +.particle:nth-child(35) { --tx: 53px; --ty: -43px; animation-delay: 1.7s; left: 48%; top: 50%; animation-duration: 3.3s; } +.particle:nth-child(36) { --tx: -75px; --ty: 33px; animation-delay: 1.9s; left: 52%; top: 51%; animation-duration: 2.6s; } +.particle:nth-child(37) { --tx: 68px; --ty: -48px; animation-delay: 2.1s; left: 46%; top: 47%; animation-duration: 3.4s; } +.particle:nth-child(38) { --tx: -80px; --ty: 43px; animation-delay: 2.3s; left: 54%; top: 53%; animation-duration: 3s; } +.particle:nth-child(39) { --tx: 63px; --ty: -37px; animation-delay: 2.5s; left: 49%; top: 49%; animation-duration: 2.8s; } +.particle:nth-child(40) { --tx: -70px; --ty: 42px; animation-delay: 2.7s; left: 51%; top: 52%; animation-duration: 3.2s; } +.particle:nth-child(27) { --tx: 55px; --ty: -68px; animation-delay: 1.4s; left: 49%; top: 42%; animation-duration: 2.6s; } +.particle:nth-child(28) { --tx: -68px; --ty: 72px; animation-delay: 1.6s; left: 52%; top: 58%; animation-duration: 3.4s; } +.particle:nth-child(29) { --tx: 95px; --ty: -110px; animation-delay: 0.2s; left: 33%; top: 12%; animation-duration: 3s; } +.particle:nth-child(30) { --tx: -88px; --ty: -102px; animation-delay: 0.5s; left: 67%; top: 17%; animation-duration: 2.8s; } +.particle:nth-child(31) { --tx: 82px; --ty: 110px; animation-delay: 0.7s; left: 36%; top: 82%; animation-duration: 3.2s; } +.particle:nth-child(32) { --tx: -95px; --ty: 98px; animation-delay: 0.9s; left: 64%; top: 79%; animation-duration: 2.9s; } +.particle:nth-child(33) { --tx: 60px; --ty: -78px; animation-delay: 1.3s; left: 50%; top: 36%; animation-duration: 3.1s; } +.particle:nth-child(34) { --tx: -65px; --ty: 70px; animation-delay: 1.5s; left: 50%; top: 64%; animation-duration: 2.7s; } +.particle:nth-child(35) { --tx: 53px; --ty: -85px; animation-delay: 1.7s; left: 48%; top: 50%; animation-duration: 3.3s; } +.particle:nth-child(36) { --tx: -75px; --ty: 65px; animation-delay: 1.9s; left: 52%; top: 52%; animation-duration: 2.6s; } +.particle:nth-child(37) { --tx: 68px; --ty: -95px; animation-delay: 2.1s; left: 46%; top: 44%; animation-duration: 3.4s; } +.particle:nth-child(38) { --tx: -80px; --ty: 86px; animation-delay: 2.3s; left: 54%; top: 56%; animation-duration: 3s; } +.particle:nth-child(39) { --tx: 63px; --ty: -73px; animation-delay: 2.5s; left: 49%; top: 48%; animation-duration: 2.8s; } +.particle:nth-child(40) { --tx: -70px; --ty: 83px; animation-delay: 2.7s; left: 51%; top: 53%; animation-duration: 3.2s; } + +/* Loading Page Styles */ +.hero-section.loading-page { + justify-content: center; + padding: 2rem; +} + +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3rem; + max-width: 800px; + margin: 0 auto; + padding: 2rem; + font-family: 'Rajdhani', sans-serif; + color: #ffffff; + text-align: center; +} + +.loading-profile { + display: flex; + flex-direction: column; + align-items: center; + gap: 2rem; +} + +.loading-profile-icon { + width: 120px; + height: 120px; + border-radius: 50%; + border: 3px solid rgba(255, 255, 255, 0.3); + box-shadow: 0 0 30px rgba(255,255,255,0.1); + background: rgba(255, 255, 255, 0.05); +} + +.loading-indicator { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + font-size: 1.2rem; + color: rgba(255, 255, 255, 0.9); + font-family: 'Rajdhani', sans-serif; + font-weight: 300; + letter-spacing: 0.1em; +} + +.loading-spinner { + width: 40px; + height: 40px; + border: 3px solid rgba(255, 255, 255, 0.1); + border-top: 3px solid rgba(255, 255, 255, 0.6); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Progress Bar */ +.progress-container { + width: 400px; + display: flex; + flex-direction: column; + gap: 1rem; + margin: 1rem 0; +} + +.progress-bar { + width: 100%; + height: 6px; + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.progress-fill { + height: 100%; + background: rgba(255, 255, 255, 0.8); + border-radius: 3px; + transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 0 10px rgba(255, 255, 255, 0.3); +} + +.progress-text { + text-align: center; + font-size: 0.9rem; + color: rgba(255, 255, 255, 0.7); + font-family: 'Rajdhani', sans-serif; + font-weight: 300; + letter-spacing: 0.1em; +} + +/* Current Step */ +.current-step { + background: rgba(255, 255, 255, 0.05); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.1); + padding: 1.5rem 3rem; + border-radius: 8px; + font-size: 1rem; + font-weight: 300; + text-align: center; + color: rgba(255, 255, 255, 0.9); + font-family: 'Rajdhani', sans-serif; + letter-spacing: 0.05em; + max-width: 600px; + width: 100%; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Game Statistics */ +.game-stats { + width: 100%; + max-width: 600px; + background: #1a1a1a; + border: 1px solid #333; + border-radius: 12px; + padding: 1.5rem; + margin: 1rem 0; +} + +.game-stats h4 { + margin-bottom: 1rem; + text-align: center; + color: #fff; + font-size: 1.2rem; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 1rem; +} + +.stat-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + padding: 1rem; + border-radius: 8px; + transition: all 0.3s ease; +} + +.stat-item:hover { + transform: translateY(-2px); +} + +.stat-item.ranked { + background: linear-gradient(135deg, #FFD700, #FFA000); + color: #000; +} + +.stat-item.normal { + background: linear-gradient(135deg, #4CAF50, #66BB6A); + color: #fff; +} + +.stat-item.other { + background: linear-gradient(135deg, #9E9E9E, #BDBDBD); + color: #000; +} + +.stat-item.valid { + background: linear-gradient(135deg, #2196F3, #42A5F5); + color: #fff; +} + +.stat-icon { + font-size: 1.5rem; +} + +.stat-label { + font-size: 0.9rem; + font-weight: 600; + text-align: center; +} + +.stat-value { + font-size: 1.2rem; + font-weight: 700; +} + +.loading-status-list { + display: flex; + flex-direction: column; + gap: 0.8rem; + width: 100%; + max-width: 600px; + max-height: 300px; + overflow-y: auto; +} + +.loading-status-item { + background: #181818; + padding: 1rem 1.5rem; + border-radius: 10px; + border-left: 4px solid #fff; + font-size: 1rem; + color: #fff; + font-family: inherit; + transition: all 0.3s ease; + animation: fadeInUp 0.5s ease forwards; +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Results Page Styles */ +.results-container { + max-width: 1000px; + margin: 0 auto; + padding: 2rem; + display: flex; + flex-direction: column; + gap: 3rem; + font-family: 'Rajdhani', sans-serif; + color: #fff; +} + +.results-header { + display: flex; + align-items: center; + gap: 2rem; + padding: 2rem; + background: #181818; + border-radius: 15px; + border: 1px solid #333; +} + +.player-name { + font-size: 2rem; + color: #fff; + margin-bottom: 0.5rem; + font-family: 'Bebas Neue', sans-serif; +} + +.player-level { + font-size: 1.1rem; + color: #cccccc; + margin: 0; + font-family: inherit; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); + gap: 2rem; +} + +.stat-card { + background: #181818; + padding: 2rem; + border-radius: 15px; + border: 1px solid #333; + transition: transform 0.3s ease, box-shadow 0.3s ease; + font-family: inherit; +} + +.stat-card:hover { + transform: translateY(-5px); + box-shadow: 0 10px 30px rgba(255,255,255,0.08); +} + +.stat-card h3 { + color: #fff; + font-size: 1.5rem; + margin-bottom: 0.5rem; + font-family: 'Bebas Neue', sans-serif; +} + +.stats-subtitle { + color: #cccccc; + font-size: 0.9rem; + margin-bottom: 1.5rem; + font-family: inherit; +} + +.avg-kda { + font-size: 1.3rem; + color: #fff; + font-weight: bold; + text-align: center; + padding: 1rem; + background: #222; + border-radius: 10px; + font-family: inherit; +} + +.players-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 1rem; +} + +.player-item { + display: flex; + align-items: center; + gap: 0.8rem; + padding: 0.8rem; + background: #222; + border-radius: 8px; + transition: background 0.3s ease; + font-family: inherit; + min-height: 60px; +} + +.player-item:hover { + background: #333; +} + +.champion-name { + font-size: 0.9rem; + color: #fff; + font-family: inherit; +} + +.mode-stats { + display: flex; + flex-direction: column; + gap: 0.8rem; +} + +.win-rate { + font-size: 1.1rem; + color: #fff; + text-align: center; + padding: 0.5rem; + background: #2a2a2a; + border-radius: 8px; + font-family: inherit; +} + +.player-info-item { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.player-name-small { + font-size: 0.9rem; + color: #fff; + font-weight: 500; + font-family: inherit; +} + +.champion-name-small { + font-size: 0.8rem; + color: #aaa; + font-family: inherit; +} + +/* Personality Analysis Styles */ +.personality-card { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + padding: 1.5rem; + backdrop-filter: blur(10px); + transition: all 0.3s ease; + grid-column: span 1; +} + +.personality-card:hover { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.2); + transform: translateY(-2px); +} + +.personality-card h3 { + font-size: 1.2rem; + margin-bottom: 1rem; + color: #ffffff; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + padding-bottom: 0.5rem; +} + +/* Archetype Card */ +.archetype-card { + grid-column: span 2; +} + +.archetype-content { + text-align: center; +} + +.archetype-name { + font-size: clamp(1.5rem, 4vw, 2.5rem); + font-weight: bold; + color: #ffffff; + margin-bottom: 0.5rem; + text-shadow: 0 0 10px rgba(255, 255, 255, 0.3); +} + +.archetype-similarity { + font-size: clamp(1rem, 2.5vw, 1.3rem); + color: #90EE90; + margin-bottom: 1rem; + font-weight: 600; +} + +.archetype-description { + font-size: clamp(0.9rem, 2vw, 1.1rem); + color: #cccccc; + margin-bottom: 1rem; + line-height: 1.5; +} + +.archetype-champions { + font-size: clamp(0.8rem, 1.8vw, 1rem); + color: #ffffff; + background: rgba(255, 255, 255, 0.1); + padding: clamp(0.5rem, 2vw, 0.75rem); + border-radius: 8px; + border-left: 3px solid #90EE90; +} + +.archetype-champions strong { + color: #90EE90; +} + +/* Traits Card */ +.traits-card { + grid-column: span 2; +} + +.traits-content { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.trait-bar { + display: flex; + align-items: center; + gap: 1rem; +} + +.trait-label { + min-width: 140px; + font-weight: 600; + color: #ffffff; +} + +.trait-progress { + flex: 1; + height: 20px; + background: rgba(255, 255, 255, 0.1); + border-radius: 10px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.trait-fill { + height: 100%; + background: linear-gradient(90deg, #ff6b6b, #4ecdc4, #45b7d1, #96ceb4, #ffeaa7); + background-size: 500% 100%; + animation: gradientShift 3s ease infinite; + transition: width 1s ease; + border-radius: 10px; +} + +.trait-score { + min-width: 80px; + text-align: right; + font-weight: bold; + color: #90EE90; + font-size: 1rem; +} + +/* Wrapped Card Specific Styles */ +.traits-display { + display: flex; + flex-direction: column; + gap: 1.5rem; + width: 100%; + max-width: 500px; +} + +.trait-item { + display: flex; + align-items: center; + gap: 1rem; + width: 100%; +} + +.trait-name { + min-width: 140px; + font-weight: 600; + color: #ffffff; + font-size: 1rem; +} + +.trait-bar { + flex: 1; + height: 20px; + background: rgba(255, 255, 255, 0.1); + border-radius: 10px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.2); + position: relative; +} + +.trait-fill { + height: 100%; + background: linear-gradient(90deg, #ff6b6b, #4ecdc4, #45b7d1, #96ceb4, #ffeaa7); + background-size: 500% 100%; + animation: gradientShift 3s ease infinite; + transition: width 1s ease; + border-radius: 10px; +} + +.trait-score { + min-width: 80px; + text-align: right; + font-weight: bold; + color: #90EE90; + font-size: 1rem; +} + +/* Welcome Card Styles */ +.welcome-title { + font-size: 2.5rem; + font-weight: bold; + color: #ffffff; + margin-bottom: 1rem; + font-family: 'Bebas Neue', sans-serif; +} + +.welcome-name { + font-size: 2rem; + color: #90EE90; + font-weight: 600; + margin-bottom: 2rem; +} + +.welcome-subtitle { + font-size: 1.2rem; + color: rgba(255, 255, 255, 0.8); + line-height: 1.5; +} + +/* Archetype Card Content */ +.archetype-display { + display: flex; + flex-direction: column; + align-items: center; + gap: clamp(1rem, 3vw, 1.5rem); + text-align: center; + width: 100%; + max-width: 90%; +} + +/* Gameplay Card Content */ +.gameplay-content { + display: flex; + flex-direction: column; + align-items: center; + gap: clamp(1rem, 4vw, 2rem); + text-align: center; +} + +.big-number { + font-size: clamp(2.5rem, 8vw, 4rem); + font-weight: bold; + color: #ffffff; + font-family: 'Bebas Neue', sans-serif; +} + +.big-label { + font-size: clamp(1rem, 2.5vw, 1.4rem); + color: rgba(255, 255, 255, 0.8); + margin-top: -1rem; +} + +/* Game Modes Card Content */ +.modes-content { + display: flex; + flex-direction: column; + gap: 1.5rem; + text-align: center; +} + +.mode-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + background: rgba(255, 255, 255, 0.1); + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.mode-name { + font-size: clamp(1rem, 2.5vw, 1.3rem); + font-weight: 600; + color: #ffffff; +} + +.mode-winrate { + font-size: clamp(0.9rem, 2vw, 1.1rem); + color: #90EE90; +} + +/* KDA Display */ +.kda-display { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +.kda-value { + font-size: 3rem; + font-weight: bold; + color: #ffffff; + font-family: 'Bebas Neue', sans-serif; +} + +.kda-label { + font-size: clamp(0.8rem, 2vw, 1rem); + color: rgba(255, 255, 255, 0.8); +} + +/* Winrate Display */ +.winrate-display { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.2rem; +} + +.winrate-number { + font-size: clamp(1.5rem, 4vw, 2.5rem); + font-weight: bold; + color: #90EE90; +} + +.winrate-label { + font-size: clamp(0.8rem, 2vw, 1rem); + color: rgba(255, 255, 255, 0.8); +} + +/*Playstyle Display */ +.role-label { + font-size: clamp(1.1rem, 2.5vw, 1.4rem); + color: rgba(255, 255, 255, 0.8); + font-weight: 400; + font-family: 'Rajdhani', sans-serif; +} + +.role-name { + font-size: clamp(1.8rem, 4vw, 2.5rem); + font-weight: bold; + color: #ffffff; + font-family: 'Bebas Neue', sans-serif; + background: linear-gradient(135deg, #4ecdc4, #45b7d1); + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + text-transform: uppercase; + letter-spacing: 1px; +} + +.insight-value { + font-size: clamp(1.8rem, 4vw, 2.5rem); + font-weight: bold; + color: #90EE90; + font-family: 'Bebas Neue', sans-serif; + line-height: 1; +} + +.insight-label { + font-size: clamp(0.85rem, 2vw, 1rem); + color: rgba(255, 255, 255, 0.7); + text-align: center; + font-weight: 500; + line-height: 1.2; +} + +/* Matchmaking Card Styles */ +.matchmaking-card .wrapped-card-content { + padding: clamp(1rem, 3vw, 2rem); + overflow-y: auto; + overflow-x: hidden; + max-height: 100%; + max-width: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: clamp(0.75rem, 2vw, 1.5rem); + box-sizing: border-box; + word-wrap: break-word; + overflow-wrap: break-word; +} + +/* When there are results, align to top */ +.matchmaking-card.has-results .wrapped-card-content { + justify-content: flex-start; +} + +.matchmaking-header { + display: flex; + justify-content: center; + align-items: center; + margin-bottom: clamp(1rem, 2.5vw, 1.5rem); + flex-shrink: 0; + width: 100%; + flex-direction: column; + gap: 0.75rem; +} + + +.reset-button { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + color: white; + padding: 0.5rem 1rem; + border-radius: 8px; + cursor: pointer; + font-size: 0.9rem; + transition: all 0.3s ease; +} + +.reset-button:hover { + background: rgba(255, 255, 255, 0.2); + transform: translateY(-2px); +} + +.matchmaking-search { + text-align: center; +} + +.search-description { + margin-bottom: clamp(0.75rem, 2vw, 1.5rem); + color: rgba(255, 255, 255, 0.8); + font-size: clamp(0.9rem, 2vw, 1.1rem); +} + +/* Matchmaking Region Selector */ +.matchmaking-region-selector { + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; +} + +.matchmaking-region-selector label { + font-size: 1rem; + color: rgba(255, 255, 255, 0.8); + font-weight: 500; +} + +.matchmaking-region-select { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + color: #ffffff; + padding: 0.5rem; + font-size: 1rem; + font-family: 'Rajdhani', sans-serif; + cursor: pointer; + transition: all 0.3s ease; +} + +.matchmaking-region-select:hover { + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.4); +} + +.matchmaking-region-select:focus { + outline: none; + border-color: #ffffff; + box-shadow: 0 0 10px rgba(255, 255, 255, 0.2); +} + +.matchmaking-region-select option { + background: #1a1a1a; + color: #ffffff; +} + +/* Matchmaking Search Form - styled like main search */ +.matchmaking-search-form { + display: flex; + align-items: center; + gap: 0.5rem; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + padding: 0.5rem; + width: 100%; + max-width: 700px; + margin: 0 auto 1rem; + border: 1px solid rgba(255, 255, 255, 0.1); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + transform-origin: center; +} + +.matchmaking-search-form:hover, +.matchmaking-search-form:focus-within { + border-color: #ffffff; + background: rgba(255, 255, 255, 0.08); + box-shadow: 0 0 20px rgba(255, 255, 255, 0.1); +} + +.matchmaking-input-group { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1; + min-width: 0; +} + +.matchmaking-search-input { + background: transparent; + border: none; + outline: none; + color: #ffffff; + font-size: 1rem; + padding: 0.5rem; + flex: 1; + min-width: 150px; + font-family: 'Rajdhani', sans-serif; + transition: all 0.3s ease; +} + + +.matchmaking-search-input::placeholder { + color: rgba(255, 255, 255, 0.4); + transition: all 0.3s ease; +} + +.matchmaking-search-form:hover .matchmaking-search-input::placeholder { + color: rgba(255, 255, 255, 0.6); +} + +.matchmaking-tag-input { + width: 100px; + flex-shrink: 0; +} + + +.matchmaking-separator { + color: rgba(255, 255, 255, 0.6); + font-size: 1.2rem; + font-weight: normal; + transition: all 0.3s ease; +} + +.matchmaking-search-form:hover .matchmaking-separator { + color: rgba(255, 255, 255, 0.8); +} + +.matchmaking-region-select-inline { + background: transparent; + border: none; + outline: none; + color: #ffffff; + font-size: 1rem; + padding: 0.5rem; + font-family: 'Rajdhani', sans-serif; + cursor: pointer; + width: auto; + min-width: 100px; + flex-shrink: 0; + transition: all 0.3s ease; +} + +.matchmaking-search-form:hover .matchmaking-region-select-inline { + background: rgba(255, 255, 255, 0.08); + border-radius: 4px; +} + +.matchmaking-region-select-inline:focus { + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; +} + +.matchmaking-region-select-inline option { + background: #000000; + color: #ffffff; +} + +.matchmaking-search-button { + background: #ffffff; + color: #000000; + border: none; + padding: 0.5rem 1.5rem; + border-radius: 6px; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; + white-space: nowrap; + font-family: 'Rajdhani', sans-serif; + flex-shrink: 0; + min-width: 100px; +} + +.matchmaking-search-button:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.9); + transform: translateY(-1px); +} + +.matchmaking-search-button:disabled { + background: rgba(255, 255, 255, 0.3); + cursor: not-allowed; + transform: none; +} + +.error-message { + color: #ef4444; + margin: 1rem 0; + padding: 0.75rem; + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: 8px; +} + +.loading-message { + margin: 2rem 0; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 1rem; +} + +.loading-spinner { + width: 40px; + height: 40px; + border: 3px solid rgba(255, 255, 255, 0.1); + border-top: 3px solid #3b82f6; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +.matchmaking-tips { + margin-top: 2rem; + text-align: left; +} + +.matchmaking-tips h4 { + margin-bottom: 1rem; + color: #f59e0b; +} + +.matchmaking-tips ul { + list-style: none; + padding: 0; +} + +.matchmaking-tips li { + margin-bottom: 0.5rem; + padding-left: 1rem; + color: rgba(255, 255, 255, 0.8); +} + + +.compatibility-result { + text-align: center; + max-width: 100%; + overflow-x: hidden; + word-wrap: break-word; + overflow-wrap: break-word; + box-sizing: border-box; +} + + +.players-comparison { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: clamp(1rem, 3vw, 2rem); + gap: clamp(0.5rem, 2vw, 1rem); + flex-shrink: 0; + max-width: 100%; + box-sizing: border-box; +} + + +.player-info { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + flex: 1; + min-width: 0; + max-width: 33%; +} + + +.player-avatar { + width: clamp(60px, 8vw, 80px); + height: clamp(60px, 8vw, 80px); + border-radius: 50%; + border: 3px solid rgba(255, 255, 255, 0.2); +} + + +.player-details { + text-align: center; + max-width: 100%; + word-wrap: break-word; + overflow-wrap: break-word; +} + + +.player-name { + font-size: 1.2rem; + font-weight: bold; + word-wrap: break-word; + overflow-wrap: break-word; + max-width: 100%; +} + +.player-tag { + color: rgba(255, 255, 255, 0.7); + font-size: 0.9rem; + word-wrap: break-word; + overflow-wrap: break-word; + max-width: 100%; +} + + +.player-archetype { + padding: 0.25rem 0.75rem; + word-wrap: break-word; + overflow-wrap: break-word; + max-width: 100%; + border-radius: 12px; + font-size: 0.8rem; + margin-top: 0.25rem; +} + +.compatibility-score-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + flex: 1; +} + +.compatibility-score { + font-size: clamp(2rem, 6vw, 3rem); + font-weight: bold; + background: linear-gradient(135deg, currentColor, currentColor); + -webkit-background-clip: text; + background-clip: text; +} + +.compatibility-level { + font-size: 1.1rem; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); +} + +.compatibility-hearts { + display: flex; + gap: 0.25rem; + margin-top: 0.5rem; +} + +.heart { + font-size: 1.5rem; + opacity: 0.3; + transition: all 0.3s ease; +} + +.heart.filled { + opacity: 1; + animation: heartbeat 0.5s ease-in-out; +} + +@keyframes heartbeat { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.1); } +} + +.compatibility-details { + text-align: left; + max-width: 100%; + width: 100%; + margin: 0 auto; + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding-right: 0.5rem; + word-wrap: break-word; + overflow-wrap: break-word; + box-sizing: border-box; +} + +.compatibility-details > div { + margin-bottom: clamp(0.75rem, 2vw, 1.5rem); + max-width: 100%; + overflow-wrap: break-word; + word-wrap: break-word; +} + +.compatibility-details h4 { + margin-bottom: clamp(0.5rem, 1.5vw, 0.75rem); + color: #f59e0b; + font-size: clamp(0.9rem, 2vw, 1.1rem); + word-wrap: break-word; + overflow-wrap: break-word; +} + +.recommendation p { + font-size: clamp(0.9rem, 2vw, 1.1rem); + color: rgba(255, 255, 255, 0.9); + background: rgba(255, 255, 255, 0.05); + padding: clamp(0.75rem, 2vw, 1rem); + border-radius: 8px; + border-left: 4px solid #3b82f6; + line-height: 1.4; + word-wrap: break-word; + overflow-wrap: break-word; + hyphens: auto; + max-width: 100%; + box-sizing: border-box; +} + +.strengths ul, .challenges ul { + list-style: none; + padding: 0; + max-width: 100%; + box-sizing: border-box; +} + +.strengths li, .challenges li { + margin-bottom: clamp(0.25rem, 1vw, 0.5rem); + padding: clamp(0.4rem, 1.5vw, 0.75rem); + border-radius: 6px; + background: rgba(255, 255, 255, 0.05); + font-size: clamp(0.85rem, 1.8vw, 1rem); + line-height: 1.3; + word-wrap: break-word; + overflow-wrap: break-word; + hyphens: auto; + max-width: 100%; + box-sizing: border-box; +} + +.strengths li { + border-left: 3px solid #22c55e; +} + +.challenges li { + border-left: 3px solid #f59e0b; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .matchmaking-card .wrapped-card-content { + padding: 1rem; + gap: 1rem; + justify-content: flex-start; + } + + .players-comparison { + flex-direction: column; + gap: 1.5rem; + margin-bottom: 1rem; + } + + .matchmaking-search-form { + flex-direction: column; + align-items: stretch; + gap: 1rem; + } + + .matchmaking-input-group { + justify-content: center; + } + + .compatibility-score { + font-size: 2.5rem; + } + + .player-avatar { + width: 60px; + height: 60px; + } +} + +/* Share Button Styles */ +.share-button { + position: absolute; + top: clamp(0.8rem, 2vw, 1.5rem); + right: clamp(0.8rem, 2vw, 1.5rem); + width: clamp(40px, 6vw, 50px); + height: clamp(40px, 6vw, 50px); + border-radius: 50%; + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(15px); + border: 1px solid rgba(255, 255, 255, 0.2); + color: #ffffff; + font-size: clamp(1rem, 2vw, 1.2rem); + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + align-items: center; + justify-content: center; + font-family: 'Rajdhani', sans-serif; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + user-select: none; + z-index: 15; +} + +.share-button:hover { + background: rgba(255, 255, 255, 0.25); + border-color: rgba(255, 255, 255, 0.5); + transform: scale(1.1); + box-shadow: 0 8px 40px rgba(255, 255, 255, 0.15); +} + +.share-button:active { + transform: scale(0.95); + background: rgba(255, 255, 255, 0.3); +} + +.share-button svg { + width: clamp(16px, 3vw, 20px); + height: clamp(16px, 3vw, 20px); + transition: transform 0.2s ease; +} + +.share-button:hover svg { + transform: rotate(15deg); +} + +/* Responsive Design for Wrapped Cards */ +@media (max-width: 1400px) { + .wrapped-container { + max-width: 1000px; /* Scale down from 1200px */ + padding: 0 1.5rem; + } +} + +@media (max-width: 1024px) { + .wrapped-container { + max-width: 800px; /* Slightly smaller on tablets */ + padding: 0 1.5rem; + } +} + +@media (max-width: 768px) { + .wrapped-container { + max-width: 95%; /* Use percentage for better mobile scaling */ + height: 600px; + padding: 0 1rem; /* Reduce padding on smaller screens */ + } + + .welcome-title { + font-size: 2rem; + } + + .welcome-name { + font-size: 1.5rem; + } + + .archetype-name { + font-size: 2rem; + } + + .big-number { + font-size: 3rem; + } + + .kda-value { + font-size: 2rem; + } + + .winrate-number { + font-size: 2.5rem; + } +} + +@media (max-width: 480px) { + .wrapped-container { + max-width: 98%; /* Even more space usage on mobile */ + height: 500px; + padding: 0 0.5rem; /* Further reduce padding on mobile */ + } + + .wrapped-card-content { + padding: 2rem 1.5rem; + } + + .welcome-title { + font-size: 1.75rem; + } + + .archetype-name { + font-size: 1.75rem; + } + + .role-name { + font-size: 2rem; + } + + .players-grid { + grid-template-columns: 1fr; + } +} + +/* Spotify Wrapped Style Results Page */ +.results-wrapped { + justify-content: center; + align-items: center; + padding: 1rem; + min-height: 100vh; + width: 100vw; + overflow: hidden; +} + +.wrapped-container { + width: 100%; + height: calc(100vh - 2rem); + position: relative; + display: flex; + flex-direction: column; + gap: 1rem; + max-width: 100%; +} + +/* Progress Indicators */ +.progress-indicators { + display: flex; + justify-content: center; + gap: 0.5rem; + padding: 0.5rem 0; + position: absolute; + bottom: 10px; + left: 50%; + transform: translateX(-50%); + z-index: 20; +} + +.progress-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.3); + cursor: pointer; + transition: all 0.3s ease; +} + +.progress-dot.active { + background: #ffffff; + transform: scale(1.2); +} + +.progress-dot.completed { + background: rgba(255, 255, 255, 0.7); +} + +/* Card Container */ +.card-container { + flex: 1; + overflow: hidden; + border-radius: 20px; + background: rgba(255, 255, 255, 0.05); + backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.1); + position: relative; + width: 100%; + max-width: 100%; + height: 100%; + margin: 0 80px; /* Space for navigation buttons */ + box-sizing: border-box; +} + +/* Main content area for navigation layout */ +.main-content-area { + display: flex; + align-items: center; + gap: 0; + flex: 1; + width: 100%; + height: 100%; + position: relative; +} + +.card-slider { + display: flex; + height: 100%; + width: 100%; + max-width: 100%; + transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1); + box-sizing: border-box; +} + +.card-slide { + min-width: 100%; + max-width: 100%; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + box-sizing: border-box; +} + +.wrapped-card { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-family: 'Rajdhani', sans-serif; + max-width: 100%; + box-sizing: border-box; +} + +.wrapped-card-content { + width: 100%; + height: 100%; + max-width: 100%; + padding: clamp(1rem, 4vw, 3rem); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; + box-sizing: border-box; + overflow-x: hidden; + overflow-y: auto; +} + +.card-title { + font-size: clamp(1.2rem, 3vw, 2rem); + font-weight: 500; + color: #ffffff; + margin-bottom: clamp(1rem, 3vh, 2rem); + font-family: 'Bebas Neue', sans-serif; + letter-spacing: 0.1em; +} + +/* Navigation Buttons */ +.nav-button { + width: clamp(50px, 5vw, 70px); + height: clamp(50px, 5vw, 70px); + border-radius: 50%; + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(15px); + border: 1px solid rgba(255, 255, 255, 0.2); + color: #ffffff; + font-size: clamp(1rem, 2vw, 1.5rem); + font-weight: bold; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + align-items: center; + justify-content: center; + font-family: 'Rajdhani', sans-serif; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + user-select: none; + opacity: 0.8; + position: absolute; + top: 50%; + transform: translateY(-50%); + z-index: 10; +} + +.nav-button.prev { + left: 10px; +} + +.nav-button.next { + right: 10px; +} + +.nav-button:hover { + background: rgba(255, 255, 255, 0.25); + border-color: rgba(255, 255, 255, 0.5); + transform: translateY(-50%) scale(1.05); + box-shadow: 0 8px 40px rgba(255, 255, 255, 0.15); + opacity: 1; +} + +.nav-button:active { + transform: translateY(-50%) scale(0.95); + background: rgba(255, 255, 255, 0.3); +} + +.nav-button:disabled { + opacity: 0.2; + cursor: not-allowed; + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.1); + pointer-events: none; +} + +.nav-button:disabled:hover { + transform: none; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.1); + opacity: 0.2; +} + +/* Additional responsive breakpoints for better scaling */ +@media (max-width: 360px) { + .results-wrapped { + padding: 0.1rem; + } + + .wrapped-container { + height: calc(100vh - 0.2rem); + } + + .card-container { + margin: 0 30px; + border-radius: 10px; + } + + .wrapped-card-content { + padding: clamp(0.5rem, 3vw, 1rem); + } + + .nav-button.prev { + left: 2px; + } + + .nav-button.next { + right: 2px; + } + + .players-grid { + grid-template-columns: repeat(2, 1fr); + } + + .gameplay-insights { + grid-template-columns: 1fr; + } +} + +@media (min-height: 900px) { + .wrapped-container { + height: calc(100vh - 4rem); + } + + .results-wrapped { + padding: 2rem; + } +} + +@media (min-width: 1400px) { + .card-container { + margin: 0 100px; + } + + .nav-button.prev { + left: 20px; + } + + .nav-button.next { + right: 20px; + } +} + +/* Landscape orientation adjustments for mobile */ +@media (max-height: 500px) and (orientation: landscape) { + .results-wrapped { + padding: 0.25rem; + } + + .wrapped-container { + height: calc(100vh - 0.5rem); + gap: 0.5rem; + } + + .progress-indicators { + bottom: 5px; + padding: 0.25rem 0; + } + + .wrapped-card-content { + padding: clamp(0.5rem, 2vw, 1rem); + } + + .players-grid { + grid-template-columns: repeat(3, 1fr); + } + + .gameplay-insights { + grid-template-columns: repeat(3, 1fr); + } +} + +/* Digital Twin Chat Card Styles */ +.digital-twin-card { + width: 100%; + max-width: 100%; + overflow: hidden; + box-sizing: border-box; + min-width: 0; +} + +.digital-twin-card .wrapped-card-content { + overflow-x: hidden; + overflow-y: auto; + justify-content: center; + align-items: center; + padding: clamp(1.5rem, 3vw, 2.5rem); + width: 100%; + max-width: 100%; + min-width: 0; +} + +.digital-twin-header { + display: flex; + align-items: center; + gap: clamp(0.8rem, 2vw, 1.2rem); + margin-bottom: clamp(1rem, 3vh, 1.5rem); + padding-bottom: 0.8rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + width: 100%; + max-width: 600px; + box-sizing: border-box; +} + +.twin-profile-icon { + width: clamp(50px, 8vw, 70px); + height: clamp(50px, 8vw, 70px); + border-radius: 50%; + border: 3px solid rgba(144, 238, 144, 0.5); + box-shadow: 0 0 15px rgba(144, 238, 144, 0.3); + flex-shrink: 0; +} + +.twin-info { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.twin-title { + font-size: clamp(1.1rem, 3vw, 1.4rem); + font-weight: bold; + color: #ffffff; + margin: 0; + font-family: 'Bebas Neue', sans-serif; + letter-spacing: 0.05em; +} + +.twin-status { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: clamp(0.8rem, 1.8vw, 0.9rem); + color: rgba(255, 255, 255, 0.7); +} + +.status-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background: #90EE90; + box-shadow: 0 0 8px rgba(144, 238, 144, 0.6); + animation: pulse 2s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.7; + transform: scale(1.1); + } +} + +.chat-container { + display: flex; + flex-direction: column; + height: clamp(300px, 50vh, 400px); + background: rgba(0, 0, 0, 0.2); + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.1); + overflow: hidden; + width: 100%; + max-width: 1000px; + box-sizing: border-box; +} + +.chat-messages { + flex: 1; + padding: clamp(0.8rem, 2vw, 1rem); + overflow-y: auto; + display: flex; + flex-direction: column; + gap: clamp(0.6rem, 1.5vw, 0.8rem); + scroll-behavior: smooth; + overflow-x: hidden; + width: 100%; + max-width: 100%; + box-sizing: border-box; + min-width: 0; +} + +.chat-messages::-webkit-scrollbar { + width: 6px; +} + +.chat-messages::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.05); + border-radius: 3px; +} + +.chat-messages::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 3px; +} + +.message { + display: flex; + align-items: flex-end; + gap: 0.5rem; + max-width: 60%; + animation: fadeInUp 0.3s ease; + box-sizing: border-box; + word-wrap: break-word; + overflow-wrap: break-word; + min-width: 0; + width: auto; +} + +.user-message { + align-self: flex-end; + flex-direction: row-reverse; + max-width: 60%; +} + +.ai-message { + align-self: flex-start; + max-width: 60%; +} + +.message-avatar { + width: clamp(28px, 4vw, 32px); + height: clamp(28px, 4vw, 32px); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + overflow: hidden; +} + +.message-avatar img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; +} + +.user-avatar { + background: rgba(255, 255, 255, 0.1); + font-size: clamp(0.8rem, 2vw, 1rem); + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.message-avatar { + position: relative; +} + +.claude-indicator { + position: absolute; + bottom: -2px; + right: -2px; + width: 14px; + height: 14px; + background: linear-gradient(135deg, #ff6b6b, #4ecdc4); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 8px; + border: 1px solid rgba(255, 255, 255, 0.3); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.message-content { + background: rgba(255, 255, 255, 0.1); + padding: clamp(0.6rem, 1.5vw, 0.8rem); + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.1); + backdrop-filter: blur(10px); + display: flex; + flex-direction: column; + gap: 0.3rem; + max-width: 100%; + min-width: 0; + box-sizing: border-box; + word-wrap: break-word; + overflow-wrap: break-word; + overflow: hidden; +} + +.user-message .message-content { + background: rgba(144, 238, 144, 0.2); + border-color: rgba(144, 238, 144, 0.3); +} + +.message-content p { + margin: 0; + font-size: clamp(0.8rem, 1.8vw, 0.9rem); + line-height: 1.4; + color: #ffffff; + word-wrap: break-word; + overflow-wrap: break-word; + hyphens: auto; + max-width: 100%; +} + +.message-time { + font-size: clamp(0.6rem, 1.4vw, 0.7rem); + color: rgba(255, 255, 255, 0.5); + align-self: flex-end; +} + +.typing-message .message-content { + background: rgba(255, 255, 255, 0.05); + padding: 0.8rem; +} + +.typing-indicator { + display: flex; + align-items: center; + gap: 0.3rem; +} + +.typing-indicator span { + width: 6px; + height: 6px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.6); + animation: typingDot 1.5s ease-in-out infinite; +} + +.typing-indicator span:nth-child(2) { + animation-delay: 0.2s; +} + +.typing-indicator span:nth-child(3) { + animation-delay: 0.4s; +} + +@keyframes typingDot { + 0%, 60%, 100% { + opacity: 0.3; + transform: translateY(0); + } + 30% { + opacity: 1; + transform: translateY(-4px); + } +} + +.chat-input-container { + display: flex; + align-items: center; + gap: 0.5rem; + padding: clamp(0.6rem, 1.5vw, 0.8rem); + background: rgba(255, 255, 255, 0.05); + border-top: 1px solid rgba(255, 255, 255, 0.1); + width: 100%; + max-width: 100%; + box-sizing: border-box; + min-width: 0; +} + +.chat-input { + flex: 1; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 20px; + padding: clamp(0.5rem, 1.2vw, 0.6rem) clamp(0.8rem, 2vw, 1rem); + color: #ffffff; + font-size: clamp(0.8rem, 1.8vw, 0.9rem); + font-family: inherit; + outline: none; + transition: all 0.3s ease; + min-width: 0; + max-width: 100%; + box-sizing: border-box; +} + +.chat-input::placeholder { + color: rgba(255, 255, 255, 0.5); +} + +.chat-input:focus { + border-color: rgba(144, 238, 144, 0.5); + box-shadow: 0 0 10px rgba(144, 238, 144, 0.2); +} + +.chat-input:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.send-button { + width: clamp(35px, 6vw, 40px); + height: clamp(35px, 6vw, 40px); + border-radius: 50%; + background: linear-gradient(135deg, rgba(144, 238, 144, 0.8), rgba(144, 238, 144, 0.6)); + border: none; + color: #ffffff; + font-size: clamp(0.9rem, 2vw, 1.1rem); + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 10px rgba(144, 238, 144, 0.3); +} + +.send-button:hover:not(:disabled) { + background: linear-gradient(135deg, rgba(144, 238, 144, 1), rgba(144, 238, 144, 0.8)); + transform: scale(1.05); + box-shadow: 0 4px 15px rgba(144, 238, 144, 0.4); +} + +.send-button:active:not(:disabled) { + transform: scale(0.95); +} + +.send-button:disabled { + opacity: 0.4; + cursor: not-allowed; + background: rgba(255, 255, 255, 0.1); + box-shadow: none; +} + +/* Responsive adjustments for chat */ +@media (max-width: 768px) { + .chat-container { + height: clamp(250px, 45vh, 350px); + } + + .digital-twin-header { + flex-direction: column; + text-align: center; + gap: 0.8rem; + } + + .message { + max-width: 90%; + } + + .matchmaking-search-form { + flex-direction: column; + align-items: stretch; + gap: 1rem; + max-width: 90%; + } + + .matchmaking-input-group { + justify-content: center; + flex-direction: column; + gap: 0.75rem; + } + + .matchmaking-search-input { + min-width: 200px; + text-align: center; + } + + .matchmaking-tag-input { + width: 120px; + text-align: center; + } + + .matchmaking-region-select-inline { + min-width: 120px; + text-align: center; + } +} + +@media (max-width: 480px) { + .chat-container { + height: clamp(200px, 40vh, 300px); + } + + .message { + max-width: 95%; + } + + .chat-input-container { + padding: 0.5rem; + } +} + +/* Share Modal Styles */ +.share-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(10px); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; + animation: fadeIn 0.3s ease; +} + +.share-modal { + background: linear-gradient(145deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05)); + backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 20px; + padding: 2rem; + max-width: 450px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + position: relative; + animation: slideUp 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(30px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.share-modal h3 { + color: #ffffff; + font-size: 1.5rem; + font-weight: 700; + margin-bottom: 0.5rem; + text-align: center; + font-family: 'Rajdhani', sans-serif; +} + +.share-subtitle { + color: rgba(255, 255, 255, 0.7); + font-size: 0.9rem; + text-align: center; + margin-bottom: 1.5rem; + font-family: 'Rajdhani', sans-serif; +} + +.share-options { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 1rem; + margin-bottom: 2rem; +} + +.share-option { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + padding: 1.5rem 1rem; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 15px; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + overflow: hidden; +} + +.share-option::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent); + transition: left 0.5s ease; +} + +.share-option:hover::before { + left: 100%; +} + +.share-option:hover { + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.3); + transform: translateY(-2px) scale(1.02); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3); +} + +.share-option:active { + transform: translateY(0) scale(0.98); +} + +.share-option-icon { + font-size: 2rem; + line-height: 1; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3)); +} + +.share-option-label { + color: #ffffff; + font-size: 0.9rem; + font-weight: 500; + text-align: center; + font-family: 'Rajdhani', sans-serif; + opacity: 0.9; + transition: opacity 0.3s ease; +} + +.share-option:hover .share-option-label { + opacity: 1; +} + +/* Specific platform colors on hover */ +.share-option:nth-child(1):hover { + background: linear-gradient(135deg, rgba(225, 48, 108, 0.2), rgba(225, 48, 108, 0.1)); + border-color: rgba(225, 48, 108, 0.4); +} + +.share-option:nth-child(2):hover { + background: linear-gradient(135deg, rgba(29, 161, 242, 0.2), rgba(29, 161, 242, 0.1)); + border-color: rgba(29, 161, 242, 0.4); +} + +.share-option:nth-child(3):hover { + background: linear-gradient(135deg, rgba(24, 119, 242, 0.2), rgba(24, 119, 242, 0.1)); + border-color: rgba(24, 119, 242, 0.4); +} + +.share-option:nth-child(4):hover { + background: linear-gradient(135deg, rgba(16, 185, 129, 0.2), rgba(16, 185, 129, 0.1)); + border-color: rgba(16, 185, 129, 0.4); +} + +.share-option:nth-child(5):hover { + background: linear-gradient(135deg, rgba(168, 85, 247, 0.2), rgba(168, 85, 247, 0.1)); + border-color: rgba(168, 85, 247, 0.4); +} + +.share-modal-close { + width: 100%; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + color: #ffffff; + padding: 0.75rem 1.5rem; + border-radius: 10px; + font-size: 1rem; + font-weight: 500; + font-family: 'Rajdhani', sans-serif; + cursor: pointer; + transition: all 0.3s ease; +} + +.share-modal-close:hover { + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.3); + transform: translateY(-1px); +} + +.share-modal-close:active { + transform: translateY(0); +} + +/* Mobile responsiveness for share modal */ +@media (max-width: 768px) { + .share-modal { + padding: 1.5rem; + max-width: 95%; + } + + .share-options { + grid-template-columns: repeat(2, 1fr); + gap: 0.75rem; + } + + .share-option { + padding: 1.25rem 0.75rem; + } + + .share-option-icon { + font-size: 1.75rem; + } + + .share-option-label { + font-size: 0.85rem; + } +} + +@media (max-width: 480px) { + .share-modal { + padding: 1rem; + } + + .share-modal h3 { + font-size: 1.3rem; + margin-bottom: 1rem; + } + + .share-options { + grid-template-columns: 1fr; + gap: 0.5rem; + margin-bottom: 1.5rem; + } + + .share-option { + flex-direction: row; + justify-content: flex-start; + padding: 1rem; + gap: 1rem; + } + + .share-option-icon { + font-size: 1.5rem; + } + + .share-option-label { + text-align: left; + flex: 1; + } + .matchmaking-search-form { + padding: 0.75rem; + max-width: 95%; + } + + .matchmaking-input-group { + gap: 1rem; + } + + .matchmaking-search-input { + font-size: 0.9rem; + padding: 0.75rem; + } + + .matchmaking-tag-input { + font-size: 0.9rem; + padding: 0.75rem; + } + + .matchmaking-region-select-inline { + font-size: 0.9rem; + padding: 0.75rem; + } + + .matchmaking-search-button { + padding: 0.75rem 1rem; + font-size: 0.9rem; + } + + .matchmaking-search-form { + padding: 0.75rem; + max-width: 95%; + } + + .matchmaking-input-group { + gap: 1rem; + } + + .matchmaking-search-input { + font-size: 0.9rem; + padding: 0.75rem; + } + + .matchmaking-tag-input { + font-size: 0.9rem; + padding: 0.75rem; + } + + .matchmaking-region-select-inline { + font-size: 0.9rem; + padding: 0.75rem; + } + + .matchmaking-search-button { + padding: 0.75rem 1rem; + font-size: 0.9rem; + } +} + + +.share-option.loading::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 20px; + height: 20px; + margin: -10px 0 0 -10px; + border: 2px solid transparent; + border-top: 2px solid rgba(255, 255, 255, 0.8); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} \ No newline at end of file diff --git a/client/src/App.tsx b/client/src/App.tsx index ec6a2e7..ad3eb26 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,191 +1,54 @@ -import React, { useState } from 'react'; +import React, { useContext, useState } from 'react'; +import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; +import { PlayerDataProvider, PlayerDataContext } from './PlayerDataContext'; +import SearchPage from './SearchPage'; +import LoadingPage from './LoadingPage'; +import ResultsPage from './ResultsPage'; import './App.css'; -interface PlayerData { - account?: { - gameName: string; - tagLine: string; - puuid: string; - }; - summoner?: { - summonerLevel: number; - profileIconId: number; - }; - ranked?: Array<{ - queueType: string; - tier: string; - rank: string; - leaguePoints: number; - wins: number; - losses: number; - }>; - champion_mastery?: { - total_score: number; - champions: Array<{ - championId: number; - championLevel: number; - championPoints: number; - }>; - }; - matches?: Array; +function AppRoutes() { + const context = useContext(PlayerDataContext); + if (!context) return null; + const { playerData, loadingStatus, profileIconUrl } = context; + + return ( + + } /> + } /> + } /> + + ); } -const regions = [ - { value: 'kr', label: 'Korea' }, - { value: 'na1', label: 'North America' }, - { value: 'euw1', label: 'Europe West' }, - { value: 'eun1', label: 'Europe Nordic & East' }, - { value: 'br1', label: 'Brazil' }, - { value: 'la1', label: 'Latin America North' }, - { value: 'la2', label: 'Latin America South' }, - { value: 'oc1', label: 'Oceania' }, - { value: 'tr1', label: 'Turkey' }, - { value: 'ru', label: 'Russia' }, - { value: 'jp1', label: 'Japan' }, -]; - function App() { - const [gameName, setGameName] = useState(''); - const [tagLine, setTagLine] = useState(''); - const [region, setRegion] = useState('kr'); - const [playerData, setPlayerData] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - - const searchPlayer = async () => { - if (!gameName.trim() || !tagLine.trim()) { - setError('Please enter both game name and tag line'); - return; - } - - setLoading(true); - setError(''); - setPlayerData(null); - - try { - const response = await fetch(`http://localhost:5000/api/player/${encodeURIComponent(gameName)}/${encodeURIComponent(tagLine)}?region=${region}&match_count=5`); - - if (!response.ok) { - throw new Error(`Player not found or API error: ${response.status}`); - } - - const data = await response.json(); - setPlayerData(data); - } catch (err) { - setError(err instanceof Error ? err.message : 'An error occurred'); - } finally { - setLoading(false); - } - }; - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - searchPlayer(); - }; + const [menuOpen, setMenuOpen] = useState(false); return ( -
-
-
- ShapeSplitter -

Shape Splitter

+ + +
+ + + + + {menuOpen &&
setMenuOpen(false)} />} + +
-
- -
-
-
-
- setGameName(e.target.value)} - className="search-input" - /> - # - setTagLine(e.target.value)} - className="search-input tag-input" - /> -
- - - - -
- - {error &&
{error}
} -
- - {playerData && ( -
-
-
-

{playerData.account?.gameName}#{playerData.account?.tagLine}

-

Level {playerData.summoner?.summonerLevel}

-
-
- -
- {playerData.ranked && playerData.ranked.length > 0 && ( -
-

Ranked Stats

- {playerData.ranked.map((rank, index) => ( -
-
{rank.queueType.replace('_', ' ')}
-
{rank.tier} {rank.rank}
-
{rank.leaguePoints} LP
-
{rank.wins}W / {rank.losses}L ({((rank.wins / (rank.wins + rank.losses)) * 100).toFixed(1)}%)
-
- ))} -
- )} - - {playerData.champion_mastery && ( -
-

Champion Mastery

-
- Total Score: {playerData.champion_mastery.total_score?.toLocaleString()} -
-
- Champions Played: {playerData.champion_mastery.champions?.length} -
-
- )} - - {playerData.matches && playerData.matches.length > 0 && ( -
-

Recent Matches

-
- {playerData.matches.length} recent matches loaded -
-
- )} -
-
- )} -
-
+ + ); } diff --git a/client/src/LoadingPage.tsx b/client/src/LoadingPage.tsx new file mode 100644 index 0000000..b9f4080 --- /dev/null +++ b/client/src/LoadingPage.tsx @@ -0,0 +1,85 @@ +import React from 'react'; + +interface LoadingPageProps { + profileIconUrl: string; + loadingStatus: string[]; +} + +const LoadingPage: React.FC = ({ profileIconUrl, loadingStatus }) => { + const getProgressPercentage = () => { + const stats = getStatsFromStatus(); + // Base progress on ranked games found out of target (25) + const targetRankedGames = 25; + const rankedProgress = Math.min((stats.ranked / targetRankedGames) * 100, 100); + + // If we have valid games analyzed, use that for final progress + if (stats.valid > 0) { + return Math.min((stats.valid / stats.target) * 100, 100); + } + + // Otherwise use ranked games progress + return Math.round(rankedProgress); + }; + + const getCurrentStep = () => { + if (loadingStatus.length === 0) return 'Initializing...'; + return loadingStatus[loadingStatus.length - 1]; + }; + + const getStatsFromStatus = () => { + const lastStatus = loadingStatus[loadingStatus.length - 1] || ''; + + // Extract game counts from status messages + const rankedMatch = lastStatus.match(/๐Ÿ† (\d+) ranked/); + const normalMatch = lastStatus.match(/๐ŸŽฎ (\d+) normal/); + const otherMatch = lastStatus.match(/๐Ÿ“Š (\d+) other/); + const validMatch = lastStatus.match(/โœ… (\d+)\/(\d+) valid games/); + const matchIdsMatch = lastStatus.match(/๐Ÿ“ฅ (\d+)\/(\d+)/); + + return { + ranked: rankedMatch ? parseInt(rankedMatch[1]) : 0, + normal: normalMatch ? parseInt(normalMatch[1]) : 0, + other: otherMatch ? parseInt(otherMatch[1]) : 0, + valid: validMatch ? parseInt(validMatch[1]) : 0, + target: validMatch ? parseInt(validMatch[2]) : 25, + fetched: matchIdsMatch ? parseInt(matchIdsMatch[1]) : 0, + maxFetch: matchIdsMatch ? parseInt(matchIdsMatch[2]) : 100 + }; + }; + + const progress = getProgressPercentage(); + + return ( +
+
+
+ {profileIconUrl && ( + Profile Icon + )} +
+
+ Analyzing... +
+
+ + {/* Progress Bar */} +
+
+
+
+ {progress}% Complete +
+ + {/* Current Step - Only Latest Message */} +
+ {getCurrentStep()} +
+
+
+ ); +}; + +export default LoadingPage; diff --git a/client/src/PlayerDataContext.tsx b/client/src/PlayerDataContext.tsx new file mode 100644 index 0000000..c02e4e6 --- /dev/null +++ b/client/src/PlayerDataContext.tsx @@ -0,0 +1,27 @@ +import React, { createContext, useState, ReactNode } from 'react'; + +interface PlayerDataContextType { + playerData: any; + setPlayerData: (data: any) => void; + loadingStatus: string[]; + setLoadingStatus: (status: string[] | ((prev: string[]) => string[])) => void; + profileIconUrl: string; + setProfileIconUrl: (url: string) => void; + errorMessage: string; + setErrorMessage: (msg: string) => void; +} + +export const PlayerDataContext = createContext(null); + +export function PlayerDataProvider({ children }: { children: ReactNode }) { + const [playerData, setPlayerData] = useState(null); + const [loadingStatus, setLoadingStatus] = useState([]); + const [profileIconUrl, setProfileIconUrl] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + + return ( + + {children} + + ); +} diff --git a/client/src/ResultsPage.tsx b/client/src/ResultsPage.tsx new file mode 100644 index 0000000..fcd687e --- /dev/null +++ b/client/src/ResultsPage.tsx @@ -0,0 +1,1494 @@ +import React, { useState, useEffect, useRef } from 'react'; +import html2canvas from 'html2canvas'; +import { getApiUrl } from './config'; + +// Matchmaking Card Component +interface MatchmakingCardProps { + playerData: PlayerData | null; +} + +interface MatchmakingResult { + success: boolean; + player1: { + gameName: string; + tagLine: string; + profileIconUrl?: string; + personality: any; + }; + player2: { + gameName: string; + tagLine: string; + profileIconUrl?: string; + personality: any; + ranked?: any[]; + championMastery?: any; + }; + compatibility: { + score: number; + level: string; + strengths: string[]; + challenges: string[]; + recommendation: string; + }; +} + +const MatchmakingCard: React.FC = ({ playerData }) => { + const [searchInput, setSearchInput] = useState(''); + const [region, setRegion] = useState('na1'); + const [isSearching, setIsSearching] = useState(false); + const [matchResult, setMatchResult] = useState(null); + const [error, setError] = useState(''); + + // Region options (same as SearchPage) + const regions = [ + { value: 'na1', label: 'North America' }, + { value: 'euw1', label: 'Europe West' }, + { value: 'eun1', label: 'Europe Nordic & East' }, + { value: 'kr', label: 'Korea' }, + { value: 'jp1', label: 'Japan' }, + { value: 'br1', label: 'Brazil' }, + { value: 'la1', label: 'Latin America North' }, + { value: 'la2', label: 'Latin America South' }, + { value: 'oc1', label: 'Oceania' }, + { value: 'tr1', label: 'Turkey' }, + { value: 'ru', label: 'Russia' } + ]; + + const handleSearch = async () => { + if (!searchInput.trim()) return; + + const [gameName, tagLine] = searchInput.split('#'); + if (!gameName || !tagLine) { + setError('Please enter a valid Riot ID (e.g. PlayerName#TAG)'); + return; + } + + setIsSearching(true); + setError(''); + setMatchResult(null); + + try { + const response = await fetch(getApiUrl('/api/matchmaking'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + player1Data: playerData, + player2GameName: gameName.trim(), + player2TagLine: tagLine.trim(), + region: region + }) + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to calculate compatibility'); + } + + setMatchResult(data); + } catch (err: any) { + setError(err.message || 'Something went wrong'); + } finally { + setIsSearching(false); + } + }; + + const getCompatibilityColor = (score: number) => { + if (score >= 80) return '#4ade80'; // green + if (score >= 65) return '#3b82f6'; // blue + if (score >= 50) return '#f59e0b'; // yellow + if (score >= 35) return '#f97316'; // orange + return '#ef4444'; // red + }; + + const reset = () => { + setMatchResult(null); + setSearchInput(''); + setError(''); + setRegion('na1'); + }; + + if (matchResult) { + return ( +
+
+
+

Matchmake

+ +
+ +
+
+
+ {matchResult.player1.profileIconUrl && ( + Player 1 + )} +
+
{matchResult.player1.gameName}
+
#{matchResult.player1.tagLine}
+
+ {matchResult.player1.personality?.personality?.archetype?.name || 'Unknown'} +
+
+
+ +
+
+ {matchResult.compatibility.score}% +
+
{matchResult.compatibility.level}
+
+ {Array.from({ length: 5 }, (_, i) => ( + + ๐Ÿค + + ))} +
+
+ +
+ {matchResult.player2.profileIconUrl && ( + Player 2 + )} +
+
{matchResult.player2.gameName}
+
#{matchResult.player2.tagLine}
+
+ {matchResult.player2.personality?.personality?.archetype?.name || 'Unknown'} +
+
+
+
+ +
+
+

๐ŸŽฏ Recommendation

+

{matchResult.compatibility.recommendation}

+
+ + {matchResult.compatibility.strengths.length > 0 && ( +
+

๐Ÿ’ช Strengths

+
    + {matchResult.compatibility.strengths.map((strength, i) => ( +
  • {strength}
  • + ))} +
+
+ )} + + {matchResult.compatibility.challenges.length > 0 && ( +
+

โš ๏ธ Things to Watch

+
    + {matchResult.compatibility.challenges.map((challenge, i) => ( +
  • {challenge}
  • + ))} +
+
+ )} +
+
+
+
+ ); + } + + return ( +
+
+

Matchmake

+
+

+ Check your compatibility with another summoner! +

+ +
+ +
+ { + const tagPart = searchInput.split('#')[1] || ''; + setSearchInput(`${e.target.value}${tagPart ? '#' + tagPart : ''}`); + }} + onKeyPress={(e) => e.key === 'Enter' && handleSearch()} + placeholder="Game Name" + className="matchmaking-search-input" + disabled={isSearching} + /> + # + { + const gamePart = searchInput.split('#')[0] || ''; + setSearchInput(`${gamePart}#${e.target.value}`); + }} + onKeyPress={(e) => e.key === 'Enter' && handleSearch()} + placeholder="Tag" + className="matchmaking-search-input matchmaking-tag-input" + disabled={isSearching} + /> +
+
+ + +
+ +
+ + {error && ( +
+ โŒ {error} +
+ )} + + {isSearching && ( +
+
+

Analyzing compatibility... This may take a moment!

+
+ )} + + +
+
+
+ ); +}; + +interface Match { + metadata?: { matchId: string }; + info?: { + gameDuration: number; + gameMode: string; + participants?: Array; + }; +} + +interface Participant { + puuid: string; + championId: number; + championName: string; + championImageUrl?: string; + win: boolean; + kills: number; + deaths: number; + assists: number; +} + +interface PersonalityData { + stats?: { + validGames: number; + features: { + primaryRole: string; + championDiversity: number; + aggressionIndex: number; + killParticipation: number; + visionPerMin: number; + avgKda: number; + [key: string]: any; + }; + }; + personality?: { + bigFive: { + [trait: string]: number; + }; + archetype: { + name: string; + similarity: number; + description: string; + champions: string[]; + }; + }; + error?: string; +} + +interface PlayerData { + account?: { + gameName: string; + tagLine: string; + puuid: string; + }; + summoner?: { + summonerLevel: number; + profileIconId: number; + profileIconUrl?: string; + }; + ranked?: Array; + championMastery?: any; + matches?: Match[]; + personality?: PersonalityData; +} + +interface ResultsPageProps { + playerData: PlayerData | null; +} + +interface Card { + id: string; + title: string; + component: React.ReactNode; +} + +function getStatsByGameMode(matches: Match[] = [], puuid: string) { + if (!matches || matches.length === 0) return {}; + + // Only include Summoner's Rift games (ranked and normal) + const allowedModes = ['CLASSIC']; + + const gameModeMap: { [key: string]: string } = { + 'CLASSIC': "Summoner's Rift" + }; + + const statsByMode: { [key: string]: { kills: number[], deaths: number[], assists: number[], wins: number, total: number } } = {}; + + matches.forEach((match) => { + const gameMode = match.info?.gameMode || 'Unknown'; + + // Only process matches from allowed game modes + if (!allowedModes.includes(gameMode)) { + return; + } + + const friendlyName = gameModeMap[gameMode] || gameMode; + const p = match.info?.participants?.find((x) => x.puuid === puuid); + if (p) { + if (!statsByMode[friendlyName]) { + statsByMode[friendlyName] = { kills: [], deaths: [], assists: [], wins: 0, total: 0 }; + } + statsByMode[friendlyName].kills.push(p.kills); + statsByMode[friendlyName].deaths.push(p.deaths); + statsByMode[friendlyName].assists.push(p.assists); + statsByMode[friendlyName].total++; + if (p.win) statsByMode[friendlyName].wins++; + } + }); + + const result: { [key: string]: any } = {}; + Object.keys(statsByMode).forEach(mode => { + const stats = statsByMode[mode]; + result[mode] = { + avgKills: (stats.kills.reduce((a, b) => a + b, 0) / stats.total).toFixed(1), + avgDeaths: (stats.deaths.reduce((a, b) => a + b, 0) / stats.total).toFixed(1), + avgAssists: (stats.assists.reduce((a, b) => a + b, 0) / stats.total).toFixed(1), + winRate: ((stats.wins / stats.total) * 100).toFixed(1), + gamesPlayed: stats.total, + }; + }); + + return result; +} + +function getLatestUniquePlayers(matches: Match[] = [], puuid: string) { + const seen = new Set(); + const players: { puuid: string; summonerName: string; championName: string; championImageUrl?: string }[] = []; + + for (const match of matches || []) { + for (const p of match.info?.participants || []) { + if (p.puuid !== puuid && !seen.has(p.puuid)) { + seen.add(p.puuid); + const summonerName = (p as any).riotIdGameName || (p as any).summonerName || `Player${players.length + 1}`; + players.push({ + puuid: p.puuid, + summonerName: summonerName, + championName: p.championName, + championImageUrl: p.championImageUrl + }); + if (players.length === 10) return players; + } + } + } + return players; +} + +const ResultsPage: React.FC = ({ playerData }) => { + const [currentCardIndex, setCurrentCardIndex] = useState(0); + const [showShareModal, setShowShareModal] = useState(false); + const [shareCardData, setShareCardData] = useState<{ title: string; content: string } | null>(null); + const cardContainerRef = useRef(null); + + // Chat functionality state + const [chatMessages, setChatMessages] = useState>([ + { + id: 'initial-1', + text: `Hello! I'm ${playerData?.account?.gameName || 'Player'}'s digital twin, powered by AWS Bedrock and Claude Sonnet 4.5. I have deep insights into their League gameplay, personality patterns, and performance data. Ask me anything about their League journey!`, + isUser: false, + timestamp: new Date() + } + ]); + const [currentMessage, setCurrentMessage] = useState(''); + const [isTyping, setIsTyping] = useState(false); + const chatMessagesRef = useRef(null); + + // Calculate cards length early for useEffect dependency + const cardsLength = React.useMemo(() => { + if (!playerData) return 0; + + let count = 1; // welcome card + + // Add personality cards count + if (playerData.personality && !playerData.personality.error) { + count += 3; // archetype, traits, gameplay + } + + // Add game mode cards count + const puuid = playerData.account?.puuid || ''; + const statsByMode = getStatsByGameMode(playerData.matches, puuid); + count += Object.keys(statsByMode).length; + + // Add matchmaking card count (always present) + count += 1; + + return count; + }, [playerData]); + + // Keyboard navigation - must be called at top level + useEffect(() => { + if (!playerData || cardsLength === 0) return; + + const handleKeyPress = (e: KeyboardEvent) => { + if (e.key === 'ArrowLeft') { + setCurrentCardIndex((prev) => (prev - 1 + cardsLength) % cardsLength); + } + if (e.key === 'ArrowRight') { + setCurrentCardIndex((prev) => (prev + 1) % cardsLength); + } + }; + + window.addEventListener('keydown', handleKeyPress); + return () => window.removeEventListener('keydown', handleKeyPress); + }, [cardsLength, playerData]); + + // Scroll to bottom when new messages are added + React.useEffect(() => { + if (chatMessagesRef.current) { + chatMessagesRef.current.scrollTop = chatMessagesRef.current.scrollHeight; + } + }, [chatMessages]); + + if (!playerData) return null; + + const puuid = playerData.account?.puuid || ''; + const statsByMode = getStatsByGameMode(playerData.matches, puuid); + const latestPlayers = getLatestUniquePlayers(playerData.matches, puuid); + + // Chat functionality functions + const handleSendMessage = async () => { + if (!currentMessage.trim()) return; + + const userMessage = { + id: Date.now().toString(), + text: currentMessage, + isUser: true, + timestamp: new Date() + }; + + setChatMessages(prev => [...prev, userMessage]); + const messageToSend = currentMessage; + setCurrentMessage(''); + setIsTyping(true); + + try { + // Call the Bedrock-powered chat endpoint + const response = await fetch(getApiUrl('/api/chat'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + message: messageToSend, + playerData: playerData, + chatHistory: chatMessages.filter(msg => !msg.id.includes('initial')) // Don't send initial greeting + }), + }); + + const data = await response.json(); + + if (data.success) { + const aiMessage = { + id: (Date.now() + 1).toString(), + text: data.response, + isUser: false, + timestamp: new Date() + }; + + setChatMessages(prev => [...prev, aiMessage]); + + // Log if fallback was used + if (data.fallback) { + console.log('Using fallback response due to Bedrock error:', data.error); + } + } else { + throw new Error(data.error || 'Chat request failed'); + } + + } catch (error) { + console.error('Chat error:', error); + + // Fallback to local response generation + const fallbackResponse = generateAIResponse(messageToSend, playerData); + const aiMessage = { + id: (Date.now() + 1).toString(), + text: fallbackResponse + " (Note: Advanced AI is temporarily unavailable)", + isUser: false, + timestamp: new Date() + }; + + setChatMessages(prev => [...prev, aiMessage]); + } finally { + setIsTyping(false); + } + }; + + const generateAIResponse = (userMessage: string, data: PlayerData | null): string => { + const message = userMessage.toLowerCase(); + const gameName = data?.account?.gameName || 'Player'; + + // Basic responses based on message content + if (message.includes('rank') || message.includes('tier')) { + const soloRank = data?.ranked?.find(r => r.queueType === 'RANKED_SOLO_5x5'); + if (soloRank) { + return `${gameName} is currently ${soloRank.tier} ${soloRank.rank} with ${soloRank.leaguePoints} LP. They have a ${Math.round((soloRank.wins / (soloRank.wins + soloRank.losses)) * 100)}% win rate this season!`; + } + return `${gameName} doesn't have a current solo queue ranking, but they're still climbing!`; + } + + if (message.includes('champion') || message.includes('main')) { + const topChampion = data?.championMastery?.champions?.[0]; + if (topChampion) { + return `${gameName}'s highest mastery champion is ${topChampion.championName || `Champion ${topChampion.championId}`} with ${topChampion.championPoints?.toLocaleString()} mastery points!`; + } + return `${gameName} plays a variety of champions. Versatility is key!`; + } + + if (message.includes('games') || message.includes('matches')) { + const matchCount = data?.matches?.length || 0; + return `I've analyzed ${matchCount} recent games from ${gameName}'s match history. They've been quite active lately!`; + } + + if (message.includes('personality') || message.includes('playstyle')) { + const archetype = data?.personality?.personality?.archetype?.name; + if (archetype) { + return `Based on ${gameName}'s gameplay patterns, they exhibit traits of a ${archetype} player. This shows in their decision-making and champion preferences.`; + } + return `${gameName} has a unique playstyle that's hard to categorize - that's what makes them special!`; + } + + if (message.includes('level')) { + const level = data?.summoner?.summonerLevel; + if (level) { + return `${gameName} is currently level ${level}. That's a lot of experience on the Rift!`; + } + return `${gameName} has been playing League for quite some time!`; + } + + // Default responses + const responses = [ + `That's an interesting question about ${gameName}! Their gameplay shows consistent improvement over time.`, + `From what I can see in ${gameName}'s data, they have some impressive patterns in their play.`, + `${gameName} would probably say that every game is a learning opportunity!`, + `Looking at ${gameName}'s match history, I can tell they're passionate about improving their gameplay.`, + `That's a great question! ${gameName}'s League journey has been quite unique.` + ]; + + return responses[Math.floor(Math.random() * responses.length)]; + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSendMessage(); + } + }; + + // Create cards array + const cards: Card[] = [ + { + id: 'digital-twin', + title: 'Digital Twin', + component: ( +
+
+
+ {playerData.summoner?.profileIconUrl && ( + Profile Icon + )} +
+

{playerData.account?.gameName}'s Digital Twin

+
+
+ Online +
+
+
+ +
+
+ {chatMessages.map((message) => ( +
+ {!message.isUser && ( +
+ AI Avatar + {!message.text.includes('(Note: Advanced AI is temporarily unavailable)') && !message.id.includes('initial') && ( +
๐Ÿง 
+ )} +
+ )} +
+

{message.text}

+ + {message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + +
+ {message.isUser && ( +
+ ๐Ÿ‘ค +
+ )} +
+ ))} + {isTyping && ( +
+
+ AI Avatar +
+
+
+ + + +
+
+
+ )} +
+ +
+ setCurrentMessage(e.target.value)} + onKeyPress={handleKeyPress} + placeholder="Ask me about your gameplay..." + className="chat-input" + disabled={isTyping} + /> + +
+
+
+
+ ) + } + ]; + + // Add personality cards if available + if (playerData.personality && !playerData.personality.error) { + cards.push({ + id: 'archetype', + title: 'Archetype', + component: ( +
+
+

๐ŸŽญ {playerData.account?.gameName}'s Archetype

+
+
{playerData.personality.personality?.archetype.name}
+
{playerData.personality.personality?.archetype.similarity}% match
+
{playerData.personality.personality?.archetype.description}
+
+ Similar Champions:
+ {playerData.personality.personality?.archetype.champions.join(', ')} +
+
+
+
+ ) + }); + + cards.push({ + id: 'traits', + title: 'Traits', + component: ( +
+
+

๐Ÿง  {playerData.account?.gameName}'s Big Five

+
+ {playerData.personality.personality?.bigFive && Object.entries(playerData.personality.personality.bigFive) + .sort(([,a], [,b]) => b - a) + .map(([trait, score]) => ( +
+
{trait}
+
+
+
+
{score}/100
+
+ ))} +
+
+
+ ) + }); + + cards.push({ + id: 'gameplay', + title: 'Gameplay', + component: ( +
+
+

๐ŸŽฎ {playerData.account?.gameName}'s Playstyle

+
+ {playerData.personality.stats?.features && ( +
+
+ You're a + {playerData.personality.stats.features.primaryRole?.replace('UTILITY', 'Support').replace('BOTTOM', 'ADC')} + main +
+
+
+ {(playerData.personality.stats.features.championDiversity * 100).toFixed(0)}% + Champion Diversity +
+
+ {playerData.personality.stats.features.aggressionIndex.toFixed(1)} + Aggression Index +
+
+ {playerData.personality.stats.features.avgKda.toFixed(2)} + Average KDA +
+
+
+ )} +
+
+
+ ) + }); + } + + // Add game mode cards + if (Object.keys(statsByMode).length > 0) { + Object.entries(statsByMode).forEach(([gameMode, stats]) => { + cards.push({ + id: `gamemode-${gameMode}`, + title: gameMode, + component: ( +
+
+

{gameMode}

+
+
+ {stats.gamesPlayed} + Games Played +
+
+
+ {stats.avgKills} + / + {stats.avgDeaths} + / + {stats.avgAssists} +
+ Average K/D/A +
+
+ {stats.winRate}% + Win Rate +
+
+
+
+ ) + }); + }); + } + + // Add matchmaking card + cards.push({ + id: 'matchmaking', + title: 'Matchmaking', + component: + }); + + // Navigation functions + const nextCard = () => { + setCurrentCardIndex((prev) => (prev + 1) % cards.length); + }; + + const prevCard = () => { + setCurrentCardIndex((prev) => (prev - 1 + cards.length) % cards.length); + }; + + const goToCard = (index: number) => { + setCurrentCardIndex(index); + }; + + // Share functions + const handleShare = async (cardIndex: number) => { + const card = cards[cardIndex]; + const playerName = playerData.account?.gameName || 'Player'; + + let shareText = ''; + switch (card.id) { + case 'welcome': + shareText = `Check out ${playerName}'s League of Legends Wrapped! ๐ŸŽฎ`; + break; + case 'personality': + const archetype = playerData.personality?.personality?.archetype?.name || 'Unknown'; + shareText = `${playerName} is a ${archetype} player! ๐Ÿง  What's your League personality?`; + break; + case 'traits': + shareText = `${playerName}'s Big Five personality traits revealed! ๐Ÿ“Š`; + break; + case 'gameplay': + const role = playerData.personality?.stats?.features?.primaryRole || 'Unknown'; + shareText = `${playerName} dominates as a ${role}! โš”๏ธ`; + break; + default: + shareText = `${playerName}'s League of Legends stats are impressive! ๐Ÿ†`; + } + + setShareCardData({ + title: card.title, + content: shareText + }); + setShowShareModal(true); + }; + + const generateCardImage = async (): Promise => { + if (!cardContainerRef.current) return null; + + try { + // Find the currently visible card slide + const cardSlides = cardContainerRef.current.querySelectorAll('.card-slide'); + const currentSlide = cardSlides[currentCardIndex] as HTMLElement; + + if (!currentSlide) return null; + + // Create a temporary container for capturing + const tempContainer = document.createElement('div'); + tempContainer.style.position = 'fixed'; + tempContainer.style.top = '-9999px'; + tempContainer.style.left = '-9999px'; + tempContainer.style.width = '800px'; + tempContainer.style.height = 'auto'; + tempContainer.style.background = '#1a1a1a'; + tempContainer.style.padding = '20px'; + tempContainer.style.zIndex = '99999'; + + // First, get computed styles from original elements before cloning + const originalElements = currentSlide.querySelectorAll('*'); + const computedStylesMap = new Map(); + + originalElements.forEach((element) => { + const computedStyle = window.getComputedStyle(element); + computedStylesMap.set(element, { + color: computedStyle.color, + webkitBackgroundClip: computedStyle.webkitBackgroundClip || '', + backgroundClip: computedStyle.backgroundClip || '' + }); + }); + + // Clone the current slide + const clonedSlide = currentSlide.cloneNode(true) as HTMLElement; + + // Apply styles to make the clone render properly + clonedSlide.style.position = 'static'; + clonedSlide.style.transform = 'none'; + clonedSlide.style.width = '100%'; + clonedSlide.style.height = 'auto'; + clonedSlide.style.display = 'flex'; + + // Find and fix the wrapped card in the clone + const clonedCard = clonedSlide.querySelector('.wrapped-card') as HTMLElement; + if (clonedCard) { + clonedCard.style.width = '100%'; + clonedCard.style.height = 'auto'; + clonedCard.style.minHeight = '600px'; + clonedCard.style.maxHeight = 'none'; + clonedCard.style.overflow = 'visible'; + clonedCard.style.display = 'flex'; + clonedCard.style.flexDirection = 'column'; + } + + // Special handling for chat messages in digital twin card + const clonedChatMessages = clonedSlide.querySelector('.chat-messages') as HTMLElement; + if (clonedChatMessages) { + clonedChatMessages.style.height = 'auto'; + clonedChatMessages.style.maxHeight = 'none'; + clonedChatMessages.style.overflow = 'visible'; + clonedChatMessages.style.display = 'flex'; + clonedChatMessages.style.flexDirection = 'column'; + } + + // Fix all text elements using the original computed styles + const clonedElements = clonedSlide.querySelectorAll('*'); + const originalElementsArray = Array.from(originalElements); + + clonedElements.forEach((clonedElement, index) => { + const htmlElement = clonedElement as HTMLElement; + const originalElement = originalElementsArray[index]; + const originalStyles = originalElement ? computedStylesMap.get(originalElement) : null; + + if (!originalStyles) return; + + // Fix background-clip: text issues + if (originalStyles.webkitBackgroundClip === 'text' || originalStyles.backgroundClip === 'text') { + htmlElement.style.background = 'none'; + htmlElement.style.webkitBackgroundClip = 'unset'; + htmlElement.style.backgroundClip = 'unset'; + htmlElement.style.webkitTextFillColor = 'unset'; + + // Set the color explicitly from the original computed color + if (originalStyles.color && originalStyles.color !== 'transparent') { + htmlElement.style.color = originalStyles.color; + } else { + htmlElement.style.color = '#ffffff'; + } + } + + // Force white color for any black text (shouldn't be black on dark background) + if (originalStyles.color === 'rgb(0, 0, 0)' || originalStyles.color === 'black') { + htmlElement.style.color = '#ffffff'; + } + + // Ensure all text content has a valid color + if (htmlElement.textContent && htmlElement.textContent.trim()) { + const currentColor = window.getComputedStyle(htmlElement).color; + if (!currentColor || currentColor === 'rgba(0, 0, 0, 0)' || currentColor === 'transparent') { + htmlElement.style.color = originalStyles.color !== 'transparent' ? originalStyles.color : '#ffffff'; + } + } + }); + + // Add the clone to temp container and container to document + tempContainer.appendChild(clonedSlide); + document.body.appendChild(tempContainer); + + // Wait for the DOM to update and styles to apply + await new Promise(resolve => setTimeout(resolve, 300)); + + // Capture the temp container + const canvas = await html2canvas(tempContainer, { + backgroundColor: '#1a1a1a', + scale: 2, + logging: false, + useCORS: true, + allowTaint: false, + width: 840, // 800 + 40 padding + height: tempContainer.scrollHeight, + scrollX: 0, + scrollY: 0, + ignoreElements: (element: HTMLElement) => { + // Ignore any elements that might cause issues + return element.classList?.contains('share-button') || false; + } + } as any); + + // Remove the temporary container + document.body.removeChild(tempContainer); + + // Create a standardized canvas with 1:1 aspect ratio (square for social media) + const standardCanvas = document.createElement('canvas'); + const standardSize = 1080; // Instagram/Twitter optimal size + standardCanvas.width = standardSize; + standardCanvas.height = standardSize; + + const ctx = standardCanvas.getContext('2d'); + if (!ctx) throw new Error('Could not get canvas context'); + + // Fill background with gradient + const gradient = ctx.createRadialGradient( + standardSize / 2, standardSize / 2, 0, + standardSize / 2, standardSize / 2, standardSize / 2 + ); + gradient.addColorStop(0, '#1a1a1a'); + gradient.addColorStop(1, '#000000'); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, standardSize, standardSize); + + // Calculate scaling to fit the card in the square canvas + const cardAspectRatio = canvas.width / canvas.height; + let drawWidth, drawHeight, drawX, drawY; + + if (cardAspectRatio > 1) { + // Card is wider than tall + drawWidth = standardSize * 0.85; // 85% of canvas width + drawHeight = drawWidth / cardAspectRatio; + } else { + // Card is taller than wide + drawHeight = standardSize * 0.85; // 85% of canvas height + drawWidth = drawHeight * cardAspectRatio; + } + + // Center the card in the canvas + drawX = (standardSize - drawWidth) / 2; + drawY = (standardSize - drawHeight) / 2; + + // Draw the card on the standard canvas + ctx.drawImage(canvas, drawX, drawY, drawWidth, drawHeight); + + // Add subtle border/frame + ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'; + ctx.lineWidth = 3; + ctx.strokeRect(drawX - 1.5, drawY - 1.5, drawWidth + 3, drawHeight + 3); + + // Add branding watermark in bottom right + ctx.fillStyle = 'rgba(255, 255, 255, 0.4)'; + ctx.font = '32px Rajdhani, sans-serif'; + ctx.textAlign = 'right'; + ctx.fillText('Shape Split', standardSize - 30, standardSize - 30); + + return standardCanvas.toDataURL('image/png', 0.95); + } catch (error) { + console.error('Error generating card image:', error); + + // Clean up any leftover temp containers + const tempContainers = document.querySelectorAll('div[style*="position: fixed"][style*="-9999px"]'); + tempContainers.forEach(container => { + try { + document.body.removeChild(container); + } catch (e) { + // Ignore errors when removing + } + }); + + return null; + } + }; + + const downloadImage = async () => { + // Add loading state + const downloadOption = document.querySelector('.share-option:nth-child(5)'); + if (downloadOption) { + downloadOption.classList.add('loading'); + } + + try { + const imageDataUrl = await generateCardImage(); + if (!imageDataUrl) { + throw new Error('Failed to generate image'); + } + + const playerName = playerData.account?.gameName || 'Player'; + const cardTitle = shareCardData?.title?.toLowerCase().replace(/\s+/g, '-') || 'card'; + const fileName = `${playerName}-league-wrapped-${cardTitle}.png`; + + const link = document.createElement('a'); + link.download = fileName; + link.href = imageDataUrl; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // Show success feedback + if (downloadOption) { + const originalLabel = downloadOption.querySelector('.share-option-label')?.textContent; + const labelElement = downloadOption.querySelector('.share-option-label'); + if (labelElement) { + labelElement.textContent = 'Downloaded! โœ“'; + setTimeout(() => { + labelElement.textContent = originalLabel || 'Download Image'; + }, 2000); + } + } + + setTimeout(closeShareModal, 1500); + } catch (error) { + console.error('Download failed:', error); + + // Show error feedback + if (downloadOption) { + const originalLabel = downloadOption.querySelector('.share-option-label')?.textContent; + const labelElement = downloadOption.querySelector('.share-option-label'); + if (labelElement) { + labelElement.textContent = 'Failed โœ—'; + setTimeout(() => { + labelElement.textContent = originalLabel || 'Download Image'; + }, 2000); + } + } + } finally { + // Remove loading state + if (downloadOption) { + downloadOption.classList.remove('loading'); + } + } + }; + + const shareToSocials = async (platform: string) => { + const imageDataUrl = await generateCardImage(); + const text = shareCardData?.content || ''; + const currentUrl = window.location.href; + + // Add loading state to the clicked option + const shareOptions = document.querySelectorAll('.share-option'); + shareOptions.forEach((option, index) => { + if ((platform === 'instagram' && index === 0) || + (platform === 'twitter' && index === 1) || + (platform === 'facebook' && index === 2) || + (platform === 'copy' && index === 3)) { + option.classList.add('loading'); + } + }); + + if (!imageDataUrl) { + console.error('Failed to generate image for sharing'); + // Remove loading state and show error + shareOptions.forEach(option => option.classList.remove('loading')); + return; + } + + // Try native sharing with image first on mobile devices + if (navigator.share && platform !== 'copy') { + try { + const response = await fetch(imageDataUrl); + const blob = await response.blob(); + const playerName = playerData.account?.gameName || 'Player'; + const fileName = `${playerName}-league-wrapped.png`; + const file = new File([blob], fileName, { type: 'image/png' }); + + await navigator.share({ + title: shareCardData?.title || 'League of Legends Wrapped', + text: text, + files: [file] + }); + closeShareModal(); + return; + } catch (error) { + console.log('Native sharing failed, using platform-specific sharing'); + } + } + + // For platforms that don't support direct image sharing, we'll handle differently + const encodedText = encodeURIComponent(text); + const hashtags = encodeURIComponent('LeagueOfLegends LoLWrapped Gaming'); + let url = ''; + + switch (platform) { + case 'twitter': + // Twitter doesn't support direct image upload via URL, but we can encourage users to add the image + try { + // Copy the image to clipboard for easy pasting + const response = await fetch(imageDataUrl); + const blob = await response.blob(); + + if (navigator.clipboard && window.ClipboardItem) { + const item = new ClipboardItem({ 'image/png': blob }); + await navigator.clipboard.write([item]); + + // Update the label to show image is copied + const option = document.querySelector('.share-option:nth-child(2)'); + if (option) { + const labelElement = option.querySelector('.share-option-label'); + if (labelElement) { + const originalLabel = labelElement.textContent; + labelElement.textContent = 'Image copied! Paste it'; + setTimeout(() => { + labelElement.textContent = originalLabel; + }, 3000); + } + } + } + } catch (error) { + console.log('Could not copy image to clipboard'); + } + + url = `https://twitter.com/intent/tweet?text=${encodedText}&hashtags=${hashtags}`; + break; + + case 'facebook': + // Facebook also doesn't support direct image upload, copy image and open Facebook + try { + const response = await fetch(imageDataUrl); + const blob = await response.blob(); + + if (navigator.clipboard && window.ClipboardItem) { + const item = new ClipboardItem({ 'image/png': blob }); + await navigator.clipboard.write([item]); + + const option = document.querySelector('.share-option:nth-child(3)'); + if (option) { + const labelElement = option.querySelector('.share-option-label'); + if (labelElement) { + const originalLabel = labelElement.textContent; + labelElement.textContent = 'Image copied! Paste it'; + setTimeout(() => { + labelElement.textContent = originalLabel; + }, 3000); + } + } + } + } catch (error) { + console.log('Could not copy image to clipboard'); + } + + url = `https://www.facebook.com/`; + break; + + case 'instagram': + // For Instagram, copy both the image and text + try { + const response = await fetch(imageDataUrl); + const blob = await response.blob(); + const instagramText = `${text}\n\n#LeagueOfLegends #LoLWrapped #Gaming`; + + // Try to copy image to clipboard + if (navigator.clipboard && window.ClipboardItem) { + const item = new ClipboardItem({ 'image/png': blob }); + await navigator.clipboard.write([item]); + } + + // Also copy text as fallback + await navigator.clipboard.writeText(instagramText); + + const option = document.querySelector('.share-option:nth-child(1)'); + if (option) { + const labelElement = option.querySelector('.share-option-label'); + if (labelElement) { + const originalLabel = labelElement.textContent; + labelElement.textContent = 'Image & text copied!'; + setTimeout(() => { + labelElement.textContent = originalLabel; + }, 3000); + } + } + } catch (error) { + console.error('Failed to copy content'); + } + url = 'https://www.instagram.com/'; + break; + + case 'copy': + // Copy both image and text for maximum flexibility + try { + const response = await fetch(imageDataUrl); + const blob = await response.blob(); + const copyText = `${text}\n\nCheck it out: ${currentUrl}`; + + // Try to copy both image and text + if (navigator.clipboard && window.ClipboardItem) { + // Try to copy image first + try { + const item = new ClipboardItem({ 'image/png': blob }); + await navigator.clipboard.write([item]); + + const option = document.querySelector('.share-option:nth-child(4)'); + if (option) { + const labelElement = option.querySelector('.share-option-label'); + if (labelElement) { + labelElement.textContent = 'Image copied! โœ“'; + setTimeout(() => { + labelElement.textContent = 'Copy Text'; + }, 2000); + } + } + } catch (imageError) { + // Fallback to text if image copy fails + await navigator.clipboard.writeText(copyText); + + const option = document.querySelector('.share-option:nth-child(4)'); + if (option) { + const labelElement = option.querySelector('.share-option-label'); + if (labelElement) { + labelElement.textContent = 'Text copied! โœ“'; + setTimeout(() => { + labelElement.textContent = 'Copy Text'; + }, 2000); + } + } + } + } else { + // Fallback to text only + await navigator.clipboard.writeText(copyText); + } + + setTimeout(closeShareModal, 1500); + return; + } catch (error) { + console.error('Failed to copy content'); + } + break; + } + + if (url) { + window.open(url, '_blank', 'noopener,noreferrer'); + } + + // Remove loading state + setTimeout(() => { + shareOptions.forEach(option => option.classList.remove('loading')); + closeShareModal(); + }, 1000); + }; + + const closeShareModal = () => { + setShowShareModal(false); + setShareCardData(null); + }; + + return ( +
+
+ {/* Main Content Area */} +
+ {/* Navigation Buttons */} + + + {/* Card Container */} +
+ {/* Share Button */} + + +
+ {cards.map((card, index) => ( +
+ {card.component} +
+ ))} +
+
+ + +
+ + {/* Progress Bar */} +
+ {cards.map((_, index) => ( +
goToCard(index)} + /> + ))} +
+
+ + {/* Share Modal */} + {showShareModal && shareCardData && ( +
+
e.stopPropagation()}> +

Share {shareCardData.title}

+

Share your League Wrapped card as an image

+
+
shareToSocials('instagram')}> +
+ + + +
+
Instagram
+
+
shareToSocials('twitter')}> +
+ + + +
+
Twitter
+
+
shareToSocials('facebook')}> +
+ + + +
+
Facebook
+
+
shareToSocials('copy')}> +
+ + + + +
+
Copy Image
+
+
+
+ + + + + +
+
Download Image
+
+
+ +
+
+ )} +
+ ); +}; + +export default ResultsPage; diff --git a/client/src/SearchPage.tsx b/client/src/SearchPage.tsx new file mode 100644 index 0000000..c153021 --- /dev/null +++ b/client/src/SearchPage.tsx @@ -0,0 +1,144 @@ +import React, { useContext, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { PlayerDataContext } from './PlayerDataContext'; +import { getApiUrl } from './config'; + +const regions = [ + { value: 'kr', label: 'Korea' }, + { value: 'na1', label: 'North America' }, + { value: 'euw1', label: 'Europe West' }, + { value: 'eun1', label: 'Europe Nordic & East' }, + { value: 'br1', label: 'Brazil' }, + { value: 'la1', label: 'Latin America North' }, + { value: 'la2', label: 'Latin America South' }, + { value: 'oc1', label: 'Oceania' }, + { value: 'tr1', label: 'Turkey' }, + { value: 'ru', label: 'Russia' }, + { value: 'jp1', label: 'Japan' }, +]; + +const SearchPage: React.FC = () => { + const [gameName, setGameName] = useState(''); + const [tagLine, setTagLine] = useState(''); + const [region, setRegion] = useState('na1'); + const navigate = useNavigate(); + const context = useContext(PlayerDataContext); + const error = context?.errorMessage || ''; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!gameName.trim() || !tagLine.trim()) { + context?.setErrorMessage('Please enter both game name and tag line'); + return; + } + context?.setErrorMessage(''); + context?.setPlayerData(null); + context?.setLoadingStatus(['๐Ÿ”„ Starting analysis...']); + context?.setProfileIconUrl(''); + navigate('/loading'); + + try { + // Update progress messages during fetch + context?.setLoadingStatus((prev) => [...prev, '๐Ÿ” Fetching player data...']); + + const response = await fetch( + getApiUrl(`/api/search/${encodeURIComponent(gameName)}/${encodeURIComponent(tagLine)}?region=${region}`) + ); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to fetch player data'); + } + + context?.setLoadingStatus((prev) => [...prev, '๐Ÿ“Š Processing match history...']); + const data = await response.json(); + + context?.setLoadingStatus((prev) => [...prev, '๐Ÿง  Analyzing personality...']); + + context?.setPlayerData(data); + if (data.summoner?.profileIconUrl) { + context?.setProfileIconUrl(data.summoner.profileIconUrl); + } + + context?.setLoadingStatus((prev) => [...prev, 'โœจ Analysis complete!']); + navigate('/results'); + + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'An error occurred'; + context?.setLoadingStatus((prev) => [...prev, `โŒ ${errorMessage}`]); + setTimeout(() => { + context?.setErrorMessage(errorMessage); + navigate('/'); + }, 1200); + } + }; + + return ( +
+
+

SHAPE

+
+ ShapeSplitter + {[...Array(20)].map((_, i) => ( +
+ ))} +
+

SPLIT

+
+

Clone. Analyze. Dominate.

+
+
+
+ setGameName(e.target.value)} + className="search-input" + /> + # + setTagLine(e.target.value)} + className="search-input tag-input" + /> +
+ + +
+ {error &&
{error}
} + {/* Clear error on input change */} +