Initial commit: SREBOT website (Express/EJS + i18n) - extracted from SREBOT monorepo
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
// 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;
|
||||
}
|
||||
};
|
||||
Vendored
+20
File diff suppressed because one or more lines are too long
@@ -0,0 +1,513 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -0,0 +1,82 @@
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,483 @@
|
||||
// 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');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,827 @@
|
||||
// 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')}">×</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>`;
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,91 @@
|
||||
(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()}`;
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,93 @@
|
||||
(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()}`;
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,697 @@
|
||||
// Pure 3D replay view. No DOM beyond its own <canvas>; driven entirely by an
|
||||
// external clock (setTime) so it stays in sync with the 2D ReplayCanvasEngine.
|
||||
// Ported from the standalone REPLAY_VIEWER three.js viewer, stripped of all of
|
||||
// its own UI (labels, axes, guide lines, controls, hover/active-list panels).
|
||||
|
||||
import * as THREE from 'three'
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
|
||||
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js'
|
||||
|
||||
const MODEL_PATH = '/models/t34/'
|
||||
const MINIMAP_URL = (level) => `/api/match/minimap/${level}`
|
||||
const MINIMAP_FULL_URL = (level) => `/api/match/minimap/${level}?type=full`
|
||||
|
||||
const WIN_COLOR = '#00c800'
|
||||
const LOSE_COLOR = '#dc1e1e'
|
||||
const DRONE_COLOR = '#cbd5e1'
|
||||
|
||||
const DIRECTION_SAMPLE_MS = 700
|
||||
const DIRECTION_BLEND = 0.22
|
||||
const KILL_LINE_WINDOW_MS = 3000
|
||||
const FLIP_MAP_TEXTURE_Z = true
|
||||
|
||||
function coordsAreValid(c) {
|
||||
return c &&
|
||||
Number.isFinite(Number(c.x0)) && Number.isFinite(Number(c.z0)) &&
|
||||
Number.isFinite(Number(c.x1)) && Number.isFinite(Number(c.z1)) &&
|
||||
Number(c.x0) !== Number(c.x1) && Number(c.z0) !== Number(c.z1)
|
||||
}
|
||||
|
||||
function normalizeCoords(c) {
|
||||
if (!coordsAreValid(c)) return null
|
||||
return { x0: Number(c.x0), z0: Number(c.z0), x1: Number(c.x1), z1: Number(c.z1) }
|
||||
}
|
||||
|
||||
function mapSourceRect(baseCoords, renderCoords) {
|
||||
const base = normalizeCoords(baseCoords)
|
||||
const render = normalizeCoords(renderCoords)
|
||||
if (!base || !render) return { u: 0, v: 0, w: 1, h: 1 }
|
||||
const dx = base.x1 - base.x0
|
||||
const dz = base.z1 - base.z0
|
||||
const xLo = Math.min(render.x0, render.x1)
|
||||
const xHi = Math.max(render.x0, render.x1)
|
||||
const zLo = Math.min(render.z0, render.z1)
|
||||
const zHi = Math.max(render.z0, render.z1)
|
||||
const u0 = (xLo - base.x0) / dx
|
||||
const u1 = (xHi - base.x0) / dx
|
||||
const v0 = (zLo - base.z0) / dz
|
||||
const v1 = (zHi - base.z0) / dz
|
||||
const uMin = Math.max(0, Math.min(1, Math.min(u0, u1)))
|
||||
const uMax = Math.max(0, Math.min(1, Math.max(u0, u1)))
|
||||
const vMin = Math.max(0, Math.min(1, Math.min(v0, v1)))
|
||||
const vMax = Math.max(0, Math.min(1, Math.max(v0, v1)))
|
||||
const w = uMax - uMin
|
||||
const h = vMax - vMin
|
||||
if (!(w > 0 && h > 0)) return { u: 0, v: 0, w: 1, h: 1 }
|
||||
return { u: uMin, v: 1 - vMax, w, h }
|
||||
}
|
||||
|
||||
function fallbackBoundsCoords(bounds) {
|
||||
const pad = Math.max(bounds.planarSpan * 0.15, 250)
|
||||
return { x0: bounds.minX - pad, z0: bounds.minZ - pad, x1: bounds.maxX + pad, z1: bounds.maxZ + pad }
|
||||
}
|
||||
|
||||
function catmullRom(a, b, c, d, t) {
|
||||
const t2 = t * t
|
||||
const t3 = t2 * t
|
||||
return 0.5 * ((2 * b) + (-a + c) * t + (2 * a - 5 * b + 4 * c - d) * t2 + (-a + 3 * b - 3 * c + d) * t3)
|
||||
}
|
||||
|
||||
class ReplayCanvas3D {
|
||||
constructor(container, data) {
|
||||
this.container = container
|
||||
this.data = data
|
||||
this.disposed = false
|
||||
this.currentT = 0
|
||||
this.selectedPlayerId = null
|
||||
this.smoothByEntity = new Map()
|
||||
|
||||
this.players = {}
|
||||
for (const p of data.players || []) this.players[p.id] = p
|
||||
this.teamWon = data.teamWon
|
||||
this.winnerSlot = Number(data.winnerSlot) || 0
|
||||
|
||||
this._buildEntities()
|
||||
this._buildCaptureModel()
|
||||
this.kills = (data.kills || []).filter((k) => k.killerPos && k.victimPos)
|
||||
|
||||
this.bounds = this._computeBounds()
|
||||
this.scale = 1200 / this.bounds.planarSpan
|
||||
|
||||
this._initThree()
|
||||
|
||||
this._mode = 'ground'
|
||||
this.mapInfo = this._resolveMapInfo('ground')
|
||||
|
||||
this._buildScene()
|
||||
this._loadTankTemplate()
|
||||
this._loadMapTexture()
|
||||
|
||||
this._animate = this._animate.bind(this)
|
||||
this._raf = requestAnimationFrame(this._animate)
|
||||
this.setTime(0)
|
||||
}
|
||||
|
||||
// ---- data adaptation -----------------------------------------------------
|
||||
|
||||
_isWinner(entity) {
|
||||
if (entity.playerId > 0) return this.players[entity.playerId]?.team === this.teamWon
|
||||
return entity.droneTeam === this.teamWon
|
||||
}
|
||||
|
||||
_colorFor(entity) {
|
||||
if (entity.playerId === 0 && entity.type === 'drone') return DRONE_COLOR
|
||||
return this._isWinner(entity) ? WIN_COLOR : LOSE_COLOR
|
||||
}
|
||||
|
||||
_buildEntities() {
|
||||
this.entities = []
|
||||
for (const e of this.data.entities || []) {
|
||||
if (!Array.isArray(e.path) || !e.path.length) continue
|
||||
const path = e.path.map((p) => ({ t: Number(p.t), x: Number(p.x), y: Number(p.y || 0), z: Number(p.z) }))
|
||||
this.entities.push({
|
||||
playerId: e.playerId,
|
||||
entityIndex: e.entityIndex,
|
||||
type: e.type,
|
||||
droneTeam: e.droneTeam,
|
||||
path,
|
||||
startT: path[0].t,
|
||||
endT: path[path.length - 1].t,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
_buildCaptureModel() {
|
||||
const areas = Array.isArray(this.data.captureAreas) ? this.data.captureAreas : []
|
||||
const state = this.data.captureState || {}
|
||||
this.captureZones = areas.map((area, index) => {
|
||||
const letter = String.fromCharCode(65 + index)
|
||||
const cap = Array.isArray(state[letter])
|
||||
? state[letter].map(([t, v]) => [Number(t), Number(v)]).filter(([t, v]) => Number.isFinite(t) && Number.isFinite(v))
|
||||
: []
|
||||
const center = area.tm?.center || [area.x, 0, area.z]
|
||||
return {
|
||||
key: letter,
|
||||
center: { x: Number(area.x ?? center[0]), y: Number(center[1] || 0), z: Number(area.z ?? center[2]) },
|
||||
radius: Math.max(1, Number(area.radius) || 1),
|
||||
cap,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
_computeBounds() {
|
||||
const b = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity, minZ: Infinity, maxZ: -Infinity }
|
||||
for (const e of this.entities) {
|
||||
for (const p of e.path) {
|
||||
b.minX = Math.min(b.minX, p.x); b.maxX = Math.max(b.maxX, p.x)
|
||||
b.minY = Math.min(b.minY, p.y); b.maxY = Math.max(b.maxY, p.y)
|
||||
b.minZ = Math.min(b.minZ, p.z); b.maxZ = Math.max(b.maxZ, p.z)
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(b.minX)) { b.minX = 0; b.maxX = 1; b.minY = 0; b.maxY = 1; b.minZ = 0; b.maxZ = 1 }
|
||||
b.centerX = (b.minX + b.maxX) / 2
|
||||
b.centerZ = (b.minZ + b.maxZ) / 2
|
||||
b.spanX = Math.max(1, b.maxX - b.minX)
|
||||
b.spanZ = Math.max(1, b.maxZ - b.minZ)
|
||||
b.planarSpan = Math.max(b.spanX, b.spanZ)
|
||||
return b
|
||||
}
|
||||
|
||||
_resolveMapInfo(mode) {
|
||||
const level = this.data.mission?.level
|
||||
const full = this.data.fullMapLevel
|
||||
if (mode === 'air' && full && normalizeCoords(this.data.mapCoords)) {
|
||||
const coords = normalizeCoords(this.data.mapCoords)
|
||||
return { image: MINIMAP_FULL_URL(full), coords, baseCoords: coords, sourceRect: { u: 0, v: 0, w: 1, h: 1 } }
|
||||
}
|
||||
const coords = normalizeCoords(this.data.levelCoords) || fallbackBoundsCoords(this.bounds)
|
||||
const baseCoords = normalizeCoords(this.data.tankMapCoords) || coords
|
||||
return {
|
||||
image: level ? MINIMAP_URL(level) : null,
|
||||
coords,
|
||||
baseCoords,
|
||||
sourceRect: mapSourceRect(baseCoords, coords),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- coordinate transforms ----------------------------------------------
|
||||
|
||||
_toWorld(p) {
|
||||
const b = this.bounds
|
||||
return new THREE.Vector3(
|
||||
(p.x - b.centerX) * this.scale,
|
||||
(p.y - b.minY) * this.scale,
|
||||
(p.z - b.centerZ) * this.scale,
|
||||
)
|
||||
}
|
||||
|
||||
_renderPoint(p) {
|
||||
if (!FLIP_MAP_TEXTURE_Z) return p
|
||||
const coords = normalizeCoords(this.mapInfo?.coords)
|
||||
if (!coords) return p
|
||||
return { ...p, z: coords.z0 + coords.z1 - p.z }
|
||||
}
|
||||
|
||||
// ---- three.js setup ------------------------------------------------------
|
||||
|
||||
_initThree() {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.className = 'rc3d-canvas'
|
||||
this.canvasEl = canvas
|
||||
this.container.appendChild(canvas)
|
||||
|
||||
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true })
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
|
||||
this.renderer.setClearColor(0x0b0d10, 1)
|
||||
|
||||
this.scene = new THREE.Scene()
|
||||
this.camera = new THREE.PerspectiveCamera(55, 1, 0.1, 20000)
|
||||
this.controls = new OrbitControls(this.camera, this.renderer.domElement)
|
||||
this.controls.enableDamping = true
|
||||
this.controls.dampingFactor = 0.08
|
||||
this.controls.screenSpacePanning = false
|
||||
this.controls.maxPolarAngle = Math.PI * 0.49
|
||||
this.controls.mouseButtons.LEFT = THREE.MOUSE.ROTATE
|
||||
this.controls.mouseButtons.MIDDLE = THREE.MOUSE.DOLLY
|
||||
this.controls.mouseButtons.RIGHT = THREE.MOUSE.PAN
|
||||
|
||||
this.root = new THREE.Group()
|
||||
this.mapGroup = new THREE.Group()
|
||||
this.pathGroup = new THREE.Group()
|
||||
this.zoneGroup = new THREE.Group()
|
||||
this.killLineGroup = new THREE.Group()
|
||||
this.markerGroup = new THREE.Group()
|
||||
this.root.add(this.mapGroup, this.pathGroup, this.zoneGroup, this.killLineGroup, this.markerGroup)
|
||||
this.scene.add(this.root)
|
||||
|
||||
this.scene.add(new THREE.AmbientLight(0xffffff, 0.72))
|
||||
const sun = new THREE.DirectionalLight(0xffffff, 1.1)
|
||||
sun.position.set(240, 500, 180)
|
||||
this.scene.add(sun)
|
||||
|
||||
this.resize()
|
||||
}
|
||||
|
||||
_worldMapExtents(coords = this.mapInfo?.coords) {
|
||||
const c = normalizeCoords(coords)
|
||||
if (!c) return null
|
||||
const a = this._toWorld({ x: c.x0, y: this.bounds.minY, z: c.z0 })
|
||||
const b = this._toWorld({ x: c.x1, y: this.bounds.minY, z: c.z1 })
|
||||
return {
|
||||
minX: Math.min(a.x, b.x), maxX: Math.max(a.x, b.x),
|
||||
minZ: Math.min(a.z, b.z), maxZ: Math.max(a.z, b.z),
|
||||
width: Math.abs(b.x - a.x), depth: Math.abs(b.z - a.z),
|
||||
}
|
||||
}
|
||||
|
||||
_resetCamera() {
|
||||
const ext = this._worldMapExtents()
|
||||
const span = Math.max(ext?.width || 1400, ext?.depth || 1400, 100)
|
||||
this.camera.position.set(span * 0.45, span * 0.55, span * 0.72)
|
||||
this.controls.target.set(0, 0, 0)
|
||||
this.controls.minDistance = Math.max(18, span * 0.025)
|
||||
this.controls.maxDistance = Math.max(320, span * 1.8)
|
||||
this.camera.far = Math.max(9000, span * 8)
|
||||
this.camera.updateProjectionMatrix()
|
||||
this.controls.update()
|
||||
}
|
||||
|
||||
// ---- scene construction --------------------------------------------------
|
||||
|
||||
_buildScene() {
|
||||
this._clearGroup(this.mapGroup)
|
||||
this._clearGroup(this.pathGroup)
|
||||
this._clearGroup(this.markerGroup)
|
||||
this._clearGroup(this.zoneGroup)
|
||||
this._clearGroup(this.killLineGroup)
|
||||
this._buildMapPlane()
|
||||
this._buildCaptureZones()
|
||||
this._buildKillLines()
|
||||
this.visualEntities = this.entities.map((e) => this._makeVisualEntity(e))
|
||||
this._applyHighlight()
|
||||
this._resetCamera()
|
||||
}
|
||||
|
||||
_clearGroup(group) {
|
||||
for (const child of [...group.children]) {
|
||||
group.remove(child)
|
||||
child.traverse?.((c) => {
|
||||
if (c.geometry) c.geometry.dispose()
|
||||
if (c.material) (Array.isArray(c.material) ? c.material : [c.material]).forEach((m) => m.dispose())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
_buildMapPlane() {
|
||||
const info = this.mapInfo
|
||||
if (!info || !coordsAreValid(info.coords)) return
|
||||
const x0 = Math.min(info.coords.x0, info.coords.x1)
|
||||
const x1 = Math.max(info.coords.x0, info.coords.x1)
|
||||
const z0 = Math.min(info.coords.z0, info.coords.z1)
|
||||
const z1 = Math.max(info.coords.z0, info.coords.z1)
|
||||
const width = Math.max(1, (x1 - x0) * this.scale)
|
||||
const depth = Math.max(1, (z1 - z0) * this.scale)
|
||||
const center = this._toWorld({ x: (x0 + x1) / 2, y: this.bounds.minY, z: (z0 + z1) / 2 })
|
||||
|
||||
const geometry = new THREE.PlaneGeometry(width, depth, 1, 1)
|
||||
this._applyPlaneUvs(geometry, info.sourceRect || { u: 0, v: 0, w: 1, h: 1 })
|
||||
geometry.rotateX(-Math.PI / 2)
|
||||
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
color: 0x18202a, transparent: true, opacity: 0.92, depthWrite: false, side: THREE.DoubleSide,
|
||||
})
|
||||
const plane = new THREE.Mesh(geometry, material)
|
||||
plane.position.set(center.x, -0.12, center.z)
|
||||
plane.renderOrder = -10
|
||||
this.mapGroup.add(plane)
|
||||
this._mapMaterial = material
|
||||
}
|
||||
|
||||
_applyPlaneUvs(geometry, rect) {
|
||||
const u0 = rect.u
|
||||
const u1 = rect.u + rect.w
|
||||
const vTop = rect.v
|
||||
const vBottom = rect.v + rect.h
|
||||
const uv = geometry.attributes.uv
|
||||
if (FLIP_MAP_TEXTURE_Z) {
|
||||
uv.setXY(0, u0, 1 - vTop); uv.setXY(1, u1, 1 - vTop)
|
||||
uv.setXY(2, u0, 1 - vBottom); uv.setXY(3, u1, 1 - vBottom)
|
||||
} else {
|
||||
uv.setXY(0, u0, 1 - vBottom); uv.setXY(1, u1, 1 - vBottom)
|
||||
uv.setXY(2, u0, 1 - vTop); uv.setXY(3, u1, 1 - vTop)
|
||||
}
|
||||
uv.needsUpdate = true
|
||||
}
|
||||
|
||||
_loadMapTexture() {
|
||||
if (!this.mapInfo.image || !this._mapMaterial) return
|
||||
const material = this._mapMaterial
|
||||
new THREE.TextureLoader().load(
|
||||
this.mapInfo.image,
|
||||
(texture) => {
|
||||
if (this.disposed) { texture.dispose(); return }
|
||||
texture.colorSpace = THREE.SRGBColorSpace
|
||||
texture.anisotropy = this.renderer.capabilities.getMaxAnisotropy()
|
||||
material.map = texture
|
||||
material.color.setHex(0xffffff)
|
||||
material.needsUpdate = true
|
||||
},
|
||||
undefined,
|
||||
() => { material.color.setHex(0x1f2933); material.opacity = 0.6 },
|
||||
)
|
||||
}
|
||||
|
||||
_makeVisualEntity(entity) {
|
||||
const color = this._colorFor(entity)
|
||||
const points = entity.path.map((p) => this._toWorld(this._renderPoint(p)))
|
||||
const lineGeometry = new THREE.BufferGeometry().setFromPoints(points)
|
||||
const lineMaterial = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.42 })
|
||||
const line = new THREE.Line(lineGeometry, lineMaterial)
|
||||
this.pathGroup.add(line)
|
||||
|
||||
const radius = 4.4
|
||||
let model
|
||||
if (this.unitTemplate && entity.type === 'ground') {
|
||||
model = this.unitTemplate.clone(true)
|
||||
model.traverse((child) => {
|
||||
if (!child.isMesh) return
|
||||
child.material = new THREE.MeshStandardMaterial({
|
||||
color, roughness: 0.72, metalness: 0.06, flatShading: true,
|
||||
emissive: new THREE.Color(color).multiplyScalar(0.22),
|
||||
})
|
||||
})
|
||||
} else {
|
||||
const geo = new THREE.SphereGeometry(radius, 18, 12)
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color, emissive: new THREE.Color(color).multiplyScalar(0.24), roughness: 0.45, metalness: 0.08,
|
||||
})
|
||||
model = new THREE.Mesh(geo, mat)
|
||||
}
|
||||
|
||||
const arrow = new THREE.ArrowHelper(
|
||||
new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 0, 0),
|
||||
radius * 4.6, new THREE.Color(color), radius * 1.55, radius * 0.9,
|
||||
)
|
||||
arrow.line.material.transparent = true
|
||||
arrow.line.material.opacity = 0.78
|
||||
arrow.cone.material.transparent = true
|
||||
arrow.cone.material.opacity = 0.95
|
||||
|
||||
const group = new THREE.Group()
|
||||
group.add(model, arrow)
|
||||
this.markerGroup.add(group)
|
||||
|
||||
return { entity, line, group, model, arrow, radius, smoothedDirection: null, lastDirectionT: null }
|
||||
}
|
||||
|
||||
async _loadTankTemplate() {
|
||||
try {
|
||||
const object = await new Promise((resolve, reject) => {
|
||||
const loader = new OBJLoader()
|
||||
loader.setPath(MODEL_PATH)
|
||||
loader.load('t_34_obj.obj', resolve, undefined, reject)
|
||||
})
|
||||
if (this.disposed) return
|
||||
|
||||
const planes = []
|
||||
object.traverse((child) => {
|
||||
if (!child.isMesh) return
|
||||
child.castShadow = false
|
||||
child.receiveShadow = false
|
||||
if (/^Plane/i.test(child.name || '')) { planes.push(child); return }
|
||||
})
|
||||
for (const plane of planes) plane.removeFromParent()
|
||||
|
||||
const box = new THREE.Box3()
|
||||
object.traverse((child) => { if (child.isMesh) box.expandByObject(child) })
|
||||
if (box.isEmpty()) box.setFromObject(object)
|
||||
const size = box.getSize(new THREE.Vector3())
|
||||
const maxDim = Math.max(size.x, size.y, size.z, 1)
|
||||
const scale = 70 / maxDim
|
||||
const center = box.getCenter(new THREE.Vector3())
|
||||
|
||||
const pivot = new THREE.Group()
|
||||
object.position.x -= center.x
|
||||
object.position.z -= center.z
|
||||
object.position.y -= box.min.y
|
||||
pivot.add(object)
|
||||
pivot.scale.setScalar(scale)
|
||||
this.unitTemplate = pivot
|
||||
|
||||
// Rebuild ground entities now that the model is available.
|
||||
this._buildScene()
|
||||
this.setTime(this.currentT)
|
||||
} catch {
|
||||
// No model -> spheres are used; nothing else to do.
|
||||
}
|
||||
}
|
||||
|
||||
// ---- capture zones -------------------------------------------------------
|
||||
|
||||
_buildCaptureZones() {
|
||||
for (const zone of this.captureZones) {
|
||||
const center = this._toWorld(this._renderPoint(zone.center))
|
||||
const radius = Math.max(8, zone.radius * this.scale)
|
||||
const group = new THREE.Group()
|
||||
group.position.set(center.x, 1.05, center.z)
|
||||
|
||||
const ringGeo = new THREE.RingGeometry(radius * 0.92, radius, 96)
|
||||
ringGeo.rotateX(-Math.PI / 2)
|
||||
const ring = new THREE.Mesh(ringGeo, new THREE.MeshBasicMaterial({
|
||||
color: 0xdde6ee, transparent: true, opacity: 0.7, depthWrite: false, side: THREE.DoubleSide,
|
||||
}))
|
||||
ring.renderOrder = 8
|
||||
|
||||
const fillGeo = new THREE.CircleGeometry(radius * 0.82, 96)
|
||||
fillGeo.rotateX(-Math.PI / 2)
|
||||
const fill = new THREE.Mesh(fillGeo, new THREE.MeshBasicMaterial({
|
||||
color: 0xd8dee9, transparent: true, opacity: 0, depthWrite: false, side: THREE.DoubleSide,
|
||||
}))
|
||||
fill.position.y = 0.02
|
||||
fill.renderOrder = 7
|
||||
|
||||
group.add(fill, ring)
|
||||
group.userData = { zone, fill }
|
||||
this.zoneGroup.add(group)
|
||||
}
|
||||
}
|
||||
|
||||
// Step interpolation, matching the 2D engine's _interpSeries(series, t, true).
|
||||
_captureValueAt(zone, t) {
|
||||
const cap = zone.cap
|
||||
if (!cap.length) return 0
|
||||
if (t <= cap[0][0]) return cap[0][1]
|
||||
const last = cap[cap.length - 1]
|
||||
if (t >= last[0]) return last[1]
|
||||
for (let i = 1; i < cap.length; i++) {
|
||||
if (cap[i][0] >= t) return cap[i - 1][1]
|
||||
}
|
||||
return last[1]
|
||||
}
|
||||
|
||||
_updateCaptureZones() {
|
||||
for (const group of this.zoneGroup.children) {
|
||||
const { zone, fill } = group.userData
|
||||
if (!zone || !fill) continue
|
||||
// Same logic as the 2D engine: owner is slot 2 if value>0 else slot 1,
|
||||
// coloured green when it matches the winning slot, red otherwise.
|
||||
const value = this._captureValueAt(zone, this.currentT)
|
||||
const frac = Math.min(1, Math.abs(value) / 100)
|
||||
if (frac <= 0.01) { fill.visible = false; continue }
|
||||
const ownerSlot = value > 0 ? 2 : 1
|
||||
fill.visible = true
|
||||
fill.material.color.set(ownerSlot === this.winnerSlot ? WIN_COLOR : LOSE_COLOR)
|
||||
fill.material.opacity = 0.18 + frac * 0.34
|
||||
const s = Math.sqrt(frac)
|
||||
fill.scale.set(s, s, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- kill lines ----------------------------------------------------------
|
||||
|
||||
_buildKillLines() {
|
||||
this.killLines = []
|
||||
for (const k of this.kills) {
|
||||
const start = this._toWorld(this._renderPoint({ x: k.killerPos.x, y: this.bounds.minY, z: k.killerPos.z }))
|
||||
const end = this._toWorld(this._renderPoint({ x: k.victimPos.x, y: this.bounds.minY, z: k.victimPos.z }))
|
||||
start.y += 12; end.y += 12
|
||||
const vec = end.clone().sub(start)
|
||||
const length = vec.length()
|
||||
if (length < 0.5) continue
|
||||
const color = new THREE.Color(this.players[k.killerId]?.team === this.teamWon ? WIN_COLOR : LOSE_COLOR)
|
||||
const arrow = new THREE.ArrowHelper(vec.clone().normalize(), start, length, color,
|
||||
Math.min(28, Math.max(10, length * 0.12)), Math.min(16, Math.max(7, length * 0.055)))
|
||||
for (const m of [arrow.line.material, arrow.cone.material]) {
|
||||
m.transparent = true; m.opacity = 0; m.depthTest = false; m.depthWrite = false
|
||||
}
|
||||
arrow.renderOrder = 18
|
||||
arrow.visible = false
|
||||
this.killLineGroup.add(arrow)
|
||||
this.killLines.push({ time: k.time, arrow })
|
||||
}
|
||||
}
|
||||
|
||||
_updateKillLines() {
|
||||
for (const { time, arrow } of this.killLines) {
|
||||
const age = this.currentT - time
|
||||
const visible = age >= 0 && age <= KILL_LINE_WINDOW_MS
|
||||
arrow.visible = visible
|
||||
if (!visible) continue
|
||||
const opacity = 1 - age / KILL_LINE_WINDOW_MS
|
||||
arrow.line.material.opacity = 0.18 + opacity * 0.62
|
||||
arrow.cone.material.opacity = 0.24 + opacity * 0.72
|
||||
}
|
||||
}
|
||||
|
||||
// ---- interpolation -------------------------------------------------------
|
||||
|
||||
_findSegment(entity, t) {
|
||||
const path = entity.path
|
||||
if (t < entity.startT || t > entity.endT) return null
|
||||
let lo = 0, hi = path.length - 1
|
||||
while (lo < hi - 1) {
|
||||
const mid = (lo + hi) >> 1
|
||||
if (path[mid].t <= t) lo = mid; else hi = mid
|
||||
}
|
||||
return { index: lo, a: path[lo], b: path[Math.min(lo + 1, path.length - 1)] }
|
||||
}
|
||||
|
||||
_interp(entity, t) {
|
||||
const seg = this._findSegment(entity, t)
|
||||
if (!seg) return null
|
||||
const { index, a, b } = seg
|
||||
const path = entity.path
|
||||
const span = Math.max(1, b.t - a.t)
|
||||
const alpha = Math.min(1, Math.max(0, (t - a.t) / span))
|
||||
const before = path[Math.max(0, index - 1)]
|
||||
const after = path[Math.min(path.length - 1, index + 2)]
|
||||
return {
|
||||
t,
|
||||
x: catmullRom(before.x, a.x, b.x, after.x, alpha),
|
||||
y: catmullRom(before.y, a.y, b.y, after.y, alpha),
|
||||
z: catmullRom(before.z, a.z, b.z, after.z, alpha),
|
||||
}
|
||||
}
|
||||
|
||||
_directionAt(entity, t) {
|
||||
const beforeT = Math.max(entity.startT, t - DIRECTION_SAMPLE_MS)
|
||||
const afterT = Math.min(entity.endT, t + DIRECTION_SAMPLE_MS)
|
||||
if (afterT <= beforeT) return null
|
||||
const before = this._interp(entity, beforeT)
|
||||
const after = this._interp(entity, afterT)
|
||||
if (!before || !after) return null
|
||||
const dir = this._toWorld(this._renderPoint(after)).sub(this._toWorld(this._renderPoint(before)))
|
||||
if (dir.lengthSq() < 0.0001) return null
|
||||
return dir.normalize()
|
||||
}
|
||||
|
||||
_blendedDirection(visual, t) {
|
||||
const raw = this._directionAt(visual.entity, t)
|
||||
if (!raw) return visual.smoothedDirection
|
||||
const reset = visual.lastDirectionT == null || Math.abs(t - visual.lastDirectionT) > 1800
|
||||
if (reset || !visual.smoothedDirection) visual.smoothedDirection = raw.clone()
|
||||
else visual.smoothedDirection.lerp(raw, DIRECTION_BLEND).normalize()
|
||||
visual.lastDirectionT = t
|
||||
return visual.smoothedDirection
|
||||
}
|
||||
|
||||
// ---- highlight / focus ---------------------------------------------------
|
||||
|
||||
_applyHighlight() {
|
||||
const sel = this.selectedPlayerId
|
||||
if (!this.visualEntities) return
|
||||
for (const visual of this.visualEntities) {
|
||||
const isSel = sel != null && visual.entity.playerId === sel
|
||||
const teamColor = this._colorFor(visual.entity)
|
||||
const mat = visual.line.material
|
||||
if (sel == null) {
|
||||
mat.opacity = 0.42; mat.color.set(teamColor); visual.line.renderOrder = 0
|
||||
} else if (isSel) {
|
||||
mat.opacity = 1; mat.color.set(teamColor).lerp(new THREE.Color(0xffffff), 0.35); visual.line.renderOrder = 30
|
||||
} else {
|
||||
mat.opacity = 0.08; mat.color.set(teamColor); visual.line.renderOrder = 0
|
||||
}
|
||||
if (visual.model?.isGroup) {
|
||||
visual.model.traverse((child) => {
|
||||
if (child.isMesh && child.material?.emissive) child.material.emissiveIntensity = isSel ? 2.6 : 1
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
focus(playerId) {
|
||||
this.selectedPlayerId = this.selectedPlayerId === playerId ? null : playerId
|
||||
this._applyHighlight()
|
||||
if (this.selectedPlayerId == null) return
|
||||
const visual = (this.visualEntities || []).find((v) => v.entity.playerId === playerId && v.group.visible)
|
||||
|| (this.visualEntities || []).find((v) => v.entity.playerId === playerId)
|
||||
if (!visual) return
|
||||
const p = this._interp(visual.entity, this.currentT)
|
||||
if (!p) return
|
||||
const world = this._toWorld(this._renderPoint(p))
|
||||
const offset = this.camera.position.clone().sub(this.controls.target)
|
||||
this.controls.target.copy(world)
|
||||
this.camera.position.copy(world.clone().add(offset))
|
||||
this.controls.update()
|
||||
}
|
||||
|
||||
// ---- public API ----------------------------------------------------------
|
||||
|
||||
setTime(t) {
|
||||
this.currentT = t
|
||||
if (!this.visualEntities) return
|
||||
for (const visual of this.visualEntities) {
|
||||
const p = this._interp(visual.entity, t)
|
||||
visual.group.visible = Boolean(p)
|
||||
if (!p) continue
|
||||
visual.group.position.copy(this._toWorld(this._renderPoint(p)))
|
||||
const dir = this._blendedDirection(visual, t)
|
||||
visual.arrow.visible = Boolean(dir)
|
||||
if (dir) {
|
||||
visual.arrow.setDirection(dir)
|
||||
if (visual.model?.isGroup) {
|
||||
const flat = dir.clone(); flat.y = 0
|
||||
if (flat.lengthSq() > 0) {
|
||||
flat.normalize()
|
||||
visual.model.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), flat)
|
||||
visual.model.rotateY(Math.PI)
|
||||
visual.model.rotateY(-Math.PI / 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this._updateCaptureZones()
|
||||
this._updateKillLines()
|
||||
}
|
||||
|
||||
setMode(mode) {
|
||||
const next = mode === 'air' && normalizeCoords(this.data.mapCoords) ? 'air' : 'ground'
|
||||
if (next === this._mode) return
|
||||
this._mode = next
|
||||
this.mapInfo = this._resolveMapInfo(next)
|
||||
this.smoothByEntity.clear()
|
||||
this._buildScene()
|
||||
this._loadMapTexture()
|
||||
this.setTime(this.currentT)
|
||||
}
|
||||
|
||||
resize() {
|
||||
const w = this.container.clientWidth || this.container.offsetWidth || 720
|
||||
const h = this.container.clientHeight || w
|
||||
this.renderer.setSize(w, h, false)
|
||||
this.camera.aspect = w / Math.max(1, h)
|
||||
this.camera.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
_animate() {
|
||||
if (this.disposed) return
|
||||
this.controls.update()
|
||||
this.renderer.render(this.scene, this.camera)
|
||||
this._raf = requestAnimationFrame(this._animate)
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.disposed = true
|
||||
if (this._raf) cancelAnimationFrame(this._raf)
|
||||
this.controls?.dispose()
|
||||
this._clearGroup(this.mapGroup)
|
||||
this._clearGroup(this.pathGroup)
|
||||
this._clearGroup(this.markerGroup)
|
||||
this._clearGroup(this.zoneGroup)
|
||||
this._clearGroup(this.killLineGroup)
|
||||
this.renderer?.dispose()
|
||||
if (this.canvasEl?.parentNode) this.canvasEl.parentNode.removeChild(this.canvasEl)
|
||||
}
|
||||
}
|
||||
|
||||
window.ReplayCanvas3D = ReplayCanvas3D
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,332 @@
|
||||
// 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();
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
// 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();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user