Initial commit: SREBOT website (Express/EJS + i18n) - extracted from SREBOT monorepo

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