Auto merge dev → main (#1258)

* meow

* updated it

* meow

* add avg TTL footer to /comp + extend freshness window to 90m when servers slow

---------

Co-authored-by: Clippii <clippii@protonmail.com>
This commit is contained in:
NotSoToothless
2026-05-17 12:58:48 -07:00
committed by GitHub
parent 5148a0c7bb
commit 47ae8b92f7
139 changed files with 44 additions and 49025 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-325
View File
@@ -1,325 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom Fonts */
@font-face {
font-family: 'skyquakesymbols';
src: url('/Fonts/symbols_skyquake.ttf');
font-display: block;
}
@layer base {
* {
@apply m-0 p-0 box-border;
}
body {
@apply font-sans leading-relaxed text-white overflow-x-hidden min-h-screen antialiased;
background: linear-gradient(rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.7)), url('/images/toothless_face.webp');
background-size: cover;
background-position: center;
background-attachment: fixed;
background-repeat: no-repeat;
-webkit-overflow-scrolling: touch;
}
html {
@apply scroll-smooth;
}
}
@layer components {
/* ========================================
COLOR SCHEME:
- Background: Dark earth green (#1C1E1D) to graphite (#0A0B0A)
- Accent/Primary text: Cream (#F5F5DC)
- Secondary/Muted text: Mint green (#90EE90)
======================================== */
/* Vehicle, Squadron, and Player names use custom font */
.vehicle-name,
.squadron-name,
.squadron-tag,
.player-name,
.player-nick {
@apply font-skyquake;
}
/* Primary Button - Cream gradient */
.btn-primary {
@apply inline-flex items-center justify-center px-6 py-3 font-bold rounded-lg
transition-all duration-300 relative overflow-hidden;
background: linear-gradient(135deg, #F5F5DC 0%, #E8E8D0 100%);
color: #1E1E1E;
box-shadow: 0 4px 20px rgba(245, 245, 220, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.4);
}
.btn-primary:hover {
box-shadow: 0 8px 25px rgba(245, 245, 220, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.4);
transform: translateY(-2px);
}
.btn-primary:active {
transform: translateY(0);
}
/* Navigation callouts */
.nav-premium {
color: #f4d35e;
}
.nav-premium:hover {
color: #ffe08a;
}
.nav-rainbow {
color: #ff9b8a;
}
.nav-rainbow:hover {
color: #ffc0b4;
}
.nav-donate {
background: linear-gradient(90deg, #ff7a7a 0%, #ffd166 25%, #90ee90 50%, #8fd3ff 75%, #c79bff 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
}
.nav-donate:hover {
filter: brightness(1.12);
}
.nav-donate i {
-webkit-text-fill-color: initial;
color: #ffd166;
}
/* Secondary Button */
.btn-secondary {
@apply inline-flex items-center justify-center px-6 py-3
bg-white/10 text-white font-semibold rounded-lg border-2 border-primary-400/50
transition-all duration-300 backdrop-blur-sm
hover:-translate-y-0.5 hover:bg-primary-400/20 hover:border-primary-400
active:translate-y-0;
}
/* Feature Card - used on homepage */
.feature-card {
background: linear-gradient(135deg, rgba(62, 78, 62, 0.2) 0%, rgba(44, 44, 44, 0.2) 100%);
border: 1px solid rgba(245, 245, 220, 0.08);
backdrop-filter: blur(12px);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.card-hover:hover {
transform: translateY(-3px);
background: linear-gradient(135deg, rgba(62, 78, 62, 0.3) 0%, rgba(44, 44, 44, 0.3) 100%);
border-color: rgba(144, 238, 144, 0.3);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
}
.card-hover:hover i {
color: #F5F5DC;
transform: scale(1.1);
transition: transform 0.3s ease;
}
/* Search Input - Glass effect */
.search-input-glass {
background: rgba(30, 30, 30, 0.6);
border: 1px solid rgba(245, 245, 220, 0.1);
backdrop-filter: blur(10px);
transition: all 0.3s ease;
box-shadow: inset 0 2px 4px rgba(0,0,0,0.2);
}
.search-input-glass:focus {
background: rgba(40, 40, 40, 0.8);
border-color: rgba(144, 238, 144, 0.4);
box-shadow: 0 0 0 2px rgba(144, 238, 144, 0.1), inset 0 2px 4px rgba(0,0,0,0.2);
}
/* Card Component */
.card {
@apply bg-dark-100/80 backdrop-blur-md rounded-2xl border border-primary-400/20
p-6 transition-all duration-300
hover:border-primary-400/40 hover:shadow-lg hover:shadow-primary-400/10;
}
/* Glass Card (more transparent) */
.glass-card {
@apply bg-dark-100/60 backdrop-blur-xl rounded-2xl border border-primary-400/20
p-6 transition-all duration-300;
}
/* Stat Card */
.stat-card {
@apply card text-center relative overflow-hidden
hover:-translate-y-1 hover:border-primary-400;
}
/* Input Field */
.input-field {
@apply w-full px-4 py-3 bg-dark-200/80 border-2 border-primary-400/30
rounded-lg text-white placeholder-white/50
transition-all duration-300 backdrop-blur-sm
focus:outline-none focus:border-primary-400 focus:shadow-lg focus:shadow-primary-400/20;
}
/* Select Dropdown */
.select-field {
@apply input-field cursor-pointer appearance-none pr-10;
background-image: url('data:image/svg+xml;charset=UTF-8,%3csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%2339ff14%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3e%3cpolyline points=%226 9 12 15 18 9%22%3e%3c/polyline%3e%3c/svg%3e');
background-position: right 0.75rem center;
background-size: 1.25rem;
background-repeat: no-repeat;
}
/* Table Styles */
.data-table {
@apply w-full border-collapse bg-dark-200/80 rounded-xl overflow-hidden
shadow-xl border border-primary-400/10;
}
.data-table thead {
@apply bg-gradient-to-r from-primary-400/20 to-primary-500/20;
}
.data-table th {
@apply px-4 py-3 text-white font-semibold text-center border-b-2 border-primary-400/30;
}
.data-table td {
@apply px-4 py-3 text-center text-white/90 border-b border-primary-400/10;
}
.data-table tbody tr {
@apply transition-all duration-300 hover:bg-gradient-to-r hover:from-primary-400/10
hover:to-primary-500/10 hover:scale-[1.01];
}
/* Loading Spinner */
.spinner {
@apply border-4 border-primary-400/30 border-t-primary-400 rounded-full
w-10 h-10 animate-spin mx-auto;
}
/* Badge */
.badge {
@apply inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold
bg-primary-400/10 text-primary-400 border border-primary-400/30;
}
/* Navbar */
.navbar {
@apply fixed top-0 w-full bg-dark-300/95 backdrop-blur-md z-50
border-b border-primary-400/20 transition-all duration-300;
}
/* Container */
.container-custom {
@apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
}
/* Section Title */
.section-title {
@apply text-4xl font-bold text-center mb-4 bg-gradient-to-r from-primary-400 via-primary-500 to-primary-400
bg-clip-text text-transparent bg-[length:200%_auto] animate-gradient-shift;
}
/* Link Hover Effect */
.link-hover {
@apply relative text-white transition-colors duration-300
hover:text-primary-400
after:content-[''] after:absolute after:bottom-0 after:left-0
after:w-0 after:h-0.5 after:bg-gradient-to-r after:from-primary-400 after:to-primary-500
after:transition-all after:duration-300
hover:after:w-full;
}
/* Date Filter Styles */
.date-filter-container {
@apply glass-card space-y-4;
}
.filter-button {
@apply px-4 py-2 rounded-lg border border-primary-400/30 bg-dark-100/50
text-white font-medium transition-all duration-300
hover:border-primary-400 hover:bg-primary-400/10
focus:outline-none focus:ring-2 focus:ring-primary-400/50;
}
.filter-button-active {
@apply filter-button border-primary-400 bg-primary-400/20 text-primary-400 shadow-lg shadow-primary-400/20;
}
}
@layer utilities {
/* Text Gradient */
.text-gradient {
@apply bg-gradient-to-r from-primary-400 via-primary-500 to-primary-600
bg-clip-text text-transparent;
}
/* Glass Effect */
.glass {
@apply bg-white/5 backdrop-blur-md;
}
/* Glow Effect */
.glow {
@apply shadow-lg shadow-primary-400/30;
}
/* Scrollbar Styles */
.custom-scrollbar::-webkit-scrollbar {
@apply w-2 h-2;
}
.custom-scrollbar::-webkit-scrollbar-track {
@apply bg-dark-200 rounded;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
@apply bg-primary-400/30 rounded hover:bg-primary-400/50;
}
}
/* Additional custom animations */
@keyframes gradientShift {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
@keyframes scrollRight {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
/* Row link overlay - makes entire table row clickable with native right-click support */
tr.row-link {
position: relative;
cursor: pointer;
}
tr.row-link a.row-link-overlay::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
}
tr.row-link td a:not(.row-link-overlay),
tr.row-link td button,
tr.row-link td [tabindex],
tr.row-link td input,
tr.row-link td select,
tr.row-link td textarea {
position: relative;
z-index: 1;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 688 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

-362
View File
@@ -1,362 +0,0 @@
// API Client with automatic token handling and caching
class APIClient {
constructor() {
this.apiKey = null;
this.apiKeyExpiresAt = 0;
this.apiKeyPromise = null;
this.baseURL = '';
this.cache = new Map();
this.pendingRequests = new Map();
this.CACHE_DURATION = 5 * 60 * 1000; // 5 minutes client-side cache for general requests
this.SEARCH_CACHE_DURATION = 15 * 60 * 1000; // 15 minutes for search results (longer because less frequently changing)
}
// Initialize the API client by fetching the API key
async init(forceRefresh = false) {
if (!forceRefresh && this.apiKey && this.apiKeyExpiresAt && Date.now() < this.apiKeyExpiresAt) {
return;
}
if (this.apiKeyPromise) {
return this.apiKeyPromise;
}
this.apiKeyPromise = (async () => {
const response = await fetch('/api-key', {
method: 'GET',
cache: 'no-cache',
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) {
throw new Error(`Failed to fetch API key: ${response.status} ${response.statusText}`);
}
const data = await response.json();
this.apiKey = data.apiKey;
const expiresAt = Date.parse(data.expires);
this.apiKeyExpiresAt = Number.isFinite(expiresAt) ? expiresAt : Date.now() + 24 * 60 * 60 * 1000;
return this.apiKey;
})();
try {
return await this.apiKeyPromise;
} finally {
this.apiKeyPromise = null;
}
}
async ensureValidApiKey() {
if (!this.apiKey || !this.apiKeyExpiresAt || Date.now() >= this.apiKeyExpiresAt) {
await this.init(true);
}
}
// Generate signature for request (matches server-side algorithm)
generateSignature(method, path, queryString, timestamp, apiSecret) {
const data = `${method}${path}${queryString}`;
// Match server-side simple hashing algorithm exactly
let hash = 0;
const combined = `${data}-${timestamp}-${apiSecret}`;
for (let i = 0; i < combined.length; i++) {
const char = combined.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash).toString(16);
}
// Make authenticated API requests with caching and in-flight dedup
async request(endpoint, options = {}) {
const method = options.method || 'GET';
const authEnabled = options.auth !== false;
const cacheKey = `${method}:${endpoint}`;
const useCache = options.cache !== false && method === 'GET'; // Only cache GET requests
// Determine cache duration based on endpoint type
const isSearchRequest = endpoint.includes('/api/search/');
const cacheDuration = isSearchRequest ? this.SEARCH_CACHE_DURATION : this.CACHE_DURATION;
// Check cache for GET requests
if (useCache && this.cache.has(cacheKey)) {
const cached = this.cache.get(cacheKey);
const age = Date.now() - cached.timestamp;
if (age < cacheDuration) {
console.log(`[API Client] Cache hit for ${endpoint} (${Math.floor(age / 1000)}s old)`);
return cached.data;
} else {
console.log(`[API Client] Cache expired for ${endpoint}`);
this.cache.delete(cacheKey);
}
}
// In-flight dedup: if an identical GET is already in flight, share its promise
if (useCache && this.pendingRequests.has(cacheKey)) {
console.log(`[API Client] In-flight dedup hit for ${endpoint}`);
return this.pendingRequests.get(cacheKey);
}
const promise = this._performRequest(endpoint, options, { method, authEnabled, cacheKey, useCache });
if (useCache) {
this.pendingRequests.set(cacheKey, promise);
promise.finally(() => {
if (this.pendingRequests.get(cacheKey) === promise) {
this.pendingRequests.delete(cacheKey);
}
});
}
return promise;
}
async _performRequest(endpoint, options, ctx) {
const { method, authEnabled, cacheKey, useCache } = ctx;
console.log('[API Client] request called for:', endpoint);
// Auto-initialize if needed
if (authEnabled) {
await this.ensureValidApiKey();
}
if (authEnabled && !this.apiKey) {
throw new Error('Failed to get API key after initialization');
}
const url = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
const timestamp = Date.now().toString();
const headers = {
'Content-Type': 'application/json',
...options.headers
};
if (authEnabled) {
headers['X-API-Key'] = this.apiKey;
headers['X-Request-Timestamp'] = timestamp;
}
const finalOptions = {
...options,
method,
headers
};
console.log('[API Client] Making request to:', url);
let response = await fetch(url, finalOptions);
console.log('[API Client] Response status:', response.status);
if (authEnabled && (response.status === 401 || response.status === 403)) {
console.warn(`[API Client] Auth failed for ${endpoint}; refreshing API key and retrying once`);
this.apiKey = null;
this.apiKeyExpiresAt = 0;
await this.init(true);
finalOptions.headers['X-API-Key'] = this.apiKey;
finalOptions.headers['X-Request-Timestamp'] = Date.now().toString();
response = await fetch(url, finalOptions);
console.log('[API Client] Retry response status:', response.status);
}
if (!response.ok) {
const errorText = await response.text();
console.error('[API Client] Request failed:', response.status, errorText);
throw new Error(`API request failed: ${response.status} - ${errorText}`);
}
const data = await response.json();
// Cache successful GET responses
if (useCache) {
this.cache.set(cacheKey, {
data: data,
timestamp: Date.now()
});
console.log(`[API Client] Cached response for ${endpoint}`);
}
return data;
}
// Clear cache manually if needed
clearCache() {
this.cache.clear();
this.pendingRequests.clear();
console.log('[API Client] Cache cleared');
}
// Get cache stats
getCacheStats() {
const now = Date.now();
const stats = {
total: this.cache.size,
fresh: 0,
stale: 0,
searchEntries: 0
};
for (const [key, value] of this.cache.entries()) {
const age = now - value.timestamp;
const isSearch = key.includes('/api/search/');
const maxAge = isSearch ? this.SEARCH_CACHE_DURATION : this.CACHE_DURATION;
if (isSearch) stats.searchEntries++;
if (age < maxAge) {
stats.fresh++;
} else {
stats.stale++;
}
}
return stats;
}
// Convenience methods
async searchPlayers(nickname) {
return this.request(`/api/search/${encodeURIComponent(nickname)}`);
}
async getPlayer(uid) {
return this.request(`/api/player/${uid}`);
}
async getPlayerGames(uid) {
return this.request(`/api/player/${uid}/games`);
}
async getStats() {
return this.request('/api/stats', { auth: false });
}
// Leaderboard methods with stale-while-revalidate option
async getPlayerLeaderboard(useStaleWhileRevalidate = true) {
return this.requestWithSWR('/api/leaderboard/players', useStaleWhileRevalidate);
}
async getVehicleLeaderboard(vehicle = null, useStaleWhileRevalidate = true) {
const endpoint = vehicle
? `/api/leaderboard/vehicles?vehicle=${encodeURIComponent(vehicle)}`
: '/api/leaderboard/vehicles';
return this.requestWithSWR(endpoint, useStaleWhileRevalidate);
}
async getSquadronLeaderboard(useStaleWhileRevalidate = true) {
return this.requestWithSWR('/api/leaderboard/squadrons', useStaleWhileRevalidate);
}
async getSquadronDetails(squadronName, startDate, endDate) {
let endpoint = `/api/squadrons/${encodeURIComponent(squadronName)}`;
if (startDate || endDate) {
const params = new URLSearchParams();
if (startDate) params.append('start_date', startDate.toISOString());
if (endDate) params.append('end_date', endDate.toISOString());
endpoint += '?' + params.toString();
}
return this.request(endpoint);
}
async getSquadronGames(squadronName, startDate, endDate) {
let endpoint = `/api/squadrons/${encodeURIComponent(squadronName)}/games`;
if (startDate || endDate) {
const params = new URLSearchParams();
if (startDate) params.append('start_date', startDate.toISOString());
if (endDate) params.append('end_date', endDate.toISOString());
endpoint += '?' + params.toString();
}
return this.request(endpoint);
}
async getLeaderboardStats(useStaleWhileRevalidate = true) {
return this.requestWithSWR('/api/leaderboard/stats', useStaleWhileRevalidate);
}
// Stale-While-Revalidate: Return cached data immediately, fetch fresh data in background
async requestWithSWR(endpoint, useSWR = true) {
const cacheKey = `GET:${endpoint}`;
if (useSWR && this.cache.has(cacheKey)) {
const cached = this.cache.get(cacheKey);
const age = Date.now() - cached.timestamp;
// If data is stale (older than cache duration) but not ancient (< 10 minutes)
if (age >= this.CACHE_DURATION && age < 10 * 60 * 1000) {
console.log(`[API Client] SWR: Returning stale data and revalidating ${endpoint}`);
// Fetch fresh data in background (don't await)
this.request(endpoint).catch(err => {
console.error('[API Client] SWR background fetch failed:', err);
});
// Return stale data immediately
return cached.data;
}
}
// Normal request (will use cache if fresh)
return this.request(endpoint);
}
async getVehicleIcons() {
return this.request('/api/vehicle-icons');
}
async getMatch(sessionId) {
return this.request(`/api/match/${sessionId}`);
}
async getMatchReplay(sessionId) {
return this.request(`/api/match/${sessionId}/replay`);
}
async getReplayCanvas(sessionId) {
return this.request(`/api/match/${sessionId}/replay-canvas`);
}
async searchGames(params = {}) {
const queryParams = new URLSearchParams();
if (params.player) queryParams.append('player', params.player);
if (params.map) queryParams.append('map', params.map);
if (params.squadron) queryParams.append('squadron', params.squadron);
if (params.time_from) queryParams.append('time_from', params.time_from);
if (params.time_to) queryParams.append('time_to', params.time_to);
if (params.limit) queryParams.append('limit', params.limit);
return this.request(`/api/games/search?${queryParams}`);
}
async getMaps() {
return this.request('/api/maps');
}
}
// Global API client instance
window.apiClient = new APIClient();
// Pre-initialize when DOM is ready (but don't block)
document.addEventListener('DOMContentLoaded', () => {
// Initialize in background without blocking
window.apiClient.init().catch(() => {
// Silently fail, will retry on first request
});
});
// Quick init function that doesn't throw
window.ensureAPIClient = async () => {
try {
if (!window.apiClient) {
console.error('[API Client] window.apiClient is not defined');
return false;
}
if (!window.apiClient.apiKey) {
await window.apiClient.init();
}
return true;
} catch (error) {
console.error('[API Client] Error in ensureAPIClient:', error);
return false;
}
};
-20
View File
File diff suppressed because one or more lines are too long
-513
View File
@@ -1,513 +0,0 @@
/**
* Date Filter Component
* Provides a reusable date filtering UI using seasons data
*/
class DateFilter {
constructor(containerId, options = {}) {
this.container = document.getElementById(containerId);
if (!this.container) {
console.error(`[Date Filter] Container #${containerId} not found`);
return;
}
this.options = {
onFilterChange: options.onFilterChange || (() => {}),
includeCustomRange: options.includeCustomRange !== false,
includeCumulative: options.includeCumulative !== false,
defaultFilter: options.defaultFilter || 'all-time',
...options
};
this.seasonsFilter = window.seasonsFilter;
this.currentFilter = {
type: 'all-time', // 'all-time', 'season', 'week', 'custom', 'cumulative'
startDate: null,
endDate: null,
season: null,
week: null
};
this.init();
}
t(key, params = {}) {
let value = (window.__t && window.__t(key)) || key;
Object.entries(params).forEach(([name, replacement]) => {
value = value.replace(`{${name}}`, replacement);
});
return value;
}
async init() {
// Load seasons data
await this.seasonsFilter.loadSeasons();
// Render the UI
this.render();
// Apply default filter
if (this.options.defaultFilter !== 'all-time') {
this.applyPresetFilter(this.options.defaultFilter);
}
}
render() {
const html = `
<div class="date-filter-container">
<div class="flex flex-col space-y-4">
<!-- Filter Type Tabs -->
<div class="flex flex-wrap gap-2">
<button class="filter-button filter-button-active" data-filter-type="all-time">
<i class="fas fa-infinity mr-2"></i>${this.t('dateFilter.allTime')}
</button>
<button class="filter-button" data-filter-type="current-season">
<i class="fas fa-calendar-alt mr-2"></i>${this.t('dateFilter.currentSeason')}
</button>
<button class="filter-button" data-filter-type="season">
<i class="fas fa-calendar mr-2"></i>${this.t('dateFilter.bySeason')}
</button>
${this.options.includeCumulative ? `
<button class="filter-button" data-filter-type="cumulative">
<i class="fas fa-chart-line mr-2"></i>${this.t('dateFilter.cumulative')}
</button>
` : ''}
${this.options.includeCustomRange ? `
<button class="filter-button" data-filter-type="custom">
<i class="fas fa-calendar-day mr-2"></i>${this.t('dateFilter.customRange')}
</button>
` : ''}
</div>
<!-- Season Selection (hidden by default) -->
<div id="season-selector" class="hidden space-y-3">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-white/80 mb-2">
<i class="fas fa-trophy mr-1 text-primary-400"></i>${this.t('dateFilter.selectSeason')}
</label>
<select id="season-select" class="select-field">
<option value="">${this.t('dateFilter.selectSeasonDots')}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-white/80 mb-2">
<i class="fas fa-calendar-week mr-1 text-primary-400"></i>${this.t('dateFilter.selectWeek')}
</label>
<select id="week-select" class="select-field" disabled>
<option value="all">${this.t('dateFilter.entireSeason')}</option>
</select>
</div>
</div>
<button id="apply-season-filter" class="btn-primary w-full md:w-auto">
<i class="fas fa-check mr-2"></i>${this.t('dateFilter.applyFilter')}
</button>
</div>
<!-- Cumulative Selection (hidden by default) -->
<div id="cumulative-selector" class="hidden space-y-3">
<div class="bg-primary-400/10 border border-primary-400/30 rounded-lg p-4 mb-3">
<p class="text-sm text-white/80">
<i class="fas fa-info-circle mr-2 text-primary-400"></i>
${this.t('dateFilter.cumulativeHelp')}
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-white/80 mb-2">${this.t('dateFilter.season')}</label>
<select id="cumulative-season-select" class="select-field">
<option value="">${this.t('dateFilter.selectSeasonDots')}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-white/80 mb-2">${this.t('dateFilter.upToWeek')}</label>
<select id="cumulative-week-select" class="select-field" disabled>
<option value="">${this.t('dateFilter.selectWeekDots')}</option>
</select>
</div>
</div>
<button id="apply-cumulative-filter" class="btn-primary w-full md:w-auto">
<i class="fas fa-chart-line mr-2"></i>${this.t('dateFilter.applyCumulativeFilter')}
</button>
</div>
<!-- Custom Range Selection (hidden by default) -->
<div id="custom-range-selector" class="hidden space-y-3">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-white/80 mb-2">
<i class="fas fa-calendar-plus mr-1 text-primary-400"></i>${this.t('dateFilter.startDate')}
</label>
<input type="date" id="start-date" class="input-field">
</div>
<div>
<label class="block text-sm font-medium text-white/80 mb-2">
<i class="fas fa-calendar-minus mr-1 text-primary-400"></i>${this.t('dateFilter.endDate')}
</label>
<input type="date" id="end-date" class="input-field">
</div>
</div>
<button id="apply-custom-filter" class="btn-primary w-full md:w-auto">
<i class="fas fa-filter mr-2"></i>${this.t('dateFilter.applyCustomRange')}
</button>
</div>
<!-- Current Filter Display -->
<div id="current-filter-display" class="hidden">
<div class="bg-primary-400/10 border border-primary-400/30 rounded-lg p-4">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<i class="fas fa-filter text-primary-400 text-xl"></i>
<div>
<p class="text-sm text-white/60">${this.t('dateFilter.activeFilter')}</p>
<p id="filter-description" class="text-white font-semibold"></p>
</div>
</div>
<button id="clear-filter" class="text-red-400 hover:text-red-300 transition-colors">
<i class="fas fa-times-circle mr-1"></i>${this.t('dateFilter.clear')}
</button>
</div>
</div>
</div>
</div>
</div>
`;
this.container.innerHTML = html;
this.attachEventListeners();
this.populateSelectors();
}
attachEventListeners() {
// Filter type buttons
const filterButtons = this.container.querySelectorAll('[data-filter-type]');
filterButtons.forEach(btn => {
btn.addEventListener('click', (e) => {
const filterType = e.currentTarget.dataset.filterType;
this.showFilterSection(filterType);
});
});
// Season selection
const seasonSelect = this.container.querySelector('#season-select');
if (seasonSelect) {
seasonSelect.addEventListener('change', (e) => {
const weekSelect = this.container.querySelector('#week-select');
if (e.target.value) {
this.seasonsFilter.populateWeekSelect(weekSelect, e.target.value, true);
weekSelect.disabled = false;
} else {
weekSelect.disabled = true;
weekSelect.innerHTML = `<option value="all">${this.t('dateFilter.entireSeason')}</option>`;
}
});
}
// Cumulative season selection
const cumulativeSeasonSelect = this.container.querySelector('#cumulative-season-select');
if (cumulativeSeasonSelect) {
cumulativeSeasonSelect.addEventListener('change', (e) => {
const weekSelect = this.container.querySelector('#cumulative-week-select');
if (e.target.value) {
this.seasonsFilter.populateWeekSelect(weekSelect, e.target.value, false);
weekSelect.disabled = false;
} else {
weekSelect.disabled = true;
weekSelect.innerHTML = `<option value="">${this.t('dateFilter.selectWeekDots')}</option>`;
}
});
}
// Apply buttons
const applySeasonBtn = this.container.querySelector('#apply-season-filter');
if (applySeasonBtn) {
applySeasonBtn.addEventListener('click', () => this.applySeasonFilter());
}
const applyCumulativeBtn = this.container.querySelector('#apply-cumulative-filter');
if (applyCumulativeBtn) {
applyCumulativeBtn.addEventListener('click', () => this.applyCumulativeFilter());
}
const applyCustomBtn = this.container.querySelector('#apply-custom-filter');
if (applyCustomBtn) {
applyCustomBtn.addEventListener('click', () => this.applyCustomFilter());
}
// Clear filter
const clearBtn = this.container.querySelector('#clear-filter');
if (clearBtn) {
clearBtn.addEventListener('click', () => this.clearFilter());
}
}
populateSelectors() {
const seasonSelect = this.container.querySelector('#season-select');
const cumulativeSeasonSelect = this.container.querySelector('#cumulative-season-select');
if (seasonSelect) {
this.seasonsFilter.populateSeasonSelect(seasonSelect, false);
}
if (cumulativeSeasonSelect) {
this.seasonsFilter.populateSeasonSelect(cumulativeSeasonSelect, false);
}
}
showFilterSection(filterType) {
// Update button states
const buttons = this.container.querySelectorAll('[data-filter-type]');
buttons.forEach(btn => {
if (btn.dataset.filterType === filterType) {
btn.classList.add('filter-button-active');
btn.classList.remove('filter-button');
} else {
btn.classList.remove('filter-button-active');
btn.classList.add('filter-button');
}
});
// Hide all sections
this.container.querySelector('#season-selector')?.classList.add('hidden');
this.container.querySelector('#cumulative-selector')?.classList.add('hidden');
this.container.querySelector('#custom-range-selector')?.classList.add('hidden');
// Show appropriate section
switch (filterType) {
case 'all-time':
this.applyAllTimeFilter();
break;
case 'current-season':
this.applyCurrentSeasonFilter();
break;
case 'season':
this.container.querySelector('#season-selector')?.classList.remove('hidden');
break;
case 'cumulative':
this.container.querySelector('#cumulative-selector')?.classList.remove('hidden');
break;
case 'custom':
this.container.querySelector('#custom-range-selector')?.classList.remove('hidden');
break;
}
}
applyAllTimeFilter() {
this.currentFilter = {
type: 'all-time',
startDate: null,
endDate: null,
season: null,
week: null
};
this.updateFilterDisplay(this.t('dateFilter.allTimeStatistics'));
this.options.onFilterChange(this.currentFilter);
}
applyCurrentSeasonFilter() {
const currentSeasonInfo = this.seasonsFilter.getCurrentSeason();
if (!currentSeasonInfo) {
console.error('[Date Filter] No current season found');
return;
}
const dateRange = this.seasonsFilter.getSeasonDateRange(currentSeasonInfo.name);
if (!dateRange) return;
this.currentFilter = {
type: 'season',
startDate: dateRange.startDate,
endDate: dateRange.endDate,
season: currentSeasonInfo.name,
week: null
};
this.updateFilterDisplay(this.t('dateFilter.currentSeasonValue', { season: currentSeasonInfo.name }));
this.options.onFilterChange(this.currentFilter);
}
applySeasonFilter() {
const seasonSelect = this.container.querySelector('#season-select');
const weekSelect = this.container.querySelector('#week-select');
const seasonName = seasonSelect.value;
if (!seasonName) {
alert(this.t('dateFilter.alertSelectSeason'));
return;
}
const weekValue = weekSelect.value;
let dateRange;
let description;
if (weekValue === 'all') {
// Entire season
dateRange = this.seasonsFilter.getSeasonDateRange(seasonName);
description = this.t('dateFilter.seasonValue', { season: seasonName });
this.currentFilter = {
type: 'season',
startDate: dateRange.startDate,
endDate: dateRange.endDate,
season: seasonName,
week: null
};
} else {
// Specific week
const weekNumber = weekValue === 'final' ? null : parseInt(weekValue);
dateRange = this.seasonsFilter.getWeekDateRange(seasonName, weekNumber);
const season = this.seasonsFilter.getSeason(seasonName);
const week = season.weeks.find(w =>
(w.weekNumber === weekNumber) || (weekValue === 'final' && w.weekNumber === null)
);
description = `${seasonName} - ${week.displayName}`;
this.currentFilter = {
type: 'week',
startDate: dateRange.startDate,
endDate: dateRange.endDate,
season: seasonName,
week: weekNumber
};
}
this.updateFilterDisplay(description);
this.options.onFilterChange(this.currentFilter);
}
applyCumulativeFilter() {
const seasonSelect = this.container.querySelector('#cumulative-season-select');
const weekSelect = this.container.querySelector('#cumulative-week-select');
const seasonName = seasonSelect.value;
const weekValue = weekSelect.value;
if (!seasonName || !weekValue) {
alert(this.t('dateFilter.alertSelectSeasonWeek'));
return;
}
const weekNumber = weekValue === 'final' ? null : parseInt(weekValue);
const dateRange = this.seasonsFilter.getWeekDateRange(seasonName, weekNumber);
if (!dateRange) return;
const season = this.seasonsFilter.getSeason(seasonName);
const week = season.weeks.find(w =>
(w.weekNumber === weekNumber) || (weekValue === 'final' && w.weekNumber === null)
);
this.currentFilter = {
type: 'cumulative',
startDate: null, // No start date for cumulative
endDate: dateRange.endDate,
season: seasonName,
week: weekNumber
};
this.updateFilterDisplay(this.t('dateFilter.cumulativeValue', { season: seasonName, week: week.displayName }));
this.options.onFilterChange(this.currentFilter);
}
applyCustomFilter() {
const startDateInput = this.container.querySelector('#start-date');
const endDateInput = this.container.querySelector('#end-date');
const startDate = startDateInput.value ? new Date(startDateInput.value) : null;
const endDate = endDateInput.value ? new Date(endDateInput.value) : null;
if (!startDate && !endDate) {
alert(this.t('dateFilter.alertSelectDate'));
return;
}
if (startDate && endDate && startDate > endDate) {
alert(this.t('dateFilter.alertStartBeforeEnd'));
return;
}
this.currentFilter = {
type: 'custom',
startDate: startDate,
endDate: endDate,
season: null,
week: null
};
let description = this.t('dateFilter.customRangePrefix') + ' ';
if (startDate && endDate) {
description += `${startDate.toLocaleDateString()} - ${endDate.toLocaleDateString()}`;
} else if (startDate) {
description += this.t('dateFilter.fromDate', { date: startDate.toLocaleDateString() });
} else {
description += this.t('dateFilter.upToDate', { date: endDate.toLocaleDateString() });
}
this.updateFilterDisplay(description);
this.options.onFilterChange(this.currentFilter);
}
clearFilter() {
this.applyAllTimeFilter();
}
updateFilterDisplay(description) {
const filterDisplay = this.container.querySelector('#current-filter-display');
const filterDescription = this.container.querySelector('#filter-description');
if (this.currentFilter.type === 'all-time') {
filterDisplay?.classList.add('hidden');
} else {
filterDisplay?.classList.remove('hidden');
if (filterDescription) {
filterDescription.textContent = description;
}
}
}
applyPresetFilter(preset) {
// Apply a preset filter (e.g., 'current-season', 'all-time')
this.showFilterSection(preset);
}
getCurrentFilter() {
return this.currentFilter;
}
getAPIQueryParams() {
// Generate query parameters for API requests
const params = new URLSearchParams();
if (this.currentFilter.type === 'all-time') {
// No params needed for all-time
return '';
}
if (this.currentFilter.startDate) {
const startDateISO = this.currentFilter.startDate.toISOString();
params.append('start_date', startDateISO);
console.log('[Date Filter] Start Date:', this.currentFilter.startDate, '→', startDateISO);
}
if (this.currentFilter.endDate) {
const endDate = new Date(this.currentFilter.endDate);
endDate.setHours(23, 59, 59, 999);
const endDateISO = endDate.toISOString();
params.append('end_date', endDateISO);
console.log('[Date Filter] End Date:', endDate, '→', endDateISO);
}
if (this.currentFilter.season) {
params.append('season', this.currentFilter.season);
}
if (this.currentFilter.week !== null) {
params.append('week', this.currentFilter.week);
}
const queryString = params.toString() ? '?' + params.toString() : '';
console.log('[Date Filter] API Query Params:', queryString);
return queryString;
}
}
// Export for use in other scripts
window.DateFilter = DateFilter;
-82
View File
@@ -1,82 +0,0 @@
// Universal Header Search Functionality
let headerSearchTimeout;
async function headerSearchPlayers() {
const searchTerm = document.getElementById('headerPlayerSearch').value.trim();
const resultsDiv = document.getElementById('headerSearchResults');
// Clear previous timeout
clearTimeout(headerSearchTimeout);
if (searchTerm.length < 2) {
resultsDiv.classList.remove('show');
return;
}
// Debounce search (increased to 500ms for better performance)
headerSearchTimeout = setTimeout(async () => {
try {
const response = await window.apiClient.searchPlayers(searchTerm);
displayHeaderSearchResults(response.results, resultsDiv);
} catch (error) {
console.error('Search error:', error);
resultsDiv.innerHTML = '<div class="header-search-result-item">' + (window.__t ? __t('js.searchError') : 'Search error. Please try again.') + '</div>';
resultsDiv.classList.add('show');
}
}, 500);
}
function displayHeaderSearchResults(players, resultsDiv) {
if (!players || players.length === 0) {
resultsDiv.innerHTML = '<div class="header-search-result-item">' + (window.__t ? __t('js.noPlayersFound') : 'No players found') + '</div>';
} else {
resultsDiv.innerHTML = players.slice(0, 8).map(player => {
const squadronTag = player.squadron_name ? `<span class="player-squadron" style="font-family: 'skyquakesymbols', 'Inter', sans-serif !important; color: rgba(255, 255, 255, 0.4); font-size: 0.85rem; margin-right: 0.4rem;">${escapeHtml(player.squadron_name)}</span>` : '';
return `
<div class="header-search-result-item" onclick="navigateToPlayer(${player.uid})">
<div class="header-result-name">${squadronTag}${escapeHtml(player.nick)}</div>
<div class="header-result-stats">${formatHeaderNumber(player.total_kills || 0)} ${window.__t ? __t('js.killsSuffix') : 'kills'}${(player.win_rate || 0).toFixed(1)}% ${window.__t ? __t('js.winRateSuffix') : 'win rate'}</div>
</div>
`;
}).join('');
}
resultsDiv.classList.add('show');
}
function navigateToPlayer(uid) {
window.location.href = `/players/${uid}`;
}
function hideHeaderSearchResults() {
const el = document.getElementById('headerSearchResults');
if (el) el.classList.remove('show');
}
function handleHeaderKeydown(event) {
if (event.key === 'Escape') {
hideHeaderSearchResults();
document.getElementById('headerPlayerSearch').blur();
}
}
function formatHeaderNumber(num) {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toString();
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Hide search results when clicking outside
document.addEventListener('click', function(event) {
if (!event.target.closest('.nav-search')) {
hideHeaderSearchResults();
}
});
-483
View File
@@ -1,483 +0,0 @@
// DOM Content Loaded
document.addEventListener('DOMContentLoaded', function() {
// Mobile Navigation
const hamburger = document.querySelector('.hamburger');
const navMenu = document.querySelector('.nav-menu');
const navLinks = document.querySelectorAll('.nav-link');
if (hamburger && navMenu) {
hamburger.addEventListener('click', function() {
hamburger.classList.toggle('active');
navMenu.classList.toggle('active');
// Prevent body scroll when menu is open
if (navMenu.classList.contains('active')) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
});
// Keyboard navigation support
hamburger.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
hamburger.click();
}
});
// Close mobile menu when clicking on nav links
navLinks.forEach(link => {
link.addEventListener('click', function() {
hamburger.classList.remove('active');
navMenu.classList.remove('active');
document.body.style.overflow = '';
});
});
// Close mobile menu when clicking outside
document.addEventListener('click', function(e) {
if (hamburger && navMenu && !hamburger.contains(e.target) && !navMenu.contains(e.target)) {
hamburger.classList.remove('active');
navMenu.classList.remove('active');
document.body.style.overflow = '';
}
});
// Close mobile menu on window resize if desktop size
window.addEventListener('resize', function() {
if (window.innerWidth > 768) {
hamburger.classList.remove('active');
navMenu.classList.remove('active');
document.body.style.overflow = '';
}
});
}
// Smooth Scrolling for Navigation Links
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function (e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute('href'));
if (target) {
target.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
});
});
// Button Click Handlers
const inviteButtons = [
'inviteBtn',
'inviteBtnMobile',
'heroInviteBtn',
'ctaInviteBtn',
'footerInviteBtn',
'freePlanInviteBtn'
];
const supportButtons = [
'supportBtn',
'heroSupportBtn',
'footerSupportBtn'
];
// Handle Invite Button Clicks
inviteButtons.forEach(buttonId => {
const button = document.getElementById(buttonId);
if (button) {
button.addEventListener('click', function() {
handleInviteClick();
});
}
});
// Handle Support Button Clicks
supportButtons.forEach(buttonId => {
const button = document.getElementById(buttonId);
if (button) {
button.addEventListener('click', function() {
handleSupportClick();
});
}
});
// Fetch and Update Stats
updateStats();
// Update stats every 30 seconds
setInterval(updateStats, 30000);
// New nav mobile menu toggle
const mobileMenuBtn = document.getElementById('mobileMenuBtn');
const mobileMenu = document.getElementById('mobileMenu');
if (mobileMenuBtn && mobileMenu) {
mobileMenuBtn.addEventListener('click', () => {
mobileMenu.classList.toggle('hidden');
});
}
// Navbar Scroll Effect
const navbar = document.querySelector('.navbar');
if (navbar) {
window.addEventListener('scroll', function() {
if (window.scrollY > 100) {
navbar.style.background = 'rgba(13, 14, 15, 0.98)';
} else {
navbar.style.background = 'rgba(13, 14, 15, 1)';
}
});
}
// Animate Numbers on Stats Section Intersection
const statsSection = document.querySelector('#stats');
if (statsSection) {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
animateNumbers();
observer.unobserve(entry.target);
}
});
}, { threshold: 0.5 });
observer.observe(statsSection);
}
// Handle touch events for better mobile experience
let touchStartY = 0;
let touchEndY = 0;
document.addEventListener('touchstart', function(e) {
touchStartY = e.changedTouches[0].screenY;
}, { passive: true });
document.addEventListener('touchend', function(e) {
touchEndY = e.changedTouches[0].screenY;
handleSwipe();
}, { passive: true });
function handleSwipe() {
const swipeThreshold = 50;
const diff = touchStartY - touchEndY;
// Close mobile menu on upward swipe when menu is open
if (navMenu && hamburger && navMenu.classList.contains('active') && diff > swipeThreshold) {
hamburger.classList.remove('active');
navMenu.classList.remove('active');
document.body.style.overflow = '';
}
}
});
// Handle Invite Button Click
async function handleInviteClick() {
try {
// Direct invite URL for Toothless SQB Bot
const inviteUrl = 'https://discord.com/oauth2/authorize?client_id=1254679514466877540&permissions=2048&scope=bot%20applications.commands';
window.open(inviteUrl, '_blank');
showNotification(window.__t ? __t('js.openingDiscordInvite') : 'Opening Discord invite!', 'success');
} catch (error) {
console.error('Error opening invite link:', error);
showNotification(window.__t ? __t('js.errorOpeningInvite') : 'Error opening invite link. Please try again later.', 'error');
// Fallback - same URL but ensure it opens
const fallbackUrl = 'https://discord.com/oauth2/authorize?client_id=1254679514466877540&permissions=2048&scope=bot%20applications.commands';
window.open(fallbackUrl, '_blank');
}
}
// Handle Support Button Click
async function handleSupportClick() {
try {
showNotification(window.__t ? __t('js.gettingSupportLink') : 'Getting support server link...', 'info');
const response = await fetch('/api/support');
const data = await response.json();
if (data.supportUrl) {
window.open(data.supportUrl, '_blank');
showNotification(window.__t ? __t('js.openingSupportServer') : 'Opening support server!', 'success');
} else {
throw new Error('No support URL received');
}
} catch (error) {
console.error('Error getting support link:', error);
showNotification(window.__t ? __t('js.errorGettingSupport') : 'Error getting support link. Please try again later.', 'error');
// Fallback - Real support server invite
const fallbackUrl = 'https://discord.gg/BCvkK8JhPe';
window.open(fallbackUrl, '_blank');
}
}
// Update Stats from API
async function updateStats() {
try {
let stats;
// Check if API client is available, if not use fallback
if (window.apiClient && window.apiClient.getStats) {
stats = await window.apiClient.getStats();
} else {
// Fallback for pages without API client
const response = await fetch('/api/stats');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
stats = await response.json();
}
// Update server count
const serverCountEl = document.getElementById('serverCount');
if (serverCountEl && stats.servers) {
serverCountEl.textContent = formatNumber(stats.servers);
}
// Update user count
const userCountEl = document.getElementById('userCount');
if (userCountEl && stats.users) {
userCountEl.textContent = formatNumber(stats.users) + '+';
}
// Update command count
const commandCountEl = document.getElementById('commandCount');
if (commandCountEl && stats.commands) {
commandCountEl.textContent = stats.commands + '+';
}
} catch (error) {
// Silently ignore — stat counters are non-critical and failures flash an annoying banner
}
}
// Format numbers with commas
function formatNumber(num) {
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
// Animate numbers when stats section comes into view
function animateNumbers() {
const numbers = document.querySelectorAll('.stat-number');
numbers.forEach(number => {
const target = parseInt(number.textContent.replace(/[^0-9]/g, ''));
const duration = 2000;
const start = performance.now();
function updateNumber(currentTime) {
const elapsed = currentTime - start;
const progress = Math.min(elapsed / duration, 1);
// Easing function
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
const current = Math.floor(target * easeOutQuart);
if (number.textContent.includes('+')) {
number.textContent = formatNumber(current) + '+';
} else {
number.textContent = formatNumber(current);
}
if (progress < 1) {
requestAnimationFrame(updateNumber);
}
}
requestAnimationFrame(updateNumber);
});
}
// Show notification system
function showNotification(message, type = 'info') {
// Remove existing notifications
const existingNotification = document.querySelector('.notification');
if (existingNotification) {
existingNotification.remove();
}
// Create notification element
const notification = document.createElement('div');
notification.className = `notification notification-${type}`;
notification.textContent = message;
// Style the notification
notification.style.cssText = `
position: fixed;
top: 100px;
right: 20px;
background: ${type === 'success' ? '#4caf50' : type === 'error' ? '#f44336' : '#2196f3'};
color: white;
padding: 15px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 10000;
font-weight: 500;
opacity: 0;
transform: translateX(100%);
transition: all 0.3s ease;
`;
document.body.appendChild(notification);
// Animate in
setTimeout(() => {
notification.style.opacity = '1';
notification.style.transform = 'translateX(0)';
}, 100);
// Auto remove after 3 seconds
setTimeout(() => {
notification.style.opacity = '0';
notification.style.transform = 'translateX(100%)';
setTimeout(() => {
if (notification.parentNode) {
notification.remove();
}
}, 300);
}, 3000);
}
// Easter egg - Konami code
let konamiCode = [];
const konamiSequence = [
'ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown',
'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight',
'KeyB', 'KeyA'
];
function triggerKonamiEasterEgg() {
var t = typeof __t === 'function' ? __t : null;
// --- 1. Screen shake ---
document.body.style.transition = 'none';
var shakeFrames = [
'3px 0', '-3px 1px', '2px -1px', '-2px 2px',
'1px -2px', '-1px 1px', '2px 0', '0 0'
];
var si = 0;
var shakeInterval = setInterval(function () {
if (si >= shakeFrames.length) { clearInterval(shakeInterval); document.body.style.transform = ''; return; }
document.body.style.transform = 'translate(' + shakeFrames[si] + ')';
si++;
}, 40);
// --- 2. Confetti burst ---
var colors = ['#ff6b6b', '#ffd93d', '#6bcb77', '#4d96ff', '#ff922b', '#cc5de8', '#20c997'];
var confettiCount = 80;
var container = document.createElement('div');
container.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:99999;overflow:hidden;';
document.body.appendChild(container);
for (var i = 0; i < confettiCount; i++) {
var piece = document.createElement('div');
var size = Math.random() * 8 + 4;
var color = colors[Math.floor(Math.random() * colors.length)];
var startX = 50 + (Math.random() - 0.5) * 20;
var startY = 50 + (Math.random() - 0.5) * 10;
var dx = (Math.random() - 0.5) * 120;
var dy = -(Math.random() * 60 + 30);
var rot = Math.random() * 720 - 360;
var dur = Math.random() * 1.5 + 1.5;
piece.style.cssText =
'position:absolute;width:' + size + 'px;height:' + (size * 0.6) + 'px;' +
'background:' + color + ';border-radius:2px;' +
'left:' + startX + '%;top:' + startY + '%;' +
'opacity:1;pointer-events:none;';
piece.animate([
{ transform: 'translate(0,0) rotate(0deg)', opacity: 1 },
{ transform: 'translate(' + dx + 'vw,' + dy + 'vh) rotate(' + rot + 'deg)', opacity: 0 }
], { duration: dur * 1000, easing: 'cubic-bezier(.25,.8,.25,1)', fill: 'forwards' });
container.appendChild(piece);
}
setTimeout(function () { container.remove(); }, 4000);
// --- 3. Barrel roll ---
setTimeout(function () {
var style = document.createElement('style');
style.textContent = '@keyframes konamiBarrelRoll{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}';
document.head.appendChild(style);
document.body.style.transformOrigin = 'center center';
document.body.style.animation = 'konamiBarrelRoll 1s ease-in-out';
document.body.addEventListener('animationend', function handler() {
document.body.style.animation = '';
document.body.style.transformOrigin = '';
style.remove();
document.body.removeEventListener('animationend', handler);
});
}, 350);
// --- 4. Themed notification ---
var msg = t ? t('js.konamiActivated') : 'Achievement Unlocked: Secret Code!';
var notif = document.createElement('div');
notif.style.cssText =
'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%) scale(0);' +
'background:linear-gradient(135deg,rgba(30,30,30,0.95),rgba(50,50,50,0.95));' +
'border:2px solid #ffd93d;color:#ffd93d;padding:20px 40px;border-radius:12px;' +
'z-index:100000;font-size:1.4rem;font-weight:700;text-align:center;' +
'box-shadow:0 0 40px rgba(255,217,61,0.3);pointer-events:none;' +
'font-family:inherit;letter-spacing:1px;text-transform:uppercase;' +
'transition:transform 0.4s cubic-bezier(.34,1.56,.64,1),opacity 0.3s ease;opacity:0;';
notif.textContent = msg;
document.body.appendChild(notif);
setTimeout(function () {
notif.style.transform = 'translate(-50%,-50%) scale(1)';
notif.style.opacity = '1';
}, 50);
setTimeout(function () {
notif.style.transform = 'translate(-50%,-50%) scale(0.8)';
notif.style.opacity = '0';
setTimeout(function () { notif.remove(); }, 400);
}, 3000);
}
document.addEventListener('keydown', function(e) {
konamiCode.push(e.code);
if (konamiCode.length > konamiSequence.length) {
konamiCode.shift();
}
if (konamiCode.join(',') === konamiSequence.join(',')) {
triggerKonamiEasterEgg();
konamiCode = [];
}
});
// Mobile menu toggle
function toggleMobileMenu() {
const navMenu = document.querySelector('.nav-menu');
const hamburger = document.querySelector('.hamburger');
navMenu.classList.toggle('active');
hamburger.classList.toggle('active');
}
// Languages dropdown toggle
function toggleLanguagesList() {
const languagesList = document.getElementById('languagesList');
const dropdownToggle = document.querySelector('.dropdown-toggle');
languagesList.classList.toggle('show');
dropdownToggle.classList.toggle('active');
}
// Language switcher (ENG/RUS)
function switchLanguage(lang) {
const next = lang || (document.documentElement.lang === 'en' ? 'ru' : 'en');
if (next === document.documentElement.lang) return;
document.cookie = 'lang=' + next + ';path=/;max-age=31536000;SameSite=Lax';
window.location.reload();
}
// Language dropdown: close on outside click
document.addEventListener('click', function(e) {
var dd = document.getElementById('langDropdown');
if (dd && !dd.contains(e.target)) {
dd.classList.remove('open');
}
});
-827
View File
@@ -1,827 +0,0 @@
// Player Details Modal - Quick-peek popup for player stats
(function () {
let modalInjected = false;
let currentTab = 'overview';
function T(key) {
return (window.__t && window.__t(key)) || key;
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatNumber(n) {
return (n || 0).toLocaleString();
}
function getWinRateColor(wr) {
if (wr >= 70) return '#90EE90';
if (wr >= 60) return '#A8E6CF';
if (wr >= 50) return '#FFD700';
if (wr >= 40) return '#FFA500';
return '#FF6B6B';
}
function getKDRColor(kdr) {
if (kdr >= 3) return '#90EE90';
if (kdr >= 2) return '#A8E6CF';
if (kdr >= 1.5) return '#FFD700';
if (kdr >= 1) return '#FFA500';
return '#FF6B6B';
}
function injectModal() {
if (modalInjected) return;
modalInjected = true;
const style = document.createElement('style');
style.textContent = `
.pdm-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.7);
backdrop-filter: blur(4px);
z-index: 9999;
display: flex; align-items: center; justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
}
.pdm-overlay.pdm-visible { opacity: 1; }
.pdm-modal {
background: #1e1e1e;
border: 1px solid rgba(144,238,144,0.15);
border-radius: 12px;
max-width: 700px; width: 90%;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
transform: scale(0.95);
transition: transform 0.2s ease;
}
.pdm-overlay.pdm-visible .pdm-modal { transform: scale(1); }
.pdm-header {
display: flex; align-items: center; justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(144,238,144,0.1);
position: sticky; top: 0;
background: #1e1e1e;
z-index: 1;
border-radius: 12px 12px 0 0;
}
.pdm-header-left {
display: flex; align-items: center; gap: 0.75rem;
min-width: 0;
}
.pdm-player-name {
font-size: 1.1rem; font-weight: 700;
color: #F5F5DC;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.pdm-profile-link {
font-size: 0.75rem;
color: #90EE90;
text-decoration: none;
white-space: nowrap;
opacity: 0.8;
transition: opacity 0.2s;
}
.pdm-profile-link:hover { opacity: 1; text-decoration: underline; }
.pdm-close {
background: none; border: none;
color: rgba(255,255,255,0.5);
font-size: 1.4rem; cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 6px;
transition: color 0.2s, background 0.2s;
flex-shrink: 0;
}
.pdm-close:hover { color: #fff; background: rgba(255,255,255,0.1); }
.pdm-tabs {
display: flex; justify-content: center;
padding: 0.75rem 1.25rem 0;
}
.pdm-tab-group {
position: relative;
display: inline-flex;
background: rgba(255,255,255,0.05);
border-radius: 2rem;
padding: 3px;
border: 1px solid rgba(144,238,144,0.2);
}
.pdm-tab-slider {
position: absolute;
top: 3px; left: 3px;
height: calc(100% - 6px);
background: #90EE90;
border-radius: calc(2rem - 2px);
transition: left 0.25s cubic-bezier(0.4,0,0.2,1), width 0.25s cubic-bezier(0.4,0,0.2,1);
pointer-events: none;
}
.pdm-tab {
position: relative; z-index: 1;
background: transparent; border: none;
color: rgba(255,255,255,0.45);
padding: 0.4rem 1.3rem;
border-radius: calc(2rem - 2px);
cursor: pointer;
font-size: 0.82rem; font-weight: 600;
transition: color 0.25s ease;
white-space: nowrap;
}
.pdm-tab.active { color: #1b1b1b; }
.pdm-tab:hover:not(.active) { color: rgba(255,255,255,0.8); }
.pdm-body { padding: 1rem 1.25rem 1.25rem; }
.pdm-loading {
display: flex; flex-direction: column;
align-items: center; justify-content: center;
padding: 3rem; color: rgba(255,255,255,0.5);
gap: 0.75rem;
}
.pdm-spinner {
width: 32px; height: 32px;
border: 3px solid rgba(144,238,144,0.2);
border-top-color: #90EE90;
border-radius: 50%;
animation: pdm-spin 0.8s linear infinite;
}
@keyframes pdm-spin { to { transform: rotate(360deg); } }
.pdm-error {
text-align: center; padding: 2rem;
color: #ff6b6b;
}
.pdm-error i { font-size: 2rem; margin-bottom: 0.5rem; }
/* Overview tab */
.pdm-stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.6rem;
}
.pdm-stats-grid-last-row {
display: flex;
justify-content: center;
gap: 0.6rem;
margin-top: 0.6rem;
}
.pdm-stats-grid-last-row .pdm-stat-card {
flex: 0 1 calc(33.333% - 0.4rem);
}
.pdm-stat-card {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(144,238,144,0.08);
border-radius: 8px;
padding: 0.6rem 0.75rem;
text-align: center;
}
.pdm-stat-value {
font-size: 1.1rem; font-weight: 700;
color: #90EE90;
}
.pdm-stat-toggle {
display: flex;
justify-content: center;
gap: 0.3rem;
margin-top: 0.15rem;
}
.pdm-stat-toggle span {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.5px;
cursor: pointer;
transition: color 0.2s;
}
.pdm-stat-toggle span.pdm-toggle-active {
color: #90EE90;
font-weight: 700;
}
.pdm-stat-toggle span.pdm-toggle-inactive {
color: rgba(255,255,255,0.3);
}
.pdm-stat-toggle span.pdm-toggle-inactive:hover {
color: rgba(255,255,255,0.55);
}
.pdm-stat-toggle .pdm-toggle-sep {
color: rgba(255,255,255,0.15);
cursor: default;
}
.pdm-stat-label {
font-size: 0.7rem;
color: rgba(255,255,255,0.5);
margin-top: 0.15rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Mini chart card */
.pdm-chart-card {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(144,238,144,0.08);
border-radius: 8px;
padding: 0.5rem 0.6rem;
margin-top: 0.6rem;
cursor: pointer;
position: relative;
overflow: hidden;
transition: border-color 0.2s;
}
.pdm-chart-card:hover {
border-color: rgba(144,238,144,0.25);
}
.pdm-chart-label {
font-size: 0.65rem;
color: rgba(255,255,255,0.5);
text-transform: uppercase;
letter-spacing: 0.5px;
text-align: center;
margin-bottom: 0.2rem;
}
.pdm-chart-hint {
font-size: 0.55rem;
color: rgba(255,255,255,0.25);
text-align: center;
margin-top: 0.15rem;
}
.pdm-mini-canvas-wrap {
position: relative;
width: 100%;
height: 60px;
}
.pdm-mini-canvas {
width: 100%;
height: 100%;
display: block;
}
/* Vehicles tab */
.pdm-vehicles-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
max-height: 55vh;
overflow-y: auto;
}
.pdm-vehicle-card {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(144,238,144,0.08);
border-radius: 8px;
padding: 0.6rem 0.75rem;
}
.pdm-vehicle-card.pdm-crown-card {
border-color: rgba(255,215,0,0.3);
background: rgba(255,215,0,0.03);
}
.pdm-vehicle-name {
font-weight: 600; font-size: 0.85rem;
color: #F5F5DC;
margin-bottom: 0.4rem;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.pdm-crown {
color: #FFD700;
margin-right: 0.3rem;
font-size: 0.75rem;
}
.pdm-vehicle-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.15rem 0.5rem;
font-size: 0.72rem;
color: rgba(255,255,255,0.6);
}
.pdm-vehicle-stats span {
white-space: nowrap;
}
.pdm-vs-val { font-weight: 600; }
/* Sessions tab */
.pdm-sessions-wrap {
max-height: 55vh;
overflow-y: auto;
}
.pdm-sessions-table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
}
.pdm-sessions-table th {
text-align: left;
padding: 0.55rem 0.6rem;
color: rgba(255,255,255,0.5);
font-weight: 600;
border-bottom: 1px solid rgba(144,238,144,0.1);
white-space: nowrap;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.pdm-sessions-table td {
padding: 0.55rem 0.6rem;
color: rgba(255,255,255,0.75);
border-bottom: 1px solid rgba(255,255,255,0.03);
white-space: nowrap;
}
.pdm-sessions-table tr:hover td {
background: rgba(255,255,255,0.03);
}
.pdm-result-badge {
display: inline-block;
padding: 0.2rem 0.55rem;
border-radius: 4px;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
}
.pdm-result-win { background: rgba(144,238,144,0.15); color: #90EE90; }
.pdm-result-loss { background: rgba(255,107,107,0.15); color: #ff6b6b; }
.pdm-no-data {
text-align: center; padding: 2rem;
color: rgba(255,255,255,0.4);
}
/* Details button */
.pdm-details-btn {
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid rgba(144,238,144,0.15);
color: rgba(144,238,144,0.5);
width: 22px; height: 22px;
border-radius: 4px;
cursor: pointer;
font-size: 0.65rem;
margin-left: 0.4rem;
vertical-align: middle;
transition: all 0.2s;
padding: 0;
line-height: 1;
flex-shrink: 0;
}
.pdm-details-btn:hover {
background: rgba(144,238,144,0.12);
color: #90EE90;
border-color: rgba(144,238,144,0.4);
}
/* Scrollbar */
.pdm-modal::-webkit-scrollbar,
.pdm-vehicles-grid::-webkit-scrollbar,
.pdm-sessions-wrap::-webkit-scrollbar {
width: 6px;
}
.pdm-modal::-webkit-scrollbar-track,
.pdm-vehicles-grid::-webkit-scrollbar-track,
.pdm-sessions-wrap::-webkit-scrollbar-track {
background: transparent;
}
.pdm-modal::-webkit-scrollbar-thumb,
.pdm-vehicles-grid::-webkit-scrollbar-thumb,
.pdm-sessions-wrap::-webkit-scrollbar-thumb {
background: rgba(144,238,144,0.15);
border-radius: 3px;
}
@media (max-width: 640px) {
.pdm-stats-grid { grid-template-columns: repeat(2, 1fr); }
.pdm-vehicles-grid { grid-template-columns: 1fr; }
.pdm-sessions-table { font-size: 0.75rem; }
.pdm-sessions-table th,
.pdm-sessions-table td { padding: 0.45rem 0.4rem; }
}
`;
document.head.appendChild(style);
const overlay = document.createElement('div');
overlay.id = 'pdm-overlay';
overlay.className = 'pdm-overlay';
overlay.style.display = 'none';
overlay.innerHTML = `
<div class="pdm-modal" id="pdm-modal">
<div class="pdm-header">
<div class="pdm-header-left">
<span class="pdm-player-name" id="pdm-player-name"></span>
<a class="pdm-profile-link" id="pdm-profile-link" href="#">${T('playerModal.viewFullProfile')}</a>
</div>
<button class="pdm-close" id="pdm-close" title="${T('playerModal.close')}">&times;</button>
</div>
<div class="pdm-tabs">
<div class="pdm-tab-group" id="pdm-tab-group">
<div class="pdm-tab-slider" id="pdm-tab-slider"></div>
<button class="pdm-tab active" data-tab="overview">${T('playerModal.overview')}</button>
<button class="pdm-tab" data-tab="vehicles">${T('playerModal.vehicles')}</button>
<button class="pdm-tab" data-tab="sessions">${T('playerModal.sessions')}</button>
</div>
</div>
<div class="pdm-body" id="pdm-body">
<div class="pdm-loading">
<div class="pdm-spinner"></div>
<span>${T('playerModal.loadingPlayerData')}</span>
</div>
</div>
</div>
`;
document.body.appendChild(overlay);
// Events
overlay.addEventListener('click', function (e) {
if (e.target === overlay) closeModal();
});
document.getElementById('pdm-close').addEventListener('click', closeModal);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && overlay.style.display !== 'none') closeModal();
});
// Tab switching
document.getElementById('pdm-tab-group').addEventListener('click', function (e) {
const btn = e.target.closest('.pdm-tab');
if (!btn || btn.classList.contains('active')) return;
document.querySelectorAll('.pdm-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentTab = btn.dataset.tab;
updateSlider();
renderTab();
});
// Prevent modal scroll from propagating
document.getElementById('pdm-modal').addEventListener('click', function (e) {
e.stopPropagation();
});
}
function updateSlider() {
const group = document.getElementById('pdm-tab-group');
const slider = document.getElementById('pdm-tab-slider');
const active = group.querySelector('.pdm-tab.active');
if (!active || !slider) return;
slider.style.left = active.offsetLeft + 'px';
slider.style.width = active.offsetWidth + 'px';
}
let playerDataCache = null;
let gamesDataCache = null;
let historyDataCache = null;
let miniChart = null;
let miniChartMetric = 'kdr';
let currentKdrKps = 'kdr';
const miniChartMetrics = ['kdr', 'win_rate', 'battles'];
const miniChartConfig = {
kdr: { label: T('playerModal.kdr'), color: '#64b5f6', suffix: '' },
win_rate: { label: T('playerModal.winRate'), color: '#90EE90', suffix: '%' },
battles: { label: T('playerModal.battles'), color: '#ffb74d', suffix: '' }
};
function closeModal() {
const overlay = document.getElementById('pdm-overlay');
overlay.classList.remove('pdm-visible');
setTimeout(() => {
overlay.style.display = 'none';
document.body.style.overflow = '';
}, 200);
// Destroy mini chart to prevent canvas reuse issues
if (miniChart) {
miniChart.destroy();
miniChart = null;
}
}
function renderTab() {
const body = document.getElementById('pdm-body');
if (!playerDataCache) return;
// Destroy old mini chart before re-rendering
if (miniChart) {
miniChart.destroy();
miniChart = null;
}
if (currentTab === 'overview') {
body.innerHTML = renderOverview(playerDataCache);
initMiniChart();
} else if (currentTab === 'vehicles') {
body.innerHTML = renderVehicles(playerDataCache);
initVehicleKdrToggle();
} else if (currentTab === 'sessions') {
body.innerHTML = renderSessions(gamesDataCache);
}
}
function renderOverview(data) {
const vehicles = (data.vehicles || []).filter(v => v.vehicle !== 'DISCONNECTED');
let totalBattles = 0, wins = 0, groundKills = 0, airKills = 0, assists = 0, deaths = 0, captures = 0;
vehicles.forEach(v => {
const s = v.stats;
totalBattles += s.total_battles || 0;
wins += s.wins || 0;
groundKills += s.ground_kills || 0;
airKills += s.air_kills || 0;
assists += s.assists || 0;
deaths += s.deaths || 0;
captures += s.captures || 0;
});
const totalKills = groundKills + airKills;
const winRate = totalBattles > 0 ? ((wins / totalBattles) * 100).toFixed(1) + '%' : '0.0%';
const kdr = deaths > 0 ? (totalKills / deaths).toFixed(2) : totalKills.toFixed(2);
const kps = totalBattles > 0 ? (totalKills / totalBattles).toFixed(2) : '0.00';
const stats = [
[T('playerModal.totalBattles'), formatNumber(totalBattles)],
[T('playerModal.wins'), formatNumber(wins)],
[T('playerModal.winRate'), winRate],
[T('playerModal.totalKills'), formatNumber(totalKills)],
[T('playerModal.kdr'), kdr],
[T('playerModal.kps'), kps],
[T('playerModal.airKills'), formatNumber(airKills)],
[T('playerModal.groundKills'), formatNumber(groundKills)],
[T('playerModal.assists'), formatNumber(assists)],
[T('playerModal.deaths'), formatNumber(deaths)],
[T('playerModal.captures'), formatNumber(captures)],
];
const cfg = miniChartConfig[miniChartMetric];
const chartCard = `<div class="pdm-chart-card" id="pdm-chart-card" title="${T('playerModal.clickToSwitchMetric')}">
<div class="pdm-chart-label" id="pdm-chart-metric-label">${cfg.label}</div>
<div class="pdm-mini-canvas-wrap"><canvas class="pdm-mini-canvas" id="pdm-mini-canvas"></canvas></div>
<div class="pdm-chart-hint">${T('playerModal.clickToCycle')}</div>
</div>`;
const fullRows = stats.slice(0, Math.floor(stats.length / 3) * 3);
const remainder = stats.slice(fullRows.length);
const cardHtml = s => `<div class="pdm-stat-card"><div class="pdm-stat-value">${s[1]}</div><div class="pdm-stat-label">${s[0]}</div></div>`;
const lastRow = remainder.length ? `<div class="pdm-stats-grid-last-row">${remainder.map(cardHtml).join('')}</div>` : '';
return `<div class="pdm-stats-grid">${fullRows.map(cardHtml).join('')}</div>${lastRow}${chartCard}`;
}
function initVehicleKdrToggle() {
const toggle = document.getElementById('pdm-veh-kdr-toggle');
if (!toggle) return;
toggle.addEventListener('click', function (e) {
const span = e.target.closest('[data-mode]');
if (!span || span.classList.contains('pdm-toggle-active')) return;
currentKdrKps = span.dataset.mode;
renderTab();
});
}
function initMiniChart() {
const card = document.getElementById('pdm-chart-card');
if (!card) return;
card.addEventListener('click', function () {
const idx = miniChartMetrics.indexOf(miniChartMetric);
miniChartMetric = miniChartMetrics[(idx + 1) % miniChartMetrics.length];
renderMiniChart();
});
// Load history data if not cached, then render
if (historyDataCache) {
renderMiniChart();
} else if (currentUid) {
window.apiClient.request('/api/player/' + currentUid + '/history').then(data => {
historyDataCache = data;
renderMiniChart();
}).catch(() => {
const canvas = document.getElementById('pdm-mini-canvas');
if (canvas) {
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.font = '11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(T('playerModal.noChartData'), canvas.width / 2, canvas.height / 2);
}
});
}
}
function renderMiniChart() {
if (!historyDataCache || !historyDataCache.history || !historyDataCache.history.length) return;
if (typeof Chart === 'undefined') return;
const canvas = document.getElementById('pdm-mini-canvas');
const label = document.getElementById('pdm-chart-metric-label');
if (!canvas) return;
const cfg = miniChartConfig[miniChartMetric];
if (label) label.textContent = cfg.label;
const history = historyDataCache.history;
const dataPoints = history
.filter(d => d[miniChartMetric] != null)
.map(d => ({ x: new Date(d.period + 'T00:00:00Z').getTime(), y: d[miniChartMetric] }));
if (!dataPoints.length) return;
if (miniChart) {
miniChart.data.datasets[0].data = dataPoints;
miniChart.data.datasets[0].borderColor = cfg.color;
miniChart.data.datasets[0].backgroundColor = cfg.color + '18';
miniChart.data.datasets[0].pointBackgroundColor = cfg.color;
miniChart.options.plugins.tooltip.borderColor = cfg.color;
miniChart.options.plugins.tooltip.bodyColor = cfg.color;
miniChart.options.plugins.tooltip.callbacks.label = ctx => `${cfg.label}: ${ctx.parsed.y}${cfg.suffix}`;
miniChart.update();
return;
}
miniChart = new Chart(canvas.getContext('2d'), {
type: 'line',
data: {
datasets: [{
data: dataPoints,
borderColor: cfg.color,
backgroundColor: cfg.color + '18',
borderWidth: 1.5,
pointBackgroundColor: cfg.color,
pointRadius: 0,
pointHoverRadius: 3,
tension: 0.15,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 300 },
plugins: {
legend: { display: false },
tooltip: {
enabled: true,
backgroundColor: 'rgba(15,15,15,0.92)',
borderColor: cfg.color,
borderWidth: 1,
titleColor: 'rgba(255,255,255,0.75)',
bodyColor: cfg.color,
padding: 6,
displayColors: false,
callbacks: {
title: () => '',
label: ctx => `${cfg.label}: ${ctx.parsed.y}${cfg.suffix}`
}
}
},
scales: {
x: { type: 'linear', display: false, min: (function() { var y = new Date(history[0].period + 'T00:00:00Z').getUTCFullYear(); return Date.UTC(y, 0, 1); })() },
y: { display: false, beginAtZero: true }
}
}
});
}
let currentUid = null;
function renderVehicles(data) {
const vehicles = (data.vehicles || []).filter(v => v.vehicle !== 'DISCONNECTED');
if (!vehicles.length) return `<div class="pdm-no-data"><i class="fas fa-box-open" style="font-size:2rem;margin-bottom:0.5rem;display:block;"></i>${T('playerModal.noVehicleData')}</div>`;
// Sort by total battles desc
vehicles.sort((a, b) => (b.stats.total_battles || 0) - (a.stats.total_battles || 0));
// Find best WR vehicle with >15 games
let bestWrVehicle = null;
let bestWr = -1;
vehicles.forEach(v => {
const battles = v.stats.total_battles || 0;
if (battles > 15) {
const wr = (v.stats.wins || 0) / battles;
if (wr > bestWr) {
bestWr = wr;
bestWrVehicle = v.vehicle;
}
}
});
const mode = currentKdrKps || 'kdr';
const kdrCls = mode === 'kdr' ? 'pdm-toggle-active' : 'pdm-toggle-inactive';
const kpsCls = mode === 'kps' ? 'pdm-toggle-active' : 'pdm-toggle-inactive';
const toggleHeader = `<div class="pdm-stat-toggle" id="pdm-veh-kdr-toggle" style="margin-bottom:0.5rem;">
<span class="${kdrCls}" data-mode="kdr">KDR</span>
<span class="pdm-toggle-sep">/</span>
<span class="${kpsCls}" data-mode="kps">KPS</span>
</div>`;
return `${toggleHeader}<div class="pdm-vehicles-grid">${vehicles.map(v => {
const s = v.stats;
const totalKills = (s.ground_kills || 0) + (s.air_kills || 0);
const battles = s.total_battles || 0;
const wins = s.wins || 0;
const wrNum = battles > 0 ? (wins / battles) * 100 : 0;
const wr = wrNum.toFixed(1) + '%';
const kdrNum = s.deaths > 0 ? totalKills / s.deaths : totalKills;
const kdr = kdrNum.toFixed(2);
const kpsNum = battles > 0 ? totalKills / battles : 0;
const kps = kpsNum.toFixed(2);
const isCrown = v.vehicle === bestWrVehicle;
const crownClass = isCrown ? ' pdm-crown-card' : '';
const crownIcon = isCrown ? '<i class="fas fa-crown pdm-crown"></i>' : '';
const wrColor = getWinRateColor(wrNum);
const activeVal = mode === 'kdr' ? kdr : kps;
const activeNum = mode === 'kdr' ? kdrNum : kpsNum;
const activeColor = getKDRColor(activeNum);
const activeLabel = mode === 'kdr' ? T('playerModal.kdr') : T('playerModal.kps');
return `<div class="pdm-vehicle-card${crownClass}">
<div class="pdm-vehicle-name" title="${escapeHtml(v.vehicle)}">${crownIcon}${escapeHtml(v.vehicle)}</div>
<div class="pdm-vehicle-stats">
<span>${T('playerModal.battles')}: <span class="pdm-vs-val" style="color:#90EE90">${battles}</span></span>
<span>${T('playerModal.wins')}: <span class="pdm-vs-val" style="color:#90EE90">${wins}</span></span>
<span>${T('playerModal.winRate')}: <span class="pdm-vs-val" style="color:${wrColor}">${wr}</span></span>
<span>${activeLabel}: <span class="pdm-vs-val" style="color:${activeColor}">${activeVal}</span></span>
<span>${T('playerModal.ground')}: <span class="pdm-vs-val" style="color:#90EE90">${s.ground_kills || 0}</span></span>
<span>${T('playerModal.air')}: <span class="pdm-vs-val" style="color:#90EE90">${s.air_kills || 0}</span></span>
<span>${T('playerModal.assists')}: <span class="pdm-vs-val" style="color:#90EE90">${s.assists || 0}</span></span>
<span>${T('playerModal.deaths')}: <span class="pdm-vs-val" style="color:#90EE90">${s.deaths || 0}</span></span>
</div>
</div>`;
}).join('')}</div>`;
}
function renderSessions(gamesData) {
if (!gamesData || !gamesData.games || !gamesData.games.length) {
return `<div class="pdm-no-data"><i class="fas fa-history" style="font-size:2rem;margin-bottom:0.5rem;display:block;"></i>${T('playerModal.noSessionData')}</div>`;
}
const games = gamesData.games
.slice()
.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))
.slice(0, 50);
const rows = games.map(g => {
const date = new Date(g.timestamp * 1000);
const fmt = date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const result = g.result || T('playerModal.unknown');
const isWin = result.toLowerCase() === 'win';
const badgeClass = isWin ? 'pdm-result-win' : 'pdm-result-loss';
return `<tr>
<td>${fmt}</td>
<td>${escapeHtml(g.vehicle || g.vehicle_internal || T('playerModal.unknown'))}</td>
<td>${g.stats.ground_kills || 0}</td>
<td>${g.stats.air_kills || 0}</td>
<td>${g.stats.assists || 0}</td>
<td>${g.stats.deaths || 0}</td>
<td><span class="pdm-result-badge ${badgeClass}">${result}</span></td>
</tr>`;
}).join('');
return `<div class="pdm-sessions-wrap"><table class="pdm-sessions-table">
<thead><tr>
<th>${T('playerModal.date')}</th><th>${T('playerModal.vehicle')}</th><th>${T('playerModal.ground')}</th><th>${T('playerModal.air')}</th><th>${T('playerModal.assists')}</th><th>${T('playerModal.deaths')}</th><th>${T('playerModal.result')}</th>
</tr></thead>
<tbody>${rows}</tbody>
</table></div>`;
}
window.openPlayerDetailsModal = async function (uid, nick) {
injectModal();
const overlay = document.getElementById('pdm-overlay');
const body = document.getElementById('pdm-body');
const nameEl = document.getElementById('pdm-player-name');
const linkEl = document.getElementById('pdm-profile-link');
// Reset state
currentTab = 'overview';
miniChartMetric = 'kdr';
currentKdrKps = 'kdr';
playerDataCache = null;
gamesDataCache = null;
historyDataCache = null;
currentUid = uid;
if (miniChart) {
miniChart.destroy();
miniChart = null;
}
document.querySelectorAll('.pdm-tab').forEach(b => b.classList.remove('active'));
document.querySelector('.pdm-tab[data-tab="overview"]').classList.add('active');
nameEl.textContent = nick || uid;
linkEl.href = '/players/' + encodeURIComponent(uid);
body.innerHTML = `<div class="pdm-loading"><div class="pdm-spinner"></div><span>${T('playerModal.loadingPlayerData')}</span></div>`;
overlay.style.display = 'flex';
document.body.style.overflow = 'hidden';
// Trigger animation
requestAnimationFrame(() => {
overlay.classList.add('pdm-visible');
updateSlider();
});
try {
const [playerData, gamesData] = await Promise.all([
window.apiClient.getPlayer(uid),
window.apiClient.getPlayerGames(uid)
]);
playerDataCache = playerData;
gamesDataCache = gamesData;
renderTab();
} catch (err) {
console.error('[Player Details Modal] Error:', err);
body.innerHTML = `<div class="pdm-error"><i class="fas fa-exclamation-triangle" style="display:block;margin-bottom:0.5rem;"></i>${T('playerModal.failedToLoadPlayerData')}</div>`;
}
};
})();
-91
View File
@@ -1,91 +0,0 @@
(function () {
'use strict';
const modal = document.getElementById('season-recap-modal');
const btn = document.getElementById('season-recap-btn');
const selectEl = document.getElementById('season-recap-select');
const genBtn = document.getElementById('season-recap-generate');
const img = document.getElementById('season-recap-image');
const statusEl = document.getElementById('season-recap-status');
if (!modal || !btn || !selectEl || !genBtn || !img) {
console.warn('[recap-player] modal elements not found; skipping init');
return;
}
const scriptTag = document.currentScript;
const uid = scriptTag && scriptTag.dataset.uid;
if (!uid) {
console.warn('[recap-player] no uid on script tag; disabling button');
btn.disabled = true;
return;
}
function openModal() {
modal.classList.remove('hidden');
modal.setAttribute('aria-hidden', 'false');
}
function closeModal() {
modal.classList.add('hidden');
modal.setAttribute('aria-hidden', 'true');
}
modal.querySelectorAll('[data-close]').forEach(el => el.addEventListener('click', closeModal));
btn.addEventListener('click', async () => {
openModal();
if (!selectEl.options.length) await loadSeasons();
});
function t(key) {
return (window.__t && window.__t('seasonCard.' + key)) || key;
}
async function loadSeasons() {
statusEl.textContent = t('loadingSeasons');
try {
if (!window.apiClient) throw new Error('apiClient not available');
const seasons = await window.apiClient.request('/api/seasons');
const entries = Object.entries(seasons).sort((a, b) => b[1].start - a[1].start);
selectEl.innerHTML = '';
for (const [name, range] of entries) {
const opt = document.createElement('option');
opt.value = name;
opt.textContent = range.status === 'in_progress'
? `${name} ${t('inProgressSuffix')}`
: name;
selectEl.appendChild(opt);
}
statusEl.textContent = '';
} catch (err) {
console.error('[recap-player] seasons fetch failed:', err);
statusEl.textContent = t('failedSeasons');
}
}
function selectedTheme() {
const checked = document.querySelector('input[name="season-recap-theme"]:checked');
return (checked && checked.value) || 'dark';
}
genBtn.addEventListener('click', () => {
const season = selectEl.value;
if (!season) return;
const theme = selectedTheme();
const lang = document.documentElement.lang || 'en';
const base = `/players/${encodeURIComponent(uid)}/recap/${encodeURIComponent(season)}.png`;
const params = new URLSearchParams({ theme, lang });
statusEl.textContent = t('generating');
img.style.display = 'none';
img.onload = () => {
statusEl.textContent = '';
img.style.display = 'block';
};
img.onerror = () => {
statusEl.textContent = t('failedGenerate');
};
const currentOpt = selectEl.options[selectEl.selectedIndex];
const inProgressLabel = t('inProgressSuffix');
const isInProgress = !!(currentOpt && inProgressLabel && currentOpt.textContent.indexOf(inProgressLabel) !== -1);
if (isInProgress) params.set('t', String(Date.now()));
img.src = `${base}?${params.toString()}`;
});
})();
-93
View File
@@ -1,93 +0,0 @@
(function () {
'use strict';
const modal = document.getElementById('season-recap-modal');
const btn = document.getElementById('season-recap-btn');
const selectEl = document.getElementById('season-recap-select');
const genBtn = document.getElementById('season-recap-generate');
const img = document.getElementById('season-recap-image');
const statusEl = document.getElementById('season-recap-status');
if (!modal || !btn || !selectEl || !genBtn || !img) {
console.warn('[recap] modal elements not found; skipping init');
return;
}
const scriptTag = document.currentScript;
const clanId = scriptTag && scriptTag.dataset.clanId;
if (!clanId) {
console.warn('[recap] no clan_id on script tag; disabling button');
btn.disabled = true;
return;
}
function openModal() {
modal.classList.remove('hidden');
modal.setAttribute('aria-hidden', 'false');
}
function closeModal() {
modal.classList.add('hidden');
modal.setAttribute('aria-hidden', 'true');
}
modal.querySelectorAll('[data-close]').forEach(el => el.addEventListener('click', closeModal));
btn.addEventListener('click', async () => {
openModal();
if (!selectEl.options.length) await loadSeasons();
});
function t(key) {
return (window.__t && window.__t('seasonCard.' + key)) || key;
}
async function loadSeasons() {
statusEl.textContent = t('loadingSeasons');
try {
if (!window.apiClient) {
throw new Error('apiClient not available');
}
const seasons = await window.apiClient.request('/api/seasons');
const entries = Object.entries(seasons).sort((a, b) => b[1].start - a[1].start);
selectEl.innerHTML = '';
for (const [name, range] of entries) {
const opt = document.createElement('option');
opt.value = name;
opt.textContent = range.status === 'in_progress'
? `${name} ${t('inProgressSuffix')}`
: name;
selectEl.appendChild(opt);
}
statusEl.textContent = '';
} catch (err) {
console.error('[recap] seasons fetch failed:', err);
statusEl.textContent = t('failedSeasons');
}
}
function selectedTheme() {
const checked = document.querySelector('input[name="season-recap-theme"]:checked');
return (checked && checked.value) || 'dark';
}
genBtn.addEventListener('click', () => {
const season = selectEl.value;
if (!season) return;
const theme = selectedTheme();
const lang = document.documentElement.lang || 'en';
const base = `/squadron/${encodeURIComponent(clanId)}/recap/${encodeURIComponent(season)}.png`;
const params = new URLSearchParams({ theme, lang });
statusEl.textContent = t('generating');
img.style.display = 'none';
img.onload = () => {
statusEl.textContent = '';
img.style.display = 'block';
};
img.onerror = () => {
statusEl.textContent = t('failedGenerate');
};
const currentOpt = selectEl.options[selectEl.selectedIndex];
const inProgressLabel = t('inProgressSuffix');
const isInProgress = !!(currentOpt && inProgressLabel && currentOpt.textContent.indexOf(inProgressLabel) !== -1);
if (isInProgress) params.set('t', String(Date.now()));
img.src = `${base}?${params.toString()}`;
});
})();
-900
View File
@@ -1,900 +0,0 @@
/**
* replay-canvas.js
*
* Interactive HTML5 Canvas replay viewer for War Thunder GOB replays.
*/
const RC = {
TRAIL_MS: 18000, AIR_TRAIL_MS: 4000, DRONE_TRAIL_MS: 2000,
KILL_TTL: 8000, DMG_TTL: 4000, GHOST_TTL: 3000, DEFAULT_SPEED: 4,
WIN: '#00c800', LOSE: '#dc1e1e',
WIN_DIM: 'rgba(0,200,0,', LOSE_DIM: 'rgba(220,30,30,',
WIN_TRAIL: 'rgba(0,120,0,', LOSE_TRAIL: 'rgba(132,18,18,',
DOT_R: 5, AIR_R: 4, DRONE_R: 3,
};
function replayT(key) {
return (window.__t && window.__t(key)) || key;
}
class ReplayCanvas {
constructor(containerEl, data) {
this.container = containerEl;
this.data = data;
this.playing = false;
this.speed = RC.DEFAULT_SPEED;
this.currentTime = 0;
this.tStart = Infinity;
this.tEnd = -Infinity;
this.lastFrameTime = 0;
this.highlightedPlayerId = null;
this.animFrameId = null;
this.canvasSize = 720;
this.canvas = null;
this.ctx = null;
this.mapCanvas = null;
this.mapCtx = null;
// Store both coordinate sets
this._groundCoords = data.levelCoords;
this._airCoords = data.mapCoords || null;
this._fullMapLevel = data.fullMapLevel || null;
this._mode = 'ground';
const hasAircraft = data.entities.some(e => e.type === 'aircraft');
this.hasAirMode = !!(this._airCoords && this._fullMapLevel && hasAircraft);
this.x0 = data.levelCoords.x0;
this.z0 = data.levelCoords.z0;
this.xRange = data.levelCoords.x1 - data.levelCoords.x0;
this.zRange = data.levelCoords.z1 - data.levelCoords.z0;
// Default map source rect (full image) — overwritten by _computeAutocrop
this._mapSrc = { u: 0, v: 0, w: 1, h: 1 };
this.players = {};
for (const p of data.players) this.players[p.id] = p;
this.entities = [];
for (const e of data.entities) {
if (!e.path || e.path.length === 0) continue;
const times = new Float64Array(e.path.length);
const positions = new Float32Array(e.path.length * 2);
for (let i = 0; i < e.path.length; i++) {
times[i] = e.path[i].t;
positions[i * 2] = e.path[i].x;
positions[i * 2 + 1] = e.path[i].z;
}
if (times[0] < this.tStart) this.tStart = times[0];
if (times[times.length - 1] > this.tEnd) this.tEnd = times[times.length - 1];
const isWinner = e.playerId > 0
? (this.players[e.playerId]?.team === data.teamWon)
: (e.droneTeam === data.teamWon);
this.entities.push({
...e, times, positions, isWinner,
deathTime: null, ghostEndTime: null, deathPos: null,
});
}
// Zoom into the area players actually use (like the video maker autocrop)
this._computeAutocrop();
// Pre-compute deaths
this._computeDeaths();
this.currentTime = this.tStart;
}
_computeDeaths() {
// Reset deaths so they can be recomputed after coord changes
for (const ent of this.entities) {
ent.deathTime = null;
ent.ghostEndTime = null;
ent.deathPos = null;
}
for (const k of this.data.kills) {
for (const ent of this.entities) {
const matched = (k.victimEntityIndex && ent.entityIndex === k.victimEntityIndex)
|| (k.victimId && ent.playerId === k.victimId && ent.playerId !== 0);
if (matched && ent.deathTime === null) {
ent.deathTime = k.time;
ent.ghostEndTime = k.time + RC.GHOST_TTL;
if (k.victimPos) ent.deathPos = this.worldToPixel(k.victimPos.x, k.victimPos.z);
break;
}
}
}
}
worldToPixel(x, z) {
return [
(x - this.x0) / this.xRange * this.canvasSize,
(this.z0 + this.zRange - z) / this.zRange * this.canvasSize
];
}
/** Recompute x0/z0/xRange/zRange to zoom into entity activity.
* In 'ground' mode crops to ground entities, in 'air' mode crops to aircraft+drones. */
_computeAutocrop() {
const origX0 = this.x0, origZ0 = this.z0;
const origXR = this.xRange, origZR = this.zRange;
const airMode = this._mode === 'air';
let minX = Infinity, maxX = -Infinity;
let minZ = Infinity, maxZ = -Infinity;
for (const ent of this.entities) {
if (airMode) {
if (ent.type !== 'aircraft' && ent.type !== 'drone') continue;
} else {
if (ent.type !== 'ground') continue;
}
const { positions } = ent;
for (let i = 0; i < positions.length; i += 2) {
const x = positions[i], z = positions[i + 1];
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (z < minZ) minZ = z;
if (z > maxZ) maxZ = z;
}
}
if (!isFinite(minX)) return; // no relevant positions — keep full map
// Padding: 10% of span or 50 world units, whichever is larger
const span = Math.max(maxX - minX, maxZ - minZ);
const pad = Math.max(50, span * 0.10);
minX -= pad; maxX += pad;
minZ -= pad; maxZ += pad;
// Expand to square, with a minimum of 15% of the map range
const minSide = Math.max(origXR, origZR) * 0.15;
let side = Math.max(maxX - minX, maxZ - minZ, minSide);
const midX = (minX + maxX) / 2, midZ = (minZ + maxZ) / 2;
minX = midX - side / 2; maxX = midX + side / 2;
minZ = midZ - side / 2; maxZ = midZ + side / 2;
// Clamp to LevelDef bounds (shift first, then hard-clamp)
const x1 = origX0 + origXR, z1 = origZ0 + origZR;
if (minX < origX0) { maxX += origX0 - minX; minX = origX0; }
if (maxX > x1) { minX -= maxX - x1; maxX = x1; }
if (minZ < origZ0) { maxZ += origZ0 - minZ; minZ = origZ0; }
if (maxZ > z1) { minZ -= maxZ - z1; maxZ = z1; }
minX = Math.max(minX, origX0); maxX = Math.min(maxX, x1);
minZ = Math.max(minZ, origZ0); maxZ = Math.min(maxZ, z1);
// Fractional source rect for the minimap image
// Image top-left = world (origX0, origZ0+origZR), bottom-right = (origX0+origXR, origZ0)
this._mapSrc = {
u: (minX - origX0) / origXR,
v: (z1 - maxZ) / origZR,
w: (maxX - minX) / origXR,
h: (maxZ - minZ) / origZR,
};
// Apply new bounds — worldToPixel will now map this sub-region to the full canvas
this.x0 = minX;
this.z0 = minZ;
this.xRange = maxX - minX;
this.zRange = maxZ - minZ;
}
getPositionAtTime(entity, time) {
const { times, positions } = entity;
if (time < times[0] || time > times[times.length - 1]) return null;
let lo = 0, hi = times.length - 1;
while (lo < hi - 1) {
const mid = (lo + hi) >> 1;
if (times[mid] <= time) lo = mid; else hi = mid;
}
const t0 = times[lo], t1 = times[hi];
const frac = t1 > t0 ? (time - t0) / (t1 - t0) : 0;
const i0 = lo * 2, i1 = hi * 2;
return this.worldToPixel(
positions[i0] + (positions[i1] - positions[i0]) * frac,
positions[i0 + 1] + (positions[i1 + 1] - positions[i0 + 1]) * frac
);
}
getHeadingAtTime(entity, time) {
// Compute heading in radians (0=up/north, CW) from position delta
const dt = 500; // sample window in game ms
const p0 = this.getPositionAtTime(entity, time - dt);
const p1 = this.getPositionAtTime(entity, time);
if (!p0 || !p1) return null;
const dx = p1[0] - p0[0];
const dy = p1[1] - p0[1];
if (Math.abs(dx) < 0.1 && Math.abs(dy) < 0.1) return null;
return Math.atan2(dx, -dy); // 0=up, CW positive
}
_entityScreenPos(entity, time) {
if (entity.deathTime !== null && time >= entity.deathTime) return entity.deathPos;
return this.getPositionAtTime(entity, time);
}
_isEntityDead(entity, time) {
return entity.deathTime !== null && time >= entity.deathTime;
}
_isEntityGone(entity, time) {
return entity.ghostEndTime !== null && time >= entity.ghostEndTime;
}
async init() {
this._buildDOM();
await Promise.all([this._loadMap(), this._loadEntityIcons()]);
this.playing = true;
this.playBtn.innerHTML = '<i class="fas fa-pause"></i>';
this.lastFrameTime = performance.now();
this._tick = this._tick.bind(this);
this.animFrameId = requestAnimationFrame(this._tick);
}
_buildDOM() {
this.container.innerHTML = '';
const layout = document.createElement('div');
layout.className = 'rc-layout';
// Left panel (winners)
this.leftPanel = document.createElement('div');
this.leftPanel.className = 'rc-panel rc-panel-win';
this._buildTeamPanel(this.leftPanel, true);
// Center
const center = document.createElement('div');
center.className = 'rc-center';
this.canvas = document.createElement('canvas');
this.canvas.width = this.canvasSize;
this.canvas.height = this.canvasSize;
this.canvas.className = 'rc-canvas';
this.ctx = this.canvas.getContext('2d');
center.appendChild(this.canvas);
// Controls
const controls = document.createElement('div');
controls.className = 'rc-controls';
controls.innerHTML = `
<button class="rc-btn rc-play" title="${replayT('replay.playPause')}"><i class="fas fa-play"></i></button>
<div class="rc-speeds">
<button class="rc-btn rc-sp" data-speed="1">1x</button>
<button class="rc-btn rc-sp" data-speed="2">2x</button>
<button class="rc-btn rc-sp active" data-speed="4">4x</button>
<button class="rc-btn rc-sp" data-speed="8">8x</button>
</div>
<input type="range" class="rc-scrub" min="0" max="1000" value="0">
<span class="rc-time">0:00 / 0:00</span>
`;
center.appendChild(controls);
// Battle log
const logWrap = document.createElement('div');
logWrap.className = 'rc-log-wrap';
logWrap.innerHTML = '<div class="rc-log" id="rcBattleLog"></div>';
center.appendChild(logWrap);
this.battleLog = logWrap.querySelector('#rcBattleLog');
// Pre-build sorted event list for the log
this._buildEventList();
// Right panel (losers)
this.rightPanel = document.createElement('div');
this.rightPanel.className = 'rc-panel rc-panel-lose';
this._buildTeamPanel(this.rightPanel, false);
layout.appendChild(this.leftPanel);
layout.appendChild(center);
layout.appendChild(this.rightPanel);
this.container.appendChild(layout);
// Wire controls
this.playBtn = controls.querySelector('.rc-play');
this.scrubber = controls.querySelector('.rc-scrub');
this.timeDisplay = controls.querySelector('.rc-time');
this.playBtn.addEventListener('click', () => this._togglePlay());
this.scrubber.addEventListener('input', () => {
this.currentTime = this.tStart + (this.scrubber.value / 1000) * (this.tEnd - this.tStart);
this._updatePanelDeathStates();
this._updateBattleLog();
this.render();
});
controls.querySelectorAll('.rc-sp').forEach(btn => {
btn.addEventListener('click', () => {
controls.querySelectorAll('.rc-sp').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
this.speed = parseInt(btn.dataset.speed);
});
});
// Canvas hover — store mouse pos, re-evaluate each frame
this._mouseOnCanvas = false;
this._mouseX = 0;
this._mouseY = 0;
this.canvas.addEventListener('mousemove', (ev) => {
this._mouseOnCanvas = true;
const rect = this.canvas.getBoundingClientRect();
this._mouseX = (ev.clientX - rect.left) * (this.canvasSize / rect.width);
this._mouseY = (ev.clientY - rect.top) * (this.canvasSize / rect.height);
});
this.canvas.addEventListener('mouseleave', () => {
this._mouseOnCanvas = false;
this._setHighlight(null);
});
// Offscreen map canvas
this.mapCanvas = document.createElement('canvas');
this.mapCanvas.width = this.canvasSize;
this.mapCanvas.height = this.canvasSize;
this.mapCtx = this.mapCanvas.getContext('2d');
}
_buildTeamPanel(panel, isWinner) {
// Show all players on this team, using their first entity (prefer ground)
const teamEntities = this.entities.filter(e => e.playerId > 0 && e.isWinner === isWinner);
const seen = new Set();
const unique = [];
// First pass: ground entities
for (const e of teamEntities) {
if (e.type === 'ground' && !seen.has(e.playerId)) { seen.add(e.playerId); unique.push(e); }
}
// Second pass: any remaining players (aircraft etc)
for (const e of teamEntities) {
if (!seen.has(e.playerId)) { seen.add(e.playerId); unique.push(e); }
}
const color = isWinner ? RC.WIN : RC.LOSE;
const dimColor = isWinner ? 'rgba(0,200,0,0.15)' : 'rgba(220,30,30,0.15)';
// Use squadron clan tag rendered with skyquake font
const firstPlayer = unique.length > 0 ? this.players[unique[0].playerId] : null;
const clanTag = firstPlayer?.clan || '';
const label = clanTag
? `<span class="rc-clan-tag">${this._esc(clanTag)}</span>`
: (isWinner ? 'Winners' : 'Losers');
let html = `<div class="rc-panel-head" style="border-bottom-color:${dimColor}">
<span class="rc-panel-label" style="color:${color}">${label}</span>
</div><div class="rc-panel-list">`;
for (const ent of unique) {
const p = this.players[ent.playerId];
const name = p ? this._esc(p.name) : '?';
const veh = this._esc(ent.vehicleName);
const panelIcon = ent.miniIcon ? ent.miniIcon.replace('mini:', '') : (ent.iconKey || 'medium');
html += `<div class="rc-row" data-player-id="${ent.playerId}" data-entity-index="${ent.entityIndex}">
<img class="rc-type-icon" src="/api/icons/type/${panelIcon}" alt="" loading="lazy" onerror="this.style.display='none'">
<div class="rc-row-info">
<span class="rc-row-name">${name}</span>
<span class="rc-row-veh">${veh}</span>
</div>
<span class="rc-row-status"></span>
</div>`;
}
html += '</div>';
panel.innerHTML = html;
// Hover
panel.querySelectorAll('.rc-row').forEach(row => {
row.addEventListener('mouseenter', () => {
const pid = parseInt(row.dataset.playerId);
const eidx = parseInt(row.dataset.entityIndex);
const ent = this.entities.find(e => e.entityIndex === eidx);
if (ent && !this._isEntityGone(ent, this.currentTime)) {
this._setHighlight(pid);
}
});
row.addEventListener('mouseleave', () => this._setHighlight(null));
});
}
_updatePanelDeathStates() {
const t = this.currentTime;
this.container.querySelectorAll('.rc-row').forEach(row => {
const eidx = parseInt(row.dataset.entityIndex);
const ent = this.entities.find(e => e.entityIndex === eidx);
if (!ent) return;
const dead = this._isEntityDead(ent, t);
const gone = this._isEntityGone(ent, t);
row.classList.toggle('rc-dead', dead);
row.classList.toggle('rc-gone', gone);
const status = row.querySelector('.rc-row-status');
if (gone) {
status.innerHTML = '<i class="fas fa-skull" style="opacity:0.3"></i>';
row.style.cursor = 'default';
} else if (dead) {
status.innerHTML = '<i class="fas fa-skull" style="opacity:0.5"></i>';
row.style.cursor = 'default';
} else {
status.innerHTML = '';
row.style.cursor = 'pointer';
}
});
}
_setHighlight(playerId) {
if (this.highlightedPlayerId === playerId) return;
this.highlightedPlayerId = playerId;
this.container.querySelectorAll('.rc-row').forEach(row => {
row.classList.toggle('rc-hl', parseInt(row.dataset.playerId) === playerId);
});
if (!this.playing) this.render();
}
_esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
_buildEventList() {
// Merge kills and damages into a single sorted timeline
this._events = [];
for (const k of this.data.kills) {
const killer = this.players[k.killerId];
// Find victim name
let victimName = '?';
let victimTeam = -1;
if (k.victimId && this.players[k.victimId]) {
victimName = this.players[k.victimId].name;
victimTeam = this.players[k.victimId].team;
} else if (k.victimVehicle) {
victimName = k.victimVehicle;
}
let html;
if (!killer) {
// No killer (crash / environment kill)
const victimIsWin = victimTeam === this.data.teamWon;
html = `<span class="${victimIsWin ? 'rc-ev-win' : 'rc-ev-lose'}">${this._esc(victimName)}</span>`
+ `<span class="rc-ev-action"> ${replayT('replay.crashed')}</span>`;
} else {
const killerIsWin = killer.team === this.data.teamWon;
html = `<span class="${killerIsWin ? 'rc-ev-win' : 'rc-ev-lose'}">${this._esc(killer.name)}</span>`
+ `<span class="rc-ev-action"> ${replayT('replay.destroyed')} </span>`
+ `<span class="${killerIsWin ? 'rc-ev-lose' : 'rc-ev-win'}">${this._esc(victimName)}</span>`
+ (k.weapon ? `<span class="rc-ev-weapon">[${this._esc(k.weapon)}]</span>` : '');
}
this._events.push({
time: k.time,
type: 'kill',
html,
});
}
for (const dm of this.data.damages) {
const atk = this.players[dm.offenderId];
const vic = this.players[dm.offendedId];
if (!atk || !vic) continue;
const atkIsWin = atk.team === this.data.teamWon;
this._events.push({
time: dm.time,
type: 'damage',
html: `<span class="${atkIsWin ? 'rc-ev-win' : 'rc-ev-lose'}">${this._esc(atk.name)}</span>`
+ `<span class="rc-ev-action"> ${replayT('replay.hit')} </span>`
+ `<span class="${atkIsWin ? 'rc-ev-lose' : 'rc-ev-win'}">${this._esc(vic.name)}</span>`,
});
}
this._events.sort((a, b) => a.time - b.time);
this._lastLogIndex = -1;
}
_updateBattleLog() {
const t = this.currentTime;
// Find how many events should be visible
let idx = -1;
for (let i = 0; i < this._events.length; i++) {
if (this._events[i].time <= t) idx = i;
else break;
}
if (idx === this._lastLogIndex) return;
this._lastLogIndex = idx;
// Rebuild log content
const log = this.battleLog;
log.innerHTML = '';
for (let i = 0; i <= idx; i++) {
const ev = this._events[i];
const el = document.createElement('div');
el.className = `rc-ev rc-ev-${ev.type}`;
const elapsed = (ev.time - this.tStart) / 1000;
const mm = Math.floor(elapsed / 60);
const ss = Math.floor(elapsed % 60);
el.innerHTML = `<span class="rc-ev-time">${mm}:${String(ss).padStart(2,'0')}</span>${ev.html}`;
log.appendChild(el);
}
// Auto-scroll to bottom
log.scrollTop = log.scrollHeight;
}
_getTintedIcon(iconKey, color, size) {
const cacheKey = `${iconKey}_${color}_${size}`;
if (!this._tintCache) this._tintCache = {};
if (this._tintCache[cacheKey]) return this._tintCache[cacheKey];
const img = this._iconCache?.[iconKey];
if (!img || !img.naturalWidth) return null;
try {
const c = document.createElement('canvas');
c.width = size; c.height = size;
const cx = c.getContext('2d');
const [dx, dy, dw, dh] = this._containedImageRect(img, size);
cx.drawImage(img, dx, dy, dw, dh);
cx.globalCompositeOperation = 'source-atop';
cx.fillStyle = color;
cx.fillRect(0, 0, size, size);
cx.globalCompositeOperation = 'source-over';
this._tintCache[cacheKey] = c;
return c;
} catch (e) {
// CORS or other canvas tainting — fall back to untinted
this._tintCache[cacheKey] = null;
return null;
}
}
_containedImageRect(img, size) {
const ratio = img.naturalWidth && img.naturalHeight ? img.naturalWidth / img.naturalHeight : 1;
let w = size, h = size;
if (ratio > 1) h = size / ratio;
else w = size * ratio;
return [(size - w) / 2, (size - h) / 2, w, h];
}
_drawContainedIcon(ctx, img, x, y, size) {
const [dx, dy, dw, dh] = this._containedImageRect(img, size);
ctx.drawImage(img, x - size / 2 + dx, y - size / 2 + dy, dw, dh);
}
async _loadEntityIcons() {
this._iconCache = {};
this._iconDebug = {};
const keysToLoad = new Set();
for (const ent of this.entities) {
if (ent.miniIcon) {
const miniKey = ent.miniIcon.replace('mini:', '');
keysToLoad.add(miniKey);
ent._canvasIconKey = miniKey;
} else if (ent.iconKey) {
keysToLoad.add(ent.iconKey);
ent._canvasIconKey = ent.iconKey;
}
}
const promises = [];
for (const key of keysToLoad) {
promises.push(new Promise((resolve) => {
const img = new Image();
img.onload = () => {
this._iconCache[key] = img;
this._iconDebug[key] = 'ok';
resolve();
};
img.onerror = () => {
this._iconDebug[key] = 'err';
resolve();
};
img.src = `/api/icons/type/${key}`;
}));
}
await Promise.all(promises);
}
async _loadMap() {
const level = this.data.mission?.level;
if (!level) { this.mapCtx.fillStyle = '#111'; this.mapCtx.fillRect(0, 0, this.canvasSize, this.canvasSize); return; }
const loadImg = (src) => new Promise((resolve) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = () => resolve(null);
img.src = src;
});
// Load ground (tank) map
this._groundMapImg = await loadImg(`/api/match/minimap/${level}`);
// Load full (air) map if available
if (this._fullMapLevel) {
this._airMapImg = await loadImg(`/api/match/minimap/${this._fullMapLevel}?type=full`);
} else {
this._airMapImg = null;
}
this._drawMapToCanvas();
}
_drawMapToCanvas() {
const img = this._mode === 'air' ? (this._airMapImg || this._groundMapImg) : this._groundMapImg;
if (!img) {
this.mapCtx.fillStyle = '#111';
this.mapCtx.fillRect(0, 0, this.canvasSize, this.canvasSize);
return;
}
const { u, v, w, h } = this._mapSrc;
const sx = u * img.naturalWidth, sy = v * img.naturalHeight;
const sw = w * img.naturalWidth, sh = h * img.naturalHeight;
this.mapCtx.drawImage(img, sx, sy, sw, sh, 0, 0, this.canvasSize, this.canvasSize);
}
setMode(mode) {
if (mode === this._mode) return;
if (mode === 'air' && !this.hasAirMode) return;
this._mode = mode;
// Swap coordinate system
const coords = mode === 'air' ? this._airCoords : this._groundCoords;
this.x0 = coords.x0;
this.z0 = coords.z0;
this.xRange = coords.x1 - coords.x0;
this.zRange = coords.z1 - coords.z0;
this._mapSrc = { u: 0, v: 0, w: 1, h: 1 };
// Recompute autocrop for the new entity filter
this._computeAutocrop();
// Redraw map background with new crop region
this._drawMapToCanvas();
// Recompute death positions in new coordinate space
this._computeDeaths();
// Render immediately
this.render();
}
_togglePlay() {
this.playing = !this.playing;
this.playBtn.innerHTML = this.playing ? '<i class="fas fa-pause"></i>' : '<i class="fas fa-play"></i>';
if (this.playing) {
if (this.currentTime >= this.tEnd) this.currentTime = this.tStart;
this.lastFrameTime = performance.now();
}
}
_tick(now) {
if (this.playing) {
const dt = now - this.lastFrameTime;
this.lastFrameTime = now;
this.currentTime += dt * this.speed;
if (this.currentTime >= this.tEnd) {
this.currentTime = this.tEnd;
this.playing = false;
this.playBtn.innerHTML = '<i class="fas fa-play"></i>';
}
}
this.render();
this._updateControls();
// Update panel death states + battle log every ~250ms
if (!this._lastPanelUpdate || now - this._lastPanelUpdate > 250) {
this._updatePanelDeathStates();
this._updateBattleLog();
this._lastPanelUpdate = now;
}
this.animFrameId = requestAnimationFrame(this._tick);
}
_updateControls() {
const frac = (this.currentTime - this.tStart) / (this.tEnd - this.tStart);
const pct = Math.round(frac * 1000);
this.scrubber.value = pct;
this.scrubber.style.setProperty('--rc-progress', (frac * 100).toFixed(1) + '%');
const cur = (this.currentTime - this.tStart) / 1000;
const total = (this.tEnd - this.tStart) / 1000;
const fmt = (s) => `${Math.floor(s/60)}:${String(Math.floor(s%60)).padStart(2,'0')}`;
this.timeDisplay.textContent = `${fmt(cur)} / ${fmt(total)}`;
}
_updateCanvasHighlight() {
if (!this._mouseOnCanvas) return;
let bestId = null, bestDist = 400;
for (const ent of this.entities) {
if (ent.playerId === 0) continue;
if (this._isEntityGone(ent, this.currentTime)) continue;
const pos = this._entityScreenPos(ent, this.currentTime);
if (!pos) continue;
const dx = pos[0] - this._mouseX, dy = pos[1] - this._mouseY;
const dist = dx * dx + dy * dy;
if (dist < bestDist) { bestDist = dist; bestId = ent.playerId; }
}
this._setHighlight(bestId);
}
render() {
const ctx = this.ctx;
const t = this.currentTime;
this._updateCanvasHighlight();
ctx.drawImage(this.mapCanvas, 0, 0);
this._drawTrails(ctx, t);
this._drawDamageLines(ctx, t);
this._drawKillLines(ctx, t);
this._drawEntities(ctx, t);
}
_drawTrails(ctx, time) {
for (const ent of this.entities) {
if (this._isEntityGone(ent, time)) continue;
const endT = ent.deathTime !== null ? Math.min(time, ent.deathTime) : time;
const trailLen = ent.type === 'ground' ? RC.TRAIL_MS
: ent.type === 'aircraft' ? (this._mode === 'air' ? RC.TRAIL_MS : RC.AIR_TRAIL_MS)
: RC.DRONE_TRAIL_MS;
const tMin = endT - trailLen;
const baseColor = ent.isWinner ? RC.WIN_TRAIL : RC.LOSE_TRAIL;
const { times, positions } = ent;
ctx.lineWidth = ent.type === 'ground' ? 2 : (this._mode === 'air' ? 2 : 1.5);
ctx.lineCap = 'round';
// Aircraft in air mode: interpolate at fixed time steps so trail
// segments are always pixel-visible (raw path points can be sub-pixel
// apart at full-map scale).
if (this._mode === 'air' && ent.type === 'aircraft') {
const step = 200; // ms between interpolated trail points
let prevPx = null, prevPy = null;
for (let t = Math.max(tMin, times[0]); t <= Math.min(endT, times[times.length - 1]); t += step) {
const pos = this.getPositionAtTime(ent, t);
if (!pos) continue;
const [px, py] = pos;
if (prevPx !== null) {
const age = time - t;
const alpha = Math.max(0.08, 1 - age / trailLen);
ctx.strokeStyle = baseColor + alpha.toFixed(2) + ')';
ctx.beginPath();
ctx.moveTo(prevPx, prevPy);
ctx.lineTo(px, py);
ctx.stroke();
}
prevPx = px; prevPy = py;
}
continue;
}
let prevPx = null, prevPy = null;
for (let i = 0; i < times.length; i++) {
if (times[i] < tMin) continue;
if (times[i] > endT) break;
const [px, py] = this.worldToPixel(positions[i*2], positions[i*2+1]);
if (prevPx !== null) {
const age = time - times[i];
const alpha = Math.max(0.08, 1 - age / trailLen);
ctx.strokeStyle = baseColor + alpha.toFixed(2) + ')';
ctx.beginPath();
ctx.moveTo(prevPx, prevPy);
ctx.lineTo(px, py);
ctx.stroke();
}
prevPx = px; prevPy = py;
}
}
}
_drawDamageLines(ctx, time) {
for (const dm of this.data.damages) {
const age = time - dm.time;
if (age < 0 || age > RC.DMG_TTL) continue;
// Find attacker and victim positions at damage time
const attacker = this.entities.find(e => e.playerId === dm.offenderId);
const victim = this.entities.find(e => e.playerId === dm.offendedId);
if (!attacker || !victim) continue;
const aPos = this.getPositionAtTime(attacker, dm.time);
const vPos = this.getPositionAtTime(victim, dm.time);
if (!aPos || !vPos) continue;
const alpha = Math.max(0, 1 - age / RC.DMG_TTL);
ctx.globalAlpha = alpha * 0.4;
ctx.strokeStyle = '#ffcc44';
ctx.lineWidth = 1;
ctx.setLineDash([3, 4]);
ctx.beginPath(); ctx.moveTo(aPos[0], aPos[1]); ctx.lineTo(vPos[0], vPos[1]); ctx.stroke();
ctx.setLineDash([]);
ctx.globalAlpha = 1;
}
}
_drawKillLines(ctx, time) {
for (const k of this.data.kills) {
const age = time - k.time;
if (age < 0 || age > RC.KILL_TTL) continue;
if (!k.killerPos || !k.victimPos) continue;
const alpha = Math.max(0, 1 - age / RC.KILL_TTL);
const [kx, ky] = this.worldToPixel(k.killerPos.x, k.killerPos.z);
const [vx, vy] = this.worldToPixel(k.victimPos.x, k.victimPos.z);
ctx.globalAlpha = alpha * 0.6;
ctx.strokeStyle = '#ff3333';
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 3]);
ctx.beginPath(); ctx.moveTo(kx, ky); ctx.lineTo(vx, vy); ctx.stroke();
ctx.setLineDash([]);
// X marker
ctx.globalAlpha = alpha * 0.9;
ctx.strokeStyle = '#ff3333';
ctx.lineWidth = 2;
const s = 5;
ctx.beginPath();
ctx.moveTo(vx-s, vy-s); ctx.lineTo(vx+s, vy+s);
ctx.moveTo(vx+s, vy-s); ctx.lineTo(vx-s, vy+s);
ctx.stroke();
// Weapon label
if (k.weapon && alpha > 0.4) {
ctx.font = '600 9px system-ui, sans-serif';
ctx.fillStyle = `rgba(255,230,100,${(alpha * 0.85).toFixed(2)})`;
ctx.fillText(k.weapon, (kx+vx)/2 + 6, (ky+vy)/2 - 6);
}
ctx.globalAlpha = 1;
}
}
_drawEntities(ctx, time) {
const hl = this.highlightedPlayerId;
// Draw dead entities first (fading)
for (const ent of this.entities) {
if (!this._isEntityDead(ent, time)) continue;
if (this._isEntityGone(ent, time)) continue;
const pos = ent.deathPos;
if (!pos) continue;
const [px, py] = pos;
const fade = 1 - (time - ent.deathTime) / RC.GHOST_TTL;
ctx.globalAlpha = Math.max(0, fade * 0.5);
ctx.fillStyle = '#333';
const r = ent.type === 'ground' ? RC.DOT_R : ent.type === 'aircraft' ? RC.AIR_R : RC.DRONE_R;
ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI*2); ctx.fill();
ctx.globalAlpha = 1;
}
// Draw alive entities
for (const ent of this.entities) {
if (this._isEntityDead(ent, time)) continue;
const pos = this.getPositionAtTime(ent, time);
if (!pos) continue;
const [px, py] = pos;
if (px < -20 || py < -20 || px > this.canvasSize+20 || py > this.canvasSize+20) continue;
let alpha = 1;
if (hl !== null && ent.playerId !== hl && ent.playerId !== 0) alpha = 0.25;
const color = ent.isWinner ? RC.WIN : RC.LOSE;
const iconSize = ent.type === 'ground' ? 12 : ent.type === 'aircraft' ? 20 : 14;
const iconImg = this._iconCache?.[ent._canvasIconKey];
ctx.globalAlpha = alpha;
// Highlight ring
if (hl === ent.playerId && ent.playerId !== 0) {
const hr = iconSize / 2 + 5;
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(px, py, hr, 0, Math.PI*2); ctx.stroke();
ctx.strokeStyle = color;
ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(px, py, hr - 2, 0, Math.PI*2); ctx.stroke();
}
if (iconImg && iconImg.naturalWidth) {
const s = iconSize;
const tinted = this._getTintedIcon(ent._canvasIconKey, color, s);
const drawSrc = tinted || iconImg;
// Rotate aircraft/drones to face their heading
if (ent.type === 'aircraft' || ent.type === 'drone') {
const heading = this.getHeadingAtTime(ent, time);
if (heading !== null) {
ctx.save();
ctx.translate(px, py);
ctx.rotate(heading);
if (tinted) ctx.drawImage(drawSrc, -s/2, -s/2);
else this._drawContainedIcon(ctx, drawSrc, 0, 0, s);
ctx.restore();
} else {
if (tinted) ctx.drawImage(drawSrc, px-s/2, py-s/2);
else this._drawContainedIcon(ctx, drawSrc, px, py, s);
}
} else {
if (tinted) ctx.drawImage(drawSrc, px-s/2, py-s/2);
else this._drawContainedIcon(ctx, drawSrc, px, py, s);
}
} else {
// Fallback: circle dot
const r = ent.type === 'ground' ? RC.DOT_R : ent.type === 'aircraft' ? RC.AIR_R : RC.DRONE_R;
ctx.fillStyle = color;
ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI*2); ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.5)';
ctx.lineWidth = 1;
ctx.stroke();
}
ctx.globalAlpha = 1;
}
}
destroy() {
if (this.animFrameId) { cancelAnimationFrame(this.animFrameId); this.animFrameId = null; }
this.container.innerHTML = '';
}
}
window.ReplayCanvas = ReplayCanvas;
-332
View File
@@ -1,332 +0,0 @@
// Seasons Filter Utility
// Parses and manages season/week data from the seasons constant file
class SeasonsFilter {
constructor() {
this.seasons = [];
this.loaded = false;
}
// Parse seasons text content into structured data
parseSeasons(content) {
const seasons = [];
const lines = content.split('\n');
let currentSeason = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
// Check if it's a season header (e.g., "2025-V")
const seasonMatch = line.match(/^(\d{4}-[IVX]+)$/);
if (seasonMatch) {
if (currentSeason) {
seasons.push(currentSeason);
}
currentSeason = {
name: seasonMatch[1],
weeks: []
};
continue;
}
// Check if it's a week line
// Format: "week 1 (01.09 07.09) <t:1756735246:R>: max BR 14.0"
// or: "until eos (27.10 31.10) <t:1761487200:R>: max BR 5.0"
const weekMatch = line.match(/(?:week\s+(\d+)|until eos)\s+\((\d{2})\.(\d{2})\s*[–—]\s*(\d{2})\.(\d{2})\)\s*<t:(\d+):R>/);
if (weekMatch && currentSeason) {
const weekNum = weekMatch[1] ? parseInt(weekMatch[1]) : null;
const startDay = parseInt(weekMatch[2]);
const startMonth = parseInt(weekMatch[3]);
const endDay = parseInt(weekMatch[4]);
const endMonth = parseInt(weekMatch[5]);
const timestamp = parseInt(weekMatch[6]);
// Extract max BR if present (preserve .0 formatting)
const brMatch = line.match(/max BR ([\d.]+)/);
const maxBR = brMatch ? brMatch[1] : null; // Keep as string to preserve ".0"
// Use the Unix timestamp directly from the file (it's already correct!)
// The timestamp is the START of the week
const startDate = new Date(timestamp * 1000); // Convert to milliseconds
// Calculate end date: 7 days from start (or until end of season for "until eos")
// For "until eos" entries, we'll need to calculate based on the actual end day
let endDate;
// Determine year from season name (e.g., "2025-V" -> 2025)
const yearMatch = currentSeason.name.match(/^(\d{4})/);
const year = yearMatch ? parseInt(yearMatch[1]) : new Date().getFullYear();
// Build end date from the parsed end day/month
// Need to handle year rollover (e.g., December -> January)
let endYear = year;
if (endMonth < startMonth) {
endYear = year + 1; // Crossed into next year
}
endDate = new Date(endYear, endMonth - 1, endDay, 23, 59, 59, 999);
// Build display name with max BR (preserve decimal formatting like 14.0)
let displayName;
if (weekNum) {
displayName = `Week ${weekNum} (${weekMatch[2]}.${weekMatch[3]} ${weekMatch[4]}.${weekMatch[5]})`;
if (maxBR) displayName += ` - BR ${maxBR}`;
} else {
displayName = `Until EOS (${weekMatch[2]}.${weekMatch[3]} ${weekMatch[4]}.${weekMatch[5]})`;
if (maxBR) displayName += ` - BR ${maxBR}`;
}
currentSeason.weeks.push({
weekNumber: weekNum,
name: weekNum ? `Week ${weekNum}` : 'Until EOS',
startDate: startDate,
endDate: endDate,
startTimestamp: timestamp, // Store original Unix timestamp (seconds)
maxBR: maxBR,
displayName: displayName
});
}
}
// Add the last season
if (currentSeason) {
seasons.push(currentSeason);
}
// Post-process: set each week's endDate to the next week's startDate - 1ms
// This uses the exact Unix timestamps instead of the approximate text-parsed dates
seasons.forEach(season => {
season.weeks.forEach((week, i) => {
if (i + 1 < season.weeks.length) {
week.endDate = new Date(season.weeks[i + 1].startDate.getTime() - 1);
}
// Last week of each season keeps its text-parsed endDate
});
});
return seasons;
}
// Load seasons data from the constant file
async loadSeasons() {
if (this.loaded) {
return this.seasons;
}
try {
const response = await fetch('/constants/seasons');
if (!response.ok) {
throw new Error(`Failed to load seasons: ${response.status}`);
}
const content = await response.text();
this.seasons = this.parseSeasons(content);
this.loaded = true;
console.log('[Seasons Filter] Loaded seasons:', this.seasons);
return this.seasons;
} catch (error) {
console.error('[Seasons Filter] Error loading seasons:', error);
this.seasons = [];
this.loaded = false;
return [];
}
}
// Get all seasons
getSeasons() {
return this.seasons;
}
// Get a specific season by name
getSeason(seasonName) {
return this.seasons.find(s => s.name === seasonName);
}
// Get current season (based on current date)
getCurrentSeason() {
const now = new Date();
for (const season of this.seasons) {
for (const week of season.weeks) {
if (now >= week.startDate && now <= week.endDate) {
return season;
}
}
}
// If no current season found, return the most recent one
return this.seasons.length > 0 ? this.seasons[this.seasons.length - 1] : null;
}
// Get current week (based on current date)
getCurrentWeek() {
const now = new Date();
for (const season of this.seasons) {
for (const week of season.weeks) {
if (now >= week.startDate && now <= week.endDate) {
return { season, week };
}
}
}
return null;
}
// Get date range for a specific season
getSeasonDateRange(seasonName) {
const season = this.getSeason(seasonName);
if (!season || season.weeks.length === 0) {
return null;
}
return {
startDate: season.weeks[0].startDate,
endDate: season.weeks[season.weeks.length - 1].endDate,
season: season
};
}
// Get date range for a specific week in a season
getWeekDateRange(seasonName, weekNumber) {
const season = this.getSeason(seasonName);
if (!season) {
return null;
}
const week = season.weeks.find(w => w.weekNumber === weekNumber);
if (!week) {
return null;
}
return {
startDate: week.startDate,
endDate: week.endDate,
week: week
};
}
// Get all unique BR values across all seasons (returns array of strings, preserving ".0")
getAllBRValues() {
const brSet = new Set();
this.seasons.forEach(season => {
season.weeks.forEach(week => {
if (week.maxBR) {
brSet.add(week.maxBR);
}
});
});
// Sort descending (convert to float for comparison, but keep as strings)
return Array.from(brSet).sort((a, b) => parseFloat(b) - parseFloat(a));
}
// Get all weeks that match a specific BR
getWeeksByBR(maxBR) {
const matchingWeeks = [];
this.seasons.forEach(season => {
season.weeks.forEach(week => {
if (week.maxBR === maxBR) {
matchingWeeks.push({
season: season,
week: week,
displayName: `${season.name} - ${week.name}`
});
}
});
});
return matchingWeeks;
}
// Get combined date range for all weeks with a specific BR (for filtering multiple weeks)
getDateRangeForBR(maxBR) {
const weeks = this.getWeeksByBR(maxBR);
if (weeks.length === 0) {
return null;
}
// Return array of date ranges (one for each week with this BR)
return weeks.map(({ season, week }) => ({
startDate: week.startDate,
endDate: week.endDate,
seasonName: season.name,
weekName: week.name,
displayName: `${season.name} - ${week.displayName}`
}));
}
// Populate a select element with season options
populateSeasonSelect(selectElement, includeAllOption = true) {
selectElement.innerHTML = '';
if (includeAllOption) {
const allOption = document.createElement('option');
allOption.value = 'all';
allOption.textContent = window.__t ? window.__t('leaderboard.allSeasons') : 'All Seasons';
selectElement.appendChild(allOption);
}
// Add seasons in reverse order (most recent first)
for (let i = this.seasons.length - 1; i >= 0; i--) {
const season = this.seasons[i];
const option = document.createElement('option');
option.value = season.name;
option.textContent = season.name;
selectElement.appendChild(option);
}
}
// Populate a select element with BR options
populateBRSelect(selectElement, includeAllOption = true) {
selectElement.innerHTML = '';
if (includeAllOption) {
const allOption = document.createElement('option');
allOption.value = '';
allOption.textContent = window.__t ? window.__t('leaderboard.allBR') : 'All BR';
selectElement.appendChild(allOption);
}
const brValues = this.getAllBRValues();
brValues.forEach(br => {
const option = document.createElement('option');
option.value = br;
option.textContent = `BR ${br}`;
selectElement.appendChild(option);
});
}
// Populate a select element with week options for a given season
populateWeekSelect(selectElement, seasonName, includeAllOption = true) {
selectElement.innerHTML = '';
if (includeAllOption) {
const allOption = document.createElement('option');
allOption.value = 'all';
allOption.textContent = window.__t ? window.__t('leaderboard.allWeeks') : 'All Weeks';
selectElement.appendChild(allOption);
}
const season = this.getSeason(seasonName);
if (!season) {
return;
}
season.weeks.forEach(week => {
const option = document.createElement('option');
option.value = week.weekNumber !== null ? week.weekNumber.toString() : 'final';
option.textContent = week.displayName;
selectElement.appendChild(option);
});
}
}
// Global instance
window.seasonsFilter = window.seasonsFilter || new SeasonsFilter();
-227
View File
@@ -1,227 +0,0 @@
// Client-side vehicle name translator.
//
// Loads the localized vehicle name map produced by the bot's
// init_vehicle_translation_cache() (BOT/utils.py) and exposes:
//
// 1. window.vehicleI18n.translate(internal, fallback, lang?)
// Synchronous lookup. Returns the localized name when available,
// otherwise English, otherwise the supplied fallback.
//
// 2. window.vehicleI18n.apply(root?)
// Walks `root` (defaults to <body>) and rewrites the textContent of
// every element carrying `data-vehicle-internal="<cdk>"` to the
// localized name. The element's *original* text is captured into
// `data-vehicle-fallback` on first apply so re-applies (e.g. after
// lang switch) don't lose the fallback.
//
// On startup the module:
// - Eagerly loads the multilang map (via window.apiClient — /api/* is
// gated by the website's apiSecurityCheck middleware).
// - On first successful load + DOMContentLoaded, runs apply(document.body)
// and installs a MutationObserver so any future inserts auto-translate.
//
// Server-side EJS or client-side template strings only need to add the
// data attribute — no per-page wiring required.
(function () {
const STORAGE_KEY = 'vehicleI18nCache_v2';
const TTL_MS = 24 * 60 * 60 * 1000;
let _map = null; // { internal: { en, ru, ... } }
let _source = null; // 'multilang' | 'english_only' | 'none'
let _loadingPromise = null;
let _observerInstalled = false;
function readCache() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!parsed || !parsed.fetchedAt || (Date.now() - parsed.fetchedAt) > TTL_MS) return null;
if (parsed.source !== 'multilang') return null;
return parsed.vehicles || null;
} catch (e) {
return null;
}
}
function writeCache(vehicles, source) {
if (source !== 'multilang') return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ fetchedAt: Date.now(), source, vehicles }));
} catch (e) {
// quota exceeded or storage disabled
}
}
function fireReady(detail) {
// Defer to next tick so any listeners installed after this module
// executes still see the event.
setTimeout(() => document.dispatchEvent(new CustomEvent('vehicle-i18n-ready', { detail })), 0);
}
async function ensureLoaded() {
if (_map) return _map;
const cached = readCache();
if (cached) {
_map = cached;
_source = 'multilang';
fireReady({ source: 'multilang', cached: true });
return _map;
}
if (_loadingPromise) return _loadingPromise;
_loadingPromise = (async () => {
try {
if (!window.apiClient) {
await new Promise((resolve) => {
const tick = () => window.apiClient ? resolve() : setTimeout(tick, 50);
tick();
});
}
const body = await window.apiClient.request('/api/i18n/vehicles');
_map = body && body.vehicles ? body.vehicles : {};
_source = body && body.source ? body.source : 'none';
writeCache(_map, _source);
fireReady({ source: _source });
return _map;
} catch (e) {
console.error('vehicle i18n load failed', e);
_map = {};
_source = 'none';
return _map;
} finally {
_loadingPromise = null;
}
})();
return _loadingPromise;
}
function currentLang() {
const m = (document.cookie || '').match(/(?:^|;\s*)lang=([\w-]+)/);
return (m && m[1]) || (document.documentElement.lang || 'en');
}
// Case-insensitive map lookup. The DB historically stored mixed casings of
// vehicle_internal (e.g. germ_leopard_I vs germ_leopard_i), and the API
// now lowercases them; the bot's translation cache may or may not match
// that casing depending on its source. Try the literal key first, then
// lowercase, then a one-time-built lowercased index for everything else.
let _lowerIndex = null;
function lookup(internal) {
if (!_map || !internal) return null;
if (_map[internal]) return _map[internal];
const lower = String(internal).toLowerCase();
if (_map[lower]) return _map[lower];
if (!_lowerIndex) {
_lowerIndex = {};
for (const k of Object.keys(_map)) _lowerIndex[k.toLowerCase()] = _map[k];
}
return _lowerIndex[lower] || null;
}
function translate(internal, fallback, lang) {
const lng = lang || currentLang();
if (!_map || !internal) return fallback || internal || '';
const entry = lookup(internal);
if (!entry) return fallback || internal;
const localized = entry[lng] || entry.en || fallback || internal;
// The player-rendered display the DB stores can have a leading
// country-leak / event glyph (▄ ◘ ◢ ␗ etc.) that WT's client prepends
// at draw time. The translation map only knows the canonical name
// without that prefix, so naive replacement would lose it — making
// e.g. "▄F-16A ADF" (Italy) and "F-16A ADF" (no leak) look identical.
// Preserve any leading run of non-letter / non-digit / non-space chars
// from the original fallback when the translation doesn't already
// include it.
if (fallback) {
const m = String(fallback).match(/^[^\p{L}\p{N}\s]+/u);
if (m && !localized.startsWith(m[0])) return m[0] + localized;
}
return localized;
}
function applyToElement(el) {
if (!el || !el.dataset || !el.dataset.vehicleInternal) return;
const internal = el.dataset.vehicleInternal;
if (!internal) return;
// Capture the rendered fallback once so language re-switches still have
// something to fall back on if the map ever loses an entry.
if (el.dataset.vehicleFallback === undefined) {
el.dataset.vehicleFallback = el.textContent || '';
}
const lng = currentLang();
// Cache the lang we last applied so we don't fight the DOM on every
// mutation tick.
if (el.dataset.vehicleAppliedLang === lng) return;
el.textContent = translate(internal, el.dataset.vehicleFallback, lng);
el.dataset.vehicleAppliedLang = lng;
}
function apply(root) {
if (!_map) return;
const node = root || document.body;
if (!node) return;
if (node.nodeType === 1 && node.dataset && node.dataset.vehicleInternal) {
applyToElement(node);
}
if (node.querySelectorAll) {
node.querySelectorAll('[data-vehicle-internal]').forEach(applyToElement);
}
}
function installMutationObserver() {
if (_observerInstalled || typeof MutationObserver === 'undefined' || !document.body) return;
_observerInstalled = true;
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.nodeType === 1) apply(node);
}
if (m.type === 'attributes' && m.target && m.target.dataset && m.target.dataset.vehicleInternal) {
delete m.target.dataset.vehicleAppliedLang;
applyToElement(m.target);
}
}
});
obs.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['data-vehicle-internal'],
});
}
function autoApply() {
if (!document.body) return;
apply(document.body);
installMutationObserver();
}
document.addEventListener('vehicle-i18n-ready', autoApply);
window.vehicleI18n = {
ensureLoaded,
translate,
apply,
get ready() { return _map !== null; },
get source() { return _source; },
get currentLang() { return currentLang(); },
invalidate() {
try { localStorage.removeItem(STORAGE_KEY); } catch (e) { /* ignore */ }
_map = null;
_source = null;
_lowerIndex = null;
// Also clear apply-state markers so a refresh re-translates.
document.querySelectorAll('[data-vehicle-applied-lang]').forEach(el => {
delete el.dataset.vehicleAppliedLang;
});
},
};
// Kick off load. apply() runs from the ready event handler above.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', ensureLoaded);
} else {
ensureLoaded();
}
})();
-8
View File
@@ -1,8 +0,0 @@
# Robots.txt for Toothless SQB Bot Website
# This file prevents web crawlers from scraping the site
User-agent: *
Disallow: /
# Block all crawlers from all content
# This includes search engines, scrapers, and bots