add SREBOT, SHARED, TSSBOT contents (fixup for #1223)

PR #1223 only staged the deletions of the old paths because the new
top-level directories were still untracked when the commit was authored.
This commit adds the actual restructured tree: SREBOT/ (existing bot),
SHARED/ (vromfs, data_parser, ICONS/MAPS/FONTS, DAGOR_FILES,
update_game_files), and TSSBOT/ (skeleton).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
FURRO404
2026-05-13 23:17:02 -07:00
commit 2b399fdb81
186 changed files with 96596 additions and 0 deletions
+362
View File
@@ -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;
}
};
+20
View File
File diff suppressed because one or more lines are too long
+513
View File
@@ -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;
+82
View File
@@ -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();
}
});
+483
View File
@@ -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');
}
});
+827
View File
@@ -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')}">&times;</button>
</div>
<div class="pdm-tabs">
<div class="pdm-tab-group" id="pdm-tab-group">
<div class="pdm-tab-slider" id="pdm-tab-slider"></div>
<button class="pdm-tab active" data-tab="overview">${T('playerModal.overview')}</button>
<button class="pdm-tab" data-tab="vehicles">${T('playerModal.vehicles')}</button>
<button class="pdm-tab" data-tab="sessions">${T('playerModal.sessions')}</button>
</div>
</div>
<div class="pdm-body" id="pdm-body">
<div class="pdm-loading">
<div class="pdm-spinner"></div>
<span>${T('playerModal.loadingPlayerData')}</span>
</div>
</div>
</div>
`;
document.body.appendChild(overlay);
// Events
overlay.addEventListener('click', function (e) {
if (e.target === overlay) closeModal();
});
document.getElementById('pdm-close').addEventListener('click', closeModal);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && overlay.style.display !== 'none') closeModal();
});
// Tab switching
document.getElementById('pdm-tab-group').addEventListener('click', function (e) {
const btn = e.target.closest('.pdm-tab');
if (!btn || btn.classList.contains('active')) return;
document.querySelectorAll('.pdm-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentTab = btn.dataset.tab;
updateSlider();
renderTab();
});
// Prevent modal scroll from propagating
document.getElementById('pdm-modal').addEventListener('click', function (e) {
e.stopPropagation();
});
}
function updateSlider() {
const group = document.getElementById('pdm-tab-group');
const slider = document.getElementById('pdm-tab-slider');
const active = group.querySelector('.pdm-tab.active');
if (!active || !slider) return;
slider.style.left = active.offsetLeft + 'px';
slider.style.width = active.offsetWidth + 'px';
}
let playerDataCache = null;
let gamesDataCache = null;
let historyDataCache = null;
let miniChart = null;
let miniChartMetric = 'kdr';
let currentKdrKps = 'kdr';
const miniChartMetrics = ['kdr', 'win_rate', 'battles'];
const miniChartConfig = {
kdr: { label: T('playerModal.kdr'), color: '#64b5f6', suffix: '' },
win_rate: { label: T('playerModal.winRate'), color: '#90EE90', suffix: '%' },
battles: { label: T('playerModal.battles'), color: '#ffb74d', suffix: '' }
};
function closeModal() {
const overlay = document.getElementById('pdm-overlay');
overlay.classList.remove('pdm-visible');
setTimeout(() => {
overlay.style.display = 'none';
document.body.style.overflow = '';
}, 200);
// Destroy mini chart to prevent canvas reuse issues
if (miniChart) {
miniChart.destroy();
miniChart = null;
}
}
function renderTab() {
const body = document.getElementById('pdm-body');
if (!playerDataCache) return;
// Destroy old mini chart before re-rendering
if (miniChart) {
miniChart.destroy();
miniChart = null;
}
if (currentTab === 'overview') {
body.innerHTML = renderOverview(playerDataCache);
initMiniChart();
} else if (currentTab === 'vehicles') {
body.innerHTML = renderVehicles(playerDataCache);
initVehicleKdrToggle();
} else if (currentTab === 'sessions') {
body.innerHTML = renderSessions(gamesDataCache);
}
}
function renderOverview(data) {
const vehicles = (data.vehicles || []).filter(v => v.vehicle !== 'DISCONNECTED');
let totalBattles = 0, wins = 0, groundKills = 0, airKills = 0, assists = 0, deaths = 0, captures = 0;
vehicles.forEach(v => {
const s = v.stats;
totalBattles += s.total_battles || 0;
wins += s.wins || 0;
groundKills += s.ground_kills || 0;
airKills += s.air_kills || 0;
assists += s.assists || 0;
deaths += s.deaths || 0;
captures += s.captures || 0;
});
const totalKills = groundKills + airKills;
const winRate = totalBattles > 0 ? ((wins / totalBattles) * 100).toFixed(1) + '%' : '0.0%';
const kdr = deaths > 0 ? (totalKills / deaths).toFixed(2) : totalKills.toFixed(2);
const kps = totalBattles > 0 ? (totalKills / totalBattles).toFixed(2) : '0.00';
const stats = [
[T('playerModal.totalBattles'), formatNumber(totalBattles)],
[T('playerModal.wins'), formatNumber(wins)],
[T('playerModal.winRate'), winRate],
[T('playerModal.totalKills'), formatNumber(totalKills)],
[T('playerModal.kdr'), kdr],
[T('playerModal.kps'), kps],
[T('playerModal.airKills'), formatNumber(airKills)],
[T('playerModal.groundKills'), formatNumber(groundKills)],
[T('playerModal.assists'), formatNumber(assists)],
[T('playerModal.deaths'), formatNumber(deaths)],
[T('playerModal.captures'), formatNumber(captures)],
];
const cfg = miniChartConfig[miniChartMetric];
const chartCard = `<div class="pdm-chart-card" id="pdm-chart-card" title="${T('playerModal.clickToSwitchMetric')}">
<div class="pdm-chart-label" id="pdm-chart-metric-label">${cfg.label}</div>
<div class="pdm-mini-canvas-wrap"><canvas class="pdm-mini-canvas" id="pdm-mini-canvas"></canvas></div>
<div class="pdm-chart-hint">${T('playerModal.clickToCycle')}</div>
</div>`;
const fullRows = stats.slice(0, Math.floor(stats.length / 3) * 3);
const remainder = stats.slice(fullRows.length);
const cardHtml = s => `<div class="pdm-stat-card"><div class="pdm-stat-value">${s[1]}</div><div class="pdm-stat-label">${s[0]}</div></div>`;
const lastRow = remainder.length ? `<div class="pdm-stats-grid-last-row">${remainder.map(cardHtml).join('')}</div>` : '';
return `<div class="pdm-stats-grid">${fullRows.map(cardHtml).join('')}</div>${lastRow}${chartCard}`;
}
function initVehicleKdrToggle() {
const toggle = document.getElementById('pdm-veh-kdr-toggle');
if (!toggle) return;
toggle.addEventListener('click', function (e) {
const span = e.target.closest('[data-mode]');
if (!span || span.classList.contains('pdm-toggle-active')) return;
currentKdrKps = span.dataset.mode;
renderTab();
});
}
function initMiniChart() {
const card = document.getElementById('pdm-chart-card');
if (!card) return;
card.addEventListener('click', function () {
const idx = miniChartMetrics.indexOf(miniChartMetric);
miniChartMetric = miniChartMetrics[(idx + 1) % miniChartMetrics.length];
renderMiniChart();
});
// Load history data if not cached, then render
if (historyDataCache) {
renderMiniChart();
} else if (currentUid) {
window.apiClient.request('/api/player/' + currentUid + '/history').then(data => {
historyDataCache = data;
renderMiniChart();
}).catch(() => {
const canvas = document.getElementById('pdm-mini-canvas');
if (canvas) {
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.font = '11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(T('playerModal.noChartData'), canvas.width / 2, canvas.height / 2);
}
});
}
}
function renderMiniChart() {
if (!historyDataCache || !historyDataCache.history || !historyDataCache.history.length) return;
if (typeof Chart === 'undefined') return;
const canvas = document.getElementById('pdm-mini-canvas');
const label = document.getElementById('pdm-chart-metric-label');
if (!canvas) return;
const cfg = miniChartConfig[miniChartMetric];
if (label) label.textContent = cfg.label;
const history = historyDataCache.history;
const dataPoints = history
.filter(d => d[miniChartMetric] != null)
.map(d => ({ x: new Date(d.period + 'T00:00:00Z').getTime(), y: d[miniChartMetric] }));
if (!dataPoints.length) return;
if (miniChart) {
miniChart.data.datasets[0].data = dataPoints;
miniChart.data.datasets[0].borderColor = cfg.color;
miniChart.data.datasets[0].backgroundColor = cfg.color + '18';
miniChart.data.datasets[0].pointBackgroundColor = cfg.color;
miniChart.options.plugins.tooltip.borderColor = cfg.color;
miniChart.options.plugins.tooltip.bodyColor = cfg.color;
miniChart.options.plugins.tooltip.callbacks.label = ctx => `${cfg.label}: ${ctx.parsed.y}${cfg.suffix}`;
miniChart.update();
return;
}
miniChart = new Chart(canvas.getContext('2d'), {
type: 'line',
data: {
datasets: [{
data: dataPoints,
borderColor: cfg.color,
backgroundColor: cfg.color + '18',
borderWidth: 1.5,
pointBackgroundColor: cfg.color,
pointRadius: 0,
pointHoverRadius: 3,
tension: 0.15,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 300 },
plugins: {
legend: { display: false },
tooltip: {
enabled: true,
backgroundColor: 'rgba(15,15,15,0.92)',
borderColor: cfg.color,
borderWidth: 1,
titleColor: 'rgba(255,255,255,0.75)',
bodyColor: cfg.color,
padding: 6,
displayColors: false,
callbacks: {
title: () => '',
label: ctx => `${cfg.label}: ${ctx.parsed.y}${cfg.suffix}`
}
}
},
scales: {
x: { type: 'linear', display: false, min: (function() { var y = new Date(history[0].period + 'T00:00:00Z').getUTCFullYear(); return Date.UTC(y, 0, 1); })() },
y: { display: false, beginAtZero: true }
}
}
});
}
let currentUid = null;
function renderVehicles(data) {
const vehicles = (data.vehicles || []).filter(v => v.vehicle !== 'DISCONNECTED');
if (!vehicles.length) return `<div class="pdm-no-data"><i class="fas fa-box-open" style="font-size:2rem;margin-bottom:0.5rem;display:block;"></i>${T('playerModal.noVehicleData')}</div>`;
// Sort by total battles desc
vehicles.sort((a, b) => (b.stats.total_battles || 0) - (a.stats.total_battles || 0));
// Find best WR vehicle with >15 games
let bestWrVehicle = null;
let bestWr = -1;
vehicles.forEach(v => {
const battles = v.stats.total_battles || 0;
if (battles > 15) {
const wr = (v.stats.wins || 0) / battles;
if (wr > bestWr) {
bestWr = wr;
bestWrVehicle = v.vehicle;
}
}
});
const mode = currentKdrKps || 'kdr';
const kdrCls = mode === 'kdr' ? 'pdm-toggle-active' : 'pdm-toggle-inactive';
const kpsCls = mode === 'kps' ? 'pdm-toggle-active' : 'pdm-toggle-inactive';
const toggleHeader = `<div class="pdm-stat-toggle" id="pdm-veh-kdr-toggle" style="margin-bottom:0.5rem;">
<span class="${kdrCls}" data-mode="kdr">KDR</span>
<span class="pdm-toggle-sep">/</span>
<span class="${kpsCls}" data-mode="kps">KPS</span>
</div>`;
return `${toggleHeader}<div class="pdm-vehicles-grid">${vehicles.map(v => {
const s = v.stats;
const totalKills = (s.ground_kills || 0) + (s.air_kills || 0);
const battles = s.total_battles || 0;
const wins = s.wins || 0;
const wrNum = battles > 0 ? (wins / battles) * 100 : 0;
const wr = wrNum.toFixed(1) + '%';
const kdrNum = s.deaths > 0 ? totalKills / s.deaths : totalKills;
const kdr = kdrNum.toFixed(2);
const kpsNum = battles > 0 ? totalKills / battles : 0;
const kps = kpsNum.toFixed(2);
const isCrown = v.vehicle === bestWrVehicle;
const crownClass = isCrown ? ' pdm-crown-card' : '';
const crownIcon = isCrown ? '<i class="fas fa-crown pdm-crown"></i>' : '';
const wrColor = getWinRateColor(wrNum);
const activeVal = mode === 'kdr' ? kdr : kps;
const activeNum = mode === 'kdr' ? kdrNum : kpsNum;
const activeColor = getKDRColor(activeNum);
const activeLabel = mode === 'kdr' ? T('playerModal.kdr') : T('playerModal.kps');
return `<div class="pdm-vehicle-card${crownClass}">
<div class="pdm-vehicle-name" title="${escapeHtml(v.vehicle)}">${crownIcon}${escapeHtml(v.vehicle)}</div>
<div class="pdm-vehicle-stats">
<span>${T('playerModal.battles')}: <span class="pdm-vs-val" style="color:#90EE90">${battles}</span></span>
<span>${T('playerModal.wins')}: <span class="pdm-vs-val" style="color:#90EE90">${wins}</span></span>
<span>${T('playerModal.winRate')}: <span class="pdm-vs-val" style="color:${wrColor}">${wr}</span></span>
<span>${activeLabel}: <span class="pdm-vs-val" style="color:${activeColor}">${activeVal}</span></span>
<span>${T('playerModal.ground')}: <span class="pdm-vs-val" style="color:#90EE90">${s.ground_kills || 0}</span></span>
<span>${T('playerModal.air')}: <span class="pdm-vs-val" style="color:#90EE90">${s.air_kills || 0}</span></span>
<span>${T('playerModal.assists')}: <span class="pdm-vs-val" style="color:#90EE90">${s.assists || 0}</span></span>
<span>${T('playerModal.deaths')}: <span class="pdm-vs-val" style="color:#90EE90">${s.deaths || 0}</span></span>
</div>
</div>`;
}).join('')}</div>`;
}
function renderSessions(gamesData) {
if (!gamesData || !gamesData.games || !gamesData.games.length) {
return `<div class="pdm-no-data"><i class="fas fa-history" style="font-size:2rem;margin-bottom:0.5rem;display:block;"></i>${T('playerModal.noSessionData')}</div>`;
}
const games = gamesData.games
.slice()
.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))
.slice(0, 50);
const rows = games.map(g => {
const date = new Date(g.timestamp * 1000);
const fmt = date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const result = g.result || T('playerModal.unknown');
const isWin = result.toLowerCase() === 'win';
const badgeClass = isWin ? 'pdm-result-win' : 'pdm-result-loss';
return `<tr>
<td>${fmt}</td>
<td>${escapeHtml(g.vehicle || g.vehicle_internal || T('playerModal.unknown'))}</td>
<td>${g.stats.ground_kills || 0}</td>
<td>${g.stats.air_kills || 0}</td>
<td>${g.stats.assists || 0}</td>
<td>${g.stats.deaths || 0}</td>
<td><span class="pdm-result-badge ${badgeClass}">${result}</span></td>
</tr>`;
}).join('');
return `<div class="pdm-sessions-wrap"><table class="pdm-sessions-table">
<thead><tr>
<th>${T('playerModal.date')}</th><th>${T('playerModal.vehicle')}</th><th>${T('playerModal.ground')}</th><th>${T('playerModal.air')}</th><th>${T('playerModal.assists')}</th><th>${T('playerModal.deaths')}</th><th>${T('playerModal.result')}</th>
</tr></thead>
<tbody>${rows}</tbody>
</table></div>`;
}
window.openPlayerDetailsModal = async function (uid, nick) {
injectModal();
const overlay = document.getElementById('pdm-overlay');
const body = document.getElementById('pdm-body');
const nameEl = document.getElementById('pdm-player-name');
const linkEl = document.getElementById('pdm-profile-link');
// Reset state
currentTab = 'overview';
miniChartMetric = 'kdr';
currentKdrKps = 'kdr';
playerDataCache = null;
gamesDataCache = null;
historyDataCache = null;
currentUid = uid;
if (miniChart) {
miniChart.destroy();
miniChart = null;
}
document.querySelectorAll('.pdm-tab').forEach(b => b.classList.remove('active'));
document.querySelector('.pdm-tab[data-tab="overview"]').classList.add('active');
nameEl.textContent = nick || uid;
linkEl.href = '/players/' + encodeURIComponent(uid);
body.innerHTML = `<div class="pdm-loading"><div class="pdm-spinner"></div><span>${T('playerModal.loadingPlayerData')}</span></div>`;
overlay.style.display = 'flex';
document.body.style.overflow = 'hidden';
// Trigger animation
requestAnimationFrame(() => {
overlay.classList.add('pdm-visible');
updateSlider();
});
try {
const [playerData, gamesData] = await Promise.all([
window.apiClient.getPlayer(uid),
window.apiClient.getPlayerGames(uid)
]);
playerDataCache = playerData;
gamesDataCache = gamesData;
renderTab();
} catch (err) {
console.error('[Player Details Modal] Error:', err);
body.innerHTML = `<div class="pdm-error"><i class="fas fa-exclamation-triangle" style="display:block;margin-bottom:0.5rem;"></i>${T('playerModal.failedToLoadPlayerData')}</div>`;
}
};
})();
+91
View File
@@ -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()}`;
});
})();
+93
View File
@@ -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()}`;
});
})();
+900
View File
@@ -0,0 +1,900 @@
/**
* replay-canvas.js
*
* Interactive HTML5 Canvas replay viewer for War Thunder GOB replays.
*/
const RC = {
TRAIL_MS: 18000, AIR_TRAIL_MS: 4000, DRONE_TRAIL_MS: 2000,
KILL_TTL: 8000, DMG_TTL: 4000, GHOST_TTL: 3000, DEFAULT_SPEED: 4,
WIN: '#00c800', LOSE: '#dc1e1e',
WIN_DIM: 'rgba(0,200,0,', LOSE_DIM: 'rgba(220,30,30,',
WIN_TRAIL: 'rgba(0,120,0,', LOSE_TRAIL: 'rgba(132,18,18,',
DOT_R: 5, AIR_R: 4, DRONE_R: 3,
};
function replayT(key) {
return (window.__t && window.__t(key)) || key;
}
class ReplayCanvas {
constructor(containerEl, data) {
this.container = containerEl;
this.data = data;
this.playing = false;
this.speed = RC.DEFAULT_SPEED;
this.currentTime = 0;
this.tStart = Infinity;
this.tEnd = -Infinity;
this.lastFrameTime = 0;
this.highlightedPlayerId = null;
this.animFrameId = null;
this.canvasSize = 720;
this.canvas = null;
this.ctx = null;
this.mapCanvas = null;
this.mapCtx = null;
// Store both coordinate sets
this._groundCoords = data.levelCoords;
this._airCoords = data.mapCoords || null;
this._fullMapLevel = data.fullMapLevel || null;
this._mode = 'ground';
const hasAircraft = data.entities.some(e => e.type === 'aircraft');
this.hasAirMode = !!(this._airCoords && this._fullMapLevel && hasAircraft);
this.x0 = data.levelCoords.x0;
this.z0 = data.levelCoords.z0;
this.xRange = data.levelCoords.x1 - data.levelCoords.x0;
this.zRange = data.levelCoords.z1 - data.levelCoords.z0;
// Default map source rect (full image) — overwritten by _computeAutocrop
this._mapSrc = { u: 0, v: 0, w: 1, h: 1 };
this.players = {};
for (const p of data.players) this.players[p.id] = p;
this.entities = [];
for (const e of data.entities) {
if (!e.path || e.path.length === 0) continue;
const times = new Float64Array(e.path.length);
const positions = new Float32Array(e.path.length * 2);
for (let i = 0; i < e.path.length; i++) {
times[i] = e.path[i].t;
positions[i * 2] = e.path[i].x;
positions[i * 2 + 1] = e.path[i].z;
}
if (times[0] < this.tStart) this.tStart = times[0];
if (times[times.length - 1] > this.tEnd) this.tEnd = times[times.length - 1];
const isWinner = e.playerId > 0
? (this.players[e.playerId]?.team === data.teamWon)
: (e.droneTeam === data.teamWon);
this.entities.push({
...e, times, positions, isWinner,
deathTime: null, ghostEndTime: null, deathPos: null,
});
}
// Zoom into the area players actually use (like the video maker autocrop)
this._computeAutocrop();
// Pre-compute deaths
this._computeDeaths();
this.currentTime = this.tStart;
}
_computeDeaths() {
// Reset deaths so they can be recomputed after coord changes
for (const ent of this.entities) {
ent.deathTime = null;
ent.ghostEndTime = null;
ent.deathPos = null;
}
for (const k of this.data.kills) {
for (const ent of this.entities) {
const matched = (k.victimEntityIndex && ent.entityIndex === k.victimEntityIndex)
|| (k.victimId && ent.playerId === k.victimId && ent.playerId !== 0);
if (matched && ent.deathTime === null) {
ent.deathTime = k.time;
ent.ghostEndTime = k.time + RC.GHOST_TTL;
if (k.victimPos) ent.deathPos = this.worldToPixel(k.victimPos.x, k.victimPos.z);
break;
}
}
}
}
worldToPixel(x, z) {
return [
(x - this.x0) / this.xRange * this.canvasSize,
(this.z0 + this.zRange - z) / this.zRange * this.canvasSize
];
}
/** Recompute x0/z0/xRange/zRange to zoom into entity activity.
* In 'ground' mode crops to ground entities, in 'air' mode crops to aircraft+drones. */
_computeAutocrop() {
const origX0 = this.x0, origZ0 = this.z0;
const origXR = this.xRange, origZR = this.zRange;
const airMode = this._mode === 'air';
let minX = Infinity, maxX = -Infinity;
let minZ = Infinity, maxZ = -Infinity;
for (const ent of this.entities) {
if (airMode) {
if (ent.type !== 'aircraft' && ent.type !== 'drone') continue;
} else {
if (ent.type !== 'ground') continue;
}
const { positions } = ent;
for (let i = 0; i < positions.length; i += 2) {
const x = positions[i], z = positions[i + 1];
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (z < minZ) minZ = z;
if (z > maxZ) maxZ = z;
}
}
if (!isFinite(minX)) return; // no relevant positions — keep full map
// Padding: 10% of span or 50 world units, whichever is larger
const span = Math.max(maxX - minX, maxZ - minZ);
const pad = Math.max(50, span * 0.10);
minX -= pad; maxX += pad;
minZ -= pad; maxZ += pad;
// Expand to square, with a minimum of 15% of the map range
const minSide = Math.max(origXR, origZR) * 0.15;
let side = Math.max(maxX - minX, maxZ - minZ, minSide);
const midX = (minX + maxX) / 2, midZ = (minZ + maxZ) / 2;
minX = midX - side / 2; maxX = midX + side / 2;
minZ = midZ - side / 2; maxZ = midZ + side / 2;
// Clamp to LevelDef bounds (shift first, then hard-clamp)
const x1 = origX0 + origXR, z1 = origZ0 + origZR;
if (minX < origX0) { maxX += origX0 - minX; minX = origX0; }
if (maxX > x1) { minX -= maxX - x1; maxX = x1; }
if (minZ < origZ0) { maxZ += origZ0 - minZ; minZ = origZ0; }
if (maxZ > z1) { minZ -= maxZ - z1; maxZ = z1; }
minX = Math.max(minX, origX0); maxX = Math.min(maxX, x1);
minZ = Math.max(minZ, origZ0); maxZ = Math.min(maxZ, z1);
// Fractional source rect for the minimap image
// Image top-left = world (origX0, origZ0+origZR), bottom-right = (origX0+origXR, origZ0)
this._mapSrc = {
u: (minX - origX0) / origXR,
v: (z1 - maxZ) / origZR,
w: (maxX - minX) / origXR,
h: (maxZ - minZ) / origZR,
};
// Apply new bounds — worldToPixel will now map this sub-region to the full canvas
this.x0 = minX;
this.z0 = minZ;
this.xRange = maxX - minX;
this.zRange = maxZ - minZ;
}
getPositionAtTime(entity, time) {
const { times, positions } = entity;
if (time < times[0] || time > times[times.length - 1]) return null;
let lo = 0, hi = times.length - 1;
while (lo < hi - 1) {
const mid = (lo + hi) >> 1;
if (times[mid] <= time) lo = mid; else hi = mid;
}
const t0 = times[lo], t1 = times[hi];
const frac = t1 > t0 ? (time - t0) / (t1 - t0) : 0;
const i0 = lo * 2, i1 = hi * 2;
return this.worldToPixel(
positions[i0] + (positions[i1] - positions[i0]) * frac,
positions[i0 + 1] + (positions[i1 + 1] - positions[i0 + 1]) * frac
);
}
getHeadingAtTime(entity, time) {
// Compute heading in radians (0=up/north, CW) from position delta
const dt = 500; // sample window in game ms
const p0 = this.getPositionAtTime(entity, time - dt);
const p1 = this.getPositionAtTime(entity, time);
if (!p0 || !p1) return null;
const dx = p1[0] - p0[0];
const dy = p1[1] - p0[1];
if (Math.abs(dx) < 0.1 && Math.abs(dy) < 0.1) return null;
return Math.atan2(dx, -dy); // 0=up, CW positive
}
_entityScreenPos(entity, time) {
if (entity.deathTime !== null && time >= entity.deathTime) return entity.deathPos;
return this.getPositionAtTime(entity, time);
}
_isEntityDead(entity, time) {
return entity.deathTime !== null && time >= entity.deathTime;
}
_isEntityGone(entity, time) {
return entity.ghostEndTime !== null && time >= entity.ghostEndTime;
}
async init() {
this._buildDOM();
await Promise.all([this._loadMap(), this._loadEntityIcons()]);
this.playing = true;
this.playBtn.innerHTML = '<i class="fas fa-pause"></i>';
this.lastFrameTime = performance.now();
this._tick = this._tick.bind(this);
this.animFrameId = requestAnimationFrame(this._tick);
}
_buildDOM() {
this.container.innerHTML = '';
const layout = document.createElement('div');
layout.className = 'rc-layout';
// Left panel (winners)
this.leftPanel = document.createElement('div');
this.leftPanel.className = 'rc-panel rc-panel-win';
this._buildTeamPanel(this.leftPanel, true);
// Center
const center = document.createElement('div');
center.className = 'rc-center';
this.canvas = document.createElement('canvas');
this.canvas.width = this.canvasSize;
this.canvas.height = this.canvasSize;
this.canvas.className = 'rc-canvas';
this.ctx = this.canvas.getContext('2d');
center.appendChild(this.canvas);
// Controls
const controls = document.createElement('div');
controls.className = 'rc-controls';
controls.innerHTML = `
<button class="rc-btn rc-play" title="${replayT('replay.playPause')}"><i class="fas fa-play"></i></button>
<div class="rc-speeds">
<button class="rc-btn rc-sp" data-speed="1">1x</button>
<button class="rc-btn rc-sp" data-speed="2">2x</button>
<button class="rc-btn rc-sp active" data-speed="4">4x</button>
<button class="rc-btn rc-sp" data-speed="8">8x</button>
</div>
<input type="range" class="rc-scrub" min="0" max="1000" value="0">
<span class="rc-time">0:00 / 0:00</span>
`;
center.appendChild(controls);
// Battle log
const logWrap = document.createElement('div');
logWrap.className = 'rc-log-wrap';
logWrap.innerHTML = '<div class="rc-log" id="rcBattleLog"></div>';
center.appendChild(logWrap);
this.battleLog = logWrap.querySelector('#rcBattleLog');
// Pre-build sorted event list for the log
this._buildEventList();
// Right panel (losers)
this.rightPanel = document.createElement('div');
this.rightPanel.className = 'rc-panel rc-panel-lose';
this._buildTeamPanel(this.rightPanel, false);
layout.appendChild(this.leftPanel);
layout.appendChild(center);
layout.appendChild(this.rightPanel);
this.container.appendChild(layout);
// Wire controls
this.playBtn = controls.querySelector('.rc-play');
this.scrubber = controls.querySelector('.rc-scrub');
this.timeDisplay = controls.querySelector('.rc-time');
this.playBtn.addEventListener('click', () => this._togglePlay());
this.scrubber.addEventListener('input', () => {
this.currentTime = this.tStart + (this.scrubber.value / 1000) * (this.tEnd - this.tStart);
this._updatePanelDeathStates();
this._updateBattleLog();
this.render();
});
controls.querySelectorAll('.rc-sp').forEach(btn => {
btn.addEventListener('click', () => {
controls.querySelectorAll('.rc-sp').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
this.speed = parseInt(btn.dataset.speed);
});
});
// Canvas hover — store mouse pos, re-evaluate each frame
this._mouseOnCanvas = false;
this._mouseX = 0;
this._mouseY = 0;
this.canvas.addEventListener('mousemove', (ev) => {
this._mouseOnCanvas = true;
const rect = this.canvas.getBoundingClientRect();
this._mouseX = (ev.clientX - rect.left) * (this.canvasSize / rect.width);
this._mouseY = (ev.clientY - rect.top) * (this.canvasSize / rect.height);
});
this.canvas.addEventListener('mouseleave', () => {
this._mouseOnCanvas = false;
this._setHighlight(null);
});
// Offscreen map canvas
this.mapCanvas = document.createElement('canvas');
this.mapCanvas.width = this.canvasSize;
this.mapCanvas.height = this.canvasSize;
this.mapCtx = this.mapCanvas.getContext('2d');
}
_buildTeamPanel(panel, isWinner) {
// Show all players on this team, using their first entity (prefer ground)
const teamEntities = this.entities.filter(e => e.playerId > 0 && e.isWinner === isWinner);
const seen = new Set();
const unique = [];
// First pass: ground entities
for (const e of teamEntities) {
if (e.type === 'ground' && !seen.has(e.playerId)) { seen.add(e.playerId); unique.push(e); }
}
// Second pass: any remaining players (aircraft etc)
for (const e of teamEntities) {
if (!seen.has(e.playerId)) { seen.add(e.playerId); unique.push(e); }
}
const color = isWinner ? RC.WIN : RC.LOSE;
const dimColor = isWinner ? 'rgba(0,200,0,0.15)' : 'rgba(220,30,30,0.15)';
// Use squadron clan tag rendered with skyquake font
const firstPlayer = unique.length > 0 ? this.players[unique[0].playerId] : null;
const clanTag = firstPlayer?.clan || '';
const label = clanTag
? `<span class="rc-clan-tag">${this._esc(clanTag)}</span>`
: (isWinner ? 'Winners' : 'Losers');
let html = `<div class="rc-panel-head" style="border-bottom-color:${dimColor}">
<span class="rc-panel-label" style="color:${color}">${label}</span>
</div><div class="rc-panel-list">`;
for (const ent of unique) {
const p = this.players[ent.playerId];
const name = p ? this._esc(p.name) : '?';
const veh = this._esc(ent.vehicleName);
const panelIcon = ent.miniIcon ? ent.miniIcon.replace('mini:', '') : (ent.iconKey || 'medium');
html += `<div class="rc-row" data-player-id="${ent.playerId}" data-entity-index="${ent.entityIndex}">
<img class="rc-type-icon" src="/api/icons/type/${panelIcon}" alt="" loading="lazy" onerror="this.style.display='none'">
<div class="rc-row-info">
<span class="rc-row-name">${name}</span>
<span class="rc-row-veh">${veh}</span>
</div>
<span class="rc-row-status"></span>
</div>`;
}
html += '</div>';
panel.innerHTML = html;
// Hover
panel.querySelectorAll('.rc-row').forEach(row => {
row.addEventListener('mouseenter', () => {
const pid = parseInt(row.dataset.playerId);
const eidx = parseInt(row.dataset.entityIndex);
const ent = this.entities.find(e => e.entityIndex === eidx);
if (ent && !this._isEntityGone(ent, this.currentTime)) {
this._setHighlight(pid);
}
});
row.addEventListener('mouseleave', () => this._setHighlight(null));
});
}
_updatePanelDeathStates() {
const t = this.currentTime;
this.container.querySelectorAll('.rc-row').forEach(row => {
const eidx = parseInt(row.dataset.entityIndex);
const ent = this.entities.find(e => e.entityIndex === eidx);
if (!ent) return;
const dead = this._isEntityDead(ent, t);
const gone = this._isEntityGone(ent, t);
row.classList.toggle('rc-dead', dead);
row.classList.toggle('rc-gone', gone);
const status = row.querySelector('.rc-row-status');
if (gone) {
status.innerHTML = '<i class="fas fa-skull" style="opacity:0.3"></i>';
row.style.cursor = 'default';
} else if (dead) {
status.innerHTML = '<i class="fas fa-skull" style="opacity:0.5"></i>';
row.style.cursor = 'default';
} else {
status.innerHTML = '';
row.style.cursor = 'pointer';
}
});
}
_setHighlight(playerId) {
if (this.highlightedPlayerId === playerId) return;
this.highlightedPlayerId = playerId;
this.container.querySelectorAll('.rc-row').forEach(row => {
row.classList.toggle('rc-hl', parseInt(row.dataset.playerId) === playerId);
});
if (!this.playing) this.render();
}
_esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
_buildEventList() {
// Merge kills and damages into a single sorted timeline
this._events = [];
for (const k of this.data.kills) {
const killer = this.players[k.killerId];
// Find victim name
let victimName = '?';
let victimTeam = -1;
if (k.victimId && this.players[k.victimId]) {
victimName = this.players[k.victimId].name;
victimTeam = this.players[k.victimId].team;
} else if (k.victimVehicle) {
victimName = k.victimVehicle;
}
let html;
if (!killer) {
// No killer (crash / environment kill)
const victimIsWin = victimTeam === this.data.teamWon;
html = `<span class="${victimIsWin ? 'rc-ev-win' : 'rc-ev-lose'}">${this._esc(victimName)}</span>`
+ `<span class="rc-ev-action"> ${replayT('replay.crashed')}</span>`;
} else {
const killerIsWin = killer.team === this.data.teamWon;
html = `<span class="${killerIsWin ? 'rc-ev-win' : 'rc-ev-lose'}">${this._esc(killer.name)}</span>`
+ `<span class="rc-ev-action"> ${replayT('replay.destroyed')} </span>`
+ `<span class="${killerIsWin ? 'rc-ev-lose' : 'rc-ev-win'}">${this._esc(victimName)}</span>`
+ (k.weapon ? `<span class="rc-ev-weapon">[${this._esc(k.weapon)}]</span>` : '');
}
this._events.push({
time: k.time,
type: 'kill',
html,
});
}
for (const dm of this.data.damages) {
const atk = this.players[dm.offenderId];
const vic = this.players[dm.offendedId];
if (!atk || !vic) continue;
const atkIsWin = atk.team === this.data.teamWon;
this._events.push({
time: dm.time,
type: 'damage',
html: `<span class="${atkIsWin ? 'rc-ev-win' : 'rc-ev-lose'}">${this._esc(atk.name)}</span>`
+ `<span class="rc-ev-action"> ${replayT('replay.hit')} </span>`
+ `<span class="${atkIsWin ? 'rc-ev-lose' : 'rc-ev-win'}">${this._esc(vic.name)}</span>`,
});
}
this._events.sort((a, b) => a.time - b.time);
this._lastLogIndex = -1;
}
_updateBattleLog() {
const t = this.currentTime;
// Find how many events should be visible
let idx = -1;
for (let i = 0; i < this._events.length; i++) {
if (this._events[i].time <= t) idx = i;
else break;
}
if (idx === this._lastLogIndex) return;
this._lastLogIndex = idx;
// Rebuild log content
const log = this.battleLog;
log.innerHTML = '';
for (let i = 0; i <= idx; i++) {
const ev = this._events[i];
const el = document.createElement('div');
el.className = `rc-ev rc-ev-${ev.type}`;
const elapsed = (ev.time - this.tStart) / 1000;
const mm = Math.floor(elapsed / 60);
const ss = Math.floor(elapsed % 60);
el.innerHTML = `<span class="rc-ev-time">${mm}:${String(ss).padStart(2,'0')}</span>${ev.html}`;
log.appendChild(el);
}
// Auto-scroll to bottom
log.scrollTop = log.scrollHeight;
}
_getTintedIcon(iconKey, color, size) {
const cacheKey = `${iconKey}_${color}_${size}`;
if (!this._tintCache) this._tintCache = {};
if (this._tintCache[cacheKey]) return this._tintCache[cacheKey];
const img = this._iconCache?.[iconKey];
if (!img || !img.naturalWidth) return null;
try {
const c = document.createElement('canvas');
c.width = size; c.height = size;
const cx = c.getContext('2d');
const [dx, dy, dw, dh] = this._containedImageRect(img, size);
cx.drawImage(img, dx, dy, dw, dh);
cx.globalCompositeOperation = 'source-atop';
cx.fillStyle = color;
cx.fillRect(0, 0, size, size);
cx.globalCompositeOperation = 'source-over';
this._tintCache[cacheKey] = c;
return c;
} catch (e) {
// CORS or other canvas tainting — fall back to untinted
this._tintCache[cacheKey] = null;
return null;
}
}
_containedImageRect(img, size) {
const ratio = img.naturalWidth && img.naturalHeight ? img.naturalWidth / img.naturalHeight : 1;
let w = size, h = size;
if (ratio > 1) h = size / ratio;
else w = size * ratio;
return [(size - w) / 2, (size - h) / 2, w, h];
}
_drawContainedIcon(ctx, img, x, y, size) {
const [dx, dy, dw, dh] = this._containedImageRect(img, size);
ctx.drawImage(img, x - size / 2 + dx, y - size / 2 + dy, dw, dh);
}
async _loadEntityIcons() {
this._iconCache = {};
this._iconDebug = {};
const keysToLoad = new Set();
for (const ent of this.entities) {
if (ent.miniIcon) {
const miniKey = ent.miniIcon.replace('mini:', '');
keysToLoad.add(miniKey);
ent._canvasIconKey = miniKey;
} else if (ent.iconKey) {
keysToLoad.add(ent.iconKey);
ent._canvasIconKey = ent.iconKey;
}
}
const promises = [];
for (const key of keysToLoad) {
promises.push(new Promise((resolve) => {
const img = new Image();
img.onload = () => {
this._iconCache[key] = img;
this._iconDebug[key] = 'ok';
resolve();
};
img.onerror = () => {
this._iconDebug[key] = 'err';
resolve();
};
img.src = `/api/icons/type/${key}`;
}));
}
await Promise.all(promises);
}
async _loadMap() {
const level = this.data.mission?.level;
if (!level) { this.mapCtx.fillStyle = '#111'; this.mapCtx.fillRect(0, 0, this.canvasSize, this.canvasSize); return; }
const loadImg = (src) => new Promise((resolve) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = () => resolve(null);
img.src = src;
});
// Load ground (tank) map
this._groundMapImg = await loadImg(`/api/match/minimap/${level}`);
// Load full (air) map if available
if (this._fullMapLevel) {
this._airMapImg = await loadImg(`/api/match/minimap/${this._fullMapLevel}?type=full`);
} else {
this._airMapImg = null;
}
this._drawMapToCanvas();
}
_drawMapToCanvas() {
const img = this._mode === 'air' ? (this._airMapImg || this._groundMapImg) : this._groundMapImg;
if (!img) {
this.mapCtx.fillStyle = '#111';
this.mapCtx.fillRect(0, 0, this.canvasSize, this.canvasSize);
return;
}
const { u, v, w, h } = this._mapSrc;
const sx = u * img.naturalWidth, sy = v * img.naturalHeight;
const sw = w * img.naturalWidth, sh = h * img.naturalHeight;
this.mapCtx.drawImage(img, sx, sy, sw, sh, 0, 0, this.canvasSize, this.canvasSize);
}
setMode(mode) {
if (mode === this._mode) return;
if (mode === 'air' && !this.hasAirMode) return;
this._mode = mode;
// Swap coordinate system
const coords = mode === 'air' ? this._airCoords : this._groundCoords;
this.x0 = coords.x0;
this.z0 = coords.z0;
this.xRange = coords.x1 - coords.x0;
this.zRange = coords.z1 - coords.z0;
this._mapSrc = { u: 0, v: 0, w: 1, h: 1 };
// Recompute autocrop for the new entity filter
this._computeAutocrop();
// Redraw map background with new crop region
this._drawMapToCanvas();
// Recompute death positions in new coordinate space
this._computeDeaths();
// Render immediately
this.render();
}
_togglePlay() {
this.playing = !this.playing;
this.playBtn.innerHTML = this.playing ? '<i class="fas fa-pause"></i>' : '<i class="fas fa-play"></i>';
if (this.playing) {
if (this.currentTime >= this.tEnd) this.currentTime = this.tStart;
this.lastFrameTime = performance.now();
}
}
_tick(now) {
if (this.playing) {
const dt = now - this.lastFrameTime;
this.lastFrameTime = now;
this.currentTime += dt * this.speed;
if (this.currentTime >= this.tEnd) {
this.currentTime = this.tEnd;
this.playing = false;
this.playBtn.innerHTML = '<i class="fas fa-play"></i>';
}
}
this.render();
this._updateControls();
// Update panel death states + battle log every ~250ms
if (!this._lastPanelUpdate || now - this._lastPanelUpdate > 250) {
this._updatePanelDeathStates();
this._updateBattleLog();
this._lastPanelUpdate = now;
}
this.animFrameId = requestAnimationFrame(this._tick);
}
_updateControls() {
const frac = (this.currentTime - this.tStart) / (this.tEnd - this.tStart);
const pct = Math.round(frac * 1000);
this.scrubber.value = pct;
this.scrubber.style.setProperty('--rc-progress', (frac * 100).toFixed(1) + '%');
const cur = (this.currentTime - this.tStart) / 1000;
const total = (this.tEnd - this.tStart) / 1000;
const fmt = (s) => `${Math.floor(s/60)}:${String(Math.floor(s%60)).padStart(2,'0')}`;
this.timeDisplay.textContent = `${fmt(cur)} / ${fmt(total)}`;
}
_updateCanvasHighlight() {
if (!this._mouseOnCanvas) return;
let bestId = null, bestDist = 400;
for (const ent of this.entities) {
if (ent.playerId === 0) continue;
if (this._isEntityGone(ent, this.currentTime)) continue;
const pos = this._entityScreenPos(ent, this.currentTime);
if (!pos) continue;
const dx = pos[0] - this._mouseX, dy = pos[1] - this._mouseY;
const dist = dx * dx + dy * dy;
if (dist < bestDist) { bestDist = dist; bestId = ent.playerId; }
}
this._setHighlight(bestId);
}
render() {
const ctx = this.ctx;
const t = this.currentTime;
this._updateCanvasHighlight();
ctx.drawImage(this.mapCanvas, 0, 0);
this._drawTrails(ctx, t);
this._drawDamageLines(ctx, t);
this._drawKillLines(ctx, t);
this._drawEntities(ctx, t);
}
_drawTrails(ctx, time) {
for (const ent of this.entities) {
if (this._isEntityGone(ent, time)) continue;
const endT = ent.deathTime !== null ? Math.min(time, ent.deathTime) : time;
const trailLen = ent.type === 'ground' ? RC.TRAIL_MS
: ent.type === 'aircraft' ? (this._mode === 'air' ? RC.TRAIL_MS : RC.AIR_TRAIL_MS)
: RC.DRONE_TRAIL_MS;
const tMin = endT - trailLen;
const baseColor = ent.isWinner ? RC.WIN_TRAIL : RC.LOSE_TRAIL;
const { times, positions } = ent;
ctx.lineWidth = ent.type === 'ground' ? 2 : (this._mode === 'air' ? 2 : 1.5);
ctx.lineCap = 'round';
// Aircraft in air mode: interpolate at fixed time steps so trail
// segments are always pixel-visible (raw path points can be sub-pixel
// apart at full-map scale).
if (this._mode === 'air' && ent.type === 'aircraft') {
const step = 200; // ms between interpolated trail points
let prevPx = null, prevPy = null;
for (let t = Math.max(tMin, times[0]); t <= Math.min(endT, times[times.length - 1]); t += step) {
const pos = this.getPositionAtTime(ent, t);
if (!pos) continue;
const [px, py] = pos;
if (prevPx !== null) {
const age = time - t;
const alpha = Math.max(0.08, 1 - age / trailLen);
ctx.strokeStyle = baseColor + alpha.toFixed(2) + ')';
ctx.beginPath();
ctx.moveTo(prevPx, prevPy);
ctx.lineTo(px, py);
ctx.stroke();
}
prevPx = px; prevPy = py;
}
continue;
}
let prevPx = null, prevPy = null;
for (let i = 0; i < times.length; i++) {
if (times[i] < tMin) continue;
if (times[i] > endT) break;
const [px, py] = this.worldToPixel(positions[i*2], positions[i*2+1]);
if (prevPx !== null) {
const age = time - times[i];
const alpha = Math.max(0.08, 1 - age / trailLen);
ctx.strokeStyle = baseColor + alpha.toFixed(2) + ')';
ctx.beginPath();
ctx.moveTo(prevPx, prevPy);
ctx.lineTo(px, py);
ctx.stroke();
}
prevPx = px; prevPy = py;
}
}
}
_drawDamageLines(ctx, time) {
for (const dm of this.data.damages) {
const age = time - dm.time;
if (age < 0 || age > RC.DMG_TTL) continue;
// Find attacker and victim positions at damage time
const attacker = this.entities.find(e => e.playerId === dm.offenderId);
const victim = this.entities.find(e => e.playerId === dm.offendedId);
if (!attacker || !victim) continue;
const aPos = this.getPositionAtTime(attacker, dm.time);
const vPos = this.getPositionAtTime(victim, dm.time);
if (!aPos || !vPos) continue;
const alpha = Math.max(0, 1 - age / RC.DMG_TTL);
ctx.globalAlpha = alpha * 0.4;
ctx.strokeStyle = '#ffcc44';
ctx.lineWidth = 1;
ctx.setLineDash([3, 4]);
ctx.beginPath(); ctx.moveTo(aPos[0], aPos[1]); ctx.lineTo(vPos[0], vPos[1]); ctx.stroke();
ctx.setLineDash([]);
ctx.globalAlpha = 1;
}
}
_drawKillLines(ctx, time) {
for (const k of this.data.kills) {
const age = time - k.time;
if (age < 0 || age > RC.KILL_TTL) continue;
if (!k.killerPos || !k.victimPos) continue;
const alpha = Math.max(0, 1 - age / RC.KILL_TTL);
const [kx, ky] = this.worldToPixel(k.killerPos.x, k.killerPos.z);
const [vx, vy] = this.worldToPixel(k.victimPos.x, k.victimPos.z);
ctx.globalAlpha = alpha * 0.6;
ctx.strokeStyle = '#ff3333';
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 3]);
ctx.beginPath(); ctx.moveTo(kx, ky); ctx.lineTo(vx, vy); ctx.stroke();
ctx.setLineDash([]);
// X marker
ctx.globalAlpha = alpha * 0.9;
ctx.strokeStyle = '#ff3333';
ctx.lineWidth = 2;
const s = 5;
ctx.beginPath();
ctx.moveTo(vx-s, vy-s); ctx.lineTo(vx+s, vy+s);
ctx.moveTo(vx+s, vy-s); ctx.lineTo(vx-s, vy+s);
ctx.stroke();
// Weapon label
if (k.weapon && alpha > 0.4) {
ctx.font = '600 9px system-ui, sans-serif';
ctx.fillStyle = `rgba(255,230,100,${(alpha * 0.85).toFixed(2)})`;
ctx.fillText(k.weapon, (kx+vx)/2 + 6, (ky+vy)/2 - 6);
}
ctx.globalAlpha = 1;
}
}
_drawEntities(ctx, time) {
const hl = this.highlightedPlayerId;
// Draw dead entities first (fading)
for (const ent of this.entities) {
if (!this._isEntityDead(ent, time)) continue;
if (this._isEntityGone(ent, time)) continue;
const pos = ent.deathPos;
if (!pos) continue;
const [px, py] = pos;
const fade = 1 - (time - ent.deathTime) / RC.GHOST_TTL;
ctx.globalAlpha = Math.max(0, fade * 0.5);
ctx.fillStyle = '#333';
const r = ent.type === 'ground' ? RC.DOT_R : ent.type === 'aircraft' ? RC.AIR_R : RC.DRONE_R;
ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI*2); ctx.fill();
ctx.globalAlpha = 1;
}
// Draw alive entities
for (const ent of this.entities) {
if (this._isEntityDead(ent, time)) continue;
const pos = this.getPositionAtTime(ent, time);
if (!pos) continue;
const [px, py] = pos;
if (px < -20 || py < -20 || px > this.canvasSize+20 || py > this.canvasSize+20) continue;
let alpha = 1;
if (hl !== null && ent.playerId !== hl && ent.playerId !== 0) alpha = 0.25;
const color = ent.isWinner ? RC.WIN : RC.LOSE;
const iconSize = ent.type === 'ground' ? 12 : ent.type === 'aircraft' ? 20 : 14;
const iconImg = this._iconCache?.[ent._canvasIconKey];
ctx.globalAlpha = alpha;
// Highlight ring
if (hl === ent.playerId && ent.playerId !== 0) {
const hr = iconSize / 2 + 5;
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(px, py, hr, 0, Math.PI*2); ctx.stroke();
ctx.strokeStyle = color;
ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(px, py, hr - 2, 0, Math.PI*2); ctx.stroke();
}
if (iconImg && iconImg.naturalWidth) {
const s = iconSize;
const tinted = this._getTintedIcon(ent._canvasIconKey, color, s);
const drawSrc = tinted || iconImg;
// Rotate aircraft/drones to face their heading
if (ent.type === 'aircraft' || ent.type === 'drone') {
const heading = this.getHeadingAtTime(ent, time);
if (heading !== null) {
ctx.save();
ctx.translate(px, py);
ctx.rotate(heading);
if (tinted) ctx.drawImage(drawSrc, -s/2, -s/2);
else this._drawContainedIcon(ctx, drawSrc, 0, 0, s);
ctx.restore();
} else {
if (tinted) ctx.drawImage(drawSrc, px-s/2, py-s/2);
else this._drawContainedIcon(ctx, drawSrc, px, py, s);
}
} else {
if (tinted) ctx.drawImage(drawSrc, px-s/2, py-s/2);
else this._drawContainedIcon(ctx, drawSrc, px, py, s);
}
} else {
// Fallback: circle dot
const r = ent.type === 'ground' ? RC.DOT_R : ent.type === 'aircraft' ? RC.AIR_R : RC.DRONE_R;
ctx.fillStyle = color;
ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI*2); ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.5)';
ctx.lineWidth = 1;
ctx.stroke();
}
ctx.globalAlpha = 1;
}
}
destroy() {
if (this.animFrameId) { cancelAnimationFrame(this.animFrameId); this.animFrameId = null; }
this.container.innerHTML = '';
}
}
window.ReplayCanvas = ReplayCanvas;
+332
View File
@@ -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();
+227
View File
@@ -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();
}
})();