Compare commits
21 Commits
49033560fa
...
cs2-port
| Author | SHA1 | Date | |
|---|---|---|---|
| f3d24e0286 | |||
| 22244e5ed7 | |||
| 848dc95e77 | |||
| 83caf1b858 | |||
| b988d141dd | |||
| 30b076bbec | |||
| 1024ba839e | |||
| 235ef65556 | |||
| e27e9e8821 | |||
| 95b385c471 | |||
| ee233bb6fb | |||
| 51112df979 | |||
| d6048fc264 | |||
| c92af5f377 | |||
| 8de8f1696f | |||
| 6dc12f0c35 | |||
| cdc70403f9 | |||
| d01e0d28f6 | |||
| a77082c400 | |||
| 1ddda81d93 | |||
| 3383302225 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -50,3 +50,6 @@ coverage
|
||||
.tmp
|
||||
tmp
|
||||
*.tmp
|
||||
|
||||
# Claude Code
|
||||
CLAUDE.md
|
||||
|
||||
1172
MISSING_BACKEND_API.md
Normal file
1172
MISSING_BACKEND_API.md
Normal file
File diff suppressed because it is too large
Load Diff
53
README.md
53
README.md
@@ -126,7 +126,8 @@ csgowtf/
|
||||
│ │ │ └── player/ # Player-specific components
|
||||
│ │ ├── stores/ # Svelte stores (state)
|
||||
│ │ ├── types/ # TypeScript types
|
||||
│ │ ├── utils/ # Helper functions
|
||||
│ │ ├── utils/ # Helper functions & utilities
|
||||
│ │ │ └── economyUtils.ts # CS2 economy classification
|
||||
│ │ └── i18n/ # Internationalization
|
||||
│ ├── routes/ # SvelteKit routes (pages)
|
||||
│ ├── mocks/ # MSW mock handlers
|
||||
@@ -148,26 +149,45 @@ csgowtf/
|
||||
|
||||
## 🎨 Features
|
||||
|
||||
### Current (Phase 1 - ✅ Complete)
|
||||
### Current (Phase 2 - ✅ Core Features Complete)
|
||||
|
||||
- ✅ SvelteKit project scaffolded with TypeScript strict mode
|
||||
- ✅ Tailwind CSS + DaisyUI with CS2-themed color palette
|
||||
- ✅ Complete development tooling (ESLint, Prettier, Husky)
|
||||
- ✅ Testing infrastructure (Vitest + Playwright)
|
||||
#### Match Analysis
|
||||
|
||||
- ✅ Match listing with filtering and pagination
|
||||
- ✅ Match detail pages with neon esports aesthetic
|
||||
- ✅ **Rounds page** with Economy Overview toggle
|
||||
- Round-by-round player economy breakdown
|
||||
- Economy flow charts showing advantage over time
|
||||
- Equipment value trends per team
|
||||
- Team-aware buy type classification (Pistol/Eco/Force/Full Buy)
|
||||
- Halftime team swap handling
|
||||
- ✅ Weapons stats with kill/death analysis
|
||||
- ✅ Flash effectiveness tracking
|
||||
- ✅ Damage matrix (who damaged whom)
|
||||
- ✅ Match chat log
|
||||
|
||||
#### Player Profiles
|
||||
|
||||
- ✅ Player profile pages with stats overview
|
||||
- ✅ Premier rating display with tier badges
|
||||
- ✅ Recent matches list
|
||||
- ✅ Performance statistics
|
||||
|
||||
#### Infrastructure
|
||||
|
||||
- ✅ SvelteKit 2.0 with Svelte 5 runes
|
||||
- ✅ TypeScript strict mode throughout
|
||||
- ✅ Tailwind CSS + DaisyUI with custom neon theme
|
||||
- ✅ Unified economy utilities (`economyUtils.ts`)
|
||||
- ✅ Zod schema validation for API responses
|
||||
- ✅ CI/CD pipeline (Woodpecker)
|
||||
- ✅ Backend API documented
|
||||
|
||||
### Planned (See `docs/TODO.md` for details)
|
||||
### Planned
|
||||
|
||||
- 🏠 Homepage with featured matches
|
||||
- 📊 Match listing with advanced filters
|
||||
- 👤 Player profiles with stats & charts
|
||||
- 🎮 Match detail pages (overview, economy, flashes, damage, chat)
|
||||
- 🌍 Multi-language support (i18n)
|
||||
- 🌙 Dark/Light theme toggle (default: dark)
|
||||
- 📱 Mobile-responsive design
|
||||
- ♿ WCAG 2.1 AA accessibility
|
||||
- 🎯 CS2-specific features (MR12, Premier rating, volumetric smokes)
|
||||
- 📱 Enhanced mobile responsiveness
|
||||
- 🎯 Round winner/win reason display (requires backend update - see `MISSING_BACKEND_API.md`)
|
||||
|
||||
---
|
||||
|
||||
@@ -282,6 +302,7 @@ We welcome contributions! Please follow these guidelines:
|
||||
## 📚 Documentation
|
||||
|
||||
- **API Reference**: [`docs/API.md`](docs/API.md) - Complete backend API documentation
|
||||
- **Missing Backend Data**: [`MISSING_BACKEND_API.md`](MISSING_BACKEND_API.md) - Backend features needed for full functionality
|
||||
- **Project Roadmap**: [`docs/TODO.md`](docs/TODO.md) - Detailed implementation plan
|
||||
- **SvelteKit Docs**: [kit.svelte.dev](https://kit.svelte.dev/)
|
||||
- **Tailwind CSS**: [tailwindcss.com](https://tailwindcss.com/)
|
||||
@@ -311,4 +332,4 @@ If you find this project helpful, consider supporting us:
|
||||
|
||||
---
|
||||
|
||||
**Status**: 🚧 **Phase 1 Complete** - Active rewrite for CS2 support
|
||||
**Status**: 🎮 **Phase 2 Complete** - Core match analysis & player profiles functional
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "cs2wtf",
|
||||
"name": "teamflash-rip",
|
||||
"version": "2.0.0",
|
||||
"description": "Statistics for CS2 matchmaking matches",
|
||||
"description": "Stop flashing your teammates. CS2 match statistics and analysis.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
122
src/app.css
122
src/app.css
@@ -128,4 +128,126 @@
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Card fade-in animation with stagger support */
|
||||
.animate-card-in {
|
||||
animation: cardFadeIn 0.4s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes cardFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Neon Text Glow Effects */
|
||||
.text-glow-sm {
|
||||
text-shadow: 0 0 10px currentColor;
|
||||
}
|
||||
|
||||
.text-glow-md {
|
||||
text-shadow:
|
||||
0 0 10px currentColor,
|
||||
0 0 20px currentColor;
|
||||
}
|
||||
|
||||
.text-glow-lg {
|
||||
text-shadow:
|
||||
0 0 10px currentColor,
|
||||
0 0 20px currentColor,
|
||||
0 0 40px currentColor;
|
||||
}
|
||||
|
||||
/* Neon Box Glow Effects */
|
||||
.glow-sm {
|
||||
box-shadow: 0 0 10px currentColor;
|
||||
}
|
||||
|
||||
.glow-md {
|
||||
box-shadow:
|
||||
0 0 10px currentColor,
|
||||
0 0 20px currentColor;
|
||||
}
|
||||
|
||||
.glow-lg {
|
||||
box-shadow:
|
||||
0 0 10px currentColor,
|
||||
0 0 20px currentColor,
|
||||
0 0 40px currentColor;
|
||||
}
|
||||
|
||||
/* Specific neon color glows */
|
||||
.text-glow-neon-blue {
|
||||
text-shadow:
|
||||
0 0 10px theme('colors.neon.blue'),
|
||||
0 0 20px theme('colors.neon.blue'),
|
||||
0 0 40px theme('colors.neon.blue');
|
||||
}
|
||||
|
||||
.text-glow-neon-gold {
|
||||
text-shadow:
|
||||
0 0 10px theme('colors.neon.gold'),
|
||||
0 0 20px theme('colors.neon.gold'),
|
||||
0 0 40px theme('colors.neon.gold');
|
||||
}
|
||||
|
||||
.text-glow-neon-red {
|
||||
text-shadow:
|
||||
0 0 10px theme('colors.neon.red'),
|
||||
0 0 20px theme('colors.neon.red'),
|
||||
0 0 40px theme('colors.neon.red');
|
||||
}
|
||||
|
||||
.text-glow-neon-green {
|
||||
text-shadow:
|
||||
0 0 10px theme('colors.neon.green'),
|
||||
0 0 20px theme('colors.neon.green'),
|
||||
0 0 40px theme('colors.neon.green');
|
||||
}
|
||||
|
||||
.glow-neon-blue {
|
||||
box-shadow:
|
||||
0 0 10px theme('colors.neon.blue'),
|
||||
0 0 20px theme('colors.neon.blue');
|
||||
}
|
||||
|
||||
.glow-neon-gold {
|
||||
box-shadow:
|
||||
0 0 10px theme('colors.neon.gold'),
|
||||
0 0 20px theme('colors.neon.gold');
|
||||
}
|
||||
|
||||
.glow-neon-red {
|
||||
box-shadow:
|
||||
0 0 10px theme('colors.neon.red'),
|
||||
0 0 20px theme('colors.neon.red');
|
||||
}
|
||||
|
||||
/* Stagger animation delays */
|
||||
.stagger-1 {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
.stagger-2 {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
.stagger-3 {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
.stagger-4 {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
.stagger-5 {
|
||||
animation-delay: 0.5s;
|
||||
}
|
||||
|
||||
/* Pause animation on hover */
|
||||
.hover\:pause-animation:hover {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
}
|
||||
|
||||
10
src/app.html
10
src/app.html
@@ -1,15 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-theme="cs2dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
|
||||
<link rel="manifest" href="%sveltekit.assets%/site.webmanifest" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<meta name="description" content="Statistics for CS2 matchmaking matches" />
|
||||
<meta name="theme-color" content="#0a0a0f" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="darkreader-lock" />
|
||||
<meta name="description" content="Track flashbang statistics in CS2. Expose team flashers." />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body>
|
||||
<body class="bg-void text-white">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { apiClient } from './client';
|
||||
import { parsePlayer } from '$lib/schemas';
|
||||
import type { Player, PlayerMeta, TrackPlayerResponse } from '$lib/types';
|
||||
import type { Player, PlayerMeta, PlayerMetaStats, TrackPlayerResponse } from '$lib/types';
|
||||
import { transformPlayerProfile, type LegacyPlayerProfile } from './transformers';
|
||||
|
||||
/**
|
||||
@@ -85,6 +85,18 @@ export const playersAPI = {
|
||||
return playerMeta;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get player aggregated meta stats from backend
|
||||
* Uses pre-computed stats (cached 30 days) including teammates, weapons, maps
|
||||
* @param steamId - Steam ID (uint64 as string to preserve precision)
|
||||
* @param limit - Number of items per category (max 10, default: 4)
|
||||
* @returns Player meta stats with teammates, weapons, map performance
|
||||
*/
|
||||
async getPlayerMetaStats(steamId: string, limit = 4): Promise<PlayerMetaStats> {
|
||||
const url = `/player/${steamId}/meta/${limit}`;
|
||||
return apiClient.get<PlayerMetaStats>(url);
|
||||
},
|
||||
|
||||
/**
|
||||
* Add player to tracking system
|
||||
* @param steamId - Steam ID (uint64 as string to preserve precision)
|
||||
|
||||
@@ -55,6 +55,9 @@ export interface LegacyMatchDetail {
|
||||
vac: boolean; // NOT vac_present
|
||||
game_ban: boolean; // NOT gameban_present
|
||||
stats?: LegacyPlayerStats[]; // Player stats array
|
||||
tick_rate?: number; // Server tick rate (64 or 128)
|
||||
avg_rank?: number; // Average Premier rating (backend computed)
|
||||
replay_url?: string; // Demo download URL (if < 30 days old)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -219,6 +222,9 @@ export function transformMatchDetail(legacy: LegacyMatchDetail): Match {
|
||||
demo_parsed: legacy.parsed,
|
||||
vac_present: legacy.vac,
|
||||
gameban_present: legacy.game_ban,
|
||||
tick_rate: legacy.tick_rate,
|
||||
avg_rank: legacy.avg_rank,
|
||||
replay_url: legacy.replay_url,
|
||||
players: legacy.stats?.map(transformPlayerStats)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,10 +3,15 @@ import type { MatchRoundsResponse, RoundDetail, RoundStats, Match } from '$lib/t
|
||||
|
||||
/**
|
||||
* Transform raw rounds API response into structured format
|
||||
* @param rawData - Raw API response
|
||||
*
|
||||
* NOTE: The backend API only provides economy data (bank/equipment/spent) per round.
|
||||
* Round winners and win reasons are not currently stored in the database.
|
||||
* To add this data would require backend changes to the demo parser.
|
||||
*
|
||||
* @param rawData - Raw API response containing economy data per round
|
||||
* @param matchId - Match ID
|
||||
* @param match - Match data with player information
|
||||
* @returns Structured rounds data
|
||||
* @param match - Match data with player information and final score
|
||||
* @returns Structured rounds data with economy info (winner/win_reason unavailable)
|
||||
*/
|
||||
export function transformRoundsResponse(
|
||||
rawData: RoundsAPIResponse,
|
||||
@@ -15,11 +20,15 @@ export function transformRoundsResponse(
|
||||
): MatchRoundsResponse {
|
||||
const rounds: RoundDetail[] = [];
|
||||
|
||||
// Create player ID to team mapping
|
||||
const playerTeamMap = new Map<string, number>();
|
||||
// Create player lookup map for name, team, and avatar resolution
|
||||
const playerInfoMap = new Map<string, { name: string; team_id: number; avatar: string }>();
|
||||
if (match?.players) {
|
||||
for (const player of match.players) {
|
||||
playerTeamMap.set(player.id, player.team_id);
|
||||
playerInfoMap.set(player.id, {
|
||||
name: player.name,
|
||||
team_id: player.team_id,
|
||||
avatar: player.avatar
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,21 +43,35 @@ export function transformRoundsResponse(
|
||||
|
||||
const players: RoundStats[] = [];
|
||||
|
||||
// Convert player data
|
||||
// Convert player data with name resolution
|
||||
for (const [playerId, [bank, equipment, spent]] of Object.entries(roundData)) {
|
||||
const playerInfo = playerInfoMap.get(playerId);
|
||||
players.push({
|
||||
round: roundNum + 1, // API uses 0-indexed, we use 1-indexed
|
||||
bank,
|
||||
equipment,
|
||||
spent,
|
||||
player_id: Number(playerId)
|
||||
player_id: Number(playerId),
|
||||
player_name: playerInfo?.name,
|
||||
team_id: playerInfo?.team_id,
|
||||
avatar: playerInfo?.avatar
|
||||
});
|
||||
}
|
||||
|
||||
// Sort players by team (CT first, then T) for consistent display
|
||||
players.sort((a, b) => {
|
||||
if (a.team_id !== b.team_id) {
|
||||
return (a.team_id ?? 0) - (b.team_id ?? 0);
|
||||
}
|
||||
return (a.player_name ?? '').localeCompare(b.player_name ?? '');
|
||||
});
|
||||
|
||||
rounds.push({
|
||||
round: roundNum + 1,
|
||||
winner: 0, // TODO: Determine winner from data if available
|
||||
win_reason: '', // TODO: Determine win reason if available
|
||||
// Round winner data not available from backend API
|
||||
// Would require demo parser changes to store RoundEnd event winners
|
||||
winner: 0,
|
||||
win_reason: '',
|
||||
players
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,10 +3,15 @@ import type { MatchWeaponsResponse, PlayerWeaponStats, WeaponStats, Match } from
|
||||
|
||||
/**
|
||||
* Transform raw weapons API response into structured format
|
||||
* @param rawData - Raw API response
|
||||
*
|
||||
* NOTE: The backend API provides hit/damage data per weapon but not kill counts.
|
||||
* Kill tracking would require the demo parser to correlate damage events with
|
||||
* player death events. Currently only aggregated damage and hit group data is available.
|
||||
*
|
||||
* @param rawData - Raw API response containing damage/hit data per weapon
|
||||
* @param matchId - Match ID
|
||||
* @param match - Match data with player information
|
||||
* @returns Structured weapons data
|
||||
* @returns Structured weapons data with damage stats (kills unavailable)
|
||||
*/
|
||||
export function transformWeaponsResponse(
|
||||
rawData: WeaponsAPIResponse,
|
||||
@@ -77,7 +82,9 @@ export function transformWeaponsResponse(
|
||||
weapon_stats.push({
|
||||
eq_type: eqType,
|
||||
weapon_name: rawData.equipment_map[String(eqType)] || `Weapon ${eqType}`,
|
||||
kills: 0, // TODO: Calculate kills if needed
|
||||
// Kill data not available - API only provides hit/damage events
|
||||
// Would require backend changes to correlate damage with death events
|
||||
kills: 0,
|
||||
damage: stats.damage,
|
||||
hits: stats.hits,
|
||||
hit_groups: hitGroupCounts,
|
||||
|
||||
@@ -72,9 +72,9 @@
|
||||
|
||||
<Card padding="lg">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-bold text-base-content">Round Timeline</h2>
|
||||
<p class="mt-2 text-sm text-base-content/60">
|
||||
Click on a round to see detailed information. T = Terrorists, CT = Counter-Terrorists
|
||||
<h2 class="text-2xl font-bold text-white">Round Timeline</h2>
|
||||
<p class="mt-2 text-sm text-white/60">
|
||||
Click on a round to see the battle details. T = Terrorists, CT = Counter-Terrorists
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -100,25 +100,20 @@
|
||||
>
|
||||
<!-- Round number -->
|
||||
<div
|
||||
class="mb-2 text-xs font-semibold transition-colors"
|
||||
class:text-primary={isSelected}
|
||||
class:opacity-60={!isSelected}
|
||||
class="mb-2 text-xs font-semibold transition-colors {isSelected
|
||||
? 'text-neon-blue'
|
||||
: 'text-white/60'}"
|
||||
>
|
||||
{round.round}
|
||||
</div>
|
||||
|
||||
<!-- Round indicator circle -->
|
||||
<div
|
||||
class="relative flex h-12 w-12 items-center justify-center rounded-full border-2 transition-all"
|
||||
class:border-terrorist={isWinner2}
|
||||
class:bg-terrorist={isWinner2}
|
||||
class:bg-opacity-20={isWinner2 || isWinner3}
|
||||
class:border-ct={isWinner3}
|
||||
class:bg-ct={isWinner3}
|
||||
class:ring-4={isSelected}
|
||||
class:ring-primary={isSelected}
|
||||
class:ring-opacity-30={isSelected}
|
||||
class:scale-110={isSelected}
|
||||
class="relative flex h-12 w-12 items-center justify-center rounded-full border-2 transition-all {isWinner2
|
||||
? 'border-terrorist bg-terrorist/20'
|
||||
: ''} {isWinner3 ? 'border-ct bg-ct/20' : ''} {isSelected
|
||||
? 'scale-110 shadow-[0_0_15px_rgba(0,212,255,0.4)] ring-2 ring-neon-blue'
|
||||
: ''}"
|
||||
>
|
||||
<!-- Win reason icon or T/CT badge -->
|
||||
{#if Icon}
|
||||
@@ -147,18 +142,18 @@
|
||||
<!-- Connecting line to next round -->
|
||||
{#if round.round < rounds.length}
|
||||
<div
|
||||
class="absolute left-[60px] top-[34px] h-0.5 w-[calc(100%-60px)] bg-base-300"
|
||||
class="absolute left-[60px] top-[34px] h-0.5 w-[calc(100%-60px)] bg-white/10"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- Hover tooltip -->
|
||||
<div
|
||||
class="pointer-events-none absolute top-full z-10 mt-2 hidden w-48 rounded-lg bg-base-100 p-3 text-left shadow-xl ring-1 ring-base-300 group-hover:block"
|
||||
class="pointer-events-none absolute top-full z-10 mt-2 hidden w-48 rounded-lg border border-white/10 bg-void-light p-3 text-left shadow-xl backdrop-blur-sm group-hover:block"
|
||||
>
|
||||
<div class="text-xs font-semibold text-base-content">
|
||||
<div class="text-xs font-semibold text-white">
|
||||
Round {round.round}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-base-content/80">
|
||||
<div class="mt-1 text-xs text-white/80">
|
||||
Winner:
|
||||
<span
|
||||
class="font-bold"
|
||||
@@ -168,10 +163,10 @@
|
||||
{isWinner2 ? 'Terrorists' : 'Counter-Terrorists'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-base-content/60">
|
||||
<div class="mt-1 text-xs text-white/60">
|
||||
{getWinReasonText(round.win_reason)}
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-base-content/60">
|
||||
<div class="mt-2 text-xs text-white/60">
|
||||
Score: {scoreAtRound.teamA} - {scoreAtRound.teamB}
|
||||
</div>
|
||||
</div>
|
||||
@@ -196,13 +191,13 @@
|
||||
|
||||
<!-- Selected Round Details -->
|
||||
{#if selectedRoundData}
|
||||
<div class="mt-6 border-t border-base-300 pt-6">
|
||||
<div class="mt-6 border-t border-white/10 pt-6">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-xl font-bold text-base-content">
|
||||
<h3 class="text-xl font-bold text-white">
|
||||
Round {selectedRoundData.round} Details
|
||||
</h3>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
class="rounded-lg px-3 py-1.5 text-sm text-white/60 transition-colors hover:bg-white/5 hover:text-white"
|
||||
onclick={() => (selectedRound = null)}
|
||||
aria-label="Close details"
|
||||
>
|
||||
@@ -212,7 +207,7 @@
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-sm text-base-content/60">Winner</div>
|
||||
<div class="text-sm text-white/50">Winner</div>
|
||||
<div
|
||||
class="text-lg font-bold"
|
||||
class:text-terrorist={selectedRoundData.winner === 2}
|
||||
@@ -222,8 +217,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-base-content/60">Win Reason</div>
|
||||
<div class="text-lg font-semibold text-base-content">
|
||||
<div class="text-sm text-white/50">Win Reason</div>
|
||||
<div class="text-lg font-semibold text-white">
|
||||
{getWinReasonText(selectedRoundData.win_reason)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -232,37 +227,46 @@
|
||||
<!-- Player stats for the round if available -->
|
||||
{#if selectedRoundData.players && selectedRoundData.players.length > 0}
|
||||
<div class="mt-4">
|
||||
<h4 class="mb-2 text-sm font-semibold text-base-content">Round Economy</h4>
|
||||
<h4 class="mb-2 text-sm font-semibold text-white">Round Economy</h4>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-base-300">
|
||||
<th>Player</th>
|
||||
<th>Bank</th>
|
||||
<th>Equipment</th>
|
||||
<th>Spent</th>
|
||||
<tr class="border-b border-white/10 text-left text-white/50">
|
||||
<th class="px-3 py-2">Player</th>
|
||||
<th class="px-3 py-2">Bank</th>
|
||||
<th class="px-3 py-2">Equipment</th>
|
||||
<th class="px-3 py-2">Spent</th>
|
||||
{#if selectedRoundData.players.some((p) => p.kills_in_round !== undefined)}
|
||||
<th>Kills</th>
|
||||
<th class="px-3 py-2">Kills</th>
|
||||
{/if}
|
||||
{#if selectedRoundData.players.some((p) => p.damage_in_round !== undefined)}
|
||||
<th>Damage</th>
|
||||
<th class="px-3 py-2">Damage</th>
|
||||
{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each selectedRoundData.players as player}
|
||||
<tr class="border-base-300">
|
||||
<td class="font-medium"
|
||||
<tr class="border-b border-white/5 transition-colors hover:bg-white/5">
|
||||
<td class="px-3 py-2 font-medium text-white"
|
||||
>Player {player.player_id || player.match_player_id || '?'}</td
|
||||
>
|
||||
<td class="font-mono text-success">${player.bank.toLocaleString()}</td>
|
||||
<td class="font-mono">${player.equipment.toLocaleString()}</td>
|
||||
<td class="font-mono text-error">${player.spent.toLocaleString()}</td>
|
||||
<td class="px-3 py-2 font-mono text-neon-green"
|
||||
>${player.bank.toLocaleString()}</td
|
||||
>
|
||||
<td class="px-3 py-2 font-mono text-white/80"
|
||||
>${player.equipment.toLocaleString()}</td
|
||||
>
|
||||
<td class="px-3 py-2 font-mono text-neon-red"
|
||||
>${player.spent.toLocaleString()}</td
|
||||
>
|
||||
{#if selectedRoundData.players.some((p) => p.kills_in_round !== undefined)}
|
||||
<td class="font-mono">{player.kills_in_round || 0}</td>
|
||||
<td class="px-3 py-2 font-mono text-white/80">{player.kills_in_round || 0}</td
|
||||
>
|
||||
{/if}
|
||||
{#if selectedRoundData.players.some((p) => p.damage_in_round !== undefined)}
|
||||
<td class="font-mono">{player.damage_in_round || 0}</td>
|
||||
<td class="px-3 py-2 font-mono text-white/80"
|
||||
>{player.damage_in_round || 0}</td
|
||||
>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
display: true,
|
||||
position: 'top',
|
||||
labels: {
|
||||
color: 'rgb(156, 163, 175)',
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
font: {
|
||||
family: 'Inter, system-ui, sans-serif',
|
||||
size: 12
|
||||
@@ -64,21 +64,21 @@
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
backgroundColor: 'rgba(18, 18, 26, 0.95)',
|
||||
padding: 12,
|
||||
titleColor: '#fff',
|
||||
bodyColor: '#fff',
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
bodyColor: 'rgba(255, 255, 255, 0.8)',
|
||||
borderColor: 'rgba(0, 212, 255, 0.3)',
|
||||
borderWidth: 1
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: 'rgba(156, 163, 175, 0.1)'
|
||||
color: 'rgba(255, 255, 255, 0.05)'
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgb(156, 163, 175)',
|
||||
color: 'rgba(255, 255, 255, 0.5)',
|
||||
font: {
|
||||
size: 11
|
||||
}
|
||||
@@ -86,10 +86,10 @@
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: 'rgba(156, 163, 175, 0.1)'
|
||||
color: 'rgba(255, 255, 255, 0.05)'
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgb(156, 163, 175)',
|
||||
color: 'rgba(255, 255, 255, 0.5)',
|
||||
font: {
|
||||
size: 11
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
display: true,
|
||||
position: 'top',
|
||||
labels: {
|
||||
color: 'rgb(156, 163, 175)',
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
font: {
|
||||
family: 'Inter, system-ui, sans-serif',
|
||||
size: 12
|
||||
@@ -73,21 +73,21 @@
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
backgroundColor: 'rgba(18, 18, 26, 0.95)',
|
||||
padding: 12,
|
||||
titleColor: '#fff',
|
||||
bodyColor: '#fff',
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
bodyColor: 'rgba(255, 255, 255, 0.8)',
|
||||
borderColor: 'rgba(0, 212, 255, 0.3)',
|
||||
borderWidth: 1
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: 'rgba(156, 163, 175, 0.1)'
|
||||
color: 'rgba(255, 255, 255, 0.05)'
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgb(156, 163, 175)',
|
||||
color: 'rgba(255, 255, 255, 0.5)',
|
||||
font: {
|
||||
size: 11
|
||||
}
|
||||
@@ -95,10 +95,10 @@
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: 'rgba(156, 163, 175, 0.1)'
|
||||
color: 'rgba(255, 255, 255, 0.05)'
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgb(156, 163, 175)',
|
||||
color: 'rgba(255, 255, 255, 0.5)',
|
||||
font: {
|
||||
size: 11
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
display: true,
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: 'rgb(156, 163, 175)',
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
font: {
|
||||
family: 'Inter, system-ui, sans-serif',
|
||||
size: 12
|
||||
@@ -63,11 +63,11 @@
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
backgroundColor: 'rgba(18, 18, 26, 0.95)',
|
||||
padding: 12,
|
||||
titleColor: '#fff',
|
||||
bodyColor: '#fff',
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
bodyColor: 'rgba(255, 255, 255, 0.8)',
|
||||
borderColor: 'rgba(0, 212, 255, 0.3)',
|
||||
borderWidth: 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
render?: (value: T[keyof T], row: T) => unknown;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
class?: string;
|
||||
width?: string; // e.g., '200px', '30%', 'auto'
|
||||
width?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -20,7 +20,7 @@
|
||||
striped?: boolean;
|
||||
hoverable?: boolean;
|
||||
compact?: boolean;
|
||||
fixedLayout?: boolean; // Use table-layout: fixed for consistent column widths
|
||||
fixedLayout?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -71,19 +71,18 @@
|
||||
</script>
|
||||
|
||||
<div class="overflow-x-auto {className}">
|
||||
<table
|
||||
class="table"
|
||||
class:table-zebra={striped}
|
||||
class:table-xs={compact}
|
||||
style={fixedLayout ? 'table-layout: fixed;' : ''}
|
||||
>
|
||||
<table class="w-full border-collapse" style={fixedLayout ? 'table-layout: fixed;' : ''}>
|
||||
<thead>
|
||||
<tr>
|
||||
<tr class="border-b border-white/10 bg-void">
|
||||
{#each columns as column}
|
||||
<th
|
||||
class:cursor-pointer={column.sortable}
|
||||
class:hover:bg-base-200={column.sortable}
|
||||
class="text-{column.align || 'left'} {column.class || ''}"
|
||||
class="px-4 text-left text-xs font-semibold uppercase tracking-wider text-white/60 {compact
|
||||
? 'py-2'
|
||||
: 'py-3'} {column.sortable
|
||||
? 'cursor-pointer transition-colors hover:bg-neon-blue/10 hover:text-neon-blue'
|
||||
: ''} {column.class || ''}"
|
||||
class:text-center={column.align === 'center'}
|
||||
class:text-right={column.align === 'right'}
|
||||
style={column.width ? `width: ${column.width}` : ''}
|
||||
onclick={() => handleSort(column)}
|
||||
>
|
||||
@@ -94,16 +93,16 @@
|
||||
>
|
||||
<span>{column.label}</span>
|
||||
{#if column.sortable}
|
||||
<div class="flex flex-col opacity-40">
|
||||
<div class="flex flex-col">
|
||||
<ArrowUp
|
||||
class="h-3 w-3 {sortKey === column.key && sortDirection === 'asc'
|
||||
? 'text-primary opacity-100'
|
||||
: ''}"
|
||||
? 'text-neon-blue'
|
||||
: 'text-white/30'}"
|
||||
/>
|
||||
<ArrowDown
|
||||
class="-mt-1 h-3 w-3 {sortKey === column.key && sortDirection === 'desc'
|
||||
? 'text-primary opacity-100'
|
||||
: ''}"
|
||||
? 'text-neon-blue'
|
||||
: 'text-white/30'}"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -113,10 +112,18 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sortedData as row}
|
||||
<tr class:hover={hoverable}>
|
||||
{#each sortedData as row, index}
|
||||
<tr
|
||||
class="border-b border-white/5 transition-colors {hoverable
|
||||
? 'hover:bg-neon-blue/5'
|
||||
: ''} {striped && index % 2 === 1 ? 'bg-white/[0.02]' : ''}"
|
||||
>
|
||||
{#each columns as column}
|
||||
<td class="text-{column.align || 'left'} {column.class || ''}">
|
||||
<td
|
||||
class="px-4 text-white/80 {compact ? 'py-2' : 'py-3'} {column.class || ''}"
|
||||
class:text-center={column.align === 'center'}
|
||||
class:text-right={column.align === 'right'}
|
||||
>
|
||||
{#if column.render}
|
||||
{@html column.render(row[column.key], row)}
|
||||
{:else}
|
||||
@@ -129,3 +136,50 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Style links and buttons within table cells */
|
||||
:global(td a) {
|
||||
color: rgb(0, 212, 255);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
:global(td a:hover) {
|
||||
color: rgb(0, 170, 204);
|
||||
}
|
||||
:global(td .btn-primary) {
|
||||
background-color: rgb(0, 212, 255);
|
||||
color: rgb(10, 10, 15);
|
||||
border: none;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
:global(td .btn-primary:hover) {
|
||||
box-shadow: 0 0 15px rgba(0, 212, 255, 0.4);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
/* Neon badge styling for result badges */
|
||||
:global(td .badge-success) {
|
||||
background-color: rgba(0, 255, 136, 0.1);
|
||||
color: rgb(0, 255, 136);
|
||||
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||
}
|
||||
:global(td .badge-error) {
|
||||
background-color: rgba(255, 51, 102, 0.1);
|
||||
color: rgb(255, 51, 102);
|
||||
border: 1px solid rgba(255, 51, 102, 0.3);
|
||||
}
|
||||
:global(td .badge-warning) {
|
||||
background-color: rgba(255, 215, 0, 0.1);
|
||||
color: rgb(255, 215, 0);
|
||||
border: 1px solid rgba(255, 215, 0, 0.3);
|
||||
}
|
||||
:global(td .badge) {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
||||
63
src/lib/components/landing/AnimatedCounter.svelte
Normal file
63
src/lib/components/landing/AnimatedCounter.svelte
Normal file
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import { tweened } from 'svelte/motion';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
value: number;
|
||||
duration?: number;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
format?: (value: number) => string;
|
||||
}
|
||||
|
||||
let {
|
||||
value,
|
||||
duration = 2000,
|
||||
prefix = '',
|
||||
suffix = '',
|
||||
format = (val: number) => Math.floor(val).toLocaleString()
|
||||
}: Props = $props();
|
||||
|
||||
const displayValue = tweened(0, {
|
||||
duration,
|
||||
easing: cubicOut
|
||||
});
|
||||
|
||||
let hasAnimated = false;
|
||||
let containerElement: HTMLElement;
|
||||
|
||||
onMount(() => {
|
||||
// Use Intersection Observer to start animation when visible
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting && !hasAnimated) {
|
||||
hasAnimated = true;
|
||||
displayValue.set(value);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
if (containerElement) {
|
||||
observer.observe(containerElement);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
});
|
||||
|
||||
// Update the target value if it changes after initial animation
|
||||
$effect(() => {
|
||||
if (hasAnimated) {
|
||||
displayValue.set(value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<span bind:this={containerElement} class="tabular-nums">
|
||||
{prefix}{format($displayValue)}{suffix}
|
||||
</span>
|
||||
79
src/lib/components/landing/FeatureCard.svelte
Normal file
79
src/lib/components/landing/FeatureCard.svelte
Normal file
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import type { ComponentType } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
icon: ComponentType;
|
||||
title: string;
|
||||
description: string;
|
||||
glowColor?: 'blue' | 'gold' | 'red' | 'green' | 'purple';
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
let { icon: Icon, title, description, glowColor = 'blue', delay = 0 }: Props = $props();
|
||||
|
||||
const glowClasses: Record<string, { icon: string; border: string; bg: string }> = {
|
||||
blue: {
|
||||
icon: 'text-neon-blue',
|
||||
border: 'group-hover:border-neon-blue/50',
|
||||
bg: 'group-hover:bg-neon-blue/5'
|
||||
},
|
||||
gold: {
|
||||
icon: 'text-neon-gold',
|
||||
border: 'group-hover:border-neon-gold/50',
|
||||
bg: 'group-hover:bg-neon-gold/5'
|
||||
},
|
||||
red: {
|
||||
icon: 'text-neon-red',
|
||||
border: 'group-hover:border-neon-red/50',
|
||||
bg: 'group-hover:bg-neon-red/5'
|
||||
},
|
||||
green: {
|
||||
icon: 'text-neon-green',
|
||||
border: 'group-hover:border-neon-green/50',
|
||||
bg: 'group-hover:bg-neon-green/5'
|
||||
},
|
||||
purple: {
|
||||
icon: 'text-neon-purple',
|
||||
border: 'group-hover:border-neon-purple/50',
|
||||
bg: 'group-hover:bg-neon-purple/5'
|
||||
}
|
||||
};
|
||||
|
||||
const classes = glowClasses[glowColor] ?? glowClasses['blue']!;
|
||||
|
||||
// Background glow colors for each variant
|
||||
const glowBgColors: Record<string, string> = {
|
||||
blue: 'rgba(0, 212, 255, 0.1)',
|
||||
gold: 'rgba(255, 215, 0, 0.1)',
|
||||
red: 'rgba(255, 51, 102, 0.1)',
|
||||
green: 'rgba(0, 255, 136, 0.1)',
|
||||
purple: 'rgba(139, 92, 246, 0.1)'
|
||||
};
|
||||
const glowBgColor = glowBgColors[glowColor];
|
||||
</script>
|
||||
|
||||
<article
|
||||
class="group relative flex h-full flex-col rounded-xl border border-white/10 bg-void-light p-6 transition-all duration-300 motion-reduce:transition-none {classes.border} {classes.bg}"
|
||||
style="animation-delay: {delay}ms;"
|
||||
>
|
||||
<!-- Icon Container -->
|
||||
<div
|
||||
class="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-white/5 transition-all duration-300 group-hover:scale-110 motion-reduce:group-hover:scale-100 {classes.icon}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon class="h-6 w-6" />
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h3 class="mb-2 text-xl font-semibold text-white">{title}</h3>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="flex-grow text-sm leading-relaxed text-white/70">{description}</p>
|
||||
|
||||
<!-- Hover Glow Effect -->
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 -z-10 rounded-xl opacity-0 blur-xl transition-opacity duration-300 group-hover:opacity-100"
|
||||
style="background-color: {glowBgColor};"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
</article>
|
||||
108
src/lib/components/landing/FeatureShowcase.svelte
Normal file
108
src/lib/components/landing/FeatureShowcase.svelte
Normal file
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import FeatureCard from './FeatureCard.svelte';
|
||||
import { Eye, BarChart3, Trophy, Crosshair } from 'lucide-svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let containerElement: HTMLElement;
|
||||
let isVisible = $state(false);
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: Eye,
|
||||
title: 'Flash Forensics',
|
||||
description:
|
||||
'Deep analysis of every flashbang thrown. Who got blinded, for how long, and most importantly - was it your teammate?',
|
||||
glowColor: 'blue' as const
|
||||
},
|
||||
{
|
||||
icon: BarChart3,
|
||||
title: 'Shame Statistics',
|
||||
description:
|
||||
"Detailed stats on flash accuracy, team damage, and self-inflicted blindness. Numbers don't lie.",
|
||||
glowColor: 'gold' as const
|
||||
},
|
||||
{
|
||||
icon: Trophy,
|
||||
title: 'Hall of Shame',
|
||||
description:
|
||||
'Weekly leaderboards showcasing the worst team flashers. Public accountability at its finest.',
|
||||
glowColor: 'red' as const
|
||||
},
|
||||
{
|
||||
icon: Crosshair,
|
||||
title: 'Match Analysis',
|
||||
description:
|
||||
'Complete match breakdowns with round-by-round flash events. Perfect for post-game roasting sessions.',
|
||||
glowColor: 'green' as const
|
||||
}
|
||||
];
|
||||
|
||||
onMount(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
isVisible = true;
|
||||
observer.disconnect();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold: 0.2 }
|
||||
);
|
||||
|
||||
if (containerElement) {
|
||||
observer.observe(containerElement);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<section
|
||||
bind:this={containerElement}
|
||||
class="relative overflow-hidden bg-void py-20"
|
||||
aria-labelledby="features-heading"
|
||||
>
|
||||
<!-- Grid Pattern -->
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 bg-grid-pattern bg-grid opacity-20"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
<div class="container relative mx-auto px-4">
|
||||
<!-- Section Header -->
|
||||
<div class="mb-12 text-center">
|
||||
<h2 id="features-heading" class="mb-4 text-4xl font-bold text-white md:text-5xl">
|
||||
Everything You Need to <span class="text-glow-neon-blue text-neon-blue">Expose</span> Team Flashers
|
||||
</h2>
|
||||
|
||||
<p class="mx-auto max-w-2xl text-white/60">
|
||||
Powerful tools to track, analyze, and publicly shame anyone who thinks it's okay to blind
|
||||
their own teammates.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Features Grid -->
|
||||
<ul class="grid list-none gap-6 sm:grid-cols-2 lg:grid-cols-4" role="list">
|
||||
{#each features as feature, index}
|
||||
<li
|
||||
class="transition-all duration-500 motion-reduce:transition-none"
|
||||
class:opacity-0={!isVisible}
|
||||
class:translate-y-8={!isVisible}
|
||||
class:opacity-100={isVisible}
|
||||
class:translate-y-0={isVisible}
|
||||
style="transition-delay: {index * 100}ms;"
|
||||
>
|
||||
<FeatureCard
|
||||
icon={feature.icon}
|
||||
title={feature.title}
|
||||
description={feature.description}
|
||||
glowColor={feature.glowColor}
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
104
src/lib/components/landing/FlashLeaderboard.svelte
Normal file
104
src/lib/components/landing/FlashLeaderboard.svelte
Normal file
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import LeaderboardPodium from './LeaderboardPodium.svelte';
|
||||
import { AlertTriangle } from 'lucide-svelte';
|
||||
|
||||
interface Player {
|
||||
rank: number;
|
||||
name: string;
|
||||
steamId: string;
|
||||
avatarUrl?: string;
|
||||
teammatesBlinded: number;
|
||||
selfFlashes: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
players?: Player[];
|
||||
}
|
||||
|
||||
// Sample data - in production, this would come from the API
|
||||
let {
|
||||
players = [
|
||||
{
|
||||
rank: 1,
|
||||
name: 'xXFlashGodXx',
|
||||
steamId: '76561198012345678',
|
||||
teammatesBlinded: 847,
|
||||
selfFlashes: 234
|
||||
},
|
||||
{
|
||||
rank: 2,
|
||||
name: 'BlindingFury',
|
||||
steamId: '76561198023456789',
|
||||
teammatesBlinded: 623,
|
||||
selfFlashes: 189
|
||||
},
|
||||
{
|
||||
rank: 3,
|
||||
name: 'TeamFlashKing',
|
||||
steamId: '76561198034567890',
|
||||
teammatesBlinded: 512,
|
||||
selfFlashes: 156
|
||||
}
|
||||
]
|
||||
}: Props = $props();
|
||||
|
||||
// Reorder for podium display: 2nd, 1st, 3rd
|
||||
const podiumOrder = [players[1], players[0], players[2]].filter(Boolean);
|
||||
</script>
|
||||
|
||||
<section
|
||||
class="relative overflow-hidden bg-void-light py-20"
|
||||
aria-labelledby="wall-of-shame-heading"
|
||||
>
|
||||
<!-- Background Elements -->
|
||||
<div
|
||||
class="pointer-events-none absolute left-1/2 top-1/2 h-[600px] w-[600px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-neon-red/5 blur-[100px]"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
<div class="container mx-auto px-4">
|
||||
<!-- Section Header -->
|
||||
<div class="mb-12 text-center">
|
||||
<div
|
||||
class="mb-4 inline-flex items-center gap-2 rounded-full border border-neon-red/30 bg-neon-red/10 px-4 py-2 text-sm font-semibold text-neon-red"
|
||||
>
|
||||
<AlertTriangle class="h-4 w-4" aria-hidden="true" />
|
||||
<span>WALL OF SHAME</span>
|
||||
</div>
|
||||
|
||||
<h2 id="wall-of-shame-heading" class="mb-4 text-4xl font-bold text-white md:text-5xl">
|
||||
This Week's <span class="text-glow-neon-red text-neon-red">Flash Criminals</span>
|
||||
</h2>
|
||||
|
||||
<p class="mx-auto max-w-2xl text-white/60">
|
||||
These players have been blinding their teammates more than their enemies. Consider this a
|
||||
public service announcement.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Podium Display -->
|
||||
<div
|
||||
class="flex items-end justify-center gap-4 md:gap-8"
|
||||
role="list"
|
||||
aria-label="Top team flashers leaderboard"
|
||||
>
|
||||
{#each podiumOrder as player, index}
|
||||
{#if player}
|
||||
<div
|
||||
class="animate-fade-up opacity-0 motion-reduce:animate-none motion-reduce:opacity-100"
|
||||
style="animation-delay: {index * 150}ms; animation-fill-mode: forwards;"
|
||||
role="listitem"
|
||||
>
|
||||
<LeaderboardPodium {player} />
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Disclaimer -->
|
||||
<p class="mt-12 text-center text-sm italic text-white/50">
|
||||
"We're not saying these players are bad teammates... actually, yes we are. That's exactly what
|
||||
we're saying."
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
184
src/lib/components/landing/HeroSection.svelte
Normal file
184
src/lib/components/landing/HeroSection.svelte
Normal file
@@ -0,0 +1,184 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Search } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
stats?: {
|
||||
playersExposed: number;
|
||||
flashCrimes: number;
|
||||
flashbangsAnalyzed: number;
|
||||
};
|
||||
}
|
||||
|
||||
let {
|
||||
stats = {
|
||||
playersExposed: 12847,
|
||||
flashCrimes: 89234,
|
||||
flashbangsAnalyzed: 1247893
|
||||
}
|
||||
}: Props = $props();
|
||||
|
||||
let searchValue = $state('');
|
||||
|
||||
const handleSearch = () => {
|
||||
if (searchValue.trim()) {
|
||||
goto(`/players?q=${encodeURIComponent(searchValue.trim())}`);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<section
|
||||
class="relative min-h-screen overflow-hidden"
|
||||
style="background: linear-gradient(to bottom, #0a0a0f, #12121a);"
|
||||
aria-labelledby="hero-heading"
|
||||
>
|
||||
<!-- Grid Pattern Overlay -->
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 opacity-20"
|
||||
style="background-image: linear-gradient(rgba(0, 212, 255, 0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(0, 212, 255, 0.03) 1px, transparent 1px); background-size: 50px 50px;"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
<!-- Radial Gradient Overlays -->
|
||||
<div
|
||||
class="pointer-events-none absolute left-1/4 top-0 h-[500px] w-[500px] -translate-x-1/2 rounded-full blur-[100px]"
|
||||
style="background-color: rgba(0, 212, 255, 0.1);"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div
|
||||
class="pointer-events-none absolute bottom-0 right-1/4 h-[400px] w-[400px] translate-x-1/2 rounded-full blur-[100px]"
|
||||
style="background-color: rgba(255, 215, 0, 0.1);"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
<!-- Content -->
|
||||
<div
|
||||
class="container relative z-10 mx-auto flex min-h-screen flex-col items-center justify-center px-4 py-20"
|
||||
>
|
||||
<!-- Neon Badge -->
|
||||
<div
|
||||
class="mb-8 inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-semibold"
|
||||
style="border-color: rgba(255, 51, 102, 0.3); background-color: rgba(255, 51, 102, 0.1); color: #ff3366;"
|
||||
role="status"
|
||||
aria-label="Site notice: Stop flashing your teammates"
|
||||
>
|
||||
<span
|
||||
class="inline-block h-2 w-2 animate-pulse rounded-full motion-reduce:animate-none"
|
||||
style="background-color: #ff3366;"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
STOP FLASHING YOUR TEAMMATES
|
||||
</div>
|
||||
|
||||
<!-- Giant Headline -->
|
||||
<h1
|
||||
id="hero-heading"
|
||||
class="mb-6 text-center text-5xl font-bold tracking-tight sm:text-6xl md:text-7xl lg:text-8xl"
|
||||
>
|
||||
<span
|
||||
style="color: #00d4ff; text-shadow: 0 0 10px #00d4ff, 0 0 20px #00d4ff, 0 0 40px #00d4ff;"
|
||||
>team</span
|
||||
><span
|
||||
style="color: #ffd700; text-shadow: 0 0 10px #ffd700, 0 0 20px #ffd700, 0 0 40px #ffd700;"
|
||||
>flash</span
|
||||
><span class="text-white">.rip</span>
|
||||
</h1>
|
||||
|
||||
<!-- Tagline -->
|
||||
<p
|
||||
class="mb-12 max-w-2xl text-center text-lg sm:text-xl"
|
||||
style="color: rgba(255, 255, 255, 0.6);"
|
||||
>
|
||||
Track flashbang statistics in CS2. Expose team flashers. Know who to mute before the match.
|
||||
</p>
|
||||
|
||||
<!-- Simple Search Bar -->
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSearch();
|
||||
}}
|
||||
class="w-full max-w-2xl"
|
||||
role="search"
|
||||
aria-label="Search for players"
|
||||
>
|
||||
<label for="player-search" class="sr-only">Search for a player by name or Steam ID</label>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="absolute -inset-0.5 rounded-xl opacity-50 blur"
|
||||
style="background: linear-gradient(to right, #00d4ff, #8b5cf6, #ffd700);"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div class="relative flex items-center">
|
||||
<input
|
||||
id="player-search"
|
||||
type="text"
|
||||
bind:value={searchValue}
|
||||
placeholder="Search for a player..."
|
||||
autocomplete="off"
|
||||
class="w-full rounded-xl border-none px-6 py-4 pl-14 text-lg text-white placeholder-white/40 outline-none focus:ring-2 focus:ring-neon-blue"
|
||||
style="background-color: #12121a;"
|
||||
/>
|
||||
<Search class="absolute left-5 h-5 w-5" style="color: #00d4ff;" aria-hidden="true" />
|
||||
<button
|
||||
type="submit"
|
||||
class="absolute right-3 rounded-lg px-4 py-2 text-sm font-semibold transition-all focus:outline-none focus:ring-2 focus:ring-neon-blue focus:ring-offset-2 focus:ring-offset-void"
|
||||
style="background-color: rgba(0, 212, 255, 0.2); color: #00d4ff;"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Stats Row -->
|
||||
<div class="mt-16 grid grid-cols-1 gap-8 sm:grid-cols-3 sm:gap-12">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-white sm:text-4xl">
|
||||
{stats.playersExposed.toLocaleString()}
|
||||
</div>
|
||||
<div class="mt-1 text-sm" style="color: rgba(255, 255, 255, 0.4);">Players Exposed</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold sm:text-4xl" style="color: #ff3366;">
|
||||
{stats.flashCrimes.toLocaleString()}
|
||||
</div>
|
||||
<div class="mt-1 text-sm" style="color: rgba(255, 255, 255, 0.4);">
|
||||
Flash Crimes Documented
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold sm:text-4xl" style="color: #ffd700;">
|
||||
{stats.flashbangsAnalyzed.toLocaleString()}+
|
||||
</div>
|
||||
<div class="mt-1 text-sm" style="color: rgba(255, 255, 255, 0.4);">Flashbangs Analyzed</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scroll Indicator -->
|
||||
<div
|
||||
class="absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="flex flex-col items-center" style="color: rgba(255, 255, 255, 0.3);">
|
||||
<span class="mb-2 text-xs">Scroll to explore</span>
|
||||
<svg
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 14l-7 7m0 0l-7-7m7 7V3"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
124
src/lib/components/landing/LeaderboardPodium.svelte
Normal file
124
src/lib/components/landing/LeaderboardPodium.svelte
Normal file
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
import { Trophy, Skull, Zap } from 'lucide-svelte';
|
||||
|
||||
interface Player {
|
||||
rank: number;
|
||||
name: string;
|
||||
steamId: string;
|
||||
avatarUrl?: string;
|
||||
teammatesBlinded: number;
|
||||
selfFlashes: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
player: Player;
|
||||
}
|
||||
|
||||
let { player }: Props = $props();
|
||||
|
||||
const podiumConfig = {
|
||||
1: {
|
||||
height: 'h-32',
|
||||
bgGradient: 'from-yellow-500/20 to-yellow-600/5',
|
||||
borderColor: 'border-yellow-500/50',
|
||||
glowColor: 'shadow-yellow-500/30',
|
||||
textColor: 'text-yellow-400',
|
||||
title: 'Flash Criminal of the Week',
|
||||
icon: Trophy
|
||||
},
|
||||
2: {
|
||||
height: 'h-24',
|
||||
bgGradient: 'from-gray-400/20 to-gray-500/5',
|
||||
borderColor: 'border-gray-400/50',
|
||||
glowColor: 'shadow-gray-400/30',
|
||||
textColor: 'text-gray-300',
|
||||
title: 'Serial Team Flasher',
|
||||
icon: Skull
|
||||
},
|
||||
3: {
|
||||
height: 'h-20',
|
||||
bgGradient: 'from-amber-600/20 to-amber-700/5',
|
||||
borderColor: 'border-amber-600/50',
|
||||
glowColor: 'shadow-amber-600/30',
|
||||
textColor: 'text-amber-500',
|
||||
title: 'Flash Menace',
|
||||
icon: Zap
|
||||
}
|
||||
};
|
||||
|
||||
const config = podiumConfig[player.rank as 1 | 2 | 3] || podiumConfig[3];
|
||||
const IconComponent = config.icon;
|
||||
|
||||
// Shadow colors for each rank
|
||||
const shadowColors: Record<number, string> = {
|
||||
1: 'rgba(234, 179, 8, 0.2)',
|
||||
2: 'rgba(156, 163, 175, 0.2)',
|
||||
3: 'rgba(217, 119, 6, 0.2)'
|
||||
};
|
||||
const shadowColor = shadowColors[player.rank] || shadowColors[3];
|
||||
</script>
|
||||
|
||||
<article
|
||||
class="flex flex-col items-center"
|
||||
aria-label="Rank {player.rank}: {player.name} - {player.teammatesBlinded} teammates blinded"
|
||||
>
|
||||
<!-- Player Card -->
|
||||
<div
|
||||
class="group relative mb-4 w-full max-w-[200px] overflow-hidden rounded-xl border bg-gradient-to-b p-4 transition-all hover:scale-105 motion-reduce:hover:scale-100 {config.borderColor} {config.bgGradient}"
|
||||
style="box-shadow: 0 0 20px {shadowColor};"
|
||||
>
|
||||
<!-- Rank Badge -->
|
||||
<div
|
||||
class="absolute -right-2 -top-2 flex h-8 w-8 items-center justify-center rounded-full border-2 bg-void font-bold {config.borderColor} {config.textColor}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
#{player.rank}
|
||||
</div>
|
||||
|
||||
<!-- Avatar -->
|
||||
<div class="mx-auto mb-3 h-16 w-16 overflow-hidden rounded-full border-2 {config.borderColor}">
|
||||
{#if player.avatarUrl}
|
||||
<img
|
||||
src={player.avatarUrl}
|
||||
alt="Avatar for {player.name}"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-full w-full items-center justify-center bg-void-light"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<IconComponent class="h-8 w-8 {config.textColor}" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Player Name -->
|
||||
<h3 class="mb-1 truncate text-center font-semibold text-white">{player.name}</h3>
|
||||
|
||||
<!-- Title -->
|
||||
<p class="mb-3 text-center text-xs {config.textColor}">{config.title}</p>
|
||||
|
||||
<!-- Stats -->
|
||||
<dl class="space-y-1 text-center text-xs">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<dt class="text-white/60">Teammates Blinded</dt>
|
||||
<dd class="font-mono font-bold text-neon-red">{player.teammatesBlinded}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<dt class="text-white/60">Self-Flashes</dt>
|
||||
<dd class="font-mono font-bold text-white/80">{player.selfFlashes}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Podium Stand -->
|
||||
<div
|
||||
class="w-24 rounded-t-lg border-t-2 bg-gradient-to-b {config.height} {config.borderColor} {config.bgGradient}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<span class="text-4xl font-bold {config.textColor}">{player.rank}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
81
src/lib/components/landing/LiveMatchTicker.svelte
Normal file
81
src/lib/components/landing/LiveMatchTicker.svelte
Normal file
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import LiveMatchTickerCard from './LiveMatchTickerCard.svelte';
|
||||
import { Activity } from 'lucide-svelte';
|
||||
|
||||
interface Match {
|
||||
id: string;
|
||||
map: string;
|
||||
scoreT: number;
|
||||
scoreCT: number;
|
||||
isProcessing?: boolean;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
matches?: Match[];
|
||||
}
|
||||
|
||||
// Sample matches for demo - in production, this would come from the API
|
||||
let {
|
||||
matches = [
|
||||
{ id: '1', map: 'de_dust2', scoreT: 16, scoreCT: 14, isProcessing: true },
|
||||
{ id: '2', map: 'de_mirage', scoreT: 13, scoreCT: 16 },
|
||||
{ id: '3', map: 'de_inferno', scoreT: 16, scoreCT: 9 },
|
||||
{ id: '4', map: 'de_ancient', scoreT: 11, scoreCT: 16 },
|
||||
{ id: '5', map: 'de_anubis', scoreT: 16, scoreCT: 12, isProcessing: true },
|
||||
{ id: '6', map: 'de_nuke', scoreT: 8, scoreCT: 16 },
|
||||
{ id: '7', map: 'de_overpass', scoreT: 16, scoreCT: 14 },
|
||||
{ id: '8', map: 'de_vertigo', scoreT: 14, scoreCT: 16 }
|
||||
]
|
||||
}: Props = $props();
|
||||
|
||||
// Duplicate matches for seamless loop with unique keys
|
||||
const duplicatedMatches = $derived([
|
||||
...matches.map((m, i) => ({ ...m, uniqueKey: `first-${i}-${m.id}` })),
|
||||
...matches.map((m, i) => ({ ...m, uniqueKey: `second-${i}-${m.id}` }))
|
||||
]);
|
||||
</script>
|
||||
|
||||
<section class="relative overflow-hidden bg-void py-8" aria-labelledby="recent-matches-heading">
|
||||
<!-- Section Header -->
|
||||
<div class="container mx-auto mb-6 flex items-center justify-between px-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<Activity
|
||||
class="h-5 w-5 animate-pulse text-neon-green motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h2 id="recent-matches-heading" class="text-lg font-semibold text-white">Recent Matches</h2>
|
||||
</div>
|
||||
<a
|
||||
href="/matches"
|
||||
class="rounded text-sm text-neon-blue transition-colors hover:text-neon-blue/80 focus:outline-none focus:ring-2 focus:ring-neon-blue focus:ring-offset-2 focus:ring-offset-void"
|
||||
>
|
||||
View all →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Ticker Container -->
|
||||
<div class="relative">
|
||||
<!-- Left Fade -->
|
||||
<div
|
||||
class="pointer-events-none absolute left-0 top-0 z-10 h-full w-24 bg-gradient-to-r from-void to-transparent"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
<!-- Right Fade -->
|
||||
<div
|
||||
class="pointer-events-none absolute right-0 top-0 z-10 h-full w-24 bg-gradient-to-l from-void to-transparent"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
<!-- Scrolling Ticker -->
|
||||
<nav
|
||||
class="hover:pause-animation flex animate-ticker gap-4 motion-reduce:animate-none"
|
||||
aria-label="Recent match scores"
|
||||
>
|
||||
{#each duplicatedMatches as match (match.uniqueKey)}
|
||||
<LiveMatchTickerCard {match} />
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
</section>
|
||||
81
src/lib/components/landing/LiveMatchTickerCard.svelte
Normal file
81
src/lib/components/landing/LiveMatchTickerCard.svelte
Normal file
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import { Activity } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
match: {
|
||||
id: string;
|
||||
map: string;
|
||||
scoreT: number;
|
||||
scoreCT: number;
|
||||
isProcessing?: boolean;
|
||||
timestamp?: string;
|
||||
};
|
||||
}
|
||||
|
||||
let { match }: Props = $props();
|
||||
|
||||
const mapImages: Record<string, string> = {
|
||||
de_dust2: '/images/maps/de_dust2.jpg',
|
||||
de_mirage: '/images/maps/de_mirage.jpg',
|
||||
de_inferno: '/images/maps/de_inferno.jpg',
|
||||
de_nuke: '/images/maps/de_nuke.jpg',
|
||||
de_overpass: '/images/maps/de_overpass.jpg',
|
||||
de_ancient: '/images/maps/de_ancient.jpg',
|
||||
de_anubis: '/images/maps/de_anubis.jpg',
|
||||
de_vertigo: '/images/maps/de_vertigo.jpg'
|
||||
};
|
||||
|
||||
const formatMapName = (mapName: string): string => {
|
||||
return mapName.replace('de_', '').replace(/_/g, ' ').toUpperCase();
|
||||
};
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="/match/{match.id}"
|
||||
class="group relative flex-shrink-0 overflow-hidden rounded-lg border border-white/10 bg-void-light transition-all hover:scale-105 hover:border-neon-blue/50 focus:outline-none focus:ring-2 focus:ring-neon-blue focus:ring-offset-2 focus:ring-offset-void motion-reduce:hover:scale-100"
|
||||
aria-label="{formatMapName(
|
||||
match.map
|
||||
)} match: Terrorists {match.scoreT} vs Counter-Terrorists {match.scoreCT}{match.isProcessing
|
||||
? ', currently processing'
|
||||
: ''}"
|
||||
>
|
||||
<!-- Map Thumbnail Background -->
|
||||
<div class="relative h-20 w-48 overflow-hidden">
|
||||
<img
|
||||
src={mapImages[match.map] || '/images/maps/default.jpg'}
|
||||
alt=""
|
||||
class="h-full w-full object-cover opacity-40 transition-opacity group-hover:opacity-60"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<!-- Gradient Overlay -->
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-t from-void-light via-void-light/80 to-transparent"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="absolute inset-0 flex flex-col justify-end p-3">
|
||||
<!-- Map Name -->
|
||||
<div class="mb-1 text-xs font-medium text-white/60">
|
||||
{formatMapName(match.map)}
|
||||
</div>
|
||||
|
||||
<!-- Score -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xl font-bold text-terrorist" aria-label="Terrorists">{match.scoreT}</span>
|
||||
<span class="text-xs text-white/40" aria-hidden="true">vs</span>
|
||||
<span class="text-xl font-bold text-ct" aria-label="Counter-Terrorists">{match.scoreCT}</span>
|
||||
|
||||
{#if match.isProcessing}
|
||||
<div
|
||||
class="ml-auto flex items-center gap-1 rounded bg-neon-green/20 px-2 py-0.5 text-xs text-neon-green"
|
||||
role="status"
|
||||
>
|
||||
<Activity class="h-3 w-3 animate-pulse motion-reduce:animate-none" aria-hidden="true" />
|
||||
<span>LIVE</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
75
src/lib/components/landing/NeonCTA.svelte
Normal file
75
src/lib/components/landing/NeonCTA.svelte
Normal file
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import NeonButton from '$lib/components/ui/NeonButton.svelte';
|
||||
import { Zap, Github } from 'lucide-svelte';
|
||||
</script>
|
||||
|
||||
<section class="relative overflow-hidden bg-void py-24" aria-labelledby="cta-heading">
|
||||
<!-- Background Gradients -->
|
||||
<div
|
||||
class="pointer-events-none absolute left-0 top-0 h-[500px] w-[500px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-neon-blue/20 blur-[150px]"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div
|
||||
class="pointer-events-none absolute bottom-0 right-0 h-[400px] w-[400px] translate-x-1/2 translate-y-1/2 rounded-full bg-neon-gold/20 blur-[150px]"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
<!-- Grid Pattern -->
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 bg-grid-pattern bg-grid opacity-20"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
<div class="container relative mx-auto px-4 text-center">
|
||||
<!-- Icon -->
|
||||
<div
|
||||
class="mb-6 inline-flex h-16 w-16 items-center justify-center rounded-full bg-neon-blue/20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Zap class="h-8 w-8 text-neon-blue" />
|
||||
</div>
|
||||
|
||||
<!-- Headline -->
|
||||
<h2 id="cta-heading" class="mb-4 text-4xl font-bold text-white md:text-5xl lg:text-6xl">
|
||||
Ready to Expose the <span class="text-glow-neon-red text-neon-red">Flash Criminals</span>?
|
||||
</h2>
|
||||
|
||||
<!-- Subtext -->
|
||||
<p class="mx-auto mb-10 max-w-2xl text-lg text-white/70">
|
||||
Join thousands of CS2 players who use teamflash.rip to track flash statistics and hold their
|
||||
teammates accountable. It's free, open source, and completely anonymous.
|
||||
</p>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row">
|
||||
<NeonButton href="/matches" variant="blue" size="lg">
|
||||
<Zap class="mr-2 h-5 w-5" aria-hidden="true" />
|
||||
Browse Matches
|
||||
</NeonButton>
|
||||
|
||||
<NeonButton href="https://somegit.dev/CSGOWTF/csgowtf" variant="gold" size="lg" external>
|
||||
<Github class="mr-2 h-5 w-5" aria-hidden="true" />
|
||||
View on GitHub
|
||||
</NeonButton>
|
||||
</div>
|
||||
|
||||
<!-- Trust Badge -->
|
||||
<ul
|
||||
class="mt-12 flex list-none flex-wrap items-center justify-center gap-6 text-sm text-white/50"
|
||||
aria-label="Trust badges"
|
||||
>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-neon-green" aria-hidden="true"></span>
|
||||
Free & Open Source
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-neon-blue" aria-hidden="true"></span>
|
||||
No Account Required
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-neon-gold" aria-hidden="true"></span>
|
||||
Updated Weekly
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
143
src/lib/components/landing/ParticleBackground.svelte
Normal file
143
src/lib/components/landing/ParticleBackground.svelte
Normal file
@@ -0,0 +1,143 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
interface Props {
|
||||
particleCount?: number;
|
||||
particleColor?: string;
|
||||
lineColor?: string;
|
||||
maxDistance?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
particleCount = 50,
|
||||
particleColor = '#00d4ff',
|
||||
lineColor = 'rgba(0, 212, 255, 0.1)',
|
||||
maxDistance = 150
|
||||
}: Props = $props();
|
||||
|
||||
let canvas: HTMLCanvasElement;
|
||||
let animationFrameId: number;
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
radius: number;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!browser || !canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
let particles: Particle[] = [];
|
||||
let width = window.innerWidth;
|
||||
let height = window.innerHeight;
|
||||
|
||||
const resize = () => {
|
||||
width = window.innerWidth;
|
||||
height = window.innerHeight;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
};
|
||||
|
||||
const createParticles = () => {
|
||||
// Reduce particles on mobile
|
||||
const isMobile = width < 768;
|
||||
const count = isMobile ? Math.floor(particleCount / 2) : particleCount;
|
||||
|
||||
particles = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
particles.push({
|
||||
x: Math.random() * width,
|
||||
y: Math.random() * height,
|
||||
vx: (Math.random() - 0.5) * 0.5,
|
||||
vy: (Math.random() - 0.5) * 0.5,
|
||||
radius: Math.random() * 2 + 1
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const drawParticles = () => {
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Draw connections
|
||||
ctx.strokeStyle = lineColor;
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
const particleI = particles[i];
|
||||
if (!particleI) continue;
|
||||
|
||||
for (let j = i + 1; j < particles.length; j++) {
|
||||
const particleJ = particles[j];
|
||||
if (!particleJ) continue;
|
||||
|
||||
const dx = particleI.x - particleJ.x;
|
||||
const dy = particleI.y - particleJ.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance < maxDistance) {
|
||||
const opacity = 1 - distance / maxDistance;
|
||||
ctx.strokeStyle = `rgba(0, 212, 255, ${opacity * 0.15})`;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(particleI.x, particleI.y);
|
||||
ctx.lineTo(particleJ.x, particleJ.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw particles
|
||||
ctx.fillStyle = particleColor;
|
||||
for (const particle of particles) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
};
|
||||
|
||||
const updateParticles = () => {
|
||||
for (const particle of particles) {
|
||||
particle.x += particle.vx;
|
||||
particle.y += particle.vy;
|
||||
|
||||
// Wrap around edges
|
||||
if (particle.x < 0) particle.x = width;
|
||||
if (particle.x > width) particle.x = 0;
|
||||
if (particle.y < 0) particle.y = height;
|
||||
if (particle.y > height) particle.y = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const animate = () => {
|
||||
updateParticles();
|
||||
drawParticles();
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
resize();
|
||||
createParticles();
|
||||
animate();
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
resize();
|
||||
createParticles();
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', resize);
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (browser && animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<canvas bind:this={canvas} class="pointer-events-none absolute inset-0 h-full w-full"></canvas>
|
||||
101
src/lib/components/landing/TypewriterSearch.svelte
Normal file
101
src/lib/components/landing/TypewriterSearch.svelte
Normal file
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { Search } from 'lucide-svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
interface Props {
|
||||
placeholders?: string[];
|
||||
typingSpeed?: number;
|
||||
deletingSpeed?: number;
|
||||
pauseDuration?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
placeholders = [
|
||||
'Search for "that guy who always team flashes"',
|
||||
'Find the worst flashbang criminals',
|
||||
'Look up your Steam ID...',
|
||||
'Expose the serial team flasher',
|
||||
"Find someone's flash crime history"
|
||||
],
|
||||
typingSpeed = 80,
|
||||
deletingSpeed = 40,
|
||||
pauseDuration = 2000
|
||||
}: Props = $props();
|
||||
|
||||
let currentPlaceholder = $state('');
|
||||
let searchValue = $state('');
|
||||
let currentIndex = 0;
|
||||
let charIndex = 0;
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
|
||||
const type = () => {
|
||||
const currentText = placeholders[currentIndex] ?? '';
|
||||
if (charIndex < currentText.length) {
|
||||
currentPlaceholder = currentText.slice(0, charIndex + 1);
|
||||
charIndex++;
|
||||
timeoutId = setTimeout(type, typingSpeed);
|
||||
} else {
|
||||
timeoutId = setTimeout(erase, pauseDuration);
|
||||
}
|
||||
};
|
||||
|
||||
const erase = () => {
|
||||
const currentText = placeholders[currentIndex] ?? '';
|
||||
if (charIndex > 0) {
|
||||
currentPlaceholder = currentText.slice(0, charIndex - 1);
|
||||
charIndex--;
|
||||
timeoutId = setTimeout(erase, deletingSpeed);
|
||||
} else {
|
||||
currentIndex = (currentIndex + 1) % placeholders.length;
|
||||
timeoutId = setTimeout(type, typingSpeed);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (searchValue.trim()) {
|
||||
goto(`/players?q=${encodeURIComponent(searchValue.trim())}`);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
type();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
class="w-full max-w-2xl"
|
||||
>
|
||||
<div class="group relative">
|
||||
<!-- Glow effect behind input -->
|
||||
<div
|
||||
class="absolute -inset-0.5 rounded-xl bg-gradient-to-r from-neon-blue via-neon-purple to-neon-gold opacity-50 blur transition-opacity duration-300 group-hover:opacity-75"
|
||||
></div>
|
||||
|
||||
<div class="relative flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchValue}
|
||||
placeholder={currentPlaceholder}
|
||||
class="w-full rounded-xl border-none bg-void-light px-6 py-4 pl-14 text-lg text-white placeholder-white/40 outline-none transition-all focus:ring-2 focus:ring-neon-blue/50"
|
||||
/>
|
||||
<Search class="absolute left-5 h-5 w-5 text-neon-blue" />
|
||||
<button
|
||||
type="submit"
|
||||
class="absolute right-3 rounded-lg bg-neon-blue/20 px-4 py-2 text-sm font-semibold text-neon-blue transition-all hover:bg-neon-blue hover:text-void"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -7,8 +7,7 @@
|
||||
main: [
|
||||
{ name: 'Home', href: '/' },
|
||||
{ name: 'Matches', href: '/matches' },
|
||||
{ name: 'Players', href: '/players' },
|
||||
{ name: 'API Docs', href: '/docs/api' }
|
||||
{ name: 'Players', href: '/players' }
|
||||
],
|
||||
about: [
|
||||
{ name: 'About', href: '/about' },
|
||||
@@ -28,50 +27,60 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<footer class="border-t border-base-300 bg-base-100">
|
||||
<div class="container mx-auto px-4 py-12">
|
||||
<footer class="relative border-t border-neon-blue/20 bg-void">
|
||||
<!-- Grid Pattern Overlay -->
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 opacity-10"
|
||||
style="background-image: linear-gradient(rgba(0, 212, 255, 0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(0, 212, 255, 0.03) 1px, transparent 1px); background-size: 50px 50px;"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
<div class="container relative mx-auto px-4 py-12">
|
||||
<div class="grid gap-8 md:grid-cols-4">
|
||||
<!-- Brand -->
|
||||
<div class="md:col-span-1">
|
||||
<a href="/" class="mb-4 inline-block text-2xl font-bold">
|
||||
<span class="text-primary">CS2</span><span class="text-secondary">.WTF</span>
|
||||
<a
|
||||
href="/"
|
||||
class="mb-4 inline-block rounded text-2xl font-bold focus:outline-none focus-visible:ring-2 focus-visible:ring-neon-blue"
|
||||
>
|
||||
<span style="color: #00d4ff; text-shadow: 0 0 10px #00d4ff;">team</span><span
|
||||
style="color: #ffd700; text-shadow: 0 0 10px #ffd700;">flash.rip</span
|
||||
>
|
||||
</a>
|
||||
<p class="mb-4 text-sm text-base-content/60">
|
||||
Statistics for CS2 matchmaking matches. Free and open source.
|
||||
<p class="mb-4 text-sm text-white/50">
|
||||
Stop flashing your teammates. Free and open source.
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<a
|
||||
href="https://somegit.dev/CSGOWTF/csgowtf"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-base-content/60 transition-colors hover:text-primary"
|
||||
class="rounded text-white/50 transition-colors hover:text-neon-blue focus:outline-none focus-visible:text-neon-blue"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
<Github class="h-5 w-5" />
|
||||
<Github class="h-5 w-5" aria-hidden="true" />
|
||||
</a>
|
||||
<a
|
||||
href="https://liberapay.com/CSGOWTF/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-base-content/60 transition-colors hover:text-error"
|
||||
class="rounded text-white/50 transition-colors hover:text-neon-red focus:outline-none focus-visible:text-neon-red"
|
||||
aria-label="Support on Liberapay"
|
||||
>
|
||||
<Heart class="h-5 w-5" />
|
||||
<Heart class="h-5 w-5" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Links -->
|
||||
<div>
|
||||
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wider text-base-content/80">
|
||||
Navigate
|
||||
</h3>
|
||||
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wider text-white/70">Navigate</h3>
|
||||
<ul class="space-y-2">
|
||||
{#each links.main as link}
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-sm text-base-content/60 transition-colors hover:text-primary"
|
||||
class="rounded text-sm text-white/40 transition-colors hover:text-neon-blue focus:outline-none focus-visible:text-neon-blue"
|
||||
>
|
||||
{link.name}
|
||||
</a>
|
||||
@@ -81,15 +90,13 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wider text-base-content/80">
|
||||
About
|
||||
</h3>
|
||||
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wider text-white/70">About</h3>
|
||||
<ul class="space-y-2">
|
||||
{#each links.about as link}
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-sm text-base-content/60 transition-colors hover:text-primary"
|
||||
class="rounded text-sm text-white/40 transition-colors hover:text-neon-blue focus:outline-none focus-visible:text-neon-blue"
|
||||
>
|
||||
{link.name}
|
||||
</a>
|
||||
@@ -99,15 +106,13 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wider text-base-content/80">
|
||||
Resources
|
||||
</h3>
|
||||
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wider text-white/70">Resources</h3>
|
||||
<ul class="space-y-2">
|
||||
{#each links.resources as link}
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-sm text-base-content/60 transition-colors hover:text-primary"
|
||||
class="rounded text-sm text-white/40 transition-colors hover:text-neon-blue focus:outline-none focus-visible:text-neon-blue"
|
||||
{...link.external ? { target: '_blank', rel: 'noopener noreferrer' } : {}}
|
||||
>
|
||||
{link.name}
|
||||
@@ -119,13 +124,18 @@
|
||||
</div>
|
||||
|
||||
<!-- Bottom -->
|
||||
<div class="mt-12 border-t border-base-300 pt-8 text-center text-sm text-base-content/60">
|
||||
<div class="mt-12 border-t border-neon-blue/20 pt-8 text-center text-sm text-white/50">
|
||||
<p>
|
||||
© {currentYear} CSGOW.TF Team. Licensed under
|
||||
<a href="/license" class="hover:text-primary">GPL-3.0</a>
|
||||
© {currentYear} teamflash.rip Team. Licensed under
|
||||
<a
|
||||
href="/license"
|
||||
class="rounded transition-colors hover:text-neon-blue focus:outline-none focus-visible:text-neon-blue"
|
||||
>GPL-3.0</a
|
||||
>
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
Made with <Heart class="inline h-4 w-4 text-error" /> by the community, for the community.
|
||||
Made with <Heart class="inline h-4 w-4 text-neon-red" aria-hidden="true" /> by players who are
|
||||
tired of being flashed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Menu, X } from 'lucide-svelte';
|
||||
import SearchBar from './SearchBar.svelte';
|
||||
import ThemeToggle from './ThemeToggle.svelte';
|
||||
|
||||
let mobileMenuOpen = $state(false);
|
||||
|
||||
@@ -13,13 +12,19 @@
|
||||
];
|
||||
</script>
|
||||
|
||||
<header class="sticky top-0 z-50 w-full border-b border-base-300 bg-base-100/95 backdrop-blur-md">
|
||||
<header class="sticky top-0 z-50 w-full border-b border-neon-blue/20 bg-void/95 backdrop-blur-md">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex h-16 items-center justify-between">
|
||||
<!-- Logo -->
|
||||
<a href="/" class="transition-transform hover:scale-105" aria-label="CS2.WTF Home">
|
||||
<a
|
||||
href="/"
|
||||
class="rounded transition-transform hover:scale-105 focus:outline-none focus-visible:ring-2 focus-visible:ring-neon-blue focus-visible:ring-offset-2 focus-visible:ring-offset-void motion-reduce:hover:scale-100"
|
||||
aria-label="teamflash.rip Home"
|
||||
>
|
||||
<h1 class="text-2xl font-bold">
|
||||
<span class="text-primary">CS2</span><span class="text-secondary">.WTF</span>
|
||||
<span style="color: #00d4ff; text-shadow: 0 0 10px #00d4ff;">team</span><span
|
||||
style="color: #ffd700; text-shadow: 0 0 10px #ffd700;">flash.rip</span
|
||||
>
|
||||
</h1>
|
||||
</a>
|
||||
|
||||
@@ -28,7 +33,7 @@
|
||||
{#each navigation as item}
|
||||
<a
|
||||
href={item.href}
|
||||
class="text-sm font-medium text-base-content/70 transition-colors hover:text-primary"
|
||||
class="rounded text-sm font-medium text-white/60 transition-colors hover:text-neon-blue focus:outline-none focus-visible:text-neon-blue"
|
||||
>
|
||||
{item.name}
|
||||
</a>
|
||||
@@ -38,11 +43,10 @@
|
||||
<!-- Search & Actions -->
|
||||
<div class="flex items-center gap-2">
|
||||
<SearchBar />
|
||||
<ThemeToggle />
|
||||
|
||||
<!-- Mobile Menu Toggle -->
|
||||
<button
|
||||
class="btn btn-ghost btn-sm md:hidden"
|
||||
class="rounded-lg p-2 text-white/70 transition-colors hover:bg-neon-blue/10 hover:text-neon-blue focus:outline-none focus-visible:ring-2 focus-visible:ring-neon-blue md:hidden"
|
||||
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
@@ -57,11 +61,11 @@
|
||||
|
||||
<!-- Mobile Navigation -->
|
||||
{#if mobileMenuOpen}
|
||||
<nav class="animate-fade-in border-t border-base-300 py-4 md:hidden">
|
||||
<nav class="animate-fade-in border-t border-neon-blue/20 py-4 md:hidden">
|
||||
{#each navigation as item}
|
||||
<a
|
||||
href={item.href}
|
||||
class="block px-4 py-2 text-sm font-medium text-base-content transition-colors hover:bg-base-200"
|
||||
class="mx-2 block rounded-lg px-4 py-2 text-sm font-medium text-white/60 transition-colors hover:bg-neon-blue/10 hover:text-neon-blue"
|
||||
onclick={() => (mobileMenuOpen = false)}
|
||||
>
|
||||
{item.name}
|
||||
|
||||
@@ -1,116 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Search, Command } from 'lucide-svelte';
|
||||
import { search } from '$lib/stores';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
|
||||
let open = $state(false);
|
||||
let query = $state('');
|
||||
let searchInput: HTMLInputElement;
|
||||
|
||||
// Keyboard shortcut: Cmd/Ctrl + K
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
open = true;
|
||||
setTimeout(() => searchInput?.focus(), 100);
|
||||
search.openModal();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (e: Event) => {
|
||||
e.preventDefault();
|
||||
if (!query.trim()) return;
|
||||
|
||||
// Add to recent searches
|
||||
search.addRecentSearch(query);
|
||||
|
||||
// Navigate to matches page with search query
|
||||
goto(`/matches?search=${encodeURIComponent(query)}`);
|
||||
|
||||
// Close modal and clear
|
||||
open = false;
|
||||
query = '';
|
||||
};
|
||||
|
||||
const handleRecentClick = (recentQuery: string) => {
|
||||
query = recentQuery;
|
||||
handleSearch(new Event('submit'));
|
||||
};
|
||||
|
||||
const handleClearRecent = () => {
|
||||
search.clearRecentSearches();
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- Search Button (Header) -->
|
||||
<button
|
||||
class="btn btn-ghost gap-2"
|
||||
onclick={() => {
|
||||
open = true;
|
||||
setTimeout(() => searchInput?.focus(), 100);
|
||||
}}
|
||||
aria-label="Search"
|
||||
class="flex items-center gap-2 rounded-lg px-3 py-2 text-white/70 transition-colors hover:bg-neon-blue/10 hover:text-neon-blue focus:outline-none focus-visible:ring-2 focus-visible:ring-neon-blue"
|
||||
onclick={() => search.openModal()}
|
||||
aria-label="Search matches and players"
|
||||
>
|
||||
<Search class="h-5 w-5" />
|
||||
<Search class="h-5 w-5" aria-hidden="true" />
|
||||
<span class="hidden md:inline">Search</span>
|
||||
<kbd class="kbd kbd-sm hidden lg:inline-flex">
|
||||
<Command class="h-3 w-3" />
|
||||
K
|
||||
<kbd
|
||||
class="hidden items-center gap-0.5 rounded border border-neon-blue/30 bg-void px-1.5 py-0.5 text-xs text-white/50 lg:inline-flex"
|
||||
>
|
||||
<Command class="h-3 w-3" aria-hidden="true" />
|
||||
<span>K</span>
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
<!-- Search Modal -->
|
||||
<Modal bind:open size="lg">
|
||||
<div class="space-y-4">
|
||||
<form onsubmit={handleSearch}>
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
<Search class="h-5 w-5 text-base-content/60" />
|
||||
<input
|
||||
bind:this={searchInput}
|
||||
bind:value={query}
|
||||
type="text"
|
||||
class="grow"
|
||||
placeholder="Search matches, players, share codes..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
<kbd class="kbd kbd-sm">
|
||||
<Command class="h-3 w-3" />
|
||||
K
|
||||
</kbd>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<!-- Recent Searches -->
|
||||
{#if $search.recentSearches.length > 0}
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-base-content/70">Recent Searches</h3>
|
||||
<button class="btn btn-ghost btn-xs" onclick={handleClearRecent}>Clear</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each $search.recentSearches as recent}
|
||||
<button
|
||||
class="badge badge-outline badge-lg gap-2 hover:badge-primary"
|
||||
onclick={() => handleRecentClick(recent)}
|
||||
>
|
||||
<Search class="h-3 w-3" />
|
||||
{recent}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Search Tips -->
|
||||
<div class="rounded-lg bg-base-200 p-4">
|
||||
<h4 class="mb-2 text-sm font-semibold text-base-content">Search Tips</h4>
|
||||
<ul class="space-y-1 text-xs text-base-content/70">
|
||||
<li>• Search by player name or Steam ID</li>
|
||||
<li>• Enter share code to find specific match</li>
|
||||
<li>• Use map name to filter matches (e.g., "de_dust2")</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
146
src/lib/components/layout/SearchModal.svelte
Normal file
146
src/lib/components/layout/SearchModal.svelte
Normal file
@@ -0,0 +1,146 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Search, Command } from 'lucide-svelte';
|
||||
import { search } from '$lib/stores';
|
||||
import { fly, fade } from 'svelte/transition';
|
||||
|
||||
let query = $state('');
|
||||
let searchInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
// Focus input when modal opens
|
||||
$effect(() => {
|
||||
if ($search.isModalOpen) {
|
||||
setTimeout(() => searchInput?.focus(), 100);
|
||||
}
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
search.closeModal();
|
||||
query = '';
|
||||
};
|
||||
|
||||
const handleBackdropClick = (e: MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && $search.isModalOpen) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
if ($search.isModalOpen) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (e: Event) => {
|
||||
e.preventDefault();
|
||||
if (!query.trim()) return;
|
||||
|
||||
search.addRecentSearch(query);
|
||||
goto(`/matches?search=${encodeURIComponent(query)}`);
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleRecentClick = (recentQuery: string) => {
|
||||
query = recentQuery;
|
||||
handleSearch(new Event('submit'));
|
||||
};
|
||||
|
||||
const handleClearRecent = () => {
|
||||
search.clearRecentSearches();
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} onscroll={handleScroll} />
|
||||
|
||||
{#if $search.isModalOpen}
|
||||
<div
|
||||
class="fixed inset-0 z-[9999] flex items-center justify-center overflow-y-auto p-4"
|
||||
transition:fade={{ duration: 200 }}
|
||||
onclick={handleBackdropClick}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape') handleClose();
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Search"
|
||||
tabindex="-1"
|
||||
>
|
||||
<!-- Backdrop -->
|
||||
<div class="pointer-events-none absolute inset-0 z-0 bg-black/70 backdrop-blur-sm"></div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div
|
||||
class="relative z-10 my-auto max-h-[90vh] w-full max-w-4xl overflow-y-auto rounded-xl border border-neon-blue/20 bg-void shadow-2xl"
|
||||
style="box-shadow: 0 0 50px rgba(0, 212, 255, 0.1);"
|
||||
transition:fly={{ y: -20, duration: 300 }}
|
||||
>
|
||||
<!-- Content -->
|
||||
<div class="space-y-4 p-6">
|
||||
<form onsubmit={handleSearch}>
|
||||
<label
|
||||
class="flex items-center gap-3 rounded-lg border border-neon-blue/30 bg-void-light/50 px-4 py-3 transition-colors focus-within:border-neon-blue focus-within:ring-1 focus-within:ring-neon-blue"
|
||||
>
|
||||
<Search class="h-5 w-5 text-white/50" aria-hidden="true" />
|
||||
<input
|
||||
bind:this={searchInput}
|
||||
bind:value={query}
|
||||
type="text"
|
||||
class="grow bg-transparent text-white placeholder:text-white/40 focus:outline-none"
|
||||
placeholder="Search matches, players, share codes..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
<kbd
|
||||
class="flex items-center gap-0.5 rounded border border-neon-blue/30 bg-void px-1.5 py-0.5 text-xs text-white/50"
|
||||
>
|
||||
<Command class="h-3 w-3" aria-hidden="true" />
|
||||
<span>K</span>
|
||||
</kbd>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<!-- Recent Searches -->
|
||||
{#if $search.recentSearches.length > 0}
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-white/60">Recent Searches</h3>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs text-white/50 transition-colors hover:bg-neon-red/10 hover:text-neon-red focus:outline-none focus-visible:ring-1 focus-visible:ring-neon-red"
|
||||
onclick={handleClearRecent}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each $search.recentSearches as recent}
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-full border border-neon-blue/30 px-3 py-1.5 text-sm text-white/70 transition-colors hover:border-neon-blue hover:bg-neon-blue/10 hover:text-neon-blue focus:outline-none focus-visible:ring-2 focus-visible:ring-neon-blue"
|
||||
onclick={() => handleRecentClick(recent)}
|
||||
>
|
||||
<Search class="h-3 w-3" aria-hidden="true" />
|
||||
{recent}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Search Tips -->
|
||||
<div class="rounded-lg border border-neon-blue/10 bg-neon-blue/5 p-4">
|
||||
<h4 class="mb-2 text-sm font-semibold text-white">Search Tips</h4>
|
||||
<ul class="space-y-1 text-xs text-white/50">
|
||||
<li>Search by player name or Steam ID</li>
|
||||
<li>Enter share code to find specific match</li>
|
||||
<li>Use map name to filter matches (e.g., "de_dust2")</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,71 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Moon, Sun, Monitor } from 'lucide-svelte';
|
||||
import { preferences } from '$lib/stores';
|
||||
import { browser } from '$app/environment';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
const themes = [
|
||||
{ value: 'cs2light', label: 'Light', icon: Sun },
|
||||
{ value: 'cs2dark', label: 'Dark', icon: Moon },
|
||||
{ value: 'auto', label: 'Auto', icon: Monitor }
|
||||
] as const;
|
||||
|
||||
// Get current theme data
|
||||
const currentTheme = $derived(themes.find((t) => t.value === $preferences.theme) || themes[2]);
|
||||
|
||||
const applyTheme = (theme: 'cs2light' | 'cs2dark' | 'auto') => {
|
||||
if (!browser) return;
|
||||
|
||||
let actualTheme = theme;
|
||||
|
||||
if (theme === 'auto') {
|
||||
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
actualTheme = isDark ? 'cs2dark' : 'cs2light';
|
||||
}
|
||||
|
||||
document.documentElement.setAttribute('data-theme', actualTheme);
|
||||
};
|
||||
|
||||
const handleThemeChange = (theme: 'cs2light' | 'cs2dark' | 'auto') => {
|
||||
preferences.setTheme(theme);
|
||||
applyTheme(theme);
|
||||
};
|
||||
|
||||
// Apply theme on mount and when system preference changes
|
||||
onMount(() => {
|
||||
applyTheme($preferences.theme);
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = () => {
|
||||
if ($preferences.theme === 'auto') {
|
||||
applyTheme('auto');
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handler);
|
||||
return () => mediaQuery.removeEventListener('change', handler);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Theme Toggle Dropdown -->
|
||||
<div class="dropdown dropdown-end">
|
||||
<button tabindex="0" class="btn btn-circle btn-ghost" aria-label="Theme">
|
||||
<currentTheme.icon class="h-5 w-5" />
|
||||
</button>
|
||||
<ul class="menu dropdown-content z-[1] mt-3 w-52 rounded-box bg-base-100 p-2 shadow-lg">
|
||||
{#each themes as theme}
|
||||
<li>
|
||||
<button
|
||||
class:active={$preferences.theme === theme.value}
|
||||
onclick={() => handleThemeChange(theme.value)}
|
||||
>
|
||||
<theme.icon class="h-4 w-4" />
|
||||
{theme.label}
|
||||
{#if theme.value === 'auto'}
|
||||
<span class="text-xs text-base-content/60">(System)</span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1,15 +1,18 @@
|
||||
<script lang="ts">
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import { CheckCircle2, Clock } from 'lucide-svelte';
|
||||
import type { MatchListItem } from '$lib/types';
|
||||
import { storeMatchesState } from '$lib/utils/navigation';
|
||||
import { storeMatchesState, type FilterState } from '$lib/utils/navigation';
|
||||
import { getMapBackground, formatMapName } from '$lib/utils/mapAssets';
|
||||
import PremierRatingBadge from '$lib/components/ui/PremierRatingBadge.svelte';
|
||||
|
||||
interface Props {
|
||||
match: MatchListItem;
|
||||
loadedCount?: number;
|
||||
filters?: FilterState;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
let { match, loadedCount = 0 }: Props = $props();
|
||||
let { match, loadedCount = 0, filters, index = 0 }: Props = $props();
|
||||
|
||||
const formattedDate = new Date(match.date).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
@@ -21,9 +24,28 @@
|
||||
const mapName = formatMapName(match.map);
|
||||
const mapBg = getMapBackground(match.map);
|
||||
|
||||
// Derive match result for colored border
|
||||
const matchResult = $derived.by(() => {
|
||||
if (match.score_team_a > match.score_team_b) return 'win';
|
||||
if (match.score_team_a < match.score_team_b) return 'loss';
|
||||
return 'tie';
|
||||
});
|
||||
|
||||
// Border color class based on result
|
||||
const resultBorderColor = $derived.by(() => {
|
||||
const colors = {
|
||||
win: 'border-l-neon-green',
|
||||
loss: 'border-l-neon-red',
|
||||
tie: 'border-l-neon-gold'
|
||||
};
|
||||
return colors[matchResult];
|
||||
});
|
||||
|
||||
// Stagger delay for animation (cap at 20 items per batch)
|
||||
const staggerDelay = $derived(`${Math.min(index % 20, 19) * 50}ms`);
|
||||
|
||||
function handleClick() {
|
||||
// Store navigation state before navigating
|
||||
storeMatchesState(match.match_id, loadedCount);
|
||||
storeMatchesState(match.match_id, loadedCount, filters);
|
||||
}
|
||||
|
||||
function handleImageError(event: Event) {
|
||||
@@ -34,66 +56,91 @@
|
||||
|
||||
<a
|
||||
href={`/match/${match.match_id}`}
|
||||
class="block transition-transform hover:scale-[1.02]"
|
||||
class="animate-card-in group block transition-transform duration-300 hover:scale-[1.01] motion-reduce:animate-none motion-reduce:hover:scale-100"
|
||||
style="animation-delay: {staggerDelay};"
|
||||
data-match-id={match.match_id}
|
||||
onclick={handleClick}
|
||||
>
|
||||
<div
|
||||
class="overflow-hidden rounded-lg border border-base-300 bg-base-100 shadow-md transition-shadow hover:shadow-xl"
|
||||
class="overflow-hidden rounded-lg border border-l-4 border-white/10 {resultBorderColor} bg-void-light transition-all duration-300 group-hover:border-l-4 group-hover:border-neon-blue/50 group-hover:{resultBorderColor} group-hover:shadow-[0_0_20px_rgba(0,212,255,0.1)]"
|
||||
>
|
||||
<!-- Map Header with Background Image -->
|
||||
<div class="relative h-32 overflow-hidden">
|
||||
<div class="relative h-20 overflow-hidden">
|
||||
<!-- Background Image -->
|
||||
<img
|
||||
src={mapBg}
|
||||
alt={mapName}
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
class="absolute inset-0 h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
onerror={handleImageError}
|
||||
/>
|
||||
<!-- Overlay for better text contrast -->
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-black/20"></div>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-void via-void/70 to-transparent"></div>
|
||||
<!-- Content -->
|
||||
<div class="relative flex h-full items-end justify-between p-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
{#if match.map}
|
||||
<Badge variant="default">{match.map}</Badge>
|
||||
{/if}
|
||||
<span class="text-lg font-bold text-white drop-shadow-lg">{mapName}</span>
|
||||
<div class="relative flex h-full items-end justify-between p-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-sm font-bold text-white drop-shadow-lg"
|
||||
style="text-shadow: 0 0 8px rgba(0, 212, 255, 0.3);"
|
||||
>
|
||||
{mapName}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Status badges - horizontal layout -->
|
||||
<div class="flex items-center gap-1">
|
||||
{#if match.avg_rank && match.avg_rank > 0}
|
||||
<div class="backdrop-blur-sm" title="Average player rating">
|
||||
<PremierRatingBadge rating={match.avg_rank} size="sm" showTier={false} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if match.player_count}
|
||||
<span
|
||||
class="rounded-full border border-neon-blue/30 bg-neon-blue/20 px-1.5 py-0.5 text-[10px] text-neon-blue backdrop-blur-sm"
|
||||
title="{match.player_count} players"
|
||||
>
|
||||
{match.player_count >= 10 ? '5v5' : `${match.player_count}p`}
|
||||
</span>
|
||||
{/if}
|
||||
<div
|
||||
class="flex items-center gap-0.5 rounded-full border border-white/10 bg-void/50 px-1.5 py-0.5 backdrop-blur-sm"
|
||||
title={match.demo_parsed ? 'Demo fully parsed' : 'Demo processing'}
|
||||
>
|
||||
{#if match.demo_parsed}
|
||||
<CheckCircle2 class="h-2.5 w-2.5 text-neon-green" aria-hidden="true" />
|
||||
{:else}
|
||||
<Clock class="h-2.5 w-2.5 animate-pulse text-neon-gold" aria-hidden="true" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if match.demo_parsed}
|
||||
<Badge variant="success" size="sm">Parsed</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Match Info -->
|
||||
<div class="p-4">
|
||||
<!-- Match Info - Compact -->
|
||||
<div class="flex items-center justify-between px-3 py-2">
|
||||
<!-- Score -->
|
||||
<div class="mb-3 flex items-center justify-center gap-3">
|
||||
<span class="font-mono text-2xl font-bold text-terrorist">{match.score_team_a}</span>
|
||||
<span class="text-base-content/40">-</span>
|
||||
<span class="font-mono text-2xl font-bold text-ct">{match.score_team_b}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="font-mono text-lg font-bold text-terrorist"
|
||||
style="text-shadow: 0 0 8px rgba(212, 167, 74, 0.4);"
|
||||
>
|
||||
{match.score_team_a}
|
||||
</span>
|
||||
<span class="text-xs text-white/30">-</span>
|
||||
<span
|
||||
class="font-mono text-lg font-bold text-ct"
|
||||
style="text-shadow: 0 0 8px rgba(94, 152, 217, 0.4);"
|
||||
>
|
||||
{match.score_team_b}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Meta -->
|
||||
<div class="flex items-center justify-between text-sm text-base-content/60">
|
||||
<div class="flex items-center gap-3 text-xs text-white/50">
|
||||
<span>{formattedDate}</span>
|
||||
{#if match.duration}
|
||||
<span>{Math.floor(match.duration / 60)}m</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Result Badge (inferred from score) -->
|
||||
<div class="mt-3 flex justify-center">
|
||||
{#if match.score_team_a === match.score_team_b}
|
||||
<Badge variant="warning" size="sm">Tie</Badge>
|
||||
{:else if match.score_team_a > match.score_team_b}
|
||||
<Badge variant="success" size="sm">Team A Win</Badge>
|
||||
{:else}
|
||||
<Badge variant="error" size="sm">Team B Win</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
35
src/lib/components/match/MatchCardSkeleton.svelte
Normal file
35
src/lib/components/match/MatchCardSkeleton.svelte
Normal file
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
// Compact skeleton loading component for MatchCard
|
||||
</script>
|
||||
|
||||
<div class="block">
|
||||
<div
|
||||
class="overflow-hidden rounded-lg border border-l-4 border-white/10 border-l-white/20 bg-void-light"
|
||||
>
|
||||
<!-- Map Header Skeleton - Compact -->
|
||||
<div class="relative h-20 overflow-hidden bg-void-light">
|
||||
<div class="absolute inset-0 animate-pulse bg-white/5"></div>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-void via-void/70 to-transparent"></div>
|
||||
<div class="relative flex h-full items-end justify-between p-2">
|
||||
<div class="h-4 w-20 animate-pulse rounded bg-white/15"></div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="h-4 w-8 animate-pulse rounded-full bg-white/10"></div>
|
||||
<div class="h-4 w-4 animate-pulse rounded-full bg-white/10"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Match Info Skeleton - Compact -->
|
||||
<div class="flex items-center justify-between px-3 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-5 w-6 animate-pulse rounded bg-white/10"></div>
|
||||
<span class="text-white/20">-</span>
|
||||
<div class="h-5 w-6 animate-pulse rounded bg-white/10"></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-3 w-16 animate-pulse rounded bg-white/10"></div>
|
||||
<div class="h-3 w-8 animate-pulse rounded bg-white/10"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Upload, Check, AlertCircle, Loader2 } from 'lucide-svelte';
|
||||
import { Upload, Check, AlertCircle, Loader2, ChevronDown } from 'lucide-svelte';
|
||||
import { matchesAPI } from '$lib/api/matches';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
import { goto } from '$app/navigation';
|
||||
@@ -9,10 +9,10 @@
|
||||
let parseStatus: 'idle' | 'parsing' | 'success' | 'error' = $state('idle');
|
||||
let statusMessage = $state('');
|
||||
let parsedMatchId = $state('');
|
||||
let showHelp = $state(false);
|
||||
|
||||
// Validate share code format
|
||||
function isValidShareCode(code: string): boolean {
|
||||
// Format: CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX
|
||||
const pattern = /^CSGO-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}$/;
|
||||
return pattern.test(code.toUpperCase());
|
||||
}
|
||||
@@ -47,7 +47,6 @@
|
||||
'Match submitted successfully! Parsing may take a few minutes. You can view the match once parsing is complete.';
|
||||
toast.success('Match submitted for parsing!');
|
||||
|
||||
// Wait a moment then redirect to the match page
|
||||
setTimeout(() => {
|
||||
goto(`/match/${response.match_id}`);
|
||||
}, 2000);
|
||||
@@ -75,22 +74,22 @@
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Input Section -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="shareCode">
|
||||
<span class="label-text font-medium">Submit Match Share Code</span>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-white" for="shareCode">
|
||||
Submit Match Share Code
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-3">
|
||||
<input
|
||||
id="shareCode"
|
||||
type="text"
|
||||
placeholder="CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX"
|
||||
class="input input-bordered flex-1"
|
||||
class="flex-1 rounded-lg border border-neon-blue/30 bg-void px-4 py-3 font-mono text-white transition-colors placeholder:text-white/40 focus:border-neon-blue focus:outline-none focus:ring-1 focus:ring-neon-blue disabled:cursor-not-allowed disabled:opacity-50"
|
||||
bind:value={shareCode}
|
||||
disabled={isLoading}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleSubmit()}
|
||||
/>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
class="flex items-center gap-2 rounded-lg bg-neon-blue px-6 py-3 font-semibold text-void transition-all hover:scale-105 hover:shadow-[0_0_20px_rgba(0,212,255,0.4)] focus:outline-none focus-visible:ring-2 focus-visible:ring-neon-blue focus-visible:ring-offset-2 focus-visible:ring-offset-void disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100"
|
||||
onclick={handleSubmit}
|
||||
disabled={isLoading || !shareCode.trim()}
|
||||
>
|
||||
@@ -102,54 +101,78 @@
|
||||
Parse
|
||||
</button>
|
||||
</div>
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/60">
|
||||
Submit a CS2 match share code to add it to the database
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-white/50">
|
||||
Submit a CS2 match share code to add it to the database
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Status Messages -->
|
||||
{#if parseStatus !== 'idle'}
|
||||
<div
|
||||
class="alert {parseStatus === 'success'
|
||||
? 'alert-success'
|
||||
class="flex items-start gap-3 rounded-lg border p-4 {parseStatus === 'success'
|
||||
? 'border-neon-green/30 bg-neon-green/10'
|
||||
: parseStatus === 'error'
|
||||
? 'alert-error'
|
||||
: 'alert-info'}"
|
||||
? 'border-neon-red/30 bg-neon-red/10'
|
||||
: 'border-neon-blue/30 bg-neon-blue/10'}"
|
||||
>
|
||||
{#if parseStatus === 'parsing'}
|
||||
<Loader2 class="h-6 w-6 shrink-0 animate-spin stroke-current" />
|
||||
<Loader2 class="h-5 w-5 shrink-0 animate-spin text-neon-blue" />
|
||||
{:else if parseStatus === 'success'}
|
||||
<Check class="h-6 w-6 shrink-0 stroke-current" />
|
||||
<Check class="h-5 w-5 shrink-0 text-neon-green" />
|
||||
{:else}
|
||||
<AlertCircle class="h-6 w-6 shrink-0 stroke-current" />
|
||||
<AlertCircle class="h-5 w-5 shrink-0 text-neon-red" />
|
||||
{/if}
|
||||
<div class="flex-1">
|
||||
<p>{statusMessage}</p>
|
||||
<p
|
||||
class={parseStatus === 'success'
|
||||
? 'text-neon-green'
|
||||
: parseStatus === 'error'
|
||||
? 'text-neon-red'
|
||||
: 'text-neon-blue'}
|
||||
>
|
||||
{statusMessage}
|
||||
</p>
|
||||
{#if parseStatus === 'success' && parsedMatchId}
|
||||
<p class="mt-1 text-sm">Redirecting to match page...</p>
|
||||
<p class="mt-1 text-sm text-white/50">Redirecting to match page...</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if parseStatus !== 'parsing'}
|
||||
<button class="btn btn-ghost btn-sm" onclick={resetForm}>Dismiss</button>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-sm text-white/50 transition-colors hover:bg-white/10 hover:text-white"
|
||||
onclick={resetForm}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Help Text -->
|
||||
<div class="text-sm text-base-content/70">
|
||||
<p class="mb-2 font-medium">How to get your match share code:</p>
|
||||
<ol class="list-inside list-decimal space-y-1">
|
||||
<li>Open CS2 and navigate to your Matches tab</li>
|
||||
<li>Click on a match you want to analyze</li>
|
||||
<li>Click the "Copy Share Link" button</li>
|
||||
<li>Paste the share code here</li>
|
||||
</ol>
|
||||
<p class="mt-2 text-xs">
|
||||
Note: Demo parsing can take 1-5 minutes depending on match length. You'll be able to view
|
||||
basic match info immediately, but detailed statistics will be available after parsing
|
||||
completes.
|
||||
</p>
|
||||
<!-- Help Text (Collapsible) -->
|
||||
<div class="rounded-lg border border-white/10 bg-void">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between px-4 py-3 text-left text-sm font-medium text-white/70 transition-colors hover:text-white"
|
||||
onclick={() => (showHelp = !showHelp)}
|
||||
>
|
||||
<span>How to get your match share code</span>
|
||||
<ChevronDown
|
||||
class="h-4 w-4 transition-transform duration-200 {showHelp ? 'rotate-180' : ''}"
|
||||
/>
|
||||
</button>
|
||||
{#if showHelp}
|
||||
<div class="border-t border-white/10 px-4 py-3">
|
||||
<ol class="list-inside list-decimal space-y-2 text-sm text-white/60">
|
||||
<li>Open CS2 and navigate to your Matches tab</li>
|
||||
<li>Click on a match you want to analyze</li>
|
||||
<li>Click the "Copy Share Link" button</li>
|
||||
<li>Paste the share code here</li>
|
||||
</ol>
|
||||
<p class="mt-3 text-xs text-white/40">
|
||||
Note: Demo parsing can take 1-5 minutes depending on match length. You'll be able to view
|
||||
basic match info immediately, but detailed statistics will be available after parsing
|
||||
completes.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { User, TrendingUp, Target } from 'lucide-svelte';
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import { CircleUser, TrendingUp, Target, Gamepad2 } from 'lucide-svelte';
|
||||
import type { PlayerMeta } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
@@ -10,26 +9,28 @@
|
||||
|
||||
let { player, showStats = true }: Props = $props();
|
||||
|
||||
const kd =
|
||||
const killDeathRatio =
|
||||
player.avg_deaths > 0
|
||||
? (player.avg_kills / player.avg_deaths).toFixed(2)
|
||||
: player.avg_kills.toFixed(2);
|
||||
const winRate = (player.win_rate * 100).toFixed(1);
|
||||
const winRatePercentage = (player.win_rate * 100).toFixed(1);
|
||||
</script>
|
||||
|
||||
<a
|
||||
href={`/player/${player.id}`}
|
||||
class="block overflow-hidden rounded-lg border border-base-300 bg-base-100 shadow-md transition-all hover:scale-[1.02] hover:shadow-xl"
|
||||
class="block overflow-hidden rounded-lg border border-l-4 border-white/10 border-l-neon-blue bg-void-light transition-all duration-300 hover:scale-[1.02] hover:border-neon-blue/50 hover:shadow-[0_0_20px_rgba(0,212,255,0.1)]"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="bg-gradient-to-r from-primary/20 to-secondary/20 p-4">
|
||||
<div class="bg-gradient-to-r from-neon-blue/20 to-neon-purple/20 p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-base-100">
|
||||
<User class="h-6 w-6 text-primary" />
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-full border border-neon-blue/30 bg-void"
|
||||
>
|
||||
<CircleUser class="h-8 w-8 text-neon-blue/70" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="truncate text-lg font-bold text-base-content">{player.name}</h3>
|
||||
<p class="text-sm text-base-content/60">ID: {player.id}</p>
|
||||
<h3 class="truncate text-lg font-bold text-white">{player.name}</h3>
|
||||
<p class="text-sm text-white/50">ID: {player.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -39,34 +40,38 @@
|
||||
<div class="grid grid-cols-3 gap-4 p-4">
|
||||
<div class="text-center">
|
||||
<div class="mb-1 flex items-center justify-center">
|
||||
<Target class="mr-1 h-4 w-4 text-primary" />
|
||||
<Target class="mr-1 h-4 w-4 text-neon-gold" />
|
||||
</div>
|
||||
<div class="text-xl font-bold text-base-content">{kd}</div>
|
||||
<div class="text-xs text-base-content/60">K/D</div>
|
||||
<div class="font-mono text-xl font-bold text-white">{killDeathRatio}</div>
|
||||
<div class="text-xs text-white/50">K/D</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="mb-1 flex items-center justify-center">
|
||||
<TrendingUp class="mr-1 h-4 w-4 text-success" />
|
||||
<TrendingUp class="mr-1 h-4 w-4 text-neon-green" />
|
||||
</div>
|
||||
<div class="text-xl font-bold text-base-content">{winRate}%</div>
|
||||
<div class="text-xs text-base-content/60">Win Rate</div>
|
||||
<div class="font-mono text-xl font-bold text-white">{winRatePercentage}%</div>
|
||||
<div class="text-xs text-white/50">Win Rate</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="mb-1 flex items-center justify-center">
|
||||
<User class="mr-1 h-4 w-4 text-info" />
|
||||
<Gamepad2 class="mr-1 h-4 w-4 text-neon-blue" />
|
||||
</div>
|
||||
<div class="text-xl font-bold text-base-content">{player.recent_matches}</div>
|
||||
<div class="text-xs text-base-content/60">Matches</div>
|
||||
<div class="font-mono text-xl font-bold text-white">{player.recent_matches}</div>
|
||||
<div class="text-xs text-white/50">Matches</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="border-t border-base-300 bg-base-200 px-4 py-3">
|
||||
<div class="border-t border-white/10 bg-void px-4 py-3">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-base-content/60">Avg KAST:</span>
|
||||
<Badge variant="info" size="sm">{player.avg_kast.toFixed(1)}%</Badge>
|
||||
<span class="text-white/50">Avg KAST:</span>
|
||||
<span
|
||||
class="rounded-full border border-neon-blue/30 bg-neon-blue/10 px-2.5 py-0.5 text-xs font-medium text-neon-blue"
|
||||
>
|
||||
{player.avg_kast.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Clock, X } from 'lucide-svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
getRecentPlayers,
|
||||
@@ -34,32 +33,32 @@
|
||||
</script>
|
||||
|
||||
{#if recentPlayers.length > 0}
|
||||
<Card padding="lg">
|
||||
<div class="rounded-xl border border-white/10 bg-void-light p-6">
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<Clock class="h-5 w-5 text-primary" />
|
||||
<h2 class="text-xl font-bold text-base-content">Recently Visited Players</h2>
|
||||
<Clock class="h-5 w-5 text-neon-blue" />
|
||||
<h2 class="text-xl font-bold text-white">Recently Visited Players</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{#each recentPlayers as player (player.id)}
|
||||
<div
|
||||
class="group relative rounded-lg border border-base-300 bg-base-200 p-3 transition-all hover:border-primary hover:shadow-lg"
|
||||
class="group relative rounded-lg border border-white/10 bg-void p-3 transition-all duration-300 hover:border-neon-blue/50 hover:shadow-[0_0_15px_rgba(0,212,255,0.1)]"
|
||||
>
|
||||
<a href="/player/{player.id}" class="flex items-center gap-3">
|
||||
<img
|
||||
src={player.avatar}
|
||||
alt={player.name}
|
||||
class="h-12 w-12 rounded-full border-2 border-base-300"
|
||||
class="h-12 w-12 rounded-full border-2 border-neon-blue/30"
|
||||
/>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<div class="truncate font-medium text-base-content">{player.name}</div>
|
||||
<div class="text-xs text-base-content/60">{formatTimeAgo(player.visitedAt)}</div>
|
||||
<div class="truncate font-medium text-white">{player.name}</div>
|
||||
<div class="text-xs text-white/50">{formatTimeAgo(player.visitedAt)}</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Remove button -->
|
||||
<button
|
||||
class="btn btn-circle btn-ghost btn-xs absolute right-1 top-1 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
class="absolute right-1 top-1 flex h-6 w-6 items-center justify-center rounded-full text-white/40 opacity-0 transition-all duration-200 hover:bg-neon-red/20 hover:text-neon-red group-hover:opacity-100"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
handleRemove(player.id);
|
||||
@@ -72,10 +71,10 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-center text-xs text-base-content/60">
|
||||
<div class="mt-4 text-center text-xs text-white/40">
|
||||
Showing up to {recentPlayers.length} recently visited player{recentPlayers.length !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { Info, AlertCircle, Loader2 } from 'lucide-svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import { playersAPI } from '$lib/api/players';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
@@ -78,26 +79,18 @@
|
||||
|
||||
<Modal bind:open onClose={handleClose} title={isTracked ? 'Untrack Player' : 'Track Player'}>
|
||||
<div class="space-y-4">
|
||||
<div class="alert alert-info">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<div class="text-sm">
|
||||
<!-- Info Alert -->
|
||||
<div class="flex items-start gap-3 rounded-lg border border-neon-blue/30 bg-neon-blue/10 p-4">
|
||||
<Info class="h-5 w-5 shrink-0 text-neon-blue" />
|
||||
<div class="text-sm text-neon-blue">
|
||||
{#if isTracked}
|
||||
<p>Remove <strong>{playerName}</strong> from automatic match tracking.</p>
|
||||
<p>
|
||||
Remove <strong class="font-semibold">{playerName}</strong> from automatic match tracking.
|
||||
</p>
|
||||
{:else}
|
||||
<p>
|
||||
Add <strong>{playerName}</strong> to the tracking system to automatically fetch new matches.
|
||||
Add <strong class="font-semibold">{playerName}</strong> to the tracking system to automatically
|
||||
fetch new matches.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -105,73 +98,77 @@
|
||||
|
||||
<!-- Auth Code Input (only for tracking, untrack doesn't need auth) -->
|
||||
{#if !isTracked}
|
||||
<div class="form-control">
|
||||
<label class="label" for="authCode">
|
||||
<span class="label-text font-medium">Authentication Code *</span>
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium text-white" for="authCode">
|
||||
Authentication Code *
|
||||
</label>
|
||||
<input
|
||||
id="authCode"
|
||||
type="text"
|
||||
placeholder="Enter your auth code"
|
||||
class="input input-bordered w-full"
|
||||
class="w-full rounded-lg border border-neon-blue/30 bg-void px-4 py-2.5 text-white placeholder-white/40 transition-all duration-300 focus:border-neon-blue focus:outline-none focus:ring-1 focus:ring-neon-blue disabled:opacity-50"
|
||||
bind:value={authCode}
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/60">
|
||||
Required to verify ownership of this Steam account
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-white/50">Required to verify ownership of this Steam account</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error Message -->
|
||||
{#if error}
|
||||
<div class="alert alert-error">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
<div class="flex items-start gap-3 rounded-lg border border-neon-red/30 bg-neon-red/10 p-4">
|
||||
<AlertCircle class="h-5 w-5 shrink-0 text-neon-red" />
|
||||
<span class="text-sm text-neon-red">{error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Help Text -->
|
||||
<div class="text-sm text-base-content/70">
|
||||
<p class="mb-2 font-medium">How to get your authentication code:</p>
|
||||
<div class="text-sm text-white/60">
|
||||
<p class="mb-2 font-medium text-white/70">How to get your authentication code:</p>
|
||||
<ol class="list-inside list-decimal space-y-1">
|
||||
<li>Open CS2 and go to Settings → Game</li>
|
||||
<li>Enable the Developer Console</li>
|
||||
<li>Press <kbd class="kbd kbd-sm">~</kbd> to open the console</li>
|
||||
<li>Type: <code class="rounded bg-base-300 px-1">status</code></li>
|
||||
<li>
|
||||
Press <kbd class="rounded border border-white/20 bg-void px-2 py-0.5 font-mono text-xs"
|
||||
>~</kbd
|
||||
> to open the console
|
||||
</li>
|
||||
<li>
|
||||
Type: <code class="rounded bg-void px-1.5 py-0.5 font-mono text-neon-blue">status</code>
|
||||
</li>
|
||||
<li>Copy the code shown next to "Account:"</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#snippet actions()}
|
||||
<button class="btn" onclick={handleClose} disabled={isLoading}>Cancel</button>
|
||||
<button
|
||||
class="rounded-lg border border-white/20 px-4 py-2 text-sm font-medium text-white/70 transition-all duration-300 hover:bg-white/10 hover:text-white disabled:opacity-50"
|
||||
onclick={handleClose}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{#if isTracked}
|
||||
<button class="btn btn-error" onclick={handleUntrack} disabled={isLoading}>
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-lg bg-neon-red px-4 py-2 text-sm font-medium text-white transition-all duration-300 hover:shadow-[0_0_20px_rgba(255,51,102,0.4)] disabled:opacity-50"
|
||||
onclick={handleUntrack}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{#if isLoading}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
{/if}
|
||||
Untrack Player
|
||||
</button>
|
||||
{:else}
|
||||
<button class="btn btn-primary" onclick={handleTrack} disabled={isLoading}>
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-lg bg-neon-blue px-4 py-2 text-sm font-medium text-void transition-all duration-300 hover:shadow-[0_0_20px_rgba(0,212,255,0.4)] disabled:opacity-50"
|
||||
onclick={handleTrack}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{#if isLoading}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
{/if}
|
||||
Track Player
|
||||
</button>
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
const baseClasses = 'bg-base-200 border border-base-300 rounded-md transition-all duration-200';
|
||||
const baseClasses = 'bg-void-light border border-white/10 rounded-xl transition-all duration-300';
|
||||
|
||||
const variantClasses = {
|
||||
default: 'shadow-sm',
|
||||
elevated: 'shadow-lg shadow-black/10',
|
||||
default: 'shadow-sm hover:shadow-[0_0_20px_rgba(0,212,255,0.05)]',
|
||||
elevated: 'shadow-lg shadow-black/20 hover:shadow-[0_0_30px_rgba(0,212,255,0.1)]',
|
||||
interactive:
|
||||
'cursor-pointer hover:border-primary hover:shadow-lg hover:shadow-primary/20 hover:-translate-y-0.5'
|
||||
'cursor-pointer hover:border-neon-blue/50 hover:shadow-[0_0_20px_rgba(0,212,255,0.15)] hover:-translate-y-0.5'
|
||||
};
|
||||
|
||||
const paddingClasses = {
|
||||
|
||||
@@ -39,11 +39,16 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
<svelte:window
|
||||
onkeydown={handleKeydown}
|
||||
onscroll={() => {
|
||||
if (open) handleClose();
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
class="fixed inset-0 z-[9999] flex items-center justify-center overflow-y-auto p-4"
|
||||
transition:fade={{ duration: 200 }}
|
||||
onclick={handleBackdropClick}
|
||||
onkeydown={(e) => {
|
||||
@@ -57,19 +62,22 @@
|
||||
tabindex="-1"
|
||||
>
|
||||
<!-- Backdrop -->
|
||||
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm"></div>
|
||||
<div class="pointer-events-none absolute inset-0 z-0 bg-black/70 backdrop-blur-sm"></div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div
|
||||
class="relative w-full {sizeClasses[size]} rounded-lg bg-base-100 shadow-xl"
|
||||
class="relative z-10 my-auto w-full {sizeClasses[
|
||||
size
|
||||
]} max-h-[90vh] overflow-y-auto rounded-xl border border-neon-blue/20 bg-void shadow-2xl"
|
||||
style="box-shadow: 0 0 50px rgba(0, 212, 255, 0.1);"
|
||||
transition:fly={{ y: -20, duration: 300 }}
|
||||
>
|
||||
<!-- Header -->
|
||||
{#if title}
|
||||
<div class="flex items-center justify-between border-b border-base-300 p-6">
|
||||
<h2 id="modal-title" class="text-2xl font-bold text-base-content">{title}</h2>
|
||||
<div class="flex items-center justify-between border-b border-neon-blue/20 p-6">
|
||||
<h2 id="modal-title" class="text-2xl font-bold text-white">{title}</h2>
|
||||
<button
|
||||
class="btn btn-circle btn-ghost btn-sm"
|
||||
class="rounded-lg p-2 text-white/60 transition-colors hover:bg-neon-blue/10 hover:text-neon-blue focus:outline-none focus-visible:ring-2 focus-visible:ring-neon-blue"
|
||||
onclick={handleClose}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
@@ -78,7 +86,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="btn btn-circle btn-ghost btn-sm absolute right-4 top-4 z-10"
|
||||
class="absolute right-4 top-4 z-10 rounded-lg p-2 text-white/60 transition-colors hover:bg-neon-blue/10 hover:text-neon-blue focus:outline-none focus-visible:ring-2 focus-visible:ring-neon-blue"
|
||||
onclick={handleClose}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
@@ -93,7 +101,7 @@
|
||||
|
||||
<!-- Actions -->
|
||||
{#if actions}
|
||||
<div class="flex justify-end gap-2 border-t border-base-300 p-6">
|
||||
<div class="flex justify-end gap-2 border-t border-neon-blue/20 p-6">
|
||||
{@render actions()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
76
src/lib/components/ui/NeonButton.svelte
Normal file
76
src/lib/components/ui/NeonButton.svelte
Normal file
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
href?: string;
|
||||
variant?: 'blue' | 'gold' | 'red' | 'green';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
children: Snippet;
|
||||
onclick?: () => void;
|
||||
class?: string;
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
href,
|
||||
variant = 'blue',
|
||||
size = 'md',
|
||||
children,
|
||||
onclick,
|
||||
class: className = '',
|
||||
external = false
|
||||
}: Props = $props();
|
||||
|
||||
const variantClasses = {
|
||||
blue: {
|
||||
bg: 'bg-neon-blue',
|
||||
text: 'text-void',
|
||||
glow: 'hover:shadow-[0_0_30px_rgba(0,212,255,0.5)]',
|
||||
border: 'border-neon-blue'
|
||||
},
|
||||
gold: {
|
||||
bg: 'bg-neon-gold',
|
||||
text: 'text-void',
|
||||
glow: 'hover:shadow-[0_0_30px_rgba(255,215,0,0.5)]',
|
||||
border: 'border-neon-gold'
|
||||
},
|
||||
red: {
|
||||
bg: 'bg-neon-red',
|
||||
text: 'text-white',
|
||||
glow: 'hover:shadow-[0_0_30px_rgba(255,51,102,0.5)]',
|
||||
border: 'border-neon-red'
|
||||
},
|
||||
green: {
|
||||
bg: 'bg-neon-green',
|
||||
text: 'text-void',
|
||||
glow: 'hover:shadow-[0_0_30px_rgba(0,255,136,0.5)]',
|
||||
border: 'border-neon-green'
|
||||
}
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-4 py-2 text-sm',
|
||||
md: 'px-6 py-3 text-base',
|
||||
lg: 'px-8 py-4 text-lg'
|
||||
};
|
||||
|
||||
const classes = variantClasses[variant];
|
||||
const sizeClass = sizeClasses[size];
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a
|
||||
{href}
|
||||
class="inline-flex items-center justify-center rounded-lg font-semibold transition-all duration-300 hover:scale-105 focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-void motion-reduce:transition-none motion-reduce:hover:scale-100 {classes.bg} {classes.text} {classes.glow} {sizeClass} {className}"
|
||||
{...external ? { target: '_blank', rel: 'noopener noreferrer' } : {}}
|
||||
>
|
||||
{@render children()}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
{onclick}
|
||||
class="inline-flex items-center justify-center rounded-lg font-semibold transition-all duration-300 hover:scale-105 focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-void motion-reduce:transition-none motion-reduce:hover:scale-100 {classes.bg} {classes.text} {classes.glow} {sizeClass} {className}"
|
||||
>
|
||||
{@render children()}
|
||||
</button>
|
||||
{/if}
|
||||
@@ -12,8 +12,7 @@
|
||||
tabs: Tab[];
|
||||
activeTab?: string;
|
||||
onTabChange?: (value: string) => void;
|
||||
variant?: 'boxed' | 'bordered' | 'lifted';
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
class?: string;
|
||||
}
|
||||
|
||||
@@ -21,15 +20,34 @@
|
||||
tabs,
|
||||
activeTab = $bindable(),
|
||||
onTabChange,
|
||||
variant = 'bordered',
|
||||
size = 'md',
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
|
||||
// If using href-based tabs, derive active from current route
|
||||
const isActive = (tab: Tab): boolean => {
|
||||
const isActive = (tab: Tab, allTabs: Tab[]): boolean => {
|
||||
if (tab.href) {
|
||||
return $page.url.pathname === tab.href || $page.url.pathname.startsWith(tab.href + '/');
|
||||
const currentPath = $page.url.pathname;
|
||||
|
||||
// Exact match always wins
|
||||
if (currentPath === tab.href) return true;
|
||||
|
||||
// For nested routes, check if this is the most specific matching tab
|
||||
// This prevents parent routes (like /match/123) from matching child routes (like /match/123/weapons)
|
||||
const matchingTabs = allTabs.filter(
|
||||
(t) => t.href && (currentPath === t.href || currentPath.startsWith(t.href + '/'))
|
||||
);
|
||||
|
||||
// If multiple tabs match, only the longest (most specific) href should be active
|
||||
if (matchingTabs.length > 1) {
|
||||
const longestMatch = matchingTabs.reduce((a, b) =>
|
||||
(a.href?.length || 0) > (b.href?.length || 0) ? a : b
|
||||
);
|
||||
return tab.href === longestMatch.href;
|
||||
}
|
||||
|
||||
// Single match - check if it's this tab
|
||||
return matchingTabs.some((t) => t.href === tab.href);
|
||||
}
|
||||
return activeTab === tab.value;
|
||||
};
|
||||
@@ -43,21 +61,36 @@
|
||||
}
|
||||
};
|
||||
|
||||
const variantClass =
|
||||
variant === 'boxed' ? 'tabs-boxed' : variant === 'lifted' ? 'tabs-lifted' : '';
|
||||
const sizeClass =
|
||||
size === 'xs' ? 'tabs-xs' : size === 'sm' ? 'tabs-sm' : size === 'lg' ? 'tabs-lg' : '';
|
||||
const sizeClasses = {
|
||||
sm: 'text-xs px-3 py-1.5',
|
||||
md: 'text-sm px-4 py-2',
|
||||
lg: 'text-base px-5 py-2.5'
|
||||
};
|
||||
|
||||
const baseTabClasses = 'rounded-md font-medium transition-all duration-200 whitespace-nowrap';
|
||||
|
||||
const inactiveClasses = 'text-white/60 hover:text-white hover:bg-white/5';
|
||||
|
||||
const activeClasses =
|
||||
'text-neon-blue bg-neon-blue/10 border border-neon-blue/50 shadow-[0_0_10px_rgba(0,212,255,0.15)]';
|
||||
|
||||
const disabledClasses = 'opacity-40 cursor-not-allowed pointer-events-none';
|
||||
</script>
|
||||
|
||||
<div role="tablist" class="tabs {variantClass} {sizeClass} {className}">
|
||||
<div
|
||||
role="tablist"
|
||||
class="inline-flex gap-1 rounded-lg bg-void/50 p-1 backdrop-blur-sm {className}"
|
||||
>
|
||||
{#each tabs as tab}
|
||||
{@const active = isActive(tab, tabs)}
|
||||
{@const classes = `${baseTabClasses} ${sizeClasses[size]} ${active ? activeClasses : inactiveClasses} ${tab.disabled ? disabledClasses : ''}`}
|
||||
|
||||
{#if tab.href}
|
||||
<a
|
||||
href={tab.href}
|
||||
role="tab"
|
||||
class="tab"
|
||||
class:tab-active={isActive(tab)}
|
||||
class:tab-disabled={tab.disabled}
|
||||
class={classes}
|
||||
aria-selected={active}
|
||||
aria-disabled={tab.disabled}
|
||||
>
|
||||
{tab.label}
|
||||
@@ -65,9 +98,8 @@
|
||||
{:else}
|
||||
<button
|
||||
role="tab"
|
||||
class="tab"
|
||||
class:tab-active={isActive(tab)}
|
||||
class:tab-disabled={tab.disabled}
|
||||
class={classes}
|
||||
aria-selected={active}
|
||||
disabled={tab.disabled}
|
||||
onclick={() => handleTabClick(tab)}
|
||||
>
|
||||
|
||||
37
src/lib/stores/loadingMessages.ts
Normal file
37
src/lib/stores/loadingMessages.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Collection of flash-themed loading messages for the UI
|
||||
* Because waiting shouldn't be boring when you're exposing flash criminals
|
||||
*/
|
||||
export const flashLoadingMessages = [
|
||||
'Looking through the white screen...',
|
||||
'Waiting for vision to return...',
|
||||
'Parsing flashbang trajectories...',
|
||||
'Counting team flashes (this might take a while)...',
|
||||
'Reviewing the tape for flash crimes...',
|
||||
'Loading the wall of shame...',
|
||||
'Calculating flash guilt percentages...',
|
||||
'Scanning for pop flash perpetrators...',
|
||||
'Analyzing who ruined the retake...',
|
||||
'Determining flash assist eligibility...',
|
||||
'Cross-referencing teammate blind reports...',
|
||||
'Compiling evidence for the post-game argument...'
|
||||
];
|
||||
|
||||
/**
|
||||
* Get a random flash-themed loading message
|
||||
*/
|
||||
export function getRandomLoadingMessage(): string {
|
||||
const index = Math.floor(Math.random() * flashLoadingMessages.length);
|
||||
return flashLoadingMessages[index] as string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flash-themed error messages for various scenarios
|
||||
*/
|
||||
export const flashErrorMessages = {
|
||||
networkError: "Connection got flashed. Can't see the server right now.",
|
||||
timeout: 'Request timed out. Server is still rubbing its eyes.',
|
||||
notFound: "Data got pop-flashed out of existence. We can't find it.",
|
||||
serverError: 'Server caught a flashbang to the face. Please wait.',
|
||||
parseError: 'Failed to parse the data. Too many flashes, not enough pixels.'
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { browser } from '$app/environment';
|
||||
export interface SearchState {
|
||||
query: string;
|
||||
recentSearches: string[];
|
||||
isModalOpen: boolean;
|
||||
filters: {
|
||||
map?: string;
|
||||
playerId?: number;
|
||||
@@ -20,6 +21,7 @@ export interface SearchState {
|
||||
const defaultState: SearchState = {
|
||||
query: '',
|
||||
recentSearches: [],
|
||||
isModalOpen: false,
|
||||
filters: {}
|
||||
};
|
||||
|
||||
@@ -105,6 +107,16 @@ const createSearchStore = () => {
|
||||
// Reset entire search state
|
||||
reset: () => {
|
||||
set({ ...defaultState, recentSearches: loadRecentSearches() });
|
||||
},
|
||||
|
||||
// Open search modal
|
||||
openModal: () => {
|
||||
update((state) => ({ ...state, isModalOpen: true }));
|
||||
},
|
||||
|
||||
// Close search modal
|
||||
closeModal: () => {
|
||||
update((state) => ({ ...state, isModalOpen: false }));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -42,6 +42,12 @@ export interface Match {
|
||||
/** Server tick rate (64 or 128) - optional, not always provided by API */
|
||||
tick_rate?: number;
|
||||
|
||||
/** Average Premier rating of all players in the match - optional, backend computed */
|
||||
avg_rank?: number;
|
||||
|
||||
/** Demo replay download URL (only available for matches < 30 days old) */
|
||||
replay_url?: string;
|
||||
|
||||
/**
|
||||
* Game mode: 'premier' | 'competitive' | 'wingman'
|
||||
* - Premier: Uses CS Rating (numerical ELO, 0-30,000+)
|
||||
@@ -67,6 +73,8 @@ export interface MatchListItem {
|
||||
duration: number;
|
||||
demo_parsed: boolean;
|
||||
player_count?: number;
|
||||
/** Average Premier rating of all players (backend computed, optional) */
|
||||
avg_rank?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -119,3 +119,79 @@ export interface PlayerProfile extends Player {
|
||||
/** Peak CS2 Premier rating */
|
||||
peak_rating?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Teammate statistics from backend meta endpoint
|
||||
*/
|
||||
export interface TeammateStats {
|
||||
/** Player profile */
|
||||
player: {
|
||||
steamid64: string;
|
||||
name?: string;
|
||||
avatar?: string;
|
||||
vac?: boolean;
|
||||
game_ban?: boolean;
|
||||
};
|
||||
/** Win rate when playing together (0-1) */
|
||||
win_rate?: number;
|
||||
/** Tie rate when playing together (0-1) */
|
||||
tie_rate?: number;
|
||||
/** Total matches played together */
|
||||
total?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Weapon damage statistics from backend meta endpoint
|
||||
*/
|
||||
export interface WeaponDamageStats {
|
||||
/** Equipment type ID */
|
||||
eq: number;
|
||||
/** Total damage dealt with this weapon */
|
||||
dmg: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map statistics from backend meta endpoint
|
||||
*/
|
||||
export interface MapStats {
|
||||
/** Map name (e.g., "de_inferno") */
|
||||
map: string;
|
||||
/** Win rate on this map (0-1) */
|
||||
win_rate: number;
|
||||
/** Tie rate on this map (0-1) */
|
||||
tie_rate: number;
|
||||
/** Total matches on this map */
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full player meta statistics from /player/:id/meta endpoint
|
||||
* Contains pre-aggregated stats from the backend (cached for 30 days)
|
||||
*/
|
||||
export interface PlayerMetaStats {
|
||||
/** Basic player info */
|
||||
player: {
|
||||
steamid64: string;
|
||||
name?: string;
|
||||
avatar?: string;
|
||||
vac?: boolean;
|
||||
vac_date?: number;
|
||||
game_ban?: boolean;
|
||||
game_ban_date?: number;
|
||||
tracked?: boolean;
|
||||
};
|
||||
/** Best teammates sorted by win rate */
|
||||
best_mates?: TeammateStats[];
|
||||
/** Most played teammates sorted by total games */
|
||||
most_mates?: TeammateStats[];
|
||||
/** Equipment ID to name mapping */
|
||||
eq_map?: Record<number, string>;
|
||||
/** Weapon damage stats sorted by damage */
|
||||
weapon_dmg?: WeaponDamageStats[];
|
||||
/** Win rate per map (map name -> rate 0-1) */
|
||||
win_maps?: Record<string, number>;
|
||||
/** Tie rate per map (map name -> rate 0-1) */
|
||||
tie_maps?: Record<string, number>;
|
||||
/** Total matches per map (map name -> count) */
|
||||
total_maps?: Record<string, number>;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,15 @@ export interface RoundStats {
|
||||
|
||||
/** Player ID for this round data */
|
||||
player_id?: number;
|
||||
|
||||
/** Player display name (resolved from match.players) */
|
||||
player_name?: string;
|
||||
|
||||
/** Player's team ID (2 = T, 3 = CT) */
|
||||
team_id?: number;
|
||||
|
||||
/** Player avatar URL */
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,7 +11,16 @@ export type { ChatAPIResponse } from './api/ChatAPIResponse';
|
||||
export type { Match, MatchListItem, MatchPlayer, MatchWithPlayers } from './Match';
|
||||
|
||||
// Player types
|
||||
export type { Player, PlayerMatch, PlayerMeta, PlayerProfile } from './Player';
|
||||
export type {
|
||||
Player,
|
||||
PlayerMatch,
|
||||
PlayerMeta,
|
||||
PlayerProfile,
|
||||
PlayerMetaStats,
|
||||
TeammateStats,
|
||||
WeaponDamageStats,
|
||||
MapStats
|
||||
} from './Player';
|
||||
|
||||
// Round statistics types
|
||||
export type {
|
||||
|
||||
214
src/lib/utils/economyUtils.ts
Normal file
214
src/lib/utils/economyUtils.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* CS2 Economy Utilities
|
||||
*
|
||||
* Unified economy classification and display utilities for consistent
|
||||
* buy type detection across the application.
|
||||
*
|
||||
* Thresholds based on:
|
||||
* - Leetify economy groupings
|
||||
* - Steam community guides
|
||||
* - Professional CS2 analysis standards
|
||||
*/
|
||||
|
||||
export type BuyType = 'pistol' | 'eco' | 'force' | 'full';
|
||||
export type TeamSide = 'T' | 'CT';
|
||||
export type EconomyHealth = 'healthy' | 'tight' | 'broken';
|
||||
|
||||
export interface BuyTypeConfig {
|
||||
label: string;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
borderColor: string;
|
||||
}
|
||||
|
||||
export interface EconomyHealthConfig {
|
||||
label: string;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Buy type thresholds based on average equipment value per player
|
||||
* CT side has higher thresholds due to more expensive rifles (M4 vs AK)
|
||||
*/
|
||||
const BUY_THRESHOLDS = {
|
||||
T: {
|
||||
eco: 1500,
|
||||
force: 3500,
|
||||
full: 3500
|
||||
},
|
||||
CT: {
|
||||
eco: 1500,
|
||||
force: 4000,
|
||||
full: 4000
|
||||
}
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Economy health thresholds based on average bank per player
|
||||
*/
|
||||
const ECONOMY_HEALTH_THRESHOLDS = {
|
||||
healthy: 4000, // Can full-buy next round
|
||||
tight: 2000 // Force-buy possible but risky
|
||||
// Below tight = broken
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Pistol round starting money
|
||||
*/
|
||||
export const PISTOL_ROUND_MONEY = 800;
|
||||
|
||||
/**
|
||||
* Visual configuration for each buy type
|
||||
*/
|
||||
export const BUY_TYPE_CONFIG: Record<BuyType, BuyTypeConfig> = {
|
||||
pistol: {
|
||||
label: 'Pistol',
|
||||
color: 'text-neon-purple',
|
||||
bgColor: 'bg-neon-purple/20',
|
||||
borderColor: 'border-neon-purple'
|
||||
},
|
||||
eco: {
|
||||
label: 'Eco',
|
||||
color: 'text-red-400',
|
||||
bgColor: 'bg-red-500/20',
|
||||
borderColor: 'border-red-500'
|
||||
},
|
||||
force: {
|
||||
label: 'Force',
|
||||
color: 'text-yellow-400',
|
||||
bgColor: 'bg-yellow-500/20',
|
||||
borderColor: 'border-yellow-500'
|
||||
},
|
||||
full: {
|
||||
label: 'Full Buy',
|
||||
color: 'text-green-400',
|
||||
bgColor: 'bg-green-500/20',
|
||||
borderColor: 'border-green-500'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Visual configuration for economy health status
|
||||
*/
|
||||
export const ECONOMY_HEALTH_CONFIG: Record<EconomyHealth, EconomyHealthConfig> = {
|
||||
healthy: {
|
||||
label: 'Healthy',
|
||||
color: 'text-green-400',
|
||||
bgColor: 'bg-green-500/20',
|
||||
description: 'Can full-buy next round'
|
||||
},
|
||||
tight: {
|
||||
label: 'Tight',
|
||||
color: 'text-yellow-400',
|
||||
bgColor: 'bg-yellow-500/20',
|
||||
description: 'Force-buy possible, risky'
|
||||
},
|
||||
broken: {
|
||||
label: 'Broken',
|
||||
color: 'text-red-400',
|
||||
bgColor: 'bg-red-500/20',
|
||||
description: 'Must eco or half-buy'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine buy type based on average equipment value
|
||||
*
|
||||
* @param avgEquipment - Average equipment value per player
|
||||
* @param teamSide - Team side ('T' or 'CT')
|
||||
* @param isPistolRound - Whether this is a pistol round
|
||||
* @returns Buy type classification
|
||||
*/
|
||||
export function getBuyType(
|
||||
avgEquipment: number,
|
||||
teamSide: TeamSide,
|
||||
isPistolRound: boolean = false
|
||||
): BuyType {
|
||||
if (isPistolRound) {
|
||||
return 'pistol';
|
||||
}
|
||||
|
||||
const thresholds = BUY_THRESHOLDS[teamSide];
|
||||
|
||||
if (avgEquipment < thresholds.eco) {
|
||||
return 'eco';
|
||||
}
|
||||
if (avgEquipment < thresholds.force) {
|
||||
return 'force';
|
||||
}
|
||||
return 'full';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get visual configuration for a buy type
|
||||
*/
|
||||
export function getBuyTypeConfig(buyType: BuyType): BuyTypeConfig {
|
||||
return BUY_TYPE_CONFIG[buyType];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine economy health based on average bank per player
|
||||
*
|
||||
* @param avgBank - Average bank per player
|
||||
* @returns Economy health status
|
||||
*/
|
||||
export function getEconomyHealth(avgBank: number): EconomyHealth {
|
||||
if (avgBank >= ECONOMY_HEALTH_THRESHOLDS.healthy) {
|
||||
return 'healthy';
|
||||
}
|
||||
if (avgBank >= ECONOMY_HEALTH_THRESHOLDS.tight) {
|
||||
return 'tight';
|
||||
}
|
||||
return 'broken';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get visual configuration for economy health
|
||||
*/
|
||||
export function getEconomyHealthConfig(health: EconomyHealth): EconomyHealthConfig {
|
||||
return ECONOMY_HEALTH_CONFIG[health];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a round is a pistol round
|
||||
*
|
||||
* @param roundNumber - Current round number (1-indexed)
|
||||
* @param halftimeRound - The halftime round number (12 for MR12, 15 for MR15)
|
||||
* @returns Whether this is a pistol round
|
||||
*/
|
||||
export function isPistolRound(roundNumber: number, halftimeRound: number): boolean {
|
||||
return roundNumber === 1 || roundNumber === halftimeRound + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the halftime round based on max rounds
|
||||
*
|
||||
* @param maxRounds - Maximum rounds in the match (24 for MR12, 30 for MR15)
|
||||
* @returns Halftime round number
|
||||
*/
|
||||
export function getHalftimeRound(maxRounds: number): number {
|
||||
return maxRounds === 30 ? 15 : 12;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total team economy (bank + equipment value)
|
||||
*
|
||||
* @param totalBank - Total team bank
|
||||
* @param totalEquipment - Total team equipment value
|
||||
* @returns Combined economy value
|
||||
*/
|
||||
export function calculateTeamEconomy(totalBank: number, totalEquipment: number): number {
|
||||
return totalBank + totalEquipment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format money value for display
|
||||
*
|
||||
* @param value - Money value
|
||||
* @returns Formatted string with $ prefix and comma separators
|
||||
*/
|
||||
export function formatMoney(value: number): string {
|
||||
return `$${value.toLocaleString()}`;
|
||||
}
|
||||
@@ -5,24 +5,41 @@
|
||||
|
||||
const STORAGE_KEY = 'matches-navigation-state';
|
||||
|
||||
/**
|
||||
* Filter state for matches page - used for preserving filters across navigation
|
||||
*/
|
||||
export interface FilterState {
|
||||
resultFilter: 'all' | 'win' | 'loss' | 'tie';
|
||||
sortBy: 'date' | 'duration' | 'score';
|
||||
sortOrder: 'desc' | 'asc';
|
||||
fromDate: string;
|
||||
toDate: string;
|
||||
}
|
||||
|
||||
interface NavigationState {
|
||||
matchId: string;
|
||||
scrollY: number;
|
||||
timestamp: number;
|
||||
loadedCount: number; // Number of matches loaded (for pagination)
|
||||
filters?: FilterState; // Optional filter state
|
||||
}
|
||||
|
||||
/**
|
||||
* Store navigation state when leaving the matches page
|
||||
*/
|
||||
export function storeMatchesState(matchId: string, loadedCount: number): void {
|
||||
export function storeMatchesState(
|
||||
matchId: string,
|
||||
loadedCount: number,
|
||||
filters?: FilterState
|
||||
): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const state: NavigationState = {
|
||||
matchId,
|
||||
scrollY: window.scrollY,
|
||||
timestamp: Date.now(),
|
||||
loadedCount
|
||||
loadedCount,
|
||||
filters
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
430
src/lib/utils/playerStats.ts
Normal file
430
src/lib/utils/playerStats.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* Player statistics calculation utilities
|
||||
* These functions calculate derived stats from match history for the player profile page
|
||||
*/
|
||||
|
||||
import type { MatchPlayer } from '$lib/types';
|
||||
|
||||
/**
|
||||
* Extended player match stats including match context
|
||||
*/
|
||||
export interface PlayerMatchStats extends MatchPlayer {
|
||||
match_id: string;
|
||||
map: string;
|
||||
date: string;
|
||||
won: boolean;
|
||||
tied: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculated player statistics
|
||||
*/
|
||||
export interface CalculatedStats {
|
||||
// Form indicator
|
||||
formRating: 'hot' | 'cold' | 'consistent';
|
||||
formDelta: number; // Percentage difference from average
|
||||
|
||||
// Win streaks
|
||||
currentStreak: { type: 'W' | 'L' | 'T'; count: number };
|
||||
longestWinStreak: number;
|
||||
longestLossStreak: number;
|
||||
|
||||
// Side preference
|
||||
tSideWinRate: number;
|
||||
ctSideWinRate: number;
|
||||
preferredSide: 'T' | 'CT' | 'balanced';
|
||||
|
||||
// Consistency (lower is more consistent)
|
||||
kdConsistency: number;
|
||||
consistencyRating: 'very_consistent' | 'consistent' | 'inconsistent' | 'very_inconsistent';
|
||||
|
||||
// Role detection
|
||||
detectedRole: 'Entry' | 'Support' | 'AWPer' | 'Lurker' | 'Flex';
|
||||
roleConfidence: number;
|
||||
|
||||
// Team damage
|
||||
avgTeamDamage: number;
|
||||
teamDamageRatio: number; // Percentage of total damage that went to team
|
||||
|
||||
// Ping
|
||||
avgPing: number;
|
||||
|
||||
// Time analysis
|
||||
bestTimeOfDay: string;
|
||||
weekendWinRate: number;
|
||||
weekdayWinRate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate average K/D ratio from stats
|
||||
*/
|
||||
function avgKD(stats: PlayerMatchStats[]): number {
|
||||
if (stats.length === 0) return 0;
|
||||
const totalKills = stats.reduce((sum, s) => sum + s.kills, 0);
|
||||
const totalDeaths = stats.reduce((sum, s) => sum + s.deaths, 0);
|
||||
return totalDeaths > 0 ? totalKills / totalDeaths : totalKills;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate standard deviation
|
||||
*/
|
||||
function standardDeviation(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
const mean = values.reduce((sum, v) => sum + v, 0) / values.length;
|
||||
const squaredDiffs = values.map((v) => Math.pow(v - mean, 2));
|
||||
const avgSquaredDiff = squaredDiffs.reduce((sum, v) => sum + v, 0) / values.length;
|
||||
return Math.sqrt(avgSquaredDiff);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate form rating based on recent performance vs overall
|
||||
*/
|
||||
export function calculateForm(stats: PlayerMatchStats[]): {
|
||||
rating: 'hot' | 'cold' | 'consistent';
|
||||
delta: number;
|
||||
} {
|
||||
if (stats.length < 5) {
|
||||
return { rating: 'consistent', delta: 0 };
|
||||
}
|
||||
|
||||
const recent5 = stats.slice(0, 5);
|
||||
const overall = stats;
|
||||
|
||||
const recentKD = avgKD(recent5);
|
||||
const overallKD = avgKD(overall);
|
||||
|
||||
if (overallKD === 0) {
|
||||
return { rating: 'consistent', delta: 0 };
|
||||
}
|
||||
|
||||
const delta = ((recentKD - overallKD) / overallKD) * 100;
|
||||
|
||||
if (delta > 15) return { rating: 'hot', delta };
|
||||
if (delta < -15) return { rating: 'cold', delta };
|
||||
return { rating: 'consistent', delta };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate current win/loss streak
|
||||
*/
|
||||
export function calculateCurrentStreak(stats: PlayerMatchStats[]): {
|
||||
type: 'W' | 'L' | 'T';
|
||||
count: number;
|
||||
} {
|
||||
if (stats.length === 0) {
|
||||
return { type: 'W', count: 0 };
|
||||
}
|
||||
|
||||
const firstMatch = stats[0];
|
||||
if (!firstMatch) {
|
||||
return { type: 'W', count: 0 };
|
||||
}
|
||||
|
||||
const firstType = firstMatch.tied ? 'T' : firstMatch.won ? 'W' : 'L';
|
||||
let count = 1;
|
||||
|
||||
for (let i = 1; i < stats.length; i++) {
|
||||
const match = stats[i];
|
||||
if (!match) break;
|
||||
const type = match.tied ? 'T' : match.won ? 'W' : 'L';
|
||||
if (type === firstType) {
|
||||
count++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { type: firstType, count };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate longest streak of a given type
|
||||
*/
|
||||
export function calculateLongestStreak(stats: PlayerMatchStats[], type: 'win' | 'loss'): number {
|
||||
let maxStreak = 0;
|
||||
let currentStreak = 0;
|
||||
|
||||
for (const match of stats) {
|
||||
const isTarget = type === 'win' ? match.won : !match.won && !match.tied;
|
||||
if (isTarget) {
|
||||
currentStreak++;
|
||||
maxStreak = Math.max(maxStreak, currentStreak);
|
||||
} else {
|
||||
currentStreak = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return maxStreak;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate win rate for a specific side
|
||||
* Note: team_id 2 = T side at start, team_id 3 = CT side at start
|
||||
* This is a simplification - actual side performance would need round-by-round data
|
||||
*/
|
||||
export function calculateSideWinRate(stats: PlayerMatchStats[], side: 'T' | 'CT'): number {
|
||||
const targetTeamId = side === 'T' ? 2 : 3;
|
||||
const sideMatches = stats.filter((s) => s.team_id === targetTeamId);
|
||||
|
||||
if (sideMatches.length === 0) return 0;
|
||||
|
||||
const wins = sideMatches.filter((s) => s.won).length;
|
||||
return (wins / sideMatches.length) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate K/D consistency (coefficient of variation)
|
||||
* Lower value = more consistent
|
||||
*/
|
||||
export function calculateConsistency(stats: PlayerMatchStats[]): {
|
||||
value: number;
|
||||
rating: 'very_consistent' | 'consistent' | 'inconsistent' | 'very_inconsistent';
|
||||
} {
|
||||
if (stats.length < 3) {
|
||||
return { value: 0, rating: 'consistent' };
|
||||
}
|
||||
|
||||
const kdRatios = stats.map((s) => (s.deaths > 0 ? s.kills / s.deaths : s.kills));
|
||||
const mean = kdRatios.reduce((sum, v) => sum + v, 0) / kdRatios.length;
|
||||
const stdDev = standardDeviation(kdRatios);
|
||||
|
||||
// Coefficient of variation (CV) - normalized measure of dispersion
|
||||
const cv = mean > 0 ? (stdDev / mean) * 100 : 0;
|
||||
|
||||
// Rate consistency based on CV
|
||||
// CV < 20% is very consistent, 20-35% consistent, 35-50% inconsistent, >50% very inconsistent
|
||||
let rating: 'very_consistent' | 'consistent' | 'inconsistent' | 'very_inconsistent';
|
||||
if (cv < 20) rating = 'very_consistent';
|
||||
else if (cv < 35) rating = 'consistent';
|
||||
else if (cv < 50) rating = 'inconsistent';
|
||||
else rating = 'very_inconsistent';
|
||||
|
||||
return { value: cv, rating };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect player role based on playstyle heuristics
|
||||
*/
|
||||
export function detectRole(stats: PlayerMatchStats[]): {
|
||||
role: 'Entry' | 'Support' | 'AWPer' | 'Lurker' | 'Flex';
|
||||
confidence: number;
|
||||
} {
|
||||
if (stats.length < 3) {
|
||||
return { role: 'Flex', confidence: 0 };
|
||||
}
|
||||
|
||||
// Calculate averages
|
||||
const avgFlashAssists = stats.reduce((sum, s) => sum + (s.flash_assists || 0), 0) / stats.length;
|
||||
const avgADR = stats.reduce((sum, s) => sum + (s.adr || 0), 0) / stats.length;
|
||||
const avgKills = stats.reduce((sum, s) => sum + s.kills, 0) / stats.length;
|
||||
const avgDeaths = stats.reduce((sum, s) => sum + s.deaths, 0) / stats.length;
|
||||
const avgMultiKills =
|
||||
stats.reduce(
|
||||
(sum, s) => sum + (s.mk_2 || 0) + (s.mk_3 || 0) + (s.mk_4 || 0) + (s.mk_5 || 0),
|
||||
0
|
||||
) / stats.length;
|
||||
const avgFlashesUsed = stats.reduce((sum, s) => sum + (s.ud_flash || 0), 0) / stats.length;
|
||||
|
||||
// Scoring system for each role
|
||||
const scores = {
|
||||
Entry: 0,
|
||||
Support: 0,
|
||||
AWPer: 0,
|
||||
Lurker: 0,
|
||||
Flex: 50 // Base score for Flex
|
||||
};
|
||||
|
||||
// Entry Fragger: High ADR, high kills, high deaths (aggressive)
|
||||
if (avgADR > 80) scores.Entry += 30;
|
||||
else if (avgADR > 70) scores.Entry += 15;
|
||||
if (avgKills > 18) scores.Entry += 20;
|
||||
if (avgDeaths > 16) scores.Entry += 10; // Often dies first
|
||||
if (avgMultiKills > 2) scores.Entry += 20;
|
||||
|
||||
// Support: High flash assists, moderate ADR, lots of utility
|
||||
if (avgFlashAssists > 2) scores.Support += 40;
|
||||
else if (avgFlashAssists > 1) scores.Support += 20;
|
||||
if (avgFlashesUsed > 3) scores.Support += 15;
|
||||
if (avgADR >= 60 && avgADR <= 75) scores.Support += 15;
|
||||
|
||||
// AWPer: Would need weapon data - use high damage, fewer kills pattern
|
||||
// Without weapon data, we can't reliably detect AWPers
|
||||
// AWP kills typically have high damage per kill
|
||||
const damagePerKill = avgKills > 0 ? avgADR / (avgKills / 20) : 0; // Rough estimate
|
||||
if (damagePerKill > 100) scores.AWPer += 25;
|
||||
|
||||
// Lurker: Low flash usage, moderate stats, high survival
|
||||
if (avgFlashesUsed < 2 && avgFlashAssists < 1) scores.Lurker += 20;
|
||||
if (avgDeaths < 14 && avgKills > 14) scores.Lurker += 25; // Good K/D, survives
|
||||
if (avgADR >= 65 && avgADR <= 80) scores.Lurker += 15;
|
||||
|
||||
// Find highest scoring role
|
||||
const entries = Object.entries(scores) as [keyof typeof scores, number][];
|
||||
entries.sort((a, b) => b[1] - a[1]);
|
||||
|
||||
const topRole = entries[0];
|
||||
const secondRole = entries[1];
|
||||
|
||||
// Calculate confidence based on margin
|
||||
const margin = topRole && secondRole ? topRole[1] - secondRole[1] : 0;
|
||||
const confidence = Math.min(100, margin + 20);
|
||||
|
||||
return {
|
||||
role: topRole ? topRole[0] : 'Flex',
|
||||
confidence
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate team damage statistics
|
||||
*/
|
||||
export function calculateTeamDamage(stats: PlayerMatchStats[]): {
|
||||
avgTeamDamage: number;
|
||||
teamDamageRatio: number;
|
||||
} {
|
||||
if (stats.length === 0) {
|
||||
return { avgTeamDamage: 0, teamDamageRatio: 0 };
|
||||
}
|
||||
|
||||
const totalTeamDamage = stats.reduce((sum, s) => sum + (s.dmg_team || 0), 0);
|
||||
const totalEnemyDamage = stats.reduce((sum, s) => sum + (s.dmg_enemy || 0), 0);
|
||||
const totalDamage = totalTeamDamage + totalEnemyDamage;
|
||||
|
||||
return {
|
||||
avgTeamDamage: totalTeamDamage / stats.length,
|
||||
teamDamageRatio: totalDamage > 0 ? (totalTeamDamage / totalDamage) * 100 : 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate average ping
|
||||
*/
|
||||
export function calculateAvgPing(stats: PlayerMatchStats[]): number {
|
||||
const pings = stats.filter((s) => s.avg_ping !== undefined && s.avg_ping > 0);
|
||||
if (pings.length === 0) return 0;
|
||||
return pings.reduce((sum, s) => sum + (s.avg_ping || 0), 0) / pings.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze time-of-day performance
|
||||
*/
|
||||
export function calculateTimePerformance(stats: PlayerMatchStats[]): {
|
||||
bestTimeOfDay: string;
|
||||
weekendWinRate: number;
|
||||
weekdayWinRate: number;
|
||||
} {
|
||||
if (stats.length === 0) {
|
||||
return { bestTimeOfDay: 'N/A', weekendWinRate: 0, weekdayWinRate: 0 };
|
||||
}
|
||||
|
||||
// Group by time of day
|
||||
const timeSlots: Record<string, { wins: number; total: number }> = {
|
||||
Morning: { wins: 0, total: 0 }, // 6-12
|
||||
Afternoon: { wins: 0, total: 0 }, // 12-18
|
||||
Evening: { wins: 0, total: 0 }, // 18-24
|
||||
Night: { wins: 0, total: 0 } // 0-6
|
||||
};
|
||||
|
||||
let weekendMatches = 0;
|
||||
let weekendWins = 0;
|
||||
let weekdayMatches = 0;
|
||||
let weekdayWins = 0;
|
||||
|
||||
for (const match of stats) {
|
||||
const date = new Date(match.date);
|
||||
const hour = date.getHours();
|
||||
const dayOfWeek = date.getDay();
|
||||
|
||||
// Time of day
|
||||
let slot: string;
|
||||
if (hour >= 6 && hour < 12) slot = 'Morning';
|
||||
else if (hour >= 12 && hour < 18) slot = 'Afternoon';
|
||||
else if (hour >= 18) slot = 'Evening';
|
||||
else slot = 'Night';
|
||||
|
||||
const timeSlot = timeSlots[slot];
|
||||
if (timeSlot) {
|
||||
timeSlot.total++;
|
||||
if (match.won) timeSlot.wins++;
|
||||
}
|
||||
|
||||
// Weekend vs weekday (0 = Sunday, 6 = Saturday)
|
||||
if (dayOfWeek === 0 || dayOfWeek === 6) {
|
||||
weekendMatches++;
|
||||
if (match.won) weekendWins++;
|
||||
} else {
|
||||
weekdayMatches++;
|
||||
if (match.won) weekdayWins++;
|
||||
}
|
||||
}
|
||||
|
||||
// Find best time slot
|
||||
let bestSlot = 'Evening';
|
||||
let bestWinRate = 0;
|
||||
|
||||
for (const [slot, data] of Object.entries(timeSlots)) {
|
||||
if (data.total >= 3) {
|
||||
// Need at least 3 matches for meaningful data
|
||||
const winRate = data.wins / data.total;
|
||||
if (winRate > bestWinRate) {
|
||||
bestWinRate = winRate;
|
||||
bestSlot = slot;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
bestTimeOfDay: bestSlot,
|
||||
weekendWinRate: weekendMatches > 0 ? (weekendWins / weekendMatches) * 100 : 0,
|
||||
weekdayWinRate: weekdayMatches > 0 ? (weekdayWins / weekdayMatches) * 100 : 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate all player statistics
|
||||
*/
|
||||
export function calculateAllStats(stats: PlayerMatchStats[]): CalculatedStats | null {
|
||||
if (stats.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const form = calculateForm(stats);
|
||||
const currentStreak = calculateCurrentStreak(stats);
|
||||
const longestWinStreak = calculateLongestStreak(stats, 'win');
|
||||
const longestLossStreak = calculateLongestStreak(stats, 'loss');
|
||||
const tSideWinRate = calculateSideWinRate(stats, 'T');
|
||||
const ctSideWinRate = calculateSideWinRate(stats, 'CT');
|
||||
const consistency = calculateConsistency(stats);
|
||||
const role = detectRole(stats);
|
||||
const teamDamage = calculateTeamDamage(stats);
|
||||
const avgPing = calculateAvgPing(stats);
|
||||
const timePerf = calculateTimePerformance(stats);
|
||||
|
||||
// Determine preferred side
|
||||
let preferredSide: 'T' | 'CT' | 'balanced' = 'balanced';
|
||||
const sideDiff = Math.abs(tSideWinRate - ctSideWinRate);
|
||||
if (sideDiff > 10) {
|
||||
preferredSide = tSideWinRate > ctSideWinRate ? 'T' : 'CT';
|
||||
}
|
||||
|
||||
return {
|
||||
formRating: form.rating,
|
||||
formDelta: form.delta,
|
||||
currentStreak,
|
||||
longestWinStreak,
|
||||
longestLossStreak,
|
||||
tSideWinRate,
|
||||
ctSideWinRate,
|
||||
preferredSide,
|
||||
kdConsistency: consistency.value,
|
||||
consistencyRating: consistency.rating,
|
||||
detectedRole: role.role,
|
||||
roleConfidence: role.confidence,
|
||||
avgTeamDamage: teamDamage.avgTeamDamage,
|
||||
teamDamageRatio: teamDamage.teamDamageRatio,
|
||||
avgPing,
|
||||
bestTimeOfDay: timePerf.bestTimeOfDay,
|
||||
weekendWinRate: timePerf.weekendWinRate,
|
||||
weekdayWinRate: timePerf.weekdayWinRate
|
||||
};
|
||||
}
|
||||
@@ -1,97 +1,180 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import { Home, ArrowLeft } from 'lucide-svelte';
|
||||
import { Home, ArrowLeft, Zap } from 'lucide-svelte';
|
||||
|
||||
// Get error information
|
||||
const error = $page.error;
|
||||
const status = $page.status;
|
||||
|
||||
// Determine error message
|
||||
const getErrorMessage = (status: number): string => {
|
||||
switch (status) {
|
||||
// Flash-themed error messages
|
||||
const getErrorMessage = (statusCode: number): string => {
|
||||
switch (statusCode) {
|
||||
case 404:
|
||||
return "We couldn't find the page you're looking for.";
|
||||
return "This page got pop-flashed out of existence. Even we can't see it.";
|
||||
case 500:
|
||||
return 'Something went wrong on our end. Please try again later.';
|
||||
return "The server got flashbanged. It's currently rubbing its eyes. Please wait.";
|
||||
case 503:
|
||||
return 'Service temporarily unavailable. Please check back soon.';
|
||||
return 'Service temporarily blinded. Someone threw a flash into the server room.';
|
||||
case 400:
|
||||
return 'Bad request? More like bad flash lineup. Try again.';
|
||||
case 401:
|
||||
return "You need to authenticate. Unlike flashes, you can't just walk through this.";
|
||||
case 403:
|
||||
return 'Forbidden. This area is as off-limits as throwing flashes at your own spawn.';
|
||||
default:
|
||||
return 'An unexpected error occurred.';
|
||||
return 'Something went white. Very white. Flash-in-the-face white.';
|
||||
}
|
||||
};
|
||||
|
||||
const getErrorTitle = (status: number): string => {
|
||||
switch (status) {
|
||||
const getErrorTitle = (statusCode: number): string => {
|
||||
switch (statusCode) {
|
||||
case 404:
|
||||
return 'Page Not Found';
|
||||
return "You've Been Full-Blind";
|
||||
case 500:
|
||||
return 'Internal Server Error';
|
||||
return 'Server Got Flashbanged';
|
||||
case 503:
|
||||
return 'Service Unavailable';
|
||||
return 'Temporarily Blinded';
|
||||
case 400:
|
||||
return 'Bad Flash Lineup';
|
||||
case 401:
|
||||
return 'Flash Authentication Required';
|
||||
case 403:
|
||||
return 'Flash Access Denied';
|
||||
default:
|
||||
return 'Error';
|
||||
return 'Unexpected Flash';
|
||||
}
|
||||
};
|
||||
|
||||
// Rotating flash puns for extra fun
|
||||
const flashPuns = [
|
||||
"At least this error didn't team flash you.",
|
||||
"Error logging: You've been added to the wall of shame.",
|
||||
'Did you try turning off the flashbang and turning it back on?',
|
||||
'This error is brighter than your flash lineups.',
|
||||
'404: Your aim, also not found.',
|
||||
"The page didn't peek, but got flashed anyway.",
|
||||
'Server.exe has stopped responding to flash inputs.',
|
||||
'Have you considered not running into your own flashes?'
|
||||
];
|
||||
|
||||
const randomPun = flashPuns[Math.floor(Math.random() * flashPuns.length)];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{status} - {getErrorTitle(status)} | CS2.WTF</title>
|
||||
<title>{status} - {getErrorTitle(status)} | teamflash.rip</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container mx-auto flex min-h-[60vh] items-center justify-center px-4 py-16">
|
||||
<Card padding="lg" class="w-full max-w-2xl">
|
||||
<div class="text-center">
|
||||
<!-- Error Code -->
|
||||
<div class="mb-4 text-8xl font-bold text-primary">
|
||||
{status}
|
||||
</div>
|
||||
<div class="relative bg-void">
|
||||
<!-- Decorative Background -->
|
||||
<div class="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<!-- Blur orbs -->
|
||||
<div class="absolute -left-40 top-20 h-80 w-80 rounded-full bg-neon-gold/15 blur-[100px]"></div>
|
||||
<div
|
||||
class="absolute -right-40 bottom-20 h-80 w-80 rounded-full bg-neon-red/10 blur-[100px]"
|
||||
></div>
|
||||
<div
|
||||
class="absolute left-1/2 top-1/2 h-96 w-96 -translate-x-1/2 -translate-y-1/2 rounded-full bg-neon-gold/5 blur-[120px]"
|
||||
></div>
|
||||
<!-- Grid pattern -->
|
||||
<div
|
||||
class="absolute inset-0 opacity-20"
|
||||
style="background-image: linear-gradient(rgba(255, 170, 0, 0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(255, 170, 0, 0.03) 1px, transparent 1px); background-size: 60px 60px;"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Error Title -->
|
||||
<h1 class="mb-4 text-3xl font-bold text-base-content">
|
||||
{getErrorTitle(status)}
|
||||
</h1>
|
||||
|
||||
<!-- Error Message -->
|
||||
<p class="mb-8 text-lg text-base-content/70">
|
||||
{getErrorMessage(status)}
|
||||
</p>
|
||||
|
||||
<!-- Debug Info (only in development) -->
|
||||
{#if import.meta.env?.DEV && error}
|
||||
<div class="mb-8 rounded-lg bg-base-300 p-4 text-left">
|
||||
<p class="mb-2 font-mono text-sm text-error">
|
||||
<strong>Debug Info:</strong>
|
||||
</p>
|
||||
<pre class="overflow-x-auto text-xs text-base-content/80">{JSON.stringify(
|
||||
error,
|
||||
null,
|
||||
2
|
||||
)}</pre>
|
||||
<!-- Content -->
|
||||
<div
|
||||
class="container relative z-10 mx-auto flex min-h-[80vh] items-center justify-center px-4 py-16"
|
||||
>
|
||||
<div class="w-full max-w-2xl rounded-xl border border-white/10 bg-void-light p-8 md:p-12">
|
||||
<div class="text-center">
|
||||
<!-- Flash Icon -->
|
||||
<div class="mb-6 flex justify-center">
|
||||
<div class="relative">
|
||||
<Zap
|
||||
class="h-20 w-20 animate-pulse text-neon-gold"
|
||||
style="filter: drop-shadow(0 0 20px rgba(255, 170, 0, 0.6));"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 animate-ping rounded-full bg-neon-gold/20"
|
||||
style="animation-duration: 2s;"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-col justify-center gap-4 sm:flex-row">
|
||||
<Button variant="secondary" href="javascript:history.back()">
|
||||
<ArrowLeft class="mr-2 h-5 w-5" />
|
||||
Go Back
|
||||
</Button>
|
||||
<!-- Error Code -->
|
||||
<div
|
||||
class="mb-4 font-mono text-8xl font-bold text-neon-gold md:text-9xl"
|
||||
style="text-shadow: 0 0 40px rgba(255, 170, 0, 0.5), 0 0 80px rgba(255, 170, 0, 0.3);"
|
||||
>
|
||||
{status}
|
||||
</div>
|
||||
|
||||
<Button variant="primary" href="/">
|
||||
<Home class="mr-2 h-5 w-5" />
|
||||
Go Home
|
||||
</Button>
|
||||
<!-- Error Title -->
|
||||
<h1
|
||||
class="mb-4 text-3xl font-bold text-white"
|
||||
style="text-shadow: 0 0 20px rgba(255, 170, 0, 0.3);"
|
||||
>
|
||||
{getErrorTitle(status)}
|
||||
</h1>
|
||||
|
||||
<!-- Error Message -->
|
||||
<p class="mb-4 text-lg text-white/70">
|
||||
{getErrorMessage(status)}
|
||||
</p>
|
||||
|
||||
<!-- Random Flash Pun -->
|
||||
<p class="mb-8 text-sm italic text-white/40">
|
||||
"{randomPun}"
|
||||
</p>
|
||||
|
||||
<!-- Debug Info (only in development) -->
|
||||
{#if import.meta.env?.DEV && error}
|
||||
<div class="mb-8 rounded-lg border border-neon-red/30 bg-neon-red/5 p-4 text-left">
|
||||
<p class="mb-2 font-mono text-sm text-neon-red">
|
||||
<strong>Flash Report (Debug):</strong>
|
||||
</p>
|
||||
<pre class="overflow-x-auto text-xs text-white/70">{JSON.stringify(
|
||||
error,
|
||||
null,
|
||||
2
|
||||
)}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-col justify-center gap-4 sm:flex-row">
|
||||
<button
|
||||
onclick={() => history.back()}
|
||||
class="inline-flex items-center justify-center gap-2 rounded-lg border border-white/20 px-6 py-3 font-medium text-white/80 transition-all duration-300 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<ArrowLeft class="h-5 w-5" />
|
||||
Peek Again
|
||||
</button>
|
||||
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center justify-center gap-2 rounded-lg bg-neon-gold px-6 py-3 font-medium text-void transition-all duration-300 hover:shadow-[0_0_25px_rgba(255,170,0,0.4)]"
|
||||
>
|
||||
<Home class="h-5 w-5" />
|
||||
Return to Spawn
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Help Text -->
|
||||
<p class="mt-8 text-sm text-white/40">
|
||||
If this flash keeps happening, please
|
||||
<a
|
||||
href="https://somegit.dev/CSGOWTF/csgowtf/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-neon-blue underline decoration-neon-blue/30 transition-colors hover:text-neon-blue hover:decoration-neon-blue"
|
||||
>
|
||||
report it on GitHub
|
||||
</a>
|
||||
(we won't flash you, we promise)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Help Text -->
|
||||
<p class="mt-8 text-sm text-base-content/50">
|
||||
If this problem persists, please
|
||||
<a href="https://somegit.dev/CSGOWTF/csgowtf/issues" class="link-hover link text-primary">
|
||||
report it on GitHub
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,17 +3,19 @@
|
||||
import Header from '$lib/components/layout/Header.svelte';
|
||||
import Footer from '$lib/components/layout/Footer.svelte';
|
||||
import ToastContainer from '$lib/components/ui/ToastContainer.svelte';
|
||||
import SearchModal from '$lib/components/layout/SearchModal.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen flex-col bg-base-100">
|
||||
<div class="flex min-h-screen flex-col bg-void">
|
||||
<Header />
|
||||
<main class="flex-1">
|
||||
{@render children()}
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
<!-- Toast notifications -->
|
||||
<!-- Global overlays -->
|
||||
<ToastContainer />
|
||||
<SearchModal />
|
||||
</div>
|
||||
|
||||
@@ -1,139 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { Search, TrendingUp, Users, Zap, ChevronLeft, ChevronRight } from 'lucide-svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import MatchCard from '$lib/components/match/MatchCard.svelte';
|
||||
import HeroSection from '$lib/components/landing/HeroSection.svelte';
|
||||
import LiveMatchTicker from '$lib/components/landing/LiveMatchTicker.svelte';
|
||||
import FlashLeaderboard from '$lib/components/landing/FlashLeaderboard.svelte';
|
||||
import FeatureShowcase from '$lib/components/landing/FeatureShowcase.svelte';
|
||||
import NeonCTA from '$lib/components/landing/NeonCTA.svelte';
|
||||
import RecentPlayers from '$lib/components/player/RecentPlayers.svelte';
|
||||
import PieChart from '$lib/components/charts/PieChart.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
// Get data from page loader
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// Use matches directly - already transformed by API client
|
||||
const featuredMatches = data.featuredMatches;
|
||||
const mapStats = data.mapStats;
|
||||
// Transform featured matches for the ticker
|
||||
const tickerMatches = $derived(
|
||||
data.featuredMatches.slice(0, 10).map((match) => ({
|
||||
id: match.match_id,
|
||||
map: match.map || 'de_unknown',
|
||||
scoreT: match.score_team_a || 0,
|
||||
scoreCT: match.score_team_b || 0,
|
||||
isProcessing: !match.demo_parsed
|
||||
}))
|
||||
);
|
||||
|
||||
// Count matches being processed (demos not yet parsed)
|
||||
const processingCount = $derived(featuredMatches.filter((m) => !m.demo_parsed).length);
|
||||
|
||||
// Prepare map chart data
|
||||
const mapChartData = $derived({
|
||||
labels: mapStats.map((s) => s.map),
|
||||
datasets: [
|
||||
{
|
||||
data: mapStats.map((s) => s.count),
|
||||
backgroundColor: [
|
||||
'rgba(59, 130, 246, 0.8)', // blue
|
||||
'rgba(16, 185, 129, 0.8)', // green
|
||||
'rgba(245, 158, 11, 0.8)', // amber
|
||||
'rgba(239, 68, 68, 0.8)', // red
|
||||
'rgba(139, 92, 246, 0.8)', // purple
|
||||
'rgba(236, 72, 153, 0.8)', // pink
|
||||
'rgba(20, 184, 166, 0.8)' // teal
|
||||
],
|
||||
borderColor: [
|
||||
'rgba(255, 255, 255, 0.8)',
|
||||
'rgba(255, 255, 255, 0.8)',
|
||||
'rgba(255, 255, 255, 0.8)',
|
||||
'rgba(255, 255, 255, 0.8)',
|
||||
'rgba(255, 255, 255, 0.8)',
|
||||
'rgba(255, 255, 255, 0.8)',
|
||||
'rgba(255, 255, 255, 0.8)'
|
||||
],
|
||||
borderWidth: 2
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const stats = [
|
||||
{ icon: Users, label: 'Players Tracked', value: '1.2M+' },
|
||||
{ icon: TrendingUp, label: 'Matches Analyzed', value: '500K+' },
|
||||
{ icon: Zap, label: 'Demos Parsed', value: '2M+' }
|
||||
];
|
||||
|
||||
// Carousel state
|
||||
let currentSlide = $state(0);
|
||||
let isPaused = $state(false);
|
||||
let autoRotateInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let manualNavigationTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let windowWidth = $state(1024); // Default to desktop
|
||||
|
||||
// Track window width for responsive slides
|
||||
$effect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
windowWidth = window.innerWidth;
|
||||
|
||||
const handleResize = () => {
|
||||
windowWidth = window.innerWidth;
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}
|
||||
// Return empty cleanup function for server-side rendering path
|
||||
return () => {};
|
||||
});
|
||||
|
||||
// Determine matches per slide based on screen width
|
||||
const matchesPerSlide = $derived(windowWidth < 768 ? 1 : windowWidth < 1024 ? 2 : 3);
|
||||
|
||||
const totalSlides = $derived(Math.ceil(featuredMatches.length / matchesPerSlide));
|
||||
|
||||
// Get visible matches for current slide
|
||||
const visibleMatches = $derived.by(() => {
|
||||
const start = currentSlide * matchesPerSlide;
|
||||
return featuredMatches.slice(start, start + matchesPerSlide);
|
||||
});
|
||||
|
||||
function nextSlide() {
|
||||
currentSlide = (currentSlide + 1) % totalSlides;
|
||||
}
|
||||
|
||||
function prevSlide() {
|
||||
currentSlide = (currentSlide - 1 + totalSlides) % totalSlides;
|
||||
}
|
||||
|
||||
function goToSlide(index: number) {
|
||||
currentSlide = index;
|
||||
pauseAutoRotateTemporarily();
|
||||
}
|
||||
|
||||
function pauseAutoRotateTemporarily() {
|
||||
isPaused = true;
|
||||
if (manualNavigationTimeout) clearTimeout(manualNavigationTimeout);
|
||||
manualNavigationTimeout = setTimeout(() => {
|
||||
isPaused = false;
|
||||
}, 10000); // Resume after 10 seconds
|
||||
}
|
||||
|
||||
function handleManualNavigation(direction: 'prev' | 'next') {
|
||||
if (direction === 'prev') {
|
||||
prevSlide();
|
||||
} else {
|
||||
nextSlide();
|
||||
}
|
||||
pauseAutoRotateTemporarily();
|
||||
}
|
||||
|
||||
// Auto-rotation effect
|
||||
$effect(() => {
|
||||
if (autoRotateInterval) clearInterval(autoRotateInterval);
|
||||
|
||||
autoRotateInterval = setInterval(() => {
|
||||
if (!isPaused) {
|
||||
nextSlide();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
if (autoRotateInterval) clearInterval(autoRotateInterval);
|
||||
if (manualNavigationTimeout) clearTimeout(manualNavigationTimeout);
|
||||
};
|
||||
// Stats for hero section - use real data where available
|
||||
const heroStats = $derived({
|
||||
playersExposed: 12847,
|
||||
flashCrimes: 89234,
|
||||
flashbangsAnalyzed: data.totalMatchesAnalyzed * 150 || 1247893
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -142,292 +34,24 @@
|
||||
<meta name="description" content={data.meta.description} />
|
||||
</svelte:head>
|
||||
|
||||
<!-- Hero Section -->
|
||||
<section class="border-b border-base-300 bg-gradient-to-b from-base-100 to-base-200 py-24">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="mx-auto max-w-4xl text-center">
|
||||
<div class="mb-6">
|
||||
<Badge variant="info" size="md">🎮 Now supporting CS2</Badge>
|
||||
</div>
|
||||
<!-- Hero Section with Particle Background -->
|
||||
<HeroSection stats={heroStats} />
|
||||
|
||||
<h1 class="mb-6 text-6xl font-bold leading-tight md:text-7xl">
|
||||
<span class="text-primary">CS2</span><span class="text-secondary">.WTF</span>
|
||||
</h1>
|
||||
<!-- Live Match Ticker -->
|
||||
<LiveMatchTicker matches={tickerMatches} />
|
||||
|
||||
<p class="mb-8 text-xl text-base-content/70 md:text-2xl">
|
||||
Track your performance, analyze matches, and improve your game with
|
||||
<span class="font-semibold text-primary">detailed statistics</span> and insights.
|
||||
</p>
|
||||
|
||||
<div class="mb-12 flex flex-col justify-center gap-4 sm:flex-row">
|
||||
<Button variant="primary" size="lg" href="/matches">
|
||||
<Search class="mr-2 h-5 w-5" />
|
||||
Browse Matches
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" href="/player/76561198012345678">
|
||||
<Users class="mr-2 h-5 w-5" />
|
||||
View Demo Profile
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid gap-6 md:grid-cols-3">
|
||||
{#each stats as stat}
|
||||
{@const StatIcon = stat.icon}
|
||||
<div class="rounded-lg bg-base-100 p-6 shadow-lg">
|
||||
<StatIcon class="mx-auto mb-3 h-8 w-8 text-primary" />
|
||||
<div class="text-3xl font-bold text-base-content">{stat.value}</div>
|
||||
<div class="text-sm text-base-content/60">{stat.label}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Recently Visited Players -->
|
||||
<section class="py-8">
|
||||
<!-- Recently Visited Players (existing component, styled to fit) -->
|
||||
<section class="bg-void-light py-8">
|
||||
<div class="container mx-auto px-4">
|
||||
<RecentPlayers />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Featured Matches -->
|
||||
<section class="py-16">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="mb-8 flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-3xl font-bold text-base-content">Featured Matches</h2>
|
||||
{#if processingCount > 0}
|
||||
<Badge variant="warning" size="sm">
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span
|
||||
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-warning opacity-75"
|
||||
></span>
|
||||
<span class="relative inline-flex h-2 w-2 rounded-full bg-warning"></span>
|
||||
</span>
|
||||
<span class="ml-1.5">{processingCount} Processing</span>
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="mt-2 text-base-content/60">Latest competitive matches from our community</p>
|
||||
</div>
|
||||
<Button variant="ghost" href="/matches">View All</Button>
|
||||
</div>
|
||||
<!-- Flash Leaderboard - Wall of Shame -->
|
||||
<FlashLeaderboard />
|
||||
|
||||
{#if featuredMatches.length > 0}
|
||||
<!-- Carousel Container -->
|
||||
<div
|
||||
class="relative"
|
||||
onmouseenter={() => (isPaused = true)}
|
||||
onmouseleave={() => (isPaused = false)}
|
||||
role="region"
|
||||
aria-label="Featured matches carousel"
|
||||
>
|
||||
<!-- Matches Grid with Fade Transition -->
|
||||
<div class="transition-opacity duration-500" class:opacity-100={true}>
|
||||
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each visibleMatches as match (match.match_id)}
|
||||
<MatchCard {match} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Feature Showcase -->
|
||||
<FeatureShowcase />
|
||||
|
||||
<!-- Navigation Arrows - Only show if there are multiple slides -->
|
||||
{#if totalSlides > 1}
|
||||
<!-- Previous Button -->
|
||||
<button
|
||||
onclick={() => handleManualNavigation('prev')}
|
||||
class="group absolute left-0 top-1/2 z-10 -translate-x-4 -translate-y-1/2 rounded-md border border-base-content/10 bg-base-100/95 p-2 shadow-[0_8px_30px_rgb(0,0,0,0.12)] backdrop-blur-md transition-all duration-200 hover:-translate-x-5 hover:border-primary/30 hover:bg-base-100 hover:shadow-[0_12px_40px_rgb(0,0,0,0.15)] focus:outline-none focus:ring-2 focus:ring-primary/50 md:-translate-x-6 md:hover:-translate-x-7"
|
||||
aria-label="Previous slide"
|
||||
>
|
||||
<ChevronLeft
|
||||
class="h-6 w-6 text-base-content/70 transition-colors duration-200 group-hover:text-primary"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Next Button -->
|
||||
<button
|
||||
onclick={() => handleManualNavigation('next')}
|
||||
class="group absolute right-0 top-1/2 z-10 -translate-y-1/2 translate-x-4 rounded-md border border-base-content/10 bg-base-100/95 p-2 shadow-[0_8px_30px_rgb(0,0,0,0.12)] backdrop-blur-md transition-all duration-200 hover:translate-x-5 hover:border-primary/30 hover:bg-base-100 hover:shadow-[0_12px_40px_rgb(0,0,0,0.15)] focus:outline-none focus:ring-2 focus:ring-primary/50 md:translate-x-6 md:hover:translate-x-7"
|
||||
aria-label="Next slide"
|
||||
>
|
||||
<ChevronRight
|
||||
class="h-6 w-6 text-base-content/70 transition-colors duration-200 group-hover:text-primary"
|
||||
/>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Dot Indicators - Only show if there are multiple slides -->
|
||||
{#if totalSlides > 1}
|
||||
<div class="mt-8 flex justify-center gap-2">
|
||||
{#each Array(totalSlides) as _, i}
|
||||
<button
|
||||
onclick={() => goToSlide(i)}
|
||||
class="h-2 w-2 rounded-full transition-all duration-300 hover:scale-125 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
class:bg-primary={i === currentSlide}
|
||||
class:w-8={i === currentSlide}
|
||||
class:bg-base-300={i !== currentSlide}
|
||||
aria-label={`Go to slide ${i + 1}`}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- No Matches Found -->
|
||||
<div class="rounded-lg border border-base-300 bg-base-100 p-12 text-center">
|
||||
<p class="text-lg text-base-content/60">No featured matches available at the moment.</p>
|
||||
<p class="mt-2 text-sm text-base-content/40">Check back soon for the latest matches!</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Statistics Dashboard -->
|
||||
{#if mapStats.length > 0}
|
||||
<section class="border-t border-base-300 bg-base-100 py-16">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="mb-8 text-center">
|
||||
<h2 class="text-3xl font-bold text-base-content">Community Statistics</h2>
|
||||
<p class="mt-2 text-base-content/60">
|
||||
Insights from {data.totalMatchesAnalyzed.toLocaleString()} recent matches
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-8 lg:grid-cols-2">
|
||||
<!-- Most Played Maps -->
|
||||
<Card padding="lg">
|
||||
<h3 class="mb-6 text-xl font-semibold text-base-content">Most Played Maps</h3>
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="w-full max-w-md">
|
||||
<PieChart data={mapChartData} options={{ maintainAspectRatio: true }} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 space-y-2">
|
||||
{#each mapStats as stat, i}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="h-3 w-3 rounded-sm"
|
||||
style="background-color: {mapChartData.datasets[0]?.backgroundColor?.[i] ||
|
||||
'rgba(59, 130, 246, 0.8)'}"
|
||||
></div>
|
||||
<span class="text-sm font-medium text-base-content">{stat.map}</span>
|
||||
</div>
|
||||
<span class="text-sm text-base-content/60"
|
||||
>{stat.count} matches ({((stat.count / data.totalMatchesAnalyzed) * 100).toFixed(
|
||||
1
|
||||
)}%)</span
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Quick Stats Summary -->
|
||||
<Card padding="lg">
|
||||
<h3 class="mb-6 text-xl font-semibold text-base-content">Recent Activity</h3>
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-lg bg-base-200 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-base-content/60">Total Matches</p>
|
||||
<p class="text-3xl font-bold text-primary">
|
||||
{data.totalMatchesAnalyzed.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<TrendingUp class="h-12 w-12 text-primary/40" />
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-base-content/50">From the last 24 hours</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-base-200 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-base-content/60">Most Popular Map</p>
|
||||
<p class="text-3xl font-bold text-secondary">
|
||||
{mapStats[0]?.map || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="success" size="lg"
|
||||
>{mapStats[0]
|
||||
? `${((mapStats[0].count / data.totalMatchesAnalyzed) * 100).toFixed(0)}%`
|
||||
: '0%'}</Badge
|
||||
>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-base-content/50">
|
||||
Played in {mapStats[0]?.count || 0} matches
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<Button variant="ghost" href="/matches">View All Match Statistics →</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Features Section -->
|
||||
<section class="border-t border-base-300 bg-base-200 py-16">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="mb-12 text-center">
|
||||
<h2 class="text-3xl font-bold text-base-content">Why CS2.WTF?</h2>
|
||||
<p class="mt-2 text-base-content/60">Everything you need to analyze your CS2 performance</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Card padding="lg">
|
||||
<div class="mb-4 inline-flex rounded-lg bg-primary/10 p-3">
|
||||
<TrendingUp class="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<h3 class="mb-2 text-xl font-semibold">Detailed Statistics</h3>
|
||||
<p class="text-base-content/60">
|
||||
Track K/D, ADR, HS%, KAST, and more. Analyze your performance round-by-round with
|
||||
comprehensive stats.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg">
|
||||
<div class="mb-4 inline-flex rounded-lg bg-secondary/10 p-3">
|
||||
<Zap class="h-6 w-6 text-secondary" />
|
||||
</div>
|
||||
<h3 class="mb-2 text-xl font-semibold">Economy Tracking</h3>
|
||||
<p class="text-base-content/60">
|
||||
Understand money management with round-by-round economy analysis and spending patterns.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg">
|
||||
<div class="mb-4 inline-flex rounded-lg bg-info/10 p-3">
|
||||
<Users class="h-6 w-6 text-info" />
|
||||
</div>
|
||||
<h3 class="mb-2 text-xl font-semibold">Player Profiles</h3>
|
||||
<p class="text-base-content/60">
|
||||
View comprehensive player profiles with match history, favorite maps, and performance
|
||||
trends.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA Section -->
|
||||
<section class="py-16">
|
||||
<div class="container mx-auto px-4">
|
||||
<Card variant="elevated" padding="lg">
|
||||
<div class="text-center">
|
||||
<h2 class="mb-4 text-3xl font-bold text-base-content">Ready to improve your game?</h2>
|
||||
<p class="mb-8 text-lg text-base-content/70">
|
||||
Start tracking your CS2 matches and get insights that help you rank up.
|
||||
</p>
|
||||
<Button variant="primary" size="lg" href="/matches">Get Started - It's Free</Button>
|
||||
<p class="mt-4 text-sm text-base-content/50">Free and open source. No signup required.</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Call to Action -->
|
||||
<NeonCTA />
|
||||
|
||||
@@ -32,9 +32,9 @@ export const load: PageLoad = async ({ parent }) => {
|
||||
mapStats, // For most played maps pie chart
|
||||
totalMatchesAnalyzed: allMatches.length,
|
||||
meta: {
|
||||
title: 'CS2.WTF - Statistics for CS2 Matchmaking',
|
||||
title: 'teamflash.rip - Stop Flashing Your Teammates',
|
||||
description:
|
||||
'Track your CS2 performance, analyze matches, and improve your game with detailed statistics and insights.'
|
||||
'Track your CS2 performance, analyze matches, and finally learn to stop team flashing. Detailed flash statistics, blinding insights, and more.'
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -50,9 +50,9 @@ export const load: PageLoad = async ({ parent }) => {
|
||||
mapStats: [],
|
||||
totalMatchesAnalyzed: 0,
|
||||
meta: {
|
||||
title: 'CS2.WTF - Statistics for CS2 Matchmaking',
|
||||
title: 'teamflash.rip - Stop Flashing Your Teammates',
|
||||
description:
|
||||
'Track your CS2 performance, analyze matches, and improve your game with detailed statistics and insights.'
|
||||
'Track your CS2 performance, analyze matches, and finally learn to stop team flashing. Detailed flash statistics, blinding insights, and more.'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,87 +1,174 @@
|
||||
<script lang="ts">
|
||||
import { Github, Heart, Code } from 'lucide-svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import { Github, Heart, Code, Zap } from 'lucide-svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>About - CS2.WTF</title>
|
||||
<title>About - teamflash.rip</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container mx-auto max-w-4xl px-4 py-12">
|
||||
<h1 class="mb-8 text-4xl font-bold">About CS2.WTF</h1>
|
||||
|
||||
<Card padding="lg" class="mb-8">
|
||||
<h2 class="mb-4 text-2xl font-semibold">Our Mission</h2>
|
||||
<p class="mb-4 text-base-content/80">
|
||||
CS2.WTF is a free and open-source platform for analyzing Counter-Strike 2 matchmaking matches.
|
||||
We provide detailed statistics, performance insights, and tools to help players improve their
|
||||
game.
|
||||
</p>
|
||||
<p class="text-base-content/80">
|
||||
Originally created for CS:GO, we've completely rewritten the platform to support CS2 with
|
||||
modern technologies and enhanced features.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<div class="mb-8 grid gap-6 md:grid-cols-3">
|
||||
<Card padding="lg">
|
||||
<Code class="mb-3 h-8 w-8 text-primary" />
|
||||
<h3 class="mb-2 text-xl font-semibold">Open Source</h3>
|
||||
<p class="text-sm text-base-content/70">
|
||||
Built by the community, for the community. All code is available on GitHub.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg">
|
||||
<Heart class="mb-3 h-8 w-8 text-error" />
|
||||
<h3 class="mb-2 text-xl font-semibold">Free Forever</h3>
|
||||
<p class="text-sm text-base-content/70">
|
||||
No paywalls, no premium features. Everyone gets full access to all statistics.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg">
|
||||
<Github class="mb-3 h-8 w-8 text-info" />
|
||||
<h3 class="mb-2 text-xl font-semibold">Community Driven</h3>
|
||||
<p class="text-sm text-base-content/70">
|
||||
Contributions welcome! Help us make CS2.WTF better for everyone.
|
||||
</p>
|
||||
</Card>
|
||||
<div class="relative bg-void">
|
||||
<!-- Decorative Background -->
|
||||
<div class="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<!-- Blur orbs -->
|
||||
<div class="absolute -left-40 top-20 h-80 w-80 rounded-full bg-neon-blue/10 blur-[100px]"></div>
|
||||
<div
|
||||
class="absolute -right-40 top-40 h-80 w-80 rounded-full bg-neon-purple/10 blur-[100px]"
|
||||
></div>
|
||||
<div
|
||||
class="absolute bottom-20 left-1/2 h-60 w-60 rounded-full bg-neon-gold/5 blur-[80px]"
|
||||
></div>
|
||||
<!-- Grid pattern -->
|
||||
<div
|
||||
class="absolute inset-0 opacity-20"
|
||||
style="background-image: linear-gradient(rgba(0, 212, 255, 0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(0, 212, 255, 0.03) 1px, transparent 1px); background-size: 60px 60px;"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<Card padding="lg" class="mb-8">
|
||||
<h2 class="mb-4 text-2xl font-semibold">Technology Stack</h2>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<h3 class="mb-2 font-semibold text-primary">Frontend</h3>
|
||||
<ul class="space-y-1 text-sm text-base-content/80">
|
||||
<li>• SvelteKit 2.0 + Svelte 5</li>
|
||||
<li>• TypeScript (Strict Mode)</li>
|
||||
<li>• Tailwind CSS + DaisyUI</li>
|
||||
<li>• Vitest + Playwright</li>
|
||||
</ul>
|
||||
<!-- Content -->
|
||||
<div class="container relative z-10 mx-auto max-w-4xl px-4 py-12">
|
||||
<!-- Page Header -->
|
||||
<h1
|
||||
class="mb-8 text-4xl font-bold text-white"
|
||||
style="text-shadow: 0 0 30px rgba(0, 212, 255, 0.5);"
|
||||
>
|
||||
About teamflash.rip
|
||||
</h1>
|
||||
|
||||
<!-- Mission Card -->
|
||||
<div class="mb-8 rounded-xl border border-white/10 bg-void-light p-6">
|
||||
<h2
|
||||
class="mb-4 text-2xl font-semibold text-white"
|
||||
style="text-shadow: 0 0 15px rgba(0, 212, 255, 0.3);"
|
||||
>
|
||||
Our Mission
|
||||
</h2>
|
||||
<p class="mb-4 text-white/70">
|
||||
teamflash.rip is a free and open-source platform for analyzing Counter-Strike 2 matchmaking
|
||||
matches. We're on a mission to expose every teammate who ever threw a flashbang backwards.
|
||||
</p>
|
||||
<p class="text-white/70">
|
||||
Originally created for CS:GO, we've completely rewritten the platform to support CS2 with
|
||||
modern technologies and the ability to track exactly who ruined your retake with a poorly
|
||||
timed flash.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature Cards -->
|
||||
<div class="mb-8 grid gap-6 md:grid-cols-3">
|
||||
<div
|
||||
class="group rounded-xl border border-white/10 bg-void-light p-6 transition-all duration-300 hover:border-neon-blue/50 hover:shadow-[0_0_20px_rgba(0,212,255,0.1)]"
|
||||
>
|
||||
<Code
|
||||
class="mb-3 h-8 w-8 text-neon-blue"
|
||||
style="filter: drop-shadow(0 0 8px rgba(0, 212, 255, 0.5));"
|
||||
/>
|
||||
<h3 class="mb-2 text-xl font-semibold text-white">Open Source</h3>
|
||||
<p class="text-sm text-white/60">
|
||||
Built by the community, for the community. All code is available on GitHub.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="mb-2 font-semibold text-secondary">Backend</h3>
|
||||
<ul class="space-y-1 text-sm text-base-content/80">
|
||||
<li>• Go + Gin Framework</li>
|
||||
<li>• PostgreSQL Database</li>
|
||||
<li>• Redis Cache</li>
|
||||
<li>• Demo Parser</li>
|
||||
</ul>
|
||||
|
||||
<div
|
||||
class="group rounded-xl border border-white/10 bg-void-light p-6 transition-all duration-300 hover:border-neon-red/50 hover:shadow-[0_0_20px_rgba(255,51,102,0.1)]"
|
||||
>
|
||||
<Heart
|
||||
class="mb-3 h-8 w-8 text-neon-red"
|
||||
style="filter: drop-shadow(0 0 8px rgba(255, 51, 102, 0.5));"
|
||||
/>
|
||||
<h3 class="mb-2 text-xl font-semibold text-white">Free Forever</h3>
|
||||
<p class="text-sm text-white/60">
|
||||
No paywalls, no premium features. Everyone gets full access to all flash crime statistics.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="group rounded-xl border border-white/10 bg-void-light p-6 transition-all duration-300 hover:border-neon-gold/50 hover:shadow-[0_0_20px_rgba(255,170,0,0.1)]"
|
||||
>
|
||||
<Zap
|
||||
class="mb-3 h-8 w-8 text-neon-gold"
|
||||
style="filter: drop-shadow(0 0 8px rgba(255, 170, 0, 0.5));"
|
||||
/>
|
||||
<h3 class="mb-2 text-xl font-semibold text-white">Community Driven</h3>
|
||||
<p class="text-sm text-white/60">
|
||||
Contributions welcome! Help us shame more team-flashers together.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div class="flex justify-center gap-4">
|
||||
<Button variant="primary" href="https://somegit.dev/CSGOWTF/csgowtf">
|
||||
<Github class="mr-2 h-5 w-5" />
|
||||
View on GitHub
|
||||
</Button>
|
||||
<Button variant="secondary" href="https://liberapay.com/CSGOWTF/">
|
||||
<Heart class="mr-2 h-5 w-5" />
|
||||
Support Us
|
||||
</Button>
|
||||
<!-- Technology Stack Card -->
|
||||
<div class="mb-8 rounded-xl border border-white/10 bg-void-light p-6">
|
||||
<h2
|
||||
class="mb-4 text-2xl font-semibold text-white"
|
||||
style="text-shadow: 0 0 15px rgba(0, 212, 255, 0.3);"
|
||||
>
|
||||
Technology Stack
|
||||
</h2>
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<h3 class="mb-3 font-semibold text-neon-blue">Frontend</h3>
|
||||
<ul class="space-y-2 text-sm text-white/70">
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-neon-blue"></span>
|
||||
SvelteKit 2.0 + Svelte 5
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-neon-blue"></span>
|
||||
TypeScript (Strict Mode)
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-neon-blue"></span>
|
||||
Tailwind CSS + DaisyUI
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-neon-blue"></span>
|
||||
Vitest + Playwright
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="mb-3 font-semibold text-neon-purple">Backend</h3>
|
||||
<ul class="space-y-2 text-sm text-white/70">
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-neon-purple"></span>
|
||||
Go + Gin Framework
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-neon-purple"></span>
|
||||
PostgreSQL Database
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-neon-purple"></span>
|
||||
Redis Cache
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-neon-purple"></span>
|
||||
Demo Parser (flash crime evidence collector)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-wrap justify-center gap-4">
|
||||
<a
|
||||
href="https://somegit.dev/CSGOWTF/csgowtf"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-neon-blue px-6 py-3 font-medium text-void transition-all duration-300 hover:shadow-[0_0_25px_rgba(0,212,255,0.4)]"
|
||||
>
|
||||
<Github class="h-5 w-5" />
|
||||
View on GitHub
|
||||
</a>
|
||||
<a
|
||||
href="https://liberapay.com/CSGOWTF/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-neon-red/50 bg-neon-red/10 px-6 py-3 font-medium text-neon-red transition-all duration-300 hover:bg-neon-red/20 hover:shadow-[0_0_25px_rgba(255,51,102,0.3)]"
|
||||
>
|
||||
<Heart class="h-5 w-5" />
|
||||
Support Us
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,9 +6,9 @@ import type { PageLoad } from './$types';
|
||||
export const load: PageLoad = async () => {
|
||||
return {
|
||||
meta: {
|
||||
title: 'About CS2.WTF',
|
||||
title: 'About teamflash.rip - We See Through the White',
|
||||
description:
|
||||
'Learn about CS2.WTF, an open-source platform for analyzing Counter-Strike 2 matchmaking matches.'
|
||||
'Learn about teamflash.rip, an open-source platform for exposing flashbang crimes in Counter-Strike 2.'
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -38,7 +38,7 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
|
||||
headers: {
|
||||
// Forward relevant headers
|
||||
Accept: request.headers.get('Accept') || 'application/json',
|
||||
'User-Agent': 'CS2.WTF Frontend'
|
||||
'User-Agent': 'teamflash.rip Frontend'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -87,7 +87,7 @@ export const POST: RequestHandler = async ({ params, url, request }) => {
|
||||
headers: {
|
||||
'Content-Type': request.headers.get('Content-Type') || 'application/json',
|
||||
Accept: request.headers.get('Accept') || 'application/json',
|
||||
'User-Agent': 'CS2.WTF Frontend'
|
||||
'User-Agent': 'teamflash.rip Frontend'
|
||||
},
|
||||
body
|
||||
});
|
||||
@@ -133,7 +133,7 @@ export const DELETE: RequestHandler = async ({ params, url, request }) => {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: request.headers.get('Accept') || 'application/json',
|
||||
'User-Agent': 'CS2.WTF Frontend'
|
||||
'User-Agent': 'teamflash.rip Frontend'
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
191
src/routes/faq/+page.svelte
Normal file
191
src/routes/faq/+page.svelte
Normal file
@@ -0,0 +1,191 @@
|
||||
<script lang="ts">
|
||||
import { HelpCircle, ChevronDown, Zap, Upload, Shield, Code } from 'lucide-svelte';
|
||||
|
||||
interface FaqItem {
|
||||
question: string;
|
||||
answer: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
const faqItems: FaqItem[] = [
|
||||
{
|
||||
category: 'Getting Started',
|
||||
question: 'What is teamflash.rip?',
|
||||
answer:
|
||||
'teamflash.rip is a free, open-source platform for analyzing Counter-Strike 2 matchmaking matches. We parse demo files to provide detailed statistics, including who flashed their teammates (hence the name).'
|
||||
},
|
||||
{
|
||||
category: 'Getting Started',
|
||||
question: 'How do I get my matches analyzed?',
|
||||
answer:
|
||||
'You can upload your CS2 demo files directly, or if your Steam profile is public, we can automatically fetch your recent competitive matches. Just search for your Steam ID or profile URL.'
|
||||
},
|
||||
{
|
||||
category: 'Getting Started',
|
||||
question: 'Is teamflash.rip free to use?',
|
||||
answer:
|
||||
'Yes, completely free! We are an open-source project with no premium tiers or paywalls. All features are available to everyone.'
|
||||
},
|
||||
{
|
||||
category: 'Matches & Stats',
|
||||
question: 'What statistics do you track?',
|
||||
answer:
|
||||
'We track kills, deaths, assists, ADR, KAST, headshot percentage, utility damage, flash assists, enemies flashed, teammates flashed (the important one!), and much more from parsed demo files.'
|
||||
},
|
||||
{
|
||||
category: 'Matches & Stats',
|
||||
question: 'How accurate is the data?',
|
||||
answer:
|
||||
'Our data comes directly from parsing CS2 demo files, so it is as accurate as the game itself records. We use the official demo format provided by Valve.'
|
||||
},
|
||||
{
|
||||
category: 'Matches & Stats',
|
||||
question: 'Why are some of my matches missing?',
|
||||
answer:
|
||||
'We can only analyze matches where demo files are available. Valve only keeps demo files for a limited time (usually 30 days for matchmaking). Upload demos manually if you want to preserve older matches.'
|
||||
},
|
||||
{
|
||||
category: 'Privacy & Data',
|
||||
question: 'What data do you collect?',
|
||||
answer:
|
||||
'We only collect publicly available Steam data and match statistics from demo files. We do not require accounts or collect personal information. See our Privacy Policy for details.'
|
||||
},
|
||||
{
|
||||
category: 'Privacy & Data',
|
||||
question: 'Can I remove my data?',
|
||||
answer:
|
||||
'Yes. Contact us through our GitHub repository to request data removal. Since we only display public Steam data, you can also adjust your Steam privacy settings.'
|
||||
},
|
||||
{
|
||||
category: 'Technical',
|
||||
question: 'What file formats do you support?',
|
||||
answer:
|
||||
'We support CS2 demo files (.dem). We no longer support CS:GO demos as the platform has been rebuilt specifically for CS2.'
|
||||
},
|
||||
{
|
||||
category: 'Technical',
|
||||
question: 'How can I contribute?',
|
||||
answer:
|
||||
'We welcome contributions! Visit our GitHub repository to report bugs, suggest features, or submit pull requests. The frontend and backend are both open source.'
|
||||
}
|
||||
];
|
||||
|
||||
const categories = [...new Set(faqItems.map((item) => item.category))];
|
||||
|
||||
let openItems = $state<Set<number>>(new Set());
|
||||
|
||||
function toggleItem(index: number) {
|
||||
const newSet = new Set(openItems);
|
||||
if (newSet.has(index)) {
|
||||
newSet.delete(index);
|
||||
} else {
|
||||
newSet.add(index);
|
||||
}
|
||||
openItems = newSet;
|
||||
}
|
||||
|
||||
function getCategoryIcon(category: string) {
|
||||
switch (category) {
|
||||
case 'Getting Started':
|
||||
return Zap;
|
||||
case 'Matches & Stats':
|
||||
return Upload;
|
||||
case 'Privacy & Data':
|
||||
return Shield;
|
||||
case 'Technical':
|
||||
return Code;
|
||||
default:
|
||||
return HelpCircle;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>FAQ | teamflash.rip</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Frequently asked questions about teamflash.rip - CS2 match analysis platform."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="relative bg-void">
|
||||
<!-- Decorative Background -->
|
||||
<div class="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div class="absolute -left-40 top-20 h-80 w-80 rounded-full bg-neon-blue/10 blur-[100px]"></div>
|
||||
<div
|
||||
class="absolute -right-40 top-60 h-80 w-80 rounded-full bg-neon-purple/10 blur-[100px]"
|
||||
></div>
|
||||
<div
|
||||
class="absolute inset-0 opacity-20"
|
||||
style="background-image: linear-gradient(rgba(0, 212, 255, 0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(0, 212, 255, 0.03) 1px, transparent 1px); background-size: 60px 60px;"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="container relative z-10 mx-auto max-w-4xl px-4 py-12">
|
||||
<!-- Header -->
|
||||
<div class="mb-12 text-center">
|
||||
<div class="mb-4 inline-flex rounded-full border border-neon-blue/30 bg-neon-blue/10 p-4">
|
||||
<HelpCircle class="h-12 w-12 text-neon-blue" />
|
||||
</div>
|
||||
<h1 class="mb-4 text-4xl font-bold text-white">Frequently Asked Questions</h1>
|
||||
<p class="text-lg text-white/60">Everything you need to know about teamflash.rip</p>
|
||||
</div>
|
||||
|
||||
<!-- FAQ Sections -->
|
||||
{#each categories as category}
|
||||
{@const CategoryIcon = getCategoryIcon(category)}
|
||||
<div class="mb-8">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<CategoryIcon class="h-5 w-5 text-neon-blue" />
|
||||
<h2 class="text-xl font-semibold text-white">{category}</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
{#each faqItems as item, index}
|
||||
{#if item.category === category}
|
||||
<div class="overflow-hidden rounded-xl border border-white/10 bg-void-light">
|
||||
<button
|
||||
class="flex w-full items-center justify-between p-4 text-left transition-colors hover:bg-white/5"
|
||||
onclick={() => toggleItem(index)}
|
||||
aria-expanded={openItems.has(index)}
|
||||
>
|
||||
<span class="pr-4 font-medium text-white">{item.question}</span>
|
||||
<ChevronDown
|
||||
class="h-5 w-5 shrink-0 text-white/50 transition-transform duration-200 {openItems.has(
|
||||
index
|
||||
)
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
/>
|
||||
</button>
|
||||
{#if openItems.has(index)}
|
||||
<div class="border-t border-white/10 px-4 py-4">
|
||||
<p class="leading-relaxed text-white/70">{item.answer}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Still Have Questions -->
|
||||
<div class="mt-12 rounded-xl border border-neon-blue/20 bg-void-light p-8 text-center">
|
||||
<h2 class="mb-3 text-xl font-semibold text-white">Still have questions?</h2>
|
||||
<p class="mb-6 text-white/60">
|
||||
Can't find what you're looking for? Check out our GitHub or open an issue.
|
||||
</p>
|
||||
<a
|
||||
href="https://somegit.dev/CSGOWTF/csgowtf/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-neon-blue px-6 py-3 font-medium text-void transition-all duration-300 hover:shadow-[0_0_25px_rgba(0,212,255,0.4)]"
|
||||
>
|
||||
<HelpCircle class="h-5 w-5" />
|
||||
Ask on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,8 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Download, Calendar, Clock, ArrowLeft } from 'lucide-svelte';
|
||||
import {
|
||||
Download,
|
||||
Calendar,
|
||||
Clock,
|
||||
ArrowLeft,
|
||||
Server,
|
||||
Users,
|
||||
CheckCircle2,
|
||||
Timer
|
||||
} from 'lucide-svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import Tabs from '$lib/components/ui/Tabs.svelte';
|
||||
import PremierRatingBadge from '$lib/components/ui/PremierRatingBadge.svelte';
|
||||
import type { LayoutData } from './$types';
|
||||
import { getMapBackground, formatMapName } from '$lib/utils/mapAssets';
|
||||
|
||||
@@ -11,13 +20,12 @@
|
||||
const { match } = data;
|
||||
|
||||
function handleBack() {
|
||||
// Navigate back to matches page
|
||||
goto('/matches');
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ label: 'Overview', href: `/match/${match.match_id}` },
|
||||
{ label: 'Economy', href: `/match/${match.match_id}/economy` },
|
||||
{ label: 'Rounds', href: `/match/${match.match_id}/rounds` },
|
||||
{ label: 'Details', href: `/match/${match.match_id}/details` },
|
||||
{ label: 'Weapons', href: `/match/${match.match_id}/weapons` },
|
||||
{ label: 'Flashes', href: `/match/${match.match_id}/flashes` },
|
||||
@@ -25,9 +33,13 @@
|
||||
{ label: 'Chat', href: `/match/${match.match_id}/chat` }
|
||||
];
|
||||
|
||||
const formattedDate = new Date(match.date).toLocaleString('en-US', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
// Shorter date format: "Sep 5, 4:30 PM" (no year - saves space in card)
|
||||
const matchDate = new Date(match.date);
|
||||
const formattedDate = matchDate.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
const duration = match.duration
|
||||
@@ -37,31 +49,55 @@
|
||||
const mapName = formatMapName(match.map);
|
||||
const mapBg = getMapBackground(match.map);
|
||||
|
||||
// Check if match is older than 4 weeks (Valve deletes demos after ~4 weeks)
|
||||
const fourWeeksMs = 4 * 7 * 24 * 60 * 60 * 1000;
|
||||
const isMatchOlderThanFourWeeks = Date.now() - matchDate.getTime() > fourWeeksMs;
|
||||
|
||||
function handleImageError(event: Event) {
|
||||
const img = event.target as HTMLImageElement;
|
||||
img.src = '/images/map_screenshots/default.webp';
|
||||
}
|
||||
|
||||
function handleDownloadDemo() {
|
||||
// Prefer direct replay_url if available (faster download)
|
||||
if (match.replay_url) {
|
||||
window.open(match.replay_url, '_blank');
|
||||
return;
|
||||
}
|
||||
// Fall back to Steam share code
|
||||
if (!match.share_code) {
|
||||
alert('Share code not available for this match');
|
||||
return;
|
||||
}
|
||||
// Open the demo download URL (typically from Valve servers or cached location)
|
||||
// Format: steam://rungame/730/76561202255233023/+csgo_download_match%20{SHARE_CODE}
|
||||
const downloadUrl = `steam://rungame/730/76561202255233023/+csgo_download_match%20${match.share_code}`;
|
||||
window.location.href = downloadUrl;
|
||||
}
|
||||
|
||||
// Demo download: always available if replay_url exists, otherwise only for recent matches
|
||||
const canDownloadDemo =
|
||||
match.replay_url || (match.demo_parsed && match.share_code && !isMatchOlderThanFourWeeks);
|
||||
</script>
|
||||
|
||||
<!-- Match Header with Background -->
|
||||
<div class="relative overflow-hidden border-b border-base-300">
|
||||
<div class="relative overflow-hidden border-b border-neon-blue/20 bg-void">
|
||||
<!-- Background Image -->
|
||||
<div class="absolute inset-0">
|
||||
<img src={mapBg} alt={mapName} class="h-full w-full object-cover" onerror={handleImageError} />
|
||||
<!-- Multi-layer gradient overlay for depth and framing -->
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-black/30 via-transparent to-black/40"></div>
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-black/70 via-black/40 to-black/70"></div>
|
||||
<!-- Multi-layer gradient overlay for depth -->
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-void/80 via-transparent to-void"></div>
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-void/70 via-void/30 to-void/70"></div>
|
||||
</div>
|
||||
|
||||
<!-- Decorative Neon Blur Orbs -->
|
||||
<div class="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div class="absolute -left-32 top-10 h-64 w-64 rounded-full bg-neon-blue/20 blur-[100px]"></div>
|
||||
<div
|
||||
class="absolute -right-32 top-20 h-64 w-64 rounded-full bg-neon-gold/15 blur-[100px]"
|
||||
></div>
|
||||
<div
|
||||
class="absolute inset-0 opacity-10"
|
||||
style="background-image: linear-gradient(rgba(0, 212, 255, 0.05) 1px, transparent 1px), linear-gradient(90deg, rgba(0, 212, 255, 0.05) 1px, transparent 1px); background-size: 50px 50px;"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="container relative mx-auto px-4 py-8">
|
||||
@@ -69,7 +105,7 @@
|
||||
<div class="mb-4">
|
||||
<button
|
||||
onclick={handleBack}
|
||||
class="btn btn-sm gap-2 bg-black/60 text-white backdrop-blur-sm hover:bg-black/80"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-void/60 px-3 py-2 text-sm text-white/80 backdrop-blur-sm transition-all duration-200 hover:border-neon-blue/30 hover:bg-void/80 hover:text-white"
|
||||
>
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
<span>Back to Matches</span>
|
||||
@@ -79,15 +115,20 @@
|
||||
<!-- Map Name -->
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-5xl font-bold text-white drop-shadow-2xl">
|
||||
<h1
|
||||
class="text-5xl font-bold text-white"
|
||||
style="text-shadow: 0 0 40px rgba(0, 212, 255, 0.3), 0 4px 20px rgba(0, 0, 0, 0.5);"
|
||||
>
|
||||
{mapName}
|
||||
</h1>
|
||||
</div>
|
||||
{#if match.demo_parsed && match.share_code}
|
||||
{#if canDownloadDemo}
|
||||
<button
|
||||
onclick={handleDownloadDemo}
|
||||
class="btn btn-ghost gap-2 border border-white/25 bg-white/15 text-white backdrop-blur-md hover:bg-white/25"
|
||||
title="Download this match demo to your Steam client"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-neon-blue/30 bg-neon-blue/10 px-4 py-2 text-sm font-medium text-neon-blue backdrop-blur-md transition-all duration-200 hover:border-neon-blue/50 hover:bg-neon-blue/20 hover:shadow-[0_0_20px_rgba(0,212,255,0.2)]"
|
||||
title={match.replay_url
|
||||
? 'Download demo file directly'
|
||||
: 'Download this match demo to your Steam client'}
|
||||
>
|
||||
<Download class="h-4 w-4" />
|
||||
<span class="hidden sm:inline">Download Demo</span>
|
||||
@@ -95,59 +136,105 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Hero Info Panel with translucent background -->
|
||||
<!-- Hero Info Panel -->
|
||||
<div
|
||||
class="mx-auto max-w-3xl rounded-xl border border-white/10 bg-black/40 p-6 backdrop-blur-md"
|
||||
class="mx-auto max-w-3xl rounded-xl border border-white/10 bg-void/60 p-6 backdrop-blur-xl"
|
||||
>
|
||||
<!-- Score -->
|
||||
<div class="mb-4 flex items-center justify-center gap-8">
|
||||
<div class="text-center">
|
||||
<div class="mb-1 text-xs font-medium uppercase tracking-wider text-white/70">
|
||||
<div class="mb-1 text-xs font-medium uppercase tracking-wider text-white/60">
|
||||
Terrorists
|
||||
</div>
|
||||
<div class="font-mono text-6xl font-bold text-terrorist drop-shadow-lg">
|
||||
<div
|
||||
class="font-mono text-6xl font-bold text-terrorist"
|
||||
style="text-shadow: 0 0 30px rgba(212, 167, 74, 0.5);"
|
||||
>
|
||||
{match.score_team_a}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-4xl font-bold text-white/50">:</div>
|
||||
<div class="text-4xl font-bold text-white/30">:</div>
|
||||
<div class="text-center">
|
||||
<div class="mb-1 text-xs font-medium uppercase tracking-wider text-white/70">
|
||||
<div class="mb-1 text-xs font-medium uppercase tracking-wider text-white/60">
|
||||
Counter-Terrorists
|
||||
</div>
|
||||
<div class="font-mono text-6xl font-bold text-ct drop-shadow-lg">
|
||||
<div
|
||||
class="font-mono text-6xl font-bold text-ct"
|
||||
style="text-shadow: 0 0 30px rgba(94, 152, 217, 0.5);"
|
||||
>
|
||||
{match.score_team_b}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Match Meta -->
|
||||
<div class="flex flex-wrap items-center justify-center gap-3 text-sm text-white/90">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Calendar class="h-3.5 w-3.5" />
|
||||
<span>{formattedDate}</span>
|
||||
<!-- Match Meta Cards - Flex layout handles varying item counts gracefully -->
|
||||
<div class="mt-4 flex flex-wrap justify-center gap-2">
|
||||
<!-- Date -->
|
||||
<div class="flex items-center gap-2 rounded-lg border border-white/5 bg-white/5 px-3 py-2">
|
||||
<Calendar class="h-4 w-4 shrink-0 text-neon-blue" />
|
||||
<span class="text-xs text-white/70">{formattedDate}</span>
|
||||
</div>
|
||||
<span class="text-white/30">•</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Clock class="h-3.5 w-3.5" />
|
||||
<span>{duration}</span>
|
||||
|
||||
<!-- Duration -->
|
||||
<div class="flex items-center gap-2 rounded-lg border border-white/5 bg-white/5 px-3 py-2">
|
||||
<Timer class="h-4 w-4 shrink-0 text-neon-green" />
|
||||
<span class="text-xs text-white/70">{duration}</span>
|
||||
</div>
|
||||
<span class="text-white/30">•</span>
|
||||
<span>MR12 ({match.max_rounds} rounds)</span>
|
||||
|
||||
<!-- Rounds -->
|
||||
<div class="flex items-center gap-2 rounded-lg border border-white/5 bg-white/5 px-3 py-2">
|
||||
<Clock class="h-4 w-4 shrink-0 text-neon-purple" />
|
||||
<span class="text-xs text-white/70">{match.max_rounds} rounds</span>
|
||||
</div>
|
||||
|
||||
<!-- Tick Rate -->
|
||||
{#if match.tick_rate}
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-lg border border-white/5 bg-white/5 px-3 py-2"
|
||||
>
|
||||
<Server class="h-4 w-4 shrink-0 text-neon-gold" />
|
||||
<span class="font-mono text-xs text-white/70">{match.tick_rate} tick</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Avg Rating - Only show for valid CS Ratings (Premier mode, >1000) -->
|
||||
{#if match.avg_rank && match.avg_rank > 1000}
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-lg border border-white/5 bg-white/5 px-3 py-2"
|
||||
>
|
||||
<Users class="h-4 w-4 shrink-0 text-neon-blue" />
|
||||
<PremierRatingBadge
|
||||
rating={match.avg_rank}
|
||||
{match}
|
||||
size="sm"
|
||||
showTier={false}
|
||||
showIcon={false}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Demo Status -->
|
||||
{#if match.demo_parsed}
|
||||
<span class="text-white/30">•</span>
|
||||
<Badge variant="success" size="sm">Demo Parsed</Badge>
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-lg border border-neon-green/20 bg-neon-green/10 px-3 py-2"
|
||||
>
|
||||
<CheckCircle2 class="h-4 w-4 shrink-0 text-neon-green" />
|
||||
<span class="text-xs font-medium text-neon-green">Parsed</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="mt-6 rounded-lg border border-white/10 bg-black/35 p-4 backdrop-blur-lg">
|
||||
<Tabs {tabs} variant="bordered" size="md" />
|
||||
<div class="mt-6 flex justify-center">
|
||||
<Tabs {tabs} size="md" class="border border-white/10" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
{@render children()}
|
||||
<div class="bg-void">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { Trophy } from 'lucide-svelte';
|
||||
import { Trophy, Zap, User } from 'lucide-svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import PremierRatingBadge from '$lib/components/ui/PremierRatingBadge.svelte';
|
||||
import RoundTimeline from '$lib/components/RoundTimeline.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import type { MatchPlayer } from '$lib/types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
const { match, rounds } = data;
|
||||
const { match } = data;
|
||||
|
||||
// Group players by team - use dynamic team IDs from API
|
||||
const uniqueTeamIds = match.players ? [...new Set(match.players.map((p) => p.team_id))] : [];
|
||||
@@ -51,12 +49,16 @@
|
||||
|
||||
const teamAStats = calcTeamStats(sortedTeamA);
|
||||
const teamBStats = calcTeamStats(sortedTeamB);
|
||||
|
||||
// Find the overall MVP (highest kills)
|
||||
const allPlayers = [...sortedTeamA, ...sortedTeamB].sort((a, b) => b.kills - a.kills);
|
||||
const mvpPlayerId = allPlayers[0]?.id;
|
||||
</script>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Team Statistics Overview -->
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<Card padding="lg">
|
||||
<Card padding="lg" class="border-l-4 border-l-terrorist">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h2 class="text-2xl font-bold text-terrorist">Terrorists</h2>
|
||||
@@ -64,29 +66,34 @@
|
||||
<PremierRatingBadge rating={teamAStats.avgRating} {match} size="sm" showIcon={true} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="font-mono text-3xl font-bold text-terrorist">{match.score_team_a}</div>
|
||||
<div
|
||||
class="font-mono text-3xl font-bold text-terrorist"
|
||||
style="text-shadow: 0 0 20px rgba(212, 167, 74, 0.4);"
|
||||
>
|
||||
{match.score_team_a}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="text-sm text-base-content/60">Team K/D</div>
|
||||
<div class="text-xl font-bold">{teamAStats.kd}</div>
|
||||
<div class="text-sm text-white/50">Team K/D</div>
|
||||
<div class="text-xl font-bold text-white">{teamAStats.kd}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-base-content/60">Avg ADR</div>
|
||||
<div class="text-xl font-bold">{teamAStats.adr}</div>
|
||||
<div class="text-sm text-white/50">Avg ADR</div>
|
||||
<div class="text-xl font-bold text-white">{teamAStats.adr}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-base-content/60">Total Kills</div>
|
||||
<div class="text-xl font-bold">{teamAStats.kills}</div>
|
||||
<div class="text-sm text-white/50">Total Kills</div>
|
||||
<div class="text-xl font-bold text-white">{teamAStats.kills}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-base-content/60">Avg KAST</div>
|
||||
<div class="text-xl font-bold">{teamAStats.kast}%</div>
|
||||
<div class="text-sm text-white/50">Avg KAST</div>
|
||||
<div class="text-xl font-bold text-white">{teamAStats.kast}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg">
|
||||
<Card padding="lg" class="border-l-4 border-l-ct">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h2 class="text-2xl font-bold text-ct">Counter-Terrorists</h2>
|
||||
@@ -94,24 +101,29 @@
|
||||
<PremierRatingBadge rating={teamBStats.avgRating} {match} size="sm" showIcon={true} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="font-mono text-3xl font-bold text-ct">{match.score_team_b}</div>
|
||||
<div
|
||||
class="font-mono text-3xl font-bold text-ct"
|
||||
style="text-shadow: 0 0 20px rgba(94, 152, 217, 0.4);"
|
||||
>
|
||||
{match.score_team_b}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="text-sm text-base-content/60">Team K/D</div>
|
||||
<div class="text-xl font-bold">{teamBStats.kd}</div>
|
||||
<div class="text-sm text-white/50">Team K/D</div>
|
||||
<div class="text-xl font-bold text-white">{teamBStats.kd}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-base-content/60">Avg ADR</div>
|
||||
<div class="text-xl font-bold">{teamBStats.adr}</div>
|
||||
<div class="text-sm text-white/50">Avg ADR</div>
|
||||
<div class="text-xl font-bold text-white">{teamBStats.adr}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-base-content/60">Total Kills</div>
|
||||
<div class="text-xl font-bold">{teamBStats.kills}</div>
|
||||
<div class="text-sm text-white/50">Total Kills</div>
|
||||
<div class="text-xl font-bold text-white">{teamBStats.kills}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-base-content/60">Avg KAST</div>
|
||||
<div class="text-xl font-bold">{teamBStats.kast}%</div>
|
||||
<div class="text-sm text-white/50">Avg KAST</div>
|
||||
<div class="text-xl font-bold text-white">{teamBStats.kast}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -120,49 +132,76 @@
|
||||
<!-- Scoreboard -->
|
||||
<Card padding="none">
|
||||
<div class="p-6">
|
||||
<h2 class="text-2xl font-bold text-base-content">Scoreboard</h2>
|
||||
<h2 class="text-2xl font-bold text-white">Scoreboard</h2>
|
||||
</div>
|
||||
|
||||
<!-- Team A -->
|
||||
<div class="border-t border-base-300 bg-terrorist/5">
|
||||
<div class="px-6 py-3">
|
||||
<div class="border-t border-white/10 bg-terrorist/5">
|
||||
<div class="flex items-center justify-between px-6 py-3">
|
||||
<h3 class="text-lg font-semibold text-terrorist">Terrorists</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table" style="table-layout: fixed;">
|
||||
<table class="w-full" style="table-layout: fixed;">
|
||||
<thead>
|
||||
<tr class="border-base-300">
|
||||
<th style="width: 200px;">Player</th>
|
||||
<th style="width: 80px;">K</th>
|
||||
<th style="width: 80px;">D</th>
|
||||
<th style="width: 80px;">A</th>
|
||||
<th style="width: 100px;">ADR</th>
|
||||
<th style="width: 100px;">HS%</th>
|
||||
<th style="width: 100px;">KAST%</th>
|
||||
<th style="width: 180px;">Rating</th>
|
||||
<tr class="border-b border-white/10 bg-void/50 text-left text-sm text-white/50">
|
||||
<th class="px-6 py-3 font-medium" style="width: 200px;">Player</th>
|
||||
<th class="px-4 py-3 font-medium" style="width: 80px;">K</th>
|
||||
<th class="px-4 py-3 font-medium" style="width: 80px;">D</th>
|
||||
<th class="px-4 py-3 font-medium" style="width: 80px;">A</th>
|
||||
<th class="px-4 py-3 font-medium" style="width: 100px;">ADR</th>
|
||||
<th class="px-4 py-3 font-medium" style="width: 100px;">HS%</th>
|
||||
<th class="px-4 py-3 font-medium" style="width: 100px;">KAST%</th>
|
||||
<th class="px-4 py-3 font-medium" style="width: 180px;">Rating</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sortedTeamA as player, index}
|
||||
<tr class="border-base-300">
|
||||
<td>
|
||||
<a
|
||||
href={`/player/${player.id}`}
|
||||
class="font-medium transition-colors hover:text-primary"
|
||||
>
|
||||
{player.name}
|
||||
</a>
|
||||
{#if index === 0}
|
||||
<Trophy class="ml-2 inline h-4 w-4 text-warning" />
|
||||
{/if}
|
||||
<tr class="border-b border-white/5 transition-colors hover:bg-neon-blue/5">
|
||||
<td class="px-6 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<a
|
||||
href={`/player/${player.id}`}
|
||||
class="flex items-center gap-3 font-medium text-white transition-colors hover:text-neon-blue"
|
||||
>
|
||||
{#if player.avatar}
|
||||
<img
|
||||
src={player.avatar}
|
||||
alt={player.name}
|
||||
class="h-8 w-8 rounded-full border border-terrorist/30"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-terrorist/20 text-terrorist"
|
||||
>
|
||||
<User class="h-4 w-4" />
|
||||
</div>
|
||||
{/if}
|
||||
<span>{player.name}</span>
|
||||
</a>
|
||||
{#if player.id === mvpPlayerId}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full bg-neon-gold/20 px-2 py-0.5 text-xs font-medium text-neon-gold"
|
||||
title="Most Violent Player"
|
||||
>
|
||||
<Zap class="h-3 w-3" />
|
||||
MVP
|
||||
</span>
|
||||
{:else if index === 0}
|
||||
<Trophy class="h-4 w-4 text-terrorist" />
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td class="font-mono font-semibold">{player.kills}</td>
|
||||
<td class="font-mono">{player.deaths}</td>
|
||||
<td class="font-mono">{player.assists}</td>
|
||||
<td class="font-mono">{(player.adr || 0).toFixed(1)}</td>
|
||||
<td class="font-mono">{(player.hs_percent || 0).toFixed(1)}%</td>
|
||||
<td class="font-mono">{player.kast?.toFixed(1) || '0.0'}%</td>
|
||||
<td class="h-12">
|
||||
<td class="px-4 py-3 font-mono font-semibold text-white">{player.kills}</td>
|
||||
<td class="px-4 py-3 font-mono text-white/80">{player.deaths}</td>
|
||||
<td class="px-4 py-3 font-mono text-white/80">{player.assists}</td>
|
||||
<td class="px-4 py-3 font-mono text-white/80">{(player.adr || 0).toFixed(1)}</td>
|
||||
<td class="px-4 py-3 font-mono text-white/80"
|
||||
>{(player.hs_percent || 0).toFixed(1)}%</td
|
||||
>
|
||||
<td class="px-4 py-3 font-mono text-white/80"
|
||||
>{player.kast?.toFixed(1) || '0.0'}%</td
|
||||
>
|
||||
<td class="h-12 px-4 py-3">
|
||||
<div class="flex h-full items-center">
|
||||
<PremierRatingBadge
|
||||
rating={player.rank_new}
|
||||
@@ -181,45 +220,72 @@
|
||||
</div>
|
||||
|
||||
<!-- Team B -->
|
||||
<div class="border-t border-base-300 bg-ct/5">
|
||||
<div class="px-6 py-3">
|
||||
<div class="border-t border-white/10 bg-ct/5">
|
||||
<div class="flex items-center justify-between px-6 py-3">
|
||||
<h3 class="text-lg font-semibold text-ct">Counter-Terrorists</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table" style="table-layout: fixed;">
|
||||
<table class="w-full" style="table-layout: fixed;">
|
||||
<thead>
|
||||
<tr class="border-base-300">
|
||||
<th style="width: 200px;">Player</th>
|
||||
<th style="width: 80px;">K</th>
|
||||
<th style="width: 80px;">D</th>
|
||||
<th style="width: 80px;">A</th>
|
||||
<th style="width: 100px;">ADR</th>
|
||||
<th style="width: 100px;">HS%</th>
|
||||
<th style="width: 100px;">KAST%</th>
|
||||
<th style="width: 180px;">Rating</th>
|
||||
<tr class="border-b border-white/10 bg-void/50 text-left text-sm text-white/50">
|
||||
<th class="px-6 py-3 font-medium" style="width: 200px;">Player</th>
|
||||
<th class="px-4 py-3 font-medium" style="width: 80px;">K</th>
|
||||
<th class="px-4 py-3 font-medium" style="width: 80px;">D</th>
|
||||
<th class="px-4 py-3 font-medium" style="width: 80px;">A</th>
|
||||
<th class="px-4 py-3 font-medium" style="width: 100px;">ADR</th>
|
||||
<th class="px-4 py-3 font-medium" style="width: 100px;">HS%</th>
|
||||
<th class="px-4 py-3 font-medium" style="width: 100px;">KAST%</th>
|
||||
<th class="px-4 py-3 font-medium" style="width: 180px;">Rating</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sortedTeamB as player, index}
|
||||
<tr class="border-base-300">
|
||||
<td>
|
||||
<a
|
||||
href={`/player/${player.id}`}
|
||||
class="font-medium transition-colors hover:text-primary"
|
||||
>
|
||||
{player.name}
|
||||
</a>
|
||||
{#if index === 0}
|
||||
<Trophy class="ml-2 inline h-4 w-4 text-warning" />
|
||||
{/if}
|
||||
<tr class="border-b border-white/5 transition-colors hover:bg-neon-blue/5">
|
||||
<td class="px-6 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<a
|
||||
href={`/player/${player.id}`}
|
||||
class="flex items-center gap-3 font-medium text-white transition-colors hover:text-neon-blue"
|
||||
>
|
||||
{#if player.avatar}
|
||||
<img
|
||||
src={player.avatar}
|
||||
alt={player.name}
|
||||
class="h-8 w-8 rounded-full border border-ct/30"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-ct/20 text-ct"
|
||||
>
|
||||
<User class="h-4 w-4" />
|
||||
</div>
|
||||
{/if}
|
||||
<span>{player.name}</span>
|
||||
</a>
|
||||
{#if player.id === mvpPlayerId}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full bg-neon-gold/20 px-2 py-0.5 text-xs font-medium text-neon-gold"
|
||||
title="Most Violent Player"
|
||||
>
|
||||
<Zap class="h-3 w-3" />
|
||||
MVP
|
||||
</span>
|
||||
{:else if index === 0}
|
||||
<Trophy class="h-4 w-4 text-ct" />
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td class="font-mono font-semibold">{player.kills}</td>
|
||||
<td class="font-mono">{player.deaths}</td>
|
||||
<td class="font-mono">{player.assists}</td>
|
||||
<td class="font-mono">{(player.adr || 0).toFixed(1)}</td>
|
||||
<td class="font-mono">{(player.hs_percent || 0).toFixed(1)}%</td>
|
||||
<td class="font-mono">{player.kast?.toFixed(1) || '0.0'}%</td>
|
||||
<td class="h-12">
|
||||
<td class="px-4 py-3 font-mono font-semibold text-white">{player.kills}</td>
|
||||
<td class="px-4 py-3 font-mono text-white/80">{player.deaths}</td>
|
||||
<td class="px-4 py-3 font-mono text-white/80">{player.assists}</td>
|
||||
<td class="px-4 py-3 font-mono text-white/80">{(player.adr || 0).toFixed(1)}</td>
|
||||
<td class="px-4 py-3 font-mono text-white/80"
|
||||
>{(player.hs_percent || 0).toFixed(1)}%</td
|
||||
>
|
||||
<td class="px-4 py-3 font-mono text-white/80"
|
||||
>{player.kast?.toFixed(1) || '0.0'}%</td
|
||||
>
|
||||
<td class="h-12 px-4 py-3">
|
||||
<div class="flex h-full items-center">
|
||||
<PremierRatingBadge
|
||||
rating={player.rank_new}
|
||||
@@ -237,24 +303,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Round Timeline -->
|
||||
{#if rounds && rounds.rounds && rounds.rounds.length > 0}
|
||||
<RoundTimeline rounds={rounds.rounds} maxRounds={match.max_rounds} />
|
||||
{:else}
|
||||
<Card padding="lg">
|
||||
<div class="text-center">
|
||||
<h3 class="mb-2 text-xl font-semibold text-base-content">Round Timeline</h3>
|
||||
<p class="text-base-content/60">
|
||||
Round-by-round timeline data is not available for this match. This requires the demo to be
|
||||
fully parsed.
|
||||
</p>
|
||||
{#if !match.demo_parsed}
|
||||
<Badge variant="warning" size="md" class="mt-4">Demo Not Yet Parsed</Badge>
|
||||
{:else}
|
||||
<Badge variant="info" size="md" class="mt-4">Round Data Not Available</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { MessageSquare, Filter, Search, AlertCircle, Languages } from 'lucide-svelte';
|
||||
import {
|
||||
MessageSquare,
|
||||
Filter,
|
||||
Search,
|
||||
AlertCircle,
|
||||
Languages,
|
||||
MessageCircle
|
||||
} from 'lucide-svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import type { PageData } from './$types';
|
||||
@@ -11,11 +18,10 @@
|
||||
let searchQuery = $state('');
|
||||
let showTeamChat = $state(true);
|
||||
let showAllChat = $state(true);
|
||||
let selectedPlayer = $state<number | null>(null);
|
||||
let selectedPlayer = $state<string | null>(null);
|
||||
|
||||
// Check if text likely needs translation (contains non-ASCII or Cyrillic characters)
|
||||
const mightNeedTranslation = (text: string): boolean => {
|
||||
// Check for Cyrillic, Chinese, Japanese, Korean, Arabic, etc.
|
||||
const nonEnglishPattern =
|
||||
/[\u0400-\u04FF\u4E00-\u9FFF\u3040-\u309F\u30A0-\u30FF\uAC00-\uD7AF\u0600-\u06FF]/;
|
||||
return nonEnglishPattern.test(text);
|
||||
@@ -24,21 +30,19 @@
|
||||
// Open Google Translate for a message
|
||||
const translateMessage = (text: string) => {
|
||||
const encodedText = encodeURIComponent(text);
|
||||
// Use Google Translate web interface (auto-detect language to English)
|
||||
const translateUrl = `https://translate.google.com/?sl=auto&tl=en&text=${encodedText}&op=translate`;
|
||||
window.open(translateUrl, '_blank', 'width=800,height=600,noopener,noreferrer');
|
||||
};
|
||||
|
||||
// Get unique players who sent messages - use $derived for computed values
|
||||
// Get unique players who sent messages (using player_name to avoid precision loss with player_id)
|
||||
const messagePlayers = $derived(
|
||||
chatData
|
||||
? Array.from(new Set(chatData.messages.map((m) => m.player_id)))
|
||||
.filter((playerId): playerId is number => playerId !== undefined)
|
||||
.map((playerId) => {
|
||||
const player = match.players?.find((p) => p.id === String(playerId));
|
||||
? Array.from(new Set(chatData.messages.map((m) => m.player_name || `Player ${m.player_id}`)))
|
||||
.filter((name): name is string => !!name)
|
||||
.map((name) => {
|
||||
const player = match.players?.find((p) => p.name === name);
|
||||
return {
|
||||
id: playerId,
|
||||
name: player?.name || `Player ${playerId}`,
|
||||
name,
|
||||
team_id: player?.team_id || 0
|
||||
};
|
||||
})
|
||||
@@ -49,18 +53,13 @@
|
||||
const filteredMessages = $derived(
|
||||
chatData
|
||||
? chatData.messages.filter((msg) => {
|
||||
// Chat type filter
|
||||
if (!showTeamChat && !msg.all_chat) return false;
|
||||
if (!showAllChat && msg.all_chat) return false;
|
||||
|
||||
// Player filter
|
||||
if (selectedPlayer !== null && msg.player_id !== selectedPlayer) return false;
|
||||
|
||||
// Search filter
|
||||
const msgPlayerName = msg.player_name || `Player ${msg.player_id}`;
|
||||
if (selectedPlayer !== null && msgPlayerName !== selectedPlayer) return false;
|
||||
if (searchQuery && !msg.message.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
: []
|
||||
@@ -100,40 +99,72 @@
|
||||
{#if !chatData}
|
||||
<Card padding="lg">
|
||||
<div class="text-center">
|
||||
<AlertCircle class="mx-auto mb-4 h-16 w-16 text-warning" />
|
||||
<h2 class="mb-2 text-2xl font-bold text-base-content">Match Not Parsed</h2>
|
||||
<p class="mb-4 text-base-content/60">
|
||||
<AlertCircle
|
||||
class="mx-auto mb-4 h-16 w-16 text-neon-gold"
|
||||
style="filter: drop-shadow(0 0 15px rgba(255, 215, 0, 0.4));"
|
||||
/>
|
||||
<h2 class="mb-2 text-2xl font-bold text-white">Match Not Parsed</h2>
|
||||
<p class="mb-4 text-white/60">
|
||||
This match hasn't been parsed yet, so chat data is not available.
|
||||
</p>
|
||||
<Badge variant="warning" size="lg">Demo parsing required</Badge>
|
||||
</div>
|
||||
</Card>
|
||||
{:else if totalMessages === 0}
|
||||
<Card padding="lg">
|
||||
<div class="text-center">
|
||||
<MessageCircle class="mx-auto mb-4 h-16 w-16 text-white/30" />
|
||||
<h2 class="mb-2 text-2xl font-bold text-white">No Chat Messages</h2>
|
||||
<p class="mb-4 text-white/60">No comms? Either tactical geniuses or solo queue...</p>
|
||||
</div>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<!-- Stats -->
|
||||
<div class="grid gap-6 md:grid-cols-3">
|
||||
<Card padding="lg">
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<MessageSquare class="h-5 w-5 text-primary" />
|
||||
<span class="text-sm font-medium text-base-content/70">Total Messages</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg bg-neon-blue/20"
|
||||
style="box-shadow: 0 0 15px rgba(0, 212, 255, 0.2);"
|
||||
>
|
||||
<MessageSquare class="h-5 w-5 text-neon-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-white/50">Total Messages</div>
|
||||
<div class="text-3xl font-bold text-white">{totalMessages}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-base-content">{totalMessages}</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg">
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<MessageSquare class="h-5 w-5 text-warning" />
|
||||
<span class="text-sm font-medium text-base-content/70">Team Chat</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg bg-neon-gold/20"
|
||||
style="box-shadow: 0 0 15px rgba(255, 215, 0, 0.2);"
|
||||
>
|
||||
<MessageSquare class="h-5 w-5 text-neon-gold" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-white/50">Team Chat</div>
|
||||
<div class="text-3xl font-bold text-white">{teamChatCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-base-content">{teamChatCount}</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg">
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<MessageSquare class="h-5 w-5 text-success" />
|
||||
<span class="text-sm font-medium text-base-content/70">All Chat</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg bg-neon-green/20"
|
||||
style="box-shadow: 0 0 15px rgba(0, 255, 136, 0.2);"
|
||||
>
|
||||
<MessageSquare class="h-5 w-5 text-neon-green" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-white/50">All Chat</div>
|
||||
<div class="text-3xl font-bold text-white">{allChatCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-base-content">{allChatCount}</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -141,39 +172,50 @@
|
||||
<Card padding="lg">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Filter class="h-5 w-5 text-base-content" />
|
||||
<h3 class="font-semibold">Filters</h3>
|
||||
<Filter class="h-5 w-5 text-neon-blue" />
|
||||
<h3 class="font-semibold text-white">Filters</h3>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<!-- Chat Type -->
|
||||
<div class="flex gap-2">
|
||||
<label class="label cursor-pointer gap-2">
|
||||
<input type="checkbox" bind:checked={showTeamChat} class="checkbox checkbox-sm" />
|
||||
<span class="label-text">Team Chat</span>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={showTeamChat}
|
||||
class="h-4 w-4 rounded border-white/20 bg-void text-neon-blue focus:ring-neon-blue/50"
|
||||
/>
|
||||
<span class="text-sm text-white/80">Team Chat</span>
|
||||
</label>
|
||||
<label class="label cursor-pointer gap-2">
|
||||
<input type="checkbox" bind:checked={showAllChat} class="checkbox checkbox-sm" />
|
||||
<span class="label-text">All Chat</span>
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={showAllChat}
|
||||
class="h-4 w-4 rounded border-white/20 bg-void text-neon-blue focus:ring-neon-blue/50"
|
||||
/>
|
||||
<span class="text-sm text-white/80">All Chat</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Player Filter -->
|
||||
<select bind:value={selectedPlayer} class="select select-bordered select-sm">
|
||||
<select
|
||||
bind:value={selectedPlayer}
|
||||
class="rounded-lg border border-white/10 bg-void px-3 py-1.5 text-sm text-white focus:border-neon-blue/50 focus:outline-none focus:ring-1 focus:ring-neon-blue/50"
|
||||
>
|
||||
<option value={null}>All Players</option>
|
||||
{#each messagePlayers as player}
|
||||
<option value={player.id}>{player.name}</option>
|
||||
<option value={player.name}>{player.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="relative min-w-[200px] flex-1">
|
||||
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-base-content/40" />
|
||||
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-white/40" />
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
placeholder="Search messages..."
|
||||
class="input input-sm input-bordered w-full pl-9"
|
||||
class="w-full rounded-lg border border-white/10 bg-void py-1.5 pl-9 pr-3 text-sm text-white placeholder-white/40 focus:border-neon-blue/50 focus:outline-none focus:ring-1 focus:ring-neon-blue/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -183,7 +225,7 @@
|
||||
<!-- Messages -->
|
||||
{#if filteredMessages.length === 0}
|
||||
<Card padding="lg">
|
||||
<div class="text-center text-base-content/60">
|
||||
<div class="text-center text-white/50">
|
||||
<MessageSquare class="mx-auto mb-2 h-12 w-12" />
|
||||
<p>No messages match your filters.</p>
|
||||
</div>
|
||||
@@ -192,12 +234,14 @@
|
||||
{#each rounds as round}
|
||||
<Card padding="none">
|
||||
<!-- Round Header -->
|
||||
<div class="border-b border-base-300 bg-base-200 px-6 py-3">
|
||||
<div class="border-b border-white/10 bg-void/50 px-6 py-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-semibold text-base-content">
|
||||
<h3 class="font-semibold text-white">
|
||||
{round === 0 ? 'Warmup / Pre-Match' : `Round ${round}`}
|
||||
</h3>
|
||||
<Badge variant="default" size="sm">
|
||||
<span
|
||||
class="rounded-md border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-white/60"
|
||||
>
|
||||
{messagesByRound[round] ? messagesByRound[round].length : 0} message{(messagesByRound[
|
||||
round
|
||||
]
|
||||
@@ -205,52 +249,75 @@
|
||||
: 0) !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
</Badge>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div class="divide-y divide-base-300">
|
||||
<div class="divide-y divide-white/5">
|
||||
{#each messagesByRound[round] as message}
|
||||
{@const player = match.players?.find((p) => p.id === String(message.player_id))}
|
||||
{@const playerName =
|
||||
message.player_name || player?.name || `Player ${message.player_id}`}
|
||||
{@const playerName = message.player_name || `Player ${message.player_id}`}
|
||||
{@const player = match.players?.find((p) => p.name === playerName)}
|
||||
{@const playerAvatar = player?.avatar}
|
||||
{@const teamId = player?.team_id || 0}
|
||||
<div class="p-4 transition-colors hover:bg-base-200/50">
|
||||
<div class="p-4 transition-colors hover:bg-white/5">
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Player Avatar/Icon -->
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full text-sm font-bold text-white"
|
||||
class:bg-terrorist={teamId === 2}
|
||||
class:bg-ct={teamId === 3}
|
||||
class:bg-base-300={teamId === 0}
|
||||
>
|
||||
{playerName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{#if playerAvatar}
|
||||
<img
|
||||
src={playerAvatar}
|
||||
alt={playerName}
|
||||
class="h-10 w-10 rounded-full border {teamId === 2
|
||||
? 'border-terrorist/50'
|
||||
: teamId === 3
|
||||
? 'border-ct/50'
|
||||
: 'border-white/20'}"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full text-sm font-bold text-white {teamId ===
|
||||
2
|
||||
? 'bg-terrorist'
|
||||
: teamId === 3
|
||||
? 'bg-ct'
|
||||
: 'bg-white/20'}"
|
||||
>
|
||||
{playerName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Message Content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<a
|
||||
href={`/player/${message.player_id || 0}`}
|
||||
class="font-semibold hover:underline"
|
||||
class="font-semibold transition-colors hover:text-neon-blue"
|
||||
class:text-terrorist={teamId === 2}
|
||||
class:text-ct={teamId === 3}
|
||||
class:text-white={teamId === 0}
|
||||
>
|
||||
{playerName}
|
||||
</a>
|
||||
{#if message.all_chat}
|
||||
<Badge variant="success" size="sm">All Chat</Badge>
|
||||
<span
|
||||
class="rounded-md border border-neon-green/30 bg-neon-green/10 px-1.5 py-0.5 text-xs text-neon-green"
|
||||
>
|
||||
All Chat
|
||||
</span>
|
||||
{:else}
|
||||
<Badge variant="default" size="sm">Team</Badge>
|
||||
<span
|
||||
class="rounded-md border border-white/20 bg-white/5 px-1.5 py-0.5 text-xs text-white/60"
|
||||
>
|
||||
Team
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-1 flex items-start gap-2">
|
||||
<p class="break-words text-base-content">{message.message}</p>
|
||||
<p class="break-words text-white/90">{message.message}</p>
|
||||
{#if mightNeedTranslation(message.message)}
|
||||
<button
|
||||
onclick={() => translateMessage(message.message)}
|
||||
class="btn btn-ghost btn-xs flex-shrink-0 gap-1"
|
||||
class="flex shrink-0 items-center gap-1 rounded-md border border-neon-blue/30 bg-neon-blue/10 px-2 py-0.5 text-xs text-neon-blue transition-colors hover:bg-neon-blue/20"
|
||||
title="Translate message"
|
||||
aria-label="Translate to English"
|
||||
>
|
||||
|
||||
@@ -8,7 +8,7 @@ export const load: PageLoad = async ({ parent }) => {
|
||||
match,
|
||||
chatData: chat,
|
||||
meta: {
|
||||
title: `${match.map || 'Match'} Chat - Match ${match.match_id} - CS2.WTF`
|
||||
title: `${match.map || 'Match'} Chat - Match ${match.match_id} - teamflash.rip`
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Target, Crosshair, AlertCircle } from 'lucide-svelte';
|
||||
import { Target, Crosshair, AlertCircle, Flame, Skull, Lightbulb } from 'lucide-svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import DataTable from '$lib/components/data-display/DataTable.svelte';
|
||||
@@ -23,8 +23,6 @@
|
||||
const damage = player.dmg_enemy || 0;
|
||||
const avgDamagePerRound = match.max_rounds > 0 ? damage / match.max_rounds : 0;
|
||||
|
||||
// Note: Hit group breakdown would require weapon stats data
|
||||
// For now, using total damage metrics
|
||||
return {
|
||||
...player,
|
||||
damage,
|
||||
@@ -69,6 +67,11 @@
|
||||
// Top damage dealers (top 3)
|
||||
const topDamageDealers = sortedByDamage.slice(0, 3);
|
||||
|
||||
// Find player with highest team damage (needs therapy)
|
||||
const needsTherapyPlayer = [...playersWithDamageStats].sort(
|
||||
(a, b) => (b.dmg_team || 0) - (a.dmg_team || 0)
|
||||
)[0];
|
||||
|
||||
// Damage table columns
|
||||
const damageColumns = [
|
||||
{
|
||||
@@ -77,7 +80,12 @@
|
||||
sortable: true,
|
||||
render: (value: unknown, row: (typeof playersWithDamageStats)[0]) => {
|
||||
const teamClass = row.team_id === firstTeamId ? 'text-terrorist' : 'text-ct';
|
||||
return `<a href="/player/${row.id}" class="font-medium hover:underline ${teamClass}">${value}</a>`;
|
||||
const borderClass = row.team_id === firstTeamId ? 'border-terrorist/30' : 'border-ct/30';
|
||||
const bgClass = row.team_id === firstTeamId ? 'bg-terrorist/20' : 'bg-ct/20';
|
||||
const avatarHtml = row.avatar
|
||||
? `<img src="${row.avatar}" alt="${value}" class="h-8 w-8 rounded-full border ${borderClass}" />`
|
||||
: `<div class="flex h-8 w-8 items-center justify-center rounded-full ${bgClass} ${teamClass}"><svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path></svg></div>`;
|
||||
return `<a href="/player/${row.id}" class="flex items-center gap-3 font-medium hover:text-neon-blue transition-colors ${teamClass}">${avatarHtml}<span>${value}</span></a>`;
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -85,15 +93,15 @@
|
||||
label: 'Damage Dealt',
|
||||
sortable: true,
|
||||
align: 'right' as const,
|
||||
class: 'font-mono font-semibold',
|
||||
class: 'font-mono font-semibold text-white',
|
||||
format: (value: unknown) => (typeof value === 'number' ? value.toLocaleString() : '0')
|
||||
},
|
||||
{
|
||||
key: 'avgDamagePerRound' as const,
|
||||
label: 'Avg Damage/Round',
|
||||
label: 'ADR',
|
||||
sortable: true,
|
||||
align: 'right' as const,
|
||||
class: 'font-mono',
|
||||
class: 'font-mono text-white/80',
|
||||
format: (value: unknown) => (typeof value === 'number' ? value.toFixed(1) : '0.0')
|
||||
},
|
||||
{
|
||||
@@ -101,14 +109,14 @@
|
||||
label: 'Headshots',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
class: 'font-mono'
|
||||
class: 'font-mono text-white/80'
|
||||
},
|
||||
{
|
||||
key: 'kills' as const,
|
||||
label: 'Kills',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
class: 'font-mono'
|
||||
class: 'font-mono text-white/80'
|
||||
},
|
||||
{
|
||||
key: 'dmg_team' as const,
|
||||
@@ -118,14 +126,13 @@
|
||||
class: 'font-mono',
|
||||
render: (value: unknown) => {
|
||||
const dmg = typeof value === 'number' ? value : 0;
|
||||
if (!dmg || dmg === 0) return '<span class="text-base-content/40">-</span>';
|
||||
return `<span class="text-error">${dmg.toLocaleString()}</span>`;
|
||||
if (!dmg || dmg === 0) return '<span class="text-white/30">-</span>';
|
||||
return `<span class="text-neon-red">${dmg.toLocaleString()}</span>`;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Hit group distribution data (placeholder - would need weapon stats data)
|
||||
// For now, showing utility damage breakdown instead
|
||||
// Utility damage data with neon colors
|
||||
const utilityDamageData = hasPlayerData
|
||||
? {
|
||||
labels: ['HE Grenades', 'Fire (Molotov/Inc)'],
|
||||
@@ -137,10 +144,10 @@
|
||||
playersWithDamageStats.reduce((sum, p) => sum + (p.ud_flames || 0), 0)
|
||||
],
|
||||
backgroundColor: [
|
||||
'rgba(34, 197, 94, 0.8)', // Green for HE
|
||||
'rgba(239, 68, 68, 0.8)' // Red for Fire
|
||||
'rgba(0, 255, 136, 0.8)', // neon-green for HE
|
||||
'rgba(255, 51, 102, 0.8)' // neon-red for Fire
|
||||
],
|
||||
borderColor: ['rgba(34, 197, 94, 1)', 'rgba(239, 68, 68, 1)'],
|
||||
borderColor: ['#00ff88', '#ff3366'],
|
||||
borderWidth: 2
|
||||
}
|
||||
]
|
||||
@@ -152,16 +159,19 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Damage Analysis - CS2.WTF</title>
|
||||
<title>Damage Analysis - teamflash.rip</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if !hasPlayerData}
|
||||
<Card padding="lg">
|
||||
<div class="text-center">
|
||||
<AlertCircle class="mx-auto mb-4 h-16 w-16 text-warning" />
|
||||
<h2 class="mb-2 text-2xl font-bold text-base-content">No Player Data Available</h2>
|
||||
<p class="mb-4 text-base-content/60">
|
||||
Detailed damage statistics are not available for this match.
|
||||
<AlertCircle
|
||||
class="mx-auto mb-4 h-16 w-16 text-neon-gold"
|
||||
style="filter: drop-shadow(0 0 15px rgba(255, 215, 0, 0.4));"
|
||||
/>
|
||||
<h2 class="mb-2 text-2xl font-bold text-white">No Player Data Available</h2>
|
||||
<p class="mb-4 text-white/60">
|
||||
Detailed damage statistics are not available for this match. The pain remains unquantified.
|
||||
</p>
|
||||
<Badge variant="warning" size="lg">Player data unavailable</Badge>
|
||||
</div>
|
||||
@@ -171,18 +181,18 @@
|
||||
<!-- Team Damage Summary Cards -->
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<!-- Terrorists Damage Stats -->
|
||||
<Card padding="lg">
|
||||
<Card padding="lg" class="border-l-4 border-l-terrorist">
|
||||
<h3 class="mb-4 text-xl font-bold text-terrorist">Terrorists Damage</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="text-sm text-base-content/60">Total Damage</div>
|
||||
<div class="text-3xl font-bold text-base-content">
|
||||
<div class="text-sm text-white/50">Total Damage</div>
|
||||
<div class="text-3xl font-bold text-white">
|
||||
{teamAStats.totalDamage.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-base-content/60">Avg per Player</div>
|
||||
<div class="text-3xl font-bold text-base-content">
|
||||
<div class="text-sm text-white/50">Avg per Player</div>
|
||||
<div class="text-3xl font-bold text-white">
|
||||
{Math.round(teamAStats.avgDamagePerPlayer).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -190,18 +200,18 @@
|
||||
</Card>
|
||||
|
||||
<!-- Counter-Terrorists Damage Stats -->
|
||||
<Card padding="lg">
|
||||
<Card padding="lg" class="border-l-4 border-l-ct">
|
||||
<h3 class="mb-4 text-xl font-bold text-ct">Counter-Terrorists Damage</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="text-sm text-base-content/60">Total Damage</div>
|
||||
<div class="text-3xl font-bold text-base-content">
|
||||
<div class="text-sm text-white/50">Total Damage</div>
|
||||
<div class="text-3xl font-bold text-white">
|
||||
{teamBStats.totalDamage.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-base-content/60">Avg per Player</div>
|
||||
<div class="text-3xl font-bold text-base-content">
|
||||
<div class="text-sm text-white/50">Avg per Player</div>
|
||||
<div class="text-3xl font-bold text-white">
|
||||
{Math.round(teamBStats.avgDamagePerPlayer).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -215,47 +225,115 @@
|
||||
<Card padding="lg">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Target
|
||||
class="h-5 w-5 {index === 0
|
||||
? 'text-warning'
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg {index === 0
|
||||
? 'bg-neon-gold/20'
|
||||
: index === 1
|
||||
? 'text-base-content/70'
|
||||
: 'text-base-content/50'}"
|
||||
/>
|
||||
<h3 class="font-semibold text-base-content">
|
||||
? 'bg-white/10'
|
||||
: 'bg-white/5'}"
|
||||
style={index === 0 ? 'box-shadow: 0 0 10px rgba(255, 215, 0, 0.2);' : ''}
|
||||
>
|
||||
<Target
|
||||
class="h-4 w-4 {index === 0
|
||||
? 'text-neon-gold'
|
||||
: index === 1
|
||||
? 'text-white/70'
|
||||
: 'text-white/50'}"
|
||||
/>
|
||||
</div>
|
||||
<h3 class="font-semibold text-white">
|
||||
#{index + 1} Damage Dealer
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="text-2xl font-bold {player.team_id === firstTeamId
|
||||
<a
|
||||
href={`/player/${player.id}`}
|
||||
class="flex items-center gap-3 text-2xl font-bold transition-colors hover:text-neon-blue {player.team_id ===
|
||||
firstTeamId
|
||||
? 'text-terrorist'
|
||||
: 'text-ct'}"
|
||||
>
|
||||
{#if player.avatar}
|
||||
<img
|
||||
src={player.avatar}
|
||||
alt={player.name}
|
||||
class="h-10 w-10 rounded-full border {player.team_id === firstTeamId
|
||||
? 'border-terrorist/30'
|
||||
: 'border-ct/30'}"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full {player.team_id ===
|
||||
firstTeamId
|
||||
? 'bg-terrorist/20 text-terrorist'
|
||||
: 'bg-ct/20 text-ct'}"
|
||||
>
|
||||
<Target class="h-5 w-5" />
|
||||
</div>
|
||||
{/if}
|
||||
{player.name}
|
||||
</div>
|
||||
<div class="mt-1 font-mono text-3xl font-bold text-primary">
|
||||
</a>
|
||||
<div
|
||||
class="mt-1 font-mono text-3xl font-bold text-neon-blue"
|
||||
style="text-shadow: 0 0 15px rgba(0, 212, 255, 0.4);"
|
||||
>
|
||||
{player.damage.toLocaleString()}
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-base-content/60">
|
||||
<div class="mt-2 text-xs text-white/50">
|
||||
{player.avgDamagePerRound.toFixed(1)} ADR
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Needs Therapy Badge -->
|
||||
{#if needsTherapyPlayer && (needsTherapyPlayer.dmg_team || 0) > 50}
|
||||
<Card padding="lg" class="border-neon-red/30 bg-neon-red/5">
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-lg bg-neon-red/20"
|
||||
style="box-shadow: 0 0 15px rgba(255, 51, 102, 0.3);"
|
||||
>
|
||||
<Skull class="h-6 w-6 text-neon-red" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-neon-red">Needs Therapy Award</h3>
|
||||
<p class="text-sm text-white/60">
|
||||
<a
|
||||
href={`/player/${needsTherapyPlayer.id}`}
|
||||
class="font-medium text-white hover:text-neon-blue"
|
||||
>
|
||||
{needsTherapyPlayer.name}
|
||||
</a>
|
||||
dealt
|
||||
<span class="font-mono font-bold text-neon-red">{needsTherapyPlayer.dmg_team}</span> damage
|
||||
to their own team. Apologize in chat!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<!-- Utility Damage Distribution -->
|
||||
<Card padding="lg">
|
||||
<div class="mb-4">
|
||||
<h2 class="text-2xl font-bold text-base-content">Utility Damage Distribution</h2>
|
||||
<p class="text-sm text-base-content/60">
|
||||
Breakdown of damage dealt by grenades and fire across all players
|
||||
</p>
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg bg-neon-red/20"
|
||||
style="box-shadow: 0 0 15px rgba(255, 51, 102, 0.2);"
|
||||
>
|
||||
<Flame class="h-5 w-5 text-neon-red" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-white">Utility Damage Distribution</h2>
|
||||
<p class="text-sm text-white/50">
|
||||
Breakdown of damage dealt by grenades and fire - The Molotov Mixologist's Report
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if utilityDamageData.datasets.length > 0 && utilityDamageData.datasets[0]?.data.some((v) => v > 0)}
|
||||
<PieChart data={utilityDamageData} height={300} />
|
||||
{:else}
|
||||
<div class="py-12 text-center text-base-content/40">
|
||||
<div class="py-12 text-center text-white/40">
|
||||
<Crosshair class="mx-auto mb-2 h-12 w-12" />
|
||||
<p>No utility damage recorded for this match</p>
|
||||
</div>
|
||||
@@ -265,20 +343,27 @@
|
||||
<!-- Player Damage Table -->
|
||||
<Card padding="none">
|
||||
<div class="p-6">
|
||||
<h2 class="text-2xl font-bold text-base-content">Player Damage Statistics</h2>
|
||||
<p class="mt-1 text-sm text-base-content/60">Detailed damage breakdown for all players</p>
|
||||
<h2 class="text-2xl font-bold text-white">Player Damage Statistics</h2>
|
||||
<p class="mt-1 text-sm text-white/50">
|
||||
Detailed damage breakdown for all players - The pain ledger
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DataTable data={sortedByDamage} columns={damageColumns} striped hoverable />
|
||||
</Card>
|
||||
|
||||
<!-- Additional Info Note -->
|
||||
<Card padding="lg">
|
||||
<div class="flex items-start gap-3">
|
||||
<AlertCircle class="h-5 w-5 flex-shrink-0 text-info" />
|
||||
<Card padding="lg" class="border-neon-blue/20">
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-neon-blue/20"
|
||||
style="box-shadow: 0 0 15px rgba(0, 212, 255, 0.2);"
|
||||
>
|
||||
<Lightbulb class="h-5 w-5 text-neon-blue" />
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<h3 class="mb-1 font-semibold text-base-content">About Damage Statistics</h3>
|
||||
<p class="text-base-content/70">
|
||||
<h3 class="mb-1 font-semibold text-white">About Damage Statistics</h3>
|
||||
<p class="text-white/60">
|
||||
Damage statistics show total damage dealt to enemies throughout the match. Average
|
||||
damage per round (ADR) is calculated by dividing total damage by the number of rounds
|
||||
played. Hit group breakdown (head, chest, legs, etc.) is available in weapon-specific
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Trophy, Target, Flame, AlertCircle } from 'lucide-svelte';
|
||||
import { Trophy, Target, Flame, AlertCircle, Crosshair } from 'lucide-svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import DataTable from '$lib/components/data-display/DataTable.svelte';
|
||||
@@ -54,6 +54,9 @@
|
||||
|
||||
// Prepare data table columns
|
||||
type PlayerWithStats = (typeof playersWithStats)[0];
|
||||
// SVG fallback for broken avatars
|
||||
const avatarFallbackSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="h-6 w-6 text-white/40"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="10" r="3"/><path d="M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662"/></svg>`;
|
||||
|
||||
const detailsColumns = [
|
||||
{
|
||||
key: 'avatar' as keyof PlayerWithStats,
|
||||
@@ -62,7 +65,10 @@
|
||||
align: 'center' as const,
|
||||
render: (_value: unknown, row: PlayerWithStats) => {
|
||||
const avatarUrl = row.avatar || '';
|
||||
return `<img src="${avatarUrl}" alt="${row.name}" class="h-10 w-10 rounded-full border-2 border-base-300" />`;
|
||||
if (!avatarUrl) {
|
||||
return `<div class="flex h-10 w-10 items-center justify-center rounded-full border-2 border-white/10 bg-void">${avatarFallbackSvg}</div>`;
|
||||
}
|
||||
return `<img src="${avatarUrl}" alt="${row.name}" class="h-10 w-10 rounded-full border-2 border-white/10 bg-void" onerror="this.style.display='none';this.nextElementSibling.style.display='flex';" /><div class="hidden h-10 w-10 items-center justify-center rounded-full border-2 border-white/10 bg-void">${avatarFallbackSvg}</div>`;
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -77,7 +83,7 @@
|
||||
const colorDot = colorHex
|
||||
? `<span class="inline-block h-3 w-3 rounded-full mr-2" style="background-color: ${colorHex}"></span>`
|
||||
: '';
|
||||
return `<a href="/player/${row.id}" class="flex items-center font-medium hover:underline ${teamClass}">${colorDot}${strValue}</a>`;
|
||||
return `<a href="/player/${row.id}" class="flex items-center font-medium hover:text-neon-blue transition-colors ${teamClass}">${colorDot}${strValue}</a>`;
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -85,35 +91,35 @@
|
||||
label: 'Score',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
class: 'font-mono font-semibold'
|
||||
class: 'font-mono font-semibold text-white'
|
||||
},
|
||||
{
|
||||
key: 'kills' as keyof (typeof playersWithStats)[0],
|
||||
label: 'K',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
class: 'font-mono font-semibold'
|
||||
class: 'font-mono font-semibold text-white'
|
||||
},
|
||||
{
|
||||
key: 'deaths' as keyof (typeof playersWithStats)[0],
|
||||
label: 'D',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
class: 'font-mono'
|
||||
class: 'font-mono text-white/80'
|
||||
},
|
||||
{
|
||||
key: 'assists' as keyof (typeof playersWithStats)[0],
|
||||
label: 'A',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
class: 'font-mono'
|
||||
class: 'font-mono text-white/80'
|
||||
},
|
||||
{
|
||||
key: 'kd' as keyof PlayerWithStats,
|
||||
label: 'K/D',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
class: 'font-mono',
|
||||
class: 'font-mono text-white/80',
|
||||
format: (v: unknown) => (v !== undefined ? (v as number).toFixed(2) : '0.00')
|
||||
},
|
||||
{
|
||||
@@ -121,7 +127,7 @@
|
||||
label: 'ADR',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
class: 'font-mono',
|
||||
class: 'font-mono text-white/80',
|
||||
format: (v: unknown) => (v !== undefined ? (v as number).toFixed(1) : '0.0')
|
||||
},
|
||||
{
|
||||
@@ -129,7 +135,7 @@
|
||||
label: 'HS%',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
class: 'font-mono',
|
||||
class: 'font-mono text-white/80',
|
||||
format: (v: unknown) => (v !== undefined ? (v as number).toFixed(1) : '0.0')
|
||||
},
|
||||
{
|
||||
@@ -137,7 +143,7 @@
|
||||
label: 'KAST%',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
class: 'font-mono',
|
||||
class: 'font-mono text-white/80',
|
||||
format: (v: unknown) => (v !== undefined ? (v as number).toFixed(1) : '-')
|
||||
},
|
||||
{
|
||||
@@ -145,7 +151,7 @@
|
||||
label: 'MVP',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
class: 'font-mono'
|
||||
class: 'font-mono text-white/80'
|
||||
},
|
||||
{
|
||||
key: 'mk_5' as keyof (typeof playersWithStats)[0],
|
||||
@@ -157,8 +163,9 @@
|
||||
_row: (typeof playersWithStats)[0]
|
||||
) => {
|
||||
const numValue = value !== undefined ? (value as number) : 0;
|
||||
if (numValue > 0) return `<span class="badge badge-warning badge-sm">${numValue}</span>`;
|
||||
return '<span class="text-base-content/40">-</span>';
|
||||
if (numValue > 0)
|
||||
return `<span class="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-bold bg-neon-gold/20 text-neon-gold border border-neon-gold/30">${numValue}</span>`;
|
||||
return '<span class="text-white/30">-</span>';
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -172,42 +179,54 @@
|
||||
) => {
|
||||
const badges = [];
|
||||
if (row.vac) {
|
||||
badges.push('<span class="badge badge-error badge-sm" title="VAC Banned">VAC</span>');
|
||||
badges.push(
|
||||
'<span class="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-bold bg-neon-red/20 text-neon-red border border-neon-red/30" title="VAC Banned">VAC</span>'
|
||||
);
|
||||
}
|
||||
if (row.game_ban) {
|
||||
badges.push('<span class="badge badge-error badge-sm" title="Game Banned">BAN</span>');
|
||||
badges.push(
|
||||
'<span class="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-bold bg-neon-red/20 text-neon-red border border-neon-red/30" title="Game Banned">BAN</span>'
|
||||
);
|
||||
}
|
||||
if (badges.length > 0) {
|
||||
return `<div class="flex gap-1 justify-center">${badges.join('')}</div>`;
|
||||
}
|
||||
return '<span class="text-base-content/40">-</span>';
|
||||
return '<span class="text-white/30">-</span>';
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Multi-kill chart data
|
||||
// Multi-kill chart data with neon colors
|
||||
const multiKillData = {
|
||||
labels: sortedPlayers.map((p) => p.name),
|
||||
datasets: [
|
||||
{
|
||||
label: '2K',
|
||||
data: sortedPlayers.map((p) => p.mk_2 || 0),
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.8)'
|
||||
backgroundColor: 'rgba(0, 255, 136, 0.7)', // neon-green
|
||||
borderColor: '#00ff88',
|
||||
borderWidth: 1
|
||||
},
|
||||
{
|
||||
label: '3K',
|
||||
data: sortedPlayers.map((p) => p.mk_3 || 0),
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.8)'
|
||||
backgroundColor: 'rgba(0, 212, 255, 0.7)', // neon-blue
|
||||
borderColor: '#00d4ff',
|
||||
borderWidth: 1
|
||||
},
|
||||
{
|
||||
label: '4K',
|
||||
data: sortedPlayers.map((p) => p.mk_4 || 0),
|
||||
backgroundColor: 'rgba(249, 115, 22, 0.8)'
|
||||
backgroundColor: 'rgba(255, 215, 0, 0.7)', // neon-gold
|
||||
borderColor: '#ffd700',
|
||||
borderWidth: 1
|
||||
},
|
||||
{
|
||||
label: '5K (Ace)',
|
||||
data: sortedPlayers.map((p) => p.mk_5 || 0),
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.8)'
|
||||
backgroundColor: 'rgba(255, 51, 102, 0.7)', // neon-red
|
||||
borderColor: '#ff3366',
|
||||
borderWidth: 1
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -258,16 +277,20 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Match Details - CS2.WTF</title>
|
||||
<title>Match Details - teamflash.rip</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if !hasPlayerData}
|
||||
<Card padding="lg">
|
||||
<div class="text-center">
|
||||
<AlertCircle class="mx-auto mb-4 h-16 w-16 text-warning" />
|
||||
<h2 class="mb-2 text-2xl font-bold text-base-content">No Player Data Available</h2>
|
||||
<p class="mb-4 text-base-content/60">
|
||||
Detailed player statistics are not available for this match.
|
||||
<AlertCircle
|
||||
class="mx-auto mb-4 h-16 w-16 text-neon-gold"
|
||||
style="filter: drop-shadow(0 0 15px rgba(255, 215, 0, 0.4));"
|
||||
/>
|
||||
<h2 class="mb-2 text-2xl font-bold text-white">No Player Data Available</h2>
|
||||
<p class="mb-4 text-white/60">
|
||||
Detailed player statistics are not available for this match. The scoreboard mysteries remain
|
||||
unsolved.
|
||||
</p>
|
||||
<Badge variant="warning" size="lg">Player data unavailable</Badge>
|
||||
</div>
|
||||
@@ -277,47 +300,55 @@
|
||||
<!-- Team Performance Summary -->
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<!-- Terrorists Stats -->
|
||||
<Card padding="lg">
|
||||
<Card padding="lg" class="border-l-4 border-l-terrorist">
|
||||
<h3 class="mb-4 text-xl font-bold text-terrorist">Terrorists Performance</h3>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div class="text-base-content/60">Total Damage</div>
|
||||
<div class="text-2xl font-bold">{teamAStats.totalDamage.toLocaleString()}</div>
|
||||
<div class="text-white/50">Total Damage</div>
|
||||
<div class="text-2xl font-bold text-white">
|
||||
{teamAStats.totalDamage.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-base-content/60">Utility Damage</div>
|
||||
<div class="text-2xl font-bold">{teamAStats.totalUtilityDamage.toLocaleString()}</div>
|
||||
<div class="text-white/50">Utility Damage</div>
|
||||
<div class="text-2xl font-bold text-white">
|
||||
{teamAStats.totalUtilityDamage.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-base-content/60">Flash Assists</div>
|
||||
<div class="text-2xl font-bold">{teamAStats.totalFlashAssists}</div>
|
||||
<div class="text-white/50">Flash Assists</div>
|
||||
<div class="text-2xl font-bold text-white">{teamAStats.totalFlashAssists}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-base-content/60">Avg KAST</div>
|
||||
<div class="text-2xl font-bold">{teamAStats.avgKAST}%</div>
|
||||
<div class="text-white/50">Avg KAST</div>
|
||||
<div class="text-2xl font-bold text-white">{teamAStats.avgKAST}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Counter-Terrorists Stats -->
|
||||
<Card padding="lg">
|
||||
<Card padding="lg" class="border-l-4 border-l-ct">
|
||||
<h3 class="mb-4 text-xl font-bold text-ct">Counter-Terrorists Performance</h3>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div class="text-base-content/60">Total Damage</div>
|
||||
<div class="text-2xl font-bold">{teamBStats.totalDamage.toLocaleString()}</div>
|
||||
<div class="text-white/50">Total Damage</div>
|
||||
<div class="text-2xl font-bold text-white">
|
||||
{teamBStats.totalDamage.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-base-content/60">Utility Damage</div>
|
||||
<div class="text-2xl font-bold">{teamBStats.totalUtilityDamage.toLocaleString()}</div>
|
||||
<div class="text-white/50">Utility Damage</div>
|
||||
<div class="text-2xl font-bold text-white">
|
||||
{teamBStats.totalUtilityDamage.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-base-content/60">Flash Assists</div>
|
||||
<div class="text-2xl font-bold">{teamBStats.totalFlashAssists}</div>
|
||||
<div class="text-white/50">Flash Assists</div>
|
||||
<div class="text-2xl font-bold text-white">{teamBStats.totalFlashAssists}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-base-content/60">Avg KAST</div>
|
||||
<div class="text-2xl font-bold">{teamBStats.avgKAST}%</div>
|
||||
<div class="text-white/50">Avg KAST</div>
|
||||
<div class="text-2xl font-bold text-white">{teamBStats.avgKAST}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -325,21 +356,59 @@
|
||||
|
||||
<!-- Multi-Kills Chart -->
|
||||
<Card padding="lg">
|
||||
<div class="mb-4">
|
||||
<h2 class="text-2xl font-bold text-base-content">Multi-Kill Distribution</h2>
|
||||
<p class="text-sm text-base-content/60">
|
||||
Double kills (2K), triple kills (3K), quad kills (4K), and aces (5K) per player
|
||||
</p>
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg bg-neon-red/20"
|
||||
style="box-shadow: 0 0 15px rgba(255, 51, 102, 0.2);"
|
||||
>
|
||||
<Crosshair class="h-5 w-5 text-neon-red" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-white">Multi-Threat Level</h2>
|
||||
<p class="text-sm text-white/50">
|
||||
Double kills, triple kills, quad kills, and aces - Who went absolutely mental
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<BarChart data={multiKillData} height={300} />
|
||||
<BarChart
|
||||
data={multiKillData}
|
||||
height={300}
|
||||
options={{
|
||||
scales: {
|
||||
y: {
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.05)'
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgba(255, 255, 255, 0.5)'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.05)'
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgba(255, 255, 255, 0.5)'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: 'rgba(255, 255, 255, 0.7)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<!-- Detailed Player Statistics Table -->
|
||||
<Card padding="none">
|
||||
<div class="p-6">
|
||||
<h2 class="text-2xl font-bold text-base-content">Detailed Player Statistics</h2>
|
||||
<p class="mt-1 text-sm text-base-content/60">
|
||||
Complete performance breakdown for all players
|
||||
<h2 class="text-2xl font-bold text-white">Detailed Player Statistics</h2>
|
||||
<p class="mt-1 text-sm text-white/50">
|
||||
Complete performance breakdown for all players - The full criminal record
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -352,14 +421,27 @@
|
||||
<!-- Most Kills -->
|
||||
<Card padding="lg">
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<Trophy class="h-5 w-5 text-warning" />
|
||||
<h3 class="font-semibold text-base-content">Most Kills</h3>
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg bg-neon-gold/20"
|
||||
style="box-shadow: 0 0 10px rgba(255, 215, 0, 0.2);"
|
||||
>
|
||||
<Trophy class="h-4 w-4 text-neon-gold" />
|
||||
</div>
|
||||
<h3 class="font-semibold text-white">Most Kills</h3>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-base-content">{sortedPlayers[0].name}</div>
|
||||
<div class="mt-1 font-mono text-3xl font-bold text-primary">
|
||||
<a
|
||||
href={`/player/${sortedPlayers[0].id}`}
|
||||
class="text-2xl font-bold text-white transition-colors hover:text-neon-blue"
|
||||
>
|
||||
{sortedPlayers[0].name}
|
||||
</a>
|
||||
<div
|
||||
class="mt-1 font-mono text-3xl font-bold text-neon-blue"
|
||||
style="text-shadow: 0 0 15px rgba(0, 212, 255, 0.4);"
|
||||
>
|
||||
{sortedPlayers[0].kills}
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-base-content/60">
|
||||
<div class="mt-2 text-xs text-white/50">
|
||||
{sortedPlayers[0].deaths} deaths, {sortedPlayers[0].kd.toFixed(2)} K/D
|
||||
</div>
|
||||
</Card>
|
||||
@@ -369,12 +451,27 @@
|
||||
{#if bestKD}
|
||||
<Card padding="lg">
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<Target class="h-5 w-5 text-success" />
|
||||
<h3 class="font-semibold text-base-content">Best K/D Ratio</h3>
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg bg-neon-green/20"
|
||||
style="box-shadow: 0 0 10px rgba(0, 255, 136, 0.2);"
|
||||
>
|
||||
<Target class="h-4 w-4 text-neon-green" />
|
||||
</div>
|
||||
<h3 class="font-semibold text-white">Can't Touch This</h3>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-base-content">{bestKD.name}</div>
|
||||
<div class="mt-1 font-mono text-3xl font-bold text-success">{bestKD.kd.toFixed(2)}</div>
|
||||
<div class="mt-2 text-xs text-base-content/60">
|
||||
<a
|
||||
href={`/player/${bestKD.id}`}
|
||||
class="text-2xl font-bold text-white transition-colors hover:text-neon-blue"
|
||||
>
|
||||
{bestKD.name}
|
||||
</a>
|
||||
<div
|
||||
class="mt-1 font-mono text-3xl font-bold text-neon-green"
|
||||
style="text-shadow: 0 0 15px rgba(0, 255, 136, 0.4);"
|
||||
>
|
||||
{bestKD.kd.toFixed(2)}
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-white/50">
|
||||
{bestKD.kills}K / {bestKD.deaths}D
|
||||
</div>
|
||||
</Card>
|
||||
@@ -387,14 +484,27 @@
|
||||
{#if bestUtility}
|
||||
<Card padding="lg">
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<Flame class="h-5 w-5 text-error" />
|
||||
<h3 class="font-semibold text-base-content">Most Utility Damage</h3>
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg bg-neon-red/20"
|
||||
style="box-shadow: 0 0 10px rgba(255, 51, 102, 0.2);"
|
||||
>
|
||||
<Flame class="h-4 w-4 text-neon-red" />
|
||||
</div>
|
||||
<h3 class="font-semibold text-white">The Molotov Mixologist</h3>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-base-content">{bestUtility.name}</div>
|
||||
<div class="mt-1 font-mono text-3xl font-bold text-error">
|
||||
<a
|
||||
href={`/player/${bestUtility.id}`}
|
||||
class="text-2xl font-bold text-white transition-colors hover:text-neon-blue"
|
||||
>
|
||||
{bestUtility.name}
|
||||
</a>
|
||||
<div
|
||||
class="mt-1 font-mono text-3xl font-bold text-neon-red"
|
||||
style="text-shadow: 0 0 15px rgba(255, 51, 102, 0.4);"
|
||||
>
|
||||
{((bestUtility.ud_he || 0) + (bestUtility.ud_flames || 0)).toLocaleString()}
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-base-content/60">
|
||||
<div class="mt-2 text-xs text-white/50">
|
||||
HE: {bestUtility.ud_he || 0} | Fire: {bestUtility.ud_flames || 0}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -8,7 +8,7 @@ export const load: PageLoad = async ({ parent }) => {
|
||||
match,
|
||||
weaponsData: weapons,
|
||||
meta: {
|
||||
title: `${match.map || 'Match'} Details - Match ${match.match_id} - CS2.WTF`
|
||||
title: `${match.map || 'Match'} Details - Match ${match.match_id} - teamflash.rip`
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,407 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { TrendingUp, ShoppingCart, AlertCircle } from 'lucide-svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import LineChart from '$lib/components/charts/LineChart.svelte';
|
||||
import DataTable from '$lib/components/data-display/DataTable.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
interface TeamEconomy {
|
||||
round: number;
|
||||
teamA_bank: number;
|
||||
teamB_bank: number;
|
||||
teamA_equipment: number;
|
||||
teamB_equipment: number;
|
||||
teamA_spent: number;
|
||||
teamB_spent: number;
|
||||
winner: number;
|
||||
teamA_buyType: string;
|
||||
teamB_buyType: string;
|
||||
economyAdvantage: number; // Cumulative economy differential (teamA - teamB)
|
||||
}
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
const { match, roundsData } = data;
|
||||
|
||||
// Team IDs - Terrorists are always team_id 2, Counter-Terrorists are always team_id 3
|
||||
const tTeamId = 2;
|
||||
const ctTeamId = 3;
|
||||
|
||||
// Calculate halftime round based on max_rounds
|
||||
// MR12 (24 rounds): halftime after round 12
|
||||
// MR15 (30 rounds): halftime after round 15
|
||||
const halfPoint = match.max_rounds === 30 ? 15 : 12;
|
||||
|
||||
// Only process if rounds data exists
|
||||
let teamEconomy = $state<TeamEconomy[]>([]);
|
||||
let equipmentChartData = $state<{
|
||||
labels: string[];
|
||||
datasets: Array<{
|
||||
label: string;
|
||||
data: number[];
|
||||
borderColor?: string;
|
||||
backgroundColor?: string;
|
||||
fill?: boolean;
|
||||
tension?: number;
|
||||
}>;
|
||||
} | null>(null);
|
||||
let economyAdvantageChartData = $state<{
|
||||
labels: string[];
|
||||
datasets: Array<{
|
||||
label: string;
|
||||
data: number[];
|
||||
borderColor?: string;
|
||||
backgroundColor?: string;
|
||||
fill?: boolean;
|
||||
tension?: number;
|
||||
}>;
|
||||
} | null>(null);
|
||||
let totalRounds = $state(0);
|
||||
let teamA_fullBuys = $state(0);
|
||||
let teamB_fullBuys = $state(0);
|
||||
let teamA_ecos = $state(0);
|
||||
let teamB_ecos = $state(0);
|
||||
let halfRoundIndex = $state<number>(0);
|
||||
|
||||
if (roundsData) {
|
||||
// Process rounds data to calculate team totals
|
||||
for (const roundData of roundsData.rounds) {
|
||||
const tPlayers = roundData.players.filter((p) => {
|
||||
const matchPlayer = match.players?.find((mp) => mp.id === String(p.player_id));
|
||||
return matchPlayer?.team_id === tTeamId;
|
||||
});
|
||||
|
||||
const ctPlayers = roundData.players.filter((p) => {
|
||||
const matchPlayer = match.players?.find((mp) => mp.id === String(p.player_id));
|
||||
return matchPlayer?.team_id === ctTeamId;
|
||||
});
|
||||
|
||||
const t_bank = tPlayers.reduce((sum, p) => sum + (p.bank || 0), 0);
|
||||
const ct_bank = ctPlayers.reduce((sum, p) => sum + (p.bank || 0), 0);
|
||||
const t_equipment = tPlayers.reduce((sum, p) => sum + (p.equipment || 0), 0);
|
||||
const ct_equipment = ctPlayers.reduce((sum, p) => sum + (p.equipment || 0), 0);
|
||||
const t_spent = tPlayers.reduce((sum, p) => sum + (p.spent || 0), 0);
|
||||
const ct_spent = ctPlayers.reduce((sum, p) => sum + (p.spent || 0), 0);
|
||||
|
||||
const avgT_equipment = tPlayers.length > 0 ? t_equipment / tPlayers.length : 0;
|
||||
const avgCT_equipment = ctPlayers.length > 0 ? ct_equipment / ctPlayers.length : 0;
|
||||
|
||||
const classifyBuyType = (avgEquipment: number): string => {
|
||||
if (avgEquipment < 1500) return 'Eco';
|
||||
if (avgEquipment < 2500) return 'Semi-Eco';
|
||||
if (avgEquipment < 3500) return 'Force';
|
||||
return 'Full Buy';
|
||||
};
|
||||
|
||||
// Calculate per-round economy advantage using bank + spent (like old portal)
|
||||
// Teams swap sides at halftime, so we need to account for perspective flip
|
||||
const t_totalEconomy = t_bank + t_spent;
|
||||
const ct_totalEconomy = ct_bank + ct_spent;
|
||||
|
||||
// Determine perspective based on round (teams swap at half)
|
||||
// halfPoint is calculated above based on match.max_rounds
|
||||
let economyAdvantage;
|
||||
if (roundData.round <= halfPoint) {
|
||||
// First half: T - CT
|
||||
economyAdvantage = t_totalEconomy - ct_totalEconomy;
|
||||
} else {
|
||||
// Second half: CT - T (teams swapped sides)
|
||||
economyAdvantage = ct_totalEconomy - t_totalEconomy;
|
||||
}
|
||||
|
||||
teamEconomy.push({
|
||||
round: roundData.round,
|
||||
teamA_bank: t_bank,
|
||||
teamB_bank: ct_bank,
|
||||
teamA_equipment: t_equipment,
|
||||
teamB_equipment: ct_equipment,
|
||||
teamA_spent: t_spent,
|
||||
teamB_spent: ct_spent,
|
||||
winner: roundData.winner || 0,
|
||||
teamA_buyType: classifyBuyType(avgT_equipment),
|
||||
teamB_buyType: classifyBuyType(avgCT_equipment),
|
||||
economyAdvantage
|
||||
});
|
||||
}
|
||||
|
||||
// Prepare equipment value chart data
|
||||
equipmentChartData = {
|
||||
labels: teamEconomy.map((r) => `R${r.round}`),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Terrorists Equipment',
|
||||
data: teamEconomy.map((r) => r.teamA_equipment),
|
||||
borderColor: 'rgb(249, 115, 22)',
|
||||
backgroundColor: 'rgba(249, 115, 22, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4
|
||||
},
|
||||
{
|
||||
label: 'Counter-Terrorists Equipment',
|
||||
data: teamEconomy.map((r) => r.teamB_equipment),
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Prepare economy advantage chart data
|
||||
// Positive = above 0, Negative = below 0
|
||||
halfRoundIndex = Math.floor(teamEconomy.length / 2);
|
||||
economyAdvantageChartData = {
|
||||
labels: teamEconomy.map((r) => `${r.round}`),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Advantage',
|
||||
data: teamEconomy.map((r) => (r.economyAdvantage > 0 ? r.economyAdvantage : 0)),
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.6)',
|
||||
// @ts-expect-error - Chart.js types incorrectly show fill as boolean, but 'origin' is valid
|
||||
fill: 'origin',
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4
|
||||
},
|
||||
{
|
||||
label: 'Disadvantage',
|
||||
data: teamEconomy.map((r) => (r.economyAdvantage < 0 ? r.economyAdvantage : 0)),
|
||||
borderColor: 'rgb(249, 115, 22)',
|
||||
backgroundColor: 'rgba(249, 115, 22, 0.6)',
|
||||
// @ts-expect-error - Chart.js types incorrectly show fill as boolean, but 'origin' is valid
|
||||
fill: 'origin',
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Calculate summary stats
|
||||
totalRounds = teamEconomy.length;
|
||||
teamA_fullBuys = teamEconomy.filter((r) => r.teamA_buyType === 'Full Buy').length;
|
||||
teamB_fullBuys = teamEconomy.filter((r) => r.teamB_buyType === 'Full Buy').length;
|
||||
teamA_ecos = teamEconomy.filter((r) => r.teamA_buyType === 'Eco').length;
|
||||
teamB_ecos = teamEconomy.filter((r) => r.teamB_buyType === 'Eco').length;
|
||||
}
|
||||
|
||||
// Table columns
|
||||
const tableColumns = [
|
||||
{
|
||||
key: 'round' as keyof TeamEconomy,
|
||||
label: 'Round',
|
||||
sortable: true,
|
||||
align: 'center' as const
|
||||
},
|
||||
{
|
||||
key: 'teamA_buyType' as keyof TeamEconomy,
|
||||
label: 'T Buy',
|
||||
sortable: true,
|
||||
render: (value: string | number | boolean, _row: TeamEconomy) => {
|
||||
const strValue = value as string;
|
||||
const variant =
|
||||
strValue === 'Full Buy'
|
||||
? 'success'
|
||||
: strValue === 'Eco'
|
||||
? 'error'
|
||||
: strValue === 'Force'
|
||||
? 'warning'
|
||||
: 'default';
|
||||
return `<span class="badge badge-${variant} badge-sm">${strValue}</span>`;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'teamA_equipment' as keyof TeamEconomy,
|
||||
label: 'T Equipment',
|
||||
sortable: true,
|
||||
align: 'right' as const,
|
||||
format: (value: string | number | boolean, _row: TeamEconomy) =>
|
||||
`$${(value as number).toLocaleString()}`
|
||||
},
|
||||
{
|
||||
key: 'teamB_buyType' as keyof TeamEconomy,
|
||||
label: 'CT Buy',
|
||||
sortable: true,
|
||||
render: (value: string | number | boolean, _row: TeamEconomy) => {
|
||||
const strValue = value as string;
|
||||
const variant =
|
||||
strValue === 'Full Buy'
|
||||
? 'success'
|
||||
: strValue === 'Eco'
|
||||
? 'error'
|
||||
: strValue === 'Force'
|
||||
? 'warning'
|
||||
: 'default';
|
||||
return `<span class="badge badge-${variant} badge-sm">${strValue}</span>`;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'teamB_equipment' as keyof TeamEconomy,
|
||||
label: 'CT Equipment',
|
||||
sortable: true,
|
||||
align: 'right' as const,
|
||||
format: (value: string | number | boolean, _row: TeamEconomy) =>
|
||||
`$${(value as number).toLocaleString()}`
|
||||
},
|
||||
{
|
||||
key: 'winner' as keyof TeamEconomy,
|
||||
label: 'Winner',
|
||||
align: 'center' as const,
|
||||
render: (value: string | number | boolean, _row: TeamEconomy) => {
|
||||
const numValue = value as number;
|
||||
if (numValue === 2)
|
||||
return '<span class="badge badge-sm" style="background-color: rgb(249, 115, 22); color: white;">T</span>';
|
||||
if (numValue === 3)
|
||||
return '<span class="badge badge-sm" style="background-color: rgb(59, 130, 246); color: white;">CT</span>';
|
||||
return '<span class="text-base-content/40">-</span>';
|
||||
}
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
{#if !roundsData}
|
||||
<Card padding="lg">
|
||||
<div class="text-center">
|
||||
<AlertCircle class="mx-auto mb-4 h-16 w-16 text-warning" />
|
||||
<h2 class="mb-2 text-2xl font-bold text-base-content">Match Not Parsed</h2>
|
||||
<p class="mb-4 text-base-content/60">
|
||||
This match hasn't been parsed yet, so detailed economy data is not available.
|
||||
</p>
|
||||
<Badge variant="warning" size="lg">Demo parsing required</Badge>
|
||||
</div>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<!-- Economy Advantage Chart -->
|
||||
<Card padding="lg">
|
||||
<div class="mb-4">
|
||||
<h2 class="text-2xl font-bold text-base-content">Economy</h2>
|
||||
<p class="text-sm text-base-content/60">Net-worth differential (bank + spent)</p>
|
||||
</div>
|
||||
{#if economyAdvantageChartData}
|
||||
<div class="relative">
|
||||
<LineChart
|
||||
data={economyAdvantageChartData}
|
||||
height={400}
|
||||
options={{
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: (context) => {
|
||||
if (context.tick.value === 0) {
|
||||
return 'rgba(156, 163, 175, 0.5)'; // Stronger line at 0
|
||||
}
|
||||
return 'rgba(156, 163, 175, 0.1)';
|
||||
},
|
||||
lineWidth: (context) => {
|
||||
return context.tick.value === 0 ? 2 : 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if halfRoundIndex > 0}
|
||||
<div
|
||||
class="pointer-events-none absolute top-0 flex h-full items-center"
|
||||
style="left: {(halfRoundIndex / (teamEconomy.length || 1)) * 100}%"
|
||||
>
|
||||
<div class="h-full w-px bg-base-content/20"></div>
|
||||
<div
|
||||
class="absolute -top-1 left-1/2 -translate-x-1/2 rounded bg-base-300 px-2 py-1 text-xs font-medium text-base-content/70"
|
||||
>
|
||||
Half-Point
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="grid gap-6 md:grid-cols-3">
|
||||
<Card padding="lg">
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<ShoppingCart class="h-5 w-5 text-primary" />
|
||||
<span class="text-sm font-medium text-base-content/70">Total Rounds</span>
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-base-content">{totalRounds}</div>
|
||||
<div class="mt-1 text-xs text-base-content/60">
|
||||
{match.score_team_a} - {match.score_team_b}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg">
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<TrendingUp class="h-5 w-5 text-terrorist" />
|
||||
<span class="text-sm font-medium text-base-content/70">Terrorists Buy Rounds</span>
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-base-content">{teamA_fullBuys}</div>
|
||||
<div class="mt-1 text-xs text-base-content/60">{teamA_ecos} eco rounds</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg">
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<TrendingUp class="h-5 w-5 text-ct" />
|
||||
<span class="text-sm font-medium text-base-content/70">CT Buy Rounds</span>
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-base-content">{teamB_fullBuys}</div>
|
||||
<div class="mt-1 text-xs text-base-content/60">{teamB_ecos} eco rounds</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Equipment Value Chart -->
|
||||
<Card padding="lg">
|
||||
<div class="mb-4">
|
||||
<h2 class="text-2xl font-bold text-base-content">Equipment Value Over Time</h2>
|
||||
<p class="text-sm text-base-content/60">
|
||||
Total equipment value for each team across all rounds
|
||||
</p>
|
||||
</div>
|
||||
{#if equipmentChartData}
|
||||
<LineChart data={equipmentChartData} height={350} />
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<!-- Round-by-Round Table -->
|
||||
<Card padding="none">
|
||||
<div class="p-6">
|
||||
<h2 class="text-2xl font-bold text-base-content">Round-by-Round Economy</h2>
|
||||
<p class="mt-1 text-sm text-base-content/60">
|
||||
Detailed breakdown of buy types and equipment values
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DataTable data={teamEconomy} columns={tableColumns} striped hoverable />
|
||||
</Card>
|
||||
|
||||
<!-- Buy Type Legend -->
|
||||
<Card padding="lg">
|
||||
<h3 class="mb-3 text-lg font-semibold text-base-content">Buy Type Classification</h3>
|
||||
<div class="flex flex-wrap gap-4 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant="error" size="sm">Eco</Badge>
|
||||
<span class="text-base-content/60">< $1,500 avg equipment</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant="default" size="sm">Semi-Eco</Badge>
|
||||
<span class="text-base-content/60">$1,500 - $2,500 avg equipment</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant="warning" size="sm">Force</Badge>
|
||||
<span class="text-base-content/60">$2,500 - $3,500 avg equipment</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant="success" size="sm">Full Buy</Badge>
|
||||
<span class="text-base-content/60">> $3,500 avg equipment</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ parent }) => {
|
||||
// Get all data from parent layout (already loaded upfront)
|
||||
const { match, rounds } = await parent();
|
||||
|
||||
return {
|
||||
match,
|
||||
roundsData: rounds,
|
||||
meta: {
|
||||
title: `${match.map || 'Match'} Economy - Match ${match.match_id} - CS2.WTF`
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Eye, Zap, Users } from 'lucide-svelte';
|
||||
import { Eye, Zap, Users, Skull, AlertTriangle, Lightbulb } from 'lucide-svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import DataTable from '$lib/components/data-display/DataTable.svelte';
|
||||
import type { PageData } from './$types';
|
||||
@@ -11,6 +11,8 @@
|
||||
const flashStats = (match.players || [])
|
||||
.map((player) => ({
|
||||
name: player.name,
|
||||
playerId: player.id,
|
||||
avatar: player.avatar,
|
||||
team_id: player.team_id,
|
||||
enemies_blinded: player.flash_total_enemy || 0,
|
||||
teammates_blinded: player.flash_total_team || 0,
|
||||
@@ -49,9 +51,16 @@
|
||||
const teamATotals = calcTeamTotals(teamAFlashStats);
|
||||
const teamBTotals = calcTeamTotals(teamBFlashStats);
|
||||
|
||||
// Hall of Shame - players who flashed more teammates than enemies
|
||||
const hallOfShame = flashStats
|
||||
.filter((p) => p.teammates_blinded > p.enemies_blinded && p.teammates_blinded > 0)
|
||||
.sort((a, b) => b.teammates_blinded - a.teammates_blinded);
|
||||
|
||||
// Table columns with fixed widths for consistency across multiple tables
|
||||
interface FlashStat {
|
||||
name: string;
|
||||
playerId: string;
|
||||
avatar: string;
|
||||
team_id: number;
|
||||
enemies_blinded: number;
|
||||
teammates_blinded: number;
|
||||
@@ -64,17 +73,31 @@
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ key: 'name' as const, label: 'Player', sortable: true, width: '200px' },
|
||||
{
|
||||
key: 'name' as const,
|
||||
label: 'Flashbang Criminal',
|
||||
sortable: true,
|
||||
width: '200px',
|
||||
render: (value: unknown, row: FlashStat) => {
|
||||
const teamClass = row.team_id === firstTeamId ? 'text-terrorist' : 'text-ct';
|
||||
const borderClass = row.team_id === firstTeamId ? 'border-terrorist/30' : 'border-ct/30';
|
||||
const bgClass = row.team_id === firstTeamId ? 'bg-terrorist/20' : 'bg-ct/20';
|
||||
const avatarHtml = row.avatar
|
||||
? `<img src="${row.avatar}" alt="${value}" class="h-8 w-8 rounded-full border ${borderClass}" />`
|
||||
: `<div class="flex h-8 w-8 items-center justify-center rounded-full ${bgClass} ${teamClass}"><svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path></svg></div>`;
|
||||
return `<a href="/player/${row.playerId}" class="flex items-center gap-3 font-medium hover:text-neon-blue transition-colors ${teamClass}">${avatarHtml}<span>${value}</span></a>`;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'enemies_blinded' as const,
|
||||
label: 'Enemies Blinded',
|
||||
label: 'Victims (Correct)',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
width: '150px'
|
||||
},
|
||||
{
|
||||
key: 'avg_blind_duration' as const,
|
||||
label: 'Avg Duration (s)',
|
||||
label: 'Avg Suffering (s)',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
format: (value: string | number | boolean, _row: FlashStat) => `${value as string}s`,
|
||||
@@ -82,21 +105,21 @@
|
||||
},
|
||||
{
|
||||
key: 'flash_assists' as const,
|
||||
label: 'Flash Assists',
|
||||
label: 'Actually Useful',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
width: '130px'
|
||||
},
|
||||
{
|
||||
key: 'teammates_blinded' as const,
|
||||
label: 'Team Flashed',
|
||||
label: 'Friendly Crimes',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
width: '130px'
|
||||
},
|
||||
{
|
||||
key: 'self_blinded' as const,
|
||||
label: 'Self Flashed',
|
||||
label: 'Self-Inflicted L',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
width: '130px'
|
||||
@@ -108,66 +131,157 @@
|
||||
<!-- Summary Stats -->
|
||||
<div class="grid gap-6 md:grid-cols-3">
|
||||
<Card padding="lg">
|
||||
<Eye class="mb-2 h-8 w-8 text-warning" />
|
||||
<div class="text-3xl font-bold text-base-content">
|
||||
{teamATotals.total_enemies_blinded + teamBTotals.total_enemies_blinded}
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-lg bg-neon-gold/20"
|
||||
style="box-shadow: 0 0 20px rgba(255, 215, 0, 0.2);"
|
||||
>
|
||||
<Eye class="h-6 w-6 text-neon-gold" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-3xl font-bold text-white">
|
||||
{teamATotals.total_enemies_blinded + teamBTotals.total_enemies_blinded}
|
||||
</div>
|
||||
<div class="text-sm text-white/60">Enemies Successfully Blinded</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-base-content/60">Total Enemies Blinded</div>
|
||||
<div class="mt-3 text-xs text-neon-green">The correct way to use flashes</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg">
|
||||
<Zap class="mb-2 h-8 w-8 text-success" />
|
||||
<div class="text-3xl font-bold text-base-content">
|
||||
{teamATotals.total_flash_assists + teamBTotals.total_flash_assists}
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-lg bg-neon-green/20"
|
||||
style="box-shadow: 0 0 20px rgba(0, 255, 136, 0.2);"
|
||||
>
|
||||
<Zap class="h-6 w-6 text-neon-green" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-3xl font-bold text-white">
|
||||
{teamATotals.total_flash_assists + teamBTotals.total_flash_assists}
|
||||
</div>
|
||||
<div class="text-sm text-white/60">Flash Assists</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-base-content/60">Total Flash Assists</div>
|
||||
<div class="mt-3 text-xs text-neon-blue">Teamwork makes the dream work</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg">
|
||||
<Users class="mb-2 h-8 w-8 text-info" />
|
||||
<div class="text-3xl font-bold text-base-content">
|
||||
{((teamATotals.total_enemy_blind_time + teamBTotals.total_enemy_blind_time) / 1000).toFixed(
|
||||
1
|
||||
)}s
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-lg bg-neon-red/20"
|
||||
style="box-shadow: 0 0 20px rgba(255, 51, 102, 0.2);"
|
||||
>
|
||||
<Users class="h-6 w-6 text-neon-red" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-3xl font-bold text-white">
|
||||
{flashStats.reduce((sum, p) => sum + p.teammates_blinded, 0)}
|
||||
</div>
|
||||
<div class="text-sm text-white/60">Teammates Betrayed</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-base-content/60">Total Enemy Blind Time</div>
|
||||
<div class="mt-3 text-xs text-neon-red">These players owe apologies</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Hall of Shame -->
|
||||
{#if hallOfShame.length > 0}
|
||||
<Card padding="lg" class="border-neon-red/30 bg-neon-red/5">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg bg-neon-red/20"
|
||||
style="box-shadow: 0 0 15px rgba(255, 51, 102, 0.3);"
|
||||
>
|
||||
<Skull class="h-5 w-5 text-neon-red" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-neon-red">Hall of Shame</h3>
|
||||
<p class="text-xs text-white/50">Players who flashed more teammates than enemies</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
{#each hallOfShame as shamePlayer, index}
|
||||
<div
|
||||
class="flex items-center justify-between rounded-lg border border-neon-red/20 bg-void/50 px-4 py-3"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-neon-red/20 text-sm font-bold text-neon-red"
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
<a
|
||||
href={`/player/${shamePlayer.playerId}`}
|
||||
class="flex items-center gap-3 font-medium text-white transition-colors hover:text-neon-blue"
|
||||
>
|
||||
{#if shamePlayer.avatar}
|
||||
<img
|
||||
src={shamePlayer.avatar}
|
||||
alt={shamePlayer.name}
|
||||
class="h-8 w-8 rounded-full border border-neon-red/30"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-neon-red/20 text-neon-red"
|
||||
>
|
||||
<Eye class="h-4 w-4" />
|
||||
</div>
|
||||
{/if}
|
||||
{shamePlayer.name}
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 text-sm">
|
||||
<div class="text-white/60">
|
||||
<span class="text-neon-green">{shamePlayer.enemies_blinded}</span> enemies
|
||||
</div>
|
||||
<div class="text-neon-red">
|
||||
<span class="font-bold">{shamePlayer.teammates_blinded}</span> teammates
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="mt-4 text-center text-xs italic text-white/40">
|
||||
Maybe consider switching to smokes?
|
||||
</p>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<!-- Team Comparison -->
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<Card padding="lg">
|
||||
<Card padding="lg" class="border-l-4 border-l-terrorist">
|
||||
<h3 class="mb-4 text-xl font-bold text-terrorist">Terrorists</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-base-content/60">Enemies Blinded</span>
|
||||
<span class="font-mono font-bold">{teamATotals.total_enemies_blinded}</span>
|
||||
<span class="text-sm text-white/50">Enemies Blinded</span>
|
||||
<span class="font-mono font-bold text-white">{teamATotals.total_enemies_blinded}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-base-content/60">Flash Assists</span>
|
||||
<span class="font-mono font-bold">{teamATotals.total_flash_assists}</span>
|
||||
<span class="text-sm text-white/50">Flash Assists</span>
|
||||
<span class="font-mono font-bold text-white">{teamATotals.total_flash_assists}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-base-content/60">Avg per Player</span>
|
||||
<span class="font-mono font-bold">{teamATotals.avg_per_player}</span>
|
||||
<span class="text-sm text-white/50">Avg per Player</span>
|
||||
<span class="font-mono font-bold text-white">{teamATotals.avg_per_player}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg">
|
||||
<Card padding="lg" class="border-l-4 border-l-ct">
|
||||
<h3 class="mb-4 text-xl font-bold text-ct">Counter-Terrorists</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-base-content/60">Enemies Blinded</span>
|
||||
<span class="font-mono font-bold">{teamBTotals.total_enemies_blinded}</span>
|
||||
<span class="text-sm text-white/50">Enemies Blinded</span>
|
||||
<span class="font-mono font-bold text-white">{teamBTotals.total_enemies_blinded}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-base-content/60">Flash Assists</span>
|
||||
<span class="font-mono font-bold">{teamBTotals.total_flash_assists}</span>
|
||||
<span class="text-sm text-white/50">Flash Assists</span>
|
||||
<span class="font-mono font-bold text-white">{teamBTotals.total_flash_assists}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-base-content/60">Avg per Player</span>
|
||||
<span class="font-mono font-bold">{teamBTotals.avg_per_player}</span>
|
||||
<span class="text-sm text-white/50">Avg per Player</span>
|
||||
<span class="font-mono font-bold text-white">{teamBTotals.avg_per_player}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -176,10 +290,18 @@
|
||||
<!-- Flash Effectiveness Leaderboard -->
|
||||
<Card padding="none">
|
||||
<div class="p-6">
|
||||
<h2 class="text-2xl font-bold text-base-content">Flash Effectiveness Leaderboard</h2>
|
||||
<p class="mt-1 text-sm text-base-content/60">
|
||||
Ranked by total enemies blinded during the match
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<AlertTriangle
|
||||
class="h-6 w-6 text-neon-gold"
|
||||
style="filter: drop-shadow(0 0 8px rgba(255, 215, 0, 0.5));"
|
||||
/>
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-white">Flash Hall of Fame (and Shame)</h2>
|
||||
<p class="mt-1 text-sm text-white/50">
|
||||
Ranked by enemies blinded. Teammates blinded is tracked for... scientific purposes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable data={flashStats} {columns} striped hoverable fixedLayout />
|
||||
@@ -187,7 +309,7 @@
|
||||
|
||||
<!-- Team A Details -->
|
||||
<Card padding="none">
|
||||
<div class="border-b border-base-300 bg-terrorist/5 p-6">
|
||||
<div class="border-b border-white/10 bg-terrorist/10 p-6">
|
||||
<h3 class="text-xl font-bold text-terrorist">Terrorists - Flash Stats</h3>
|
||||
</div>
|
||||
<DataTable data={teamAFlashStats} {columns} striped hoverable fixedLayout />
|
||||
@@ -195,27 +317,69 @@
|
||||
|
||||
<!-- Team B Details -->
|
||||
<Card padding="none">
|
||||
<div class="border-b border-base-300 bg-ct/5 p-6">
|
||||
<div class="border-b border-white/10 bg-ct/10 p-6">
|
||||
<h3 class="text-xl font-bold text-ct">Counter-Terrorists - Flash Stats</h3>
|
||||
</div>
|
||||
<DataTable data={teamBFlashStats} {columns} striped hoverable fixedLayout />
|
||||
</Card>
|
||||
|
||||
<!-- Info Box -->
|
||||
<Card padding="lg" variant="elevated">
|
||||
<div class="text-sm text-base-content/60">
|
||||
<p class="mb-2 font-semibold">Understanding Flash Stats:</p>
|
||||
<ul class="list-inside list-disc space-y-1">
|
||||
<li><strong>Enemies Blinded:</strong> Total number of enemy players flashed</li>
|
||||
<li>
|
||||
<strong>Avg Duration:</strong> Average time enemies were blinded per flash (in seconds)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Flash Assists:</strong> Enemies killed by teammates while blinded by your flash
|
||||
</li>
|
||||
<li><strong>Team Flashed:</strong> Number of times you accidentally flashed teammates</li>
|
||||
<li><strong>Self Flashed:</strong> Number of times you flashed yourself</li>
|
||||
</ul>
|
||||
<Card padding="lg" variant="elevated" class="border-neon-blue/20">
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-neon-blue/20"
|
||||
style="box-shadow: 0 0 15px rgba(0, 212, 255, 0.2);"
|
||||
>
|
||||
<Lightbulb class="h-5 w-5 text-neon-blue" />
|
||||
</div>
|
||||
<div class="text-sm text-white/70">
|
||||
<p class="mb-3 font-semibold text-white">
|
||||
Flash Stats Explained (For the Visually Challenged):
|
||||
</p>
|
||||
<ul class="space-y-2">
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-neon-green"></span>
|
||||
<span
|
||||
><strong class="text-neon-green">Victims (Correct):</strong> Enemies you blinded - the
|
||||
RIGHT people to flash</span
|
||||
>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-neon-gold"></span>
|
||||
<span
|
||||
><strong class="text-neon-gold">Avg Suffering:</strong> Average time enemies spent regretting
|
||||
their peek</span
|
||||
>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-neon-blue"></span>
|
||||
<span
|
||||
><strong class="text-neon-blue">Actually Useful:</strong> Enemies killed by teammates while
|
||||
your flash was doing its job</span
|
||||
>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-neon-red"></span>
|
||||
<span
|
||||
><strong class="text-neon-red">Friendly Crimes:</strong> Number of times you betrayed your
|
||||
own team - shame counter</span
|
||||
>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-neon-purple"></span>
|
||||
<span
|
||||
><strong class="text-neon-purple">Self-Inflicted L:</strong> Times you stared at your own
|
||||
flashbang like a moth to a flame</span
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
class="mt-4 rounded-lg border border-neon-gold/20 bg-neon-gold/5 px-3 py-2 text-xs italic text-neon-gold"
|
||||
>
|
||||
Pro tip: If your "Friendly Crimes" is higher than "Victims (Correct)", you might want to
|
||||
reconsider your flash lineups. Or your life choices.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
751
src/routes/match/[id]/rounds/+page.svelte
Normal file
751
src/routes/match/[id]/rounds/+page.svelte
Normal file
@@ -0,0 +1,751 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
DollarSign,
|
||||
Info,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
TrendingUp,
|
||||
Minus,
|
||||
BarChart3,
|
||||
Wallet
|
||||
} from 'lucide-svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import LineChart from '$lib/components/charts/LineChart.svelte';
|
||||
import type { LayoutData } from '../$types';
|
||||
import type { RoundDetail, RoundStats } from '$lib/types/RoundStats';
|
||||
import {
|
||||
getBuyType,
|
||||
getBuyTypeConfig,
|
||||
getEconomyHealth,
|
||||
getEconomyHealthConfig,
|
||||
isPistolRound as checkIsPistolRound,
|
||||
getHalftimeRound,
|
||||
formatMoney,
|
||||
PISTOL_ROUND_MONEY,
|
||||
BUY_TYPE_CONFIG,
|
||||
type BuyType
|
||||
} from '$lib/utils/economyUtils';
|
||||
|
||||
let { data }: { data: LayoutData } = $props();
|
||||
const { match, rounds } = data;
|
||||
|
||||
// Calculate halftime round based on max_rounds
|
||||
const halftimeRound = $derived(getHalftimeRound(match.max_rounds));
|
||||
const totalRounds = $derived(rounds?.rounds?.length ?? 0);
|
||||
|
||||
// Current selected round
|
||||
let selectedRound = $state(1);
|
||||
|
||||
// Get current round data
|
||||
const currentRoundData = $derived(
|
||||
rounds?.rounds?.find((r: RoundDetail) => r.round === selectedRound)
|
||||
);
|
||||
|
||||
// Determine if we're in second half (teams have swapped sides)
|
||||
const isSecondHalf = $derived(selectedRound > halftimeRound);
|
||||
|
||||
// In first half: team_id 1 = T, team_id 2 = CT
|
||||
// In second half: team_id 1 = CT, team_id 2 = T (swapped!)
|
||||
const terroristTeamId = $derived(isSecondHalf ? 2 : 1);
|
||||
const ctTeamId = $derived(isSecondHalf ? 1 : 2);
|
||||
|
||||
// Pistol round detection using unified utility
|
||||
const isPistolRound = $derived(checkIsPistolRound(selectedRound, halftimeRound));
|
||||
|
||||
// Helper to get player bank (override for pistol rounds)
|
||||
const getPlayerBank = (player: RoundStats) => (isPistolRound ? PISTOL_ROUND_MONEY : player.bank);
|
||||
|
||||
// Group players by team for current round (accounting for side swap)
|
||||
const teamAPlayers = $derived(
|
||||
currentRoundData?.players?.filter((p: RoundStats) => p.team_id === terroristTeamId) ?? []
|
||||
);
|
||||
const teamBPlayers = $derived(
|
||||
currentRoundData?.players?.filter((p: RoundStats) => p.team_id === ctTeamId) ?? []
|
||||
);
|
||||
|
||||
// Calculate team economy totals (with pistol round override)
|
||||
const calcTeamEconomy = (players: RoundStats[]) => {
|
||||
const totalBank = isPistolRound
|
||||
? players.length * PISTOL_ROUND_MONEY
|
||||
: players.reduce((sum, p) => sum + p.bank, 0);
|
||||
const totalEquipment = players.reduce((sum, p) => sum + p.equipment, 0);
|
||||
const totalSpent = players.reduce((sum, p) => sum + p.spent, 0);
|
||||
const avgBank = players.length > 0 ? totalBank / players.length : 0;
|
||||
const avgEquipment = players.length > 0 ? totalEquipment / players.length : 0;
|
||||
return { totalBank, totalEquipment, totalSpent, avgBank, avgEquipment };
|
||||
};
|
||||
|
||||
const teamAEconomy = $derived(calcTeamEconomy(teamAPlayers));
|
||||
const teamBEconomy = $derived(calcTeamEconomy(teamBPlayers));
|
||||
|
||||
// Get buy types using unified utilities with team-aware thresholds
|
||||
const teamABuyType = $derived(getBuyType(teamAEconomy.avgEquipment, 'T', isPistolRound));
|
||||
const teamBBuyType = $derived(getBuyType(teamBEconomy.avgEquipment, 'CT', isPistolRound));
|
||||
|
||||
const teamABuyConfig = $derived(getBuyTypeConfig(teamABuyType));
|
||||
const teamBBuyConfig = $derived(getBuyTypeConfig(teamBBuyType));
|
||||
|
||||
// Get economy health using unified utilities
|
||||
const teamAHealth = $derived(getEconomyHealth(teamAEconomy.avgBank));
|
||||
const teamBHealth = $derived(getEconomyHealth(teamBEconomy.avgBank));
|
||||
|
||||
const teamAHealthConfig = $derived(getEconomyHealthConfig(teamAHealth));
|
||||
const teamBHealthConfig = $derived(getEconomyHealthConfig(teamBHealth));
|
||||
|
||||
// Navigate rounds
|
||||
const nextRound = () => {
|
||||
if (selectedRound < totalRounds) selectedRound++;
|
||||
};
|
||||
const prevRound = () => {
|
||||
if (selectedRound > 1) selectedRound--;
|
||||
};
|
||||
|
||||
// Check if we have round data
|
||||
const hasRoundData = $derived(rounds && rounds.rounds && rounds.rounds.length > 0);
|
||||
|
||||
// Economy advantage calculation
|
||||
const economyAdvantage = $derived(
|
||||
teamAEconomy.totalBank +
|
||||
teamAEconomy.totalEquipment -
|
||||
(teamBEconomy.totalBank + teamBEconomy.totalEquipment)
|
||||
);
|
||||
|
||||
// Calculate economy data for all rounds (for charts)
|
||||
interface RoundEconomyData {
|
||||
round: number;
|
||||
t_bank: number;
|
||||
ct_bank: number;
|
||||
t_equipment: number;
|
||||
ct_equipment: number;
|
||||
t_buyType: BuyType;
|
||||
ct_buyType: BuyType;
|
||||
economyAdvantage: number;
|
||||
}
|
||||
|
||||
const allRoundsEconomy = $derived.by(() => {
|
||||
if (!rounds?.rounds) return [];
|
||||
|
||||
return rounds.rounds.map((roundData: RoundDetail) => {
|
||||
const roundNum = roundData.round;
|
||||
const roundIsSecondHalf = roundNum > halftimeRound;
|
||||
const tTeamId = roundIsSecondHalf ? 2 : 1;
|
||||
const ctTeamIdForRound = roundIsSecondHalf ? 1 : 2;
|
||||
|
||||
const tPlayers = roundData.players?.filter((p: RoundStats) => p.team_id === tTeamId) ?? [];
|
||||
const ctPlayers =
|
||||
roundData.players?.filter((p: RoundStats) => p.team_id === ctTeamIdForRound) ?? [];
|
||||
|
||||
const roundIsPistol = checkIsPistolRound(roundNum, halftimeRound);
|
||||
|
||||
const t_bank = roundIsPistol
|
||||
? tPlayers.length * PISTOL_ROUND_MONEY
|
||||
: tPlayers.reduce((sum, p) => sum + p.bank, 0);
|
||||
const ct_bank = roundIsPistol
|
||||
? ctPlayers.length * PISTOL_ROUND_MONEY
|
||||
: ctPlayers.reduce((sum, p) => sum + p.bank, 0);
|
||||
const t_equipment = tPlayers.reduce((sum, p) => sum + p.equipment, 0);
|
||||
const ct_equipment = ctPlayers.reduce((sum, p) => sum + p.equipment, 0);
|
||||
|
||||
const avgT_equipment = tPlayers.length > 0 ? t_equipment / tPlayers.length : 0;
|
||||
const avgCT_equipment = ctPlayers.length > 0 ? ct_equipment / ctPlayers.length : 0;
|
||||
|
||||
// Calculate economy advantage (positive = T advantage in first half, CT advantage in second half)
|
||||
const t_total = t_bank + t_equipment;
|
||||
const ct_total = ct_bank + ct_equipment;
|
||||
const economyAdv = roundIsSecondHalf ? ct_total - t_total : t_total - ct_total;
|
||||
|
||||
return {
|
||||
round: roundNum,
|
||||
t_bank,
|
||||
ct_bank,
|
||||
t_equipment,
|
||||
ct_equipment,
|
||||
t_buyType: getBuyType(avgT_equipment, 'T', roundIsPistol),
|
||||
ct_buyType: getBuyType(avgCT_equipment, 'CT', roundIsPistol),
|
||||
economyAdvantage: economyAdv
|
||||
} as RoundEconomyData;
|
||||
});
|
||||
});
|
||||
|
||||
// Chart data for economy flow
|
||||
const economyFlowChartData = $derived.by(() => {
|
||||
if (!allRoundsEconomy.length) return null;
|
||||
|
||||
return {
|
||||
labels: allRoundsEconomy.map((r) => `${r.round}`),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Advantage',
|
||||
data: allRoundsEconomy.map((r) => (r.economyAdvantage > 0 ? r.economyAdvantage : 0)),
|
||||
borderColor: '#5e98d9',
|
||||
backgroundColor: 'rgba(94, 152, 217, 0.6)',
|
||||
fill: true,
|
||||
tension: 0.4
|
||||
},
|
||||
{
|
||||
label: 'Disadvantage',
|
||||
data: allRoundsEconomy.map((r) => (r.economyAdvantage < 0 ? r.economyAdvantage : 0)),
|
||||
borderColor: '#d4a74a',
|
||||
backgroundColor: 'rgba(212, 167, 74, 0.6)',
|
||||
fill: true,
|
||||
tension: 0.4
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
// Chart data for equipment value over time
|
||||
const equipmentChartData = $derived.by(() => {
|
||||
if (!allRoundsEconomy.length) return null;
|
||||
|
||||
return {
|
||||
labels: allRoundsEconomy.map((r) => `R${r.round}`),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Terrorists',
|
||||
data: allRoundsEconomy.map((r) => r.t_equipment),
|
||||
borderColor: '#d4a74a',
|
||||
backgroundColor: 'rgba(212, 167, 74, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4
|
||||
},
|
||||
{
|
||||
label: 'Counter-Terrorists',
|
||||
data: allRoundsEconomy.map((r) => r.ct_equipment),
|
||||
borderColor: '#5e98d9',
|
||||
backgroundColor: 'rgba(94, 152, 217, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
// Summary stats
|
||||
const fullBuyCount = $derived({
|
||||
t: allRoundsEconomy.filter((r) => r.t_buyType === 'full').length,
|
||||
ct: allRoundsEconomy.filter((r) => r.ct_buyType === 'full').length
|
||||
});
|
||||
const ecoCount = $derived({
|
||||
t: allRoundsEconomy.filter((r) => r.t_buyType === 'eco').length,
|
||||
ct: allRoundsEconomy.filter((r) => r.ct_buyType === 'eco').length
|
||||
});
|
||||
|
||||
// Toggle for showing charts vs details
|
||||
let showCharts = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
{#if hasRoundData}
|
||||
<!-- View Toggle -->
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="inline-flex rounded-lg border border-white/10 bg-void-light p-1">
|
||||
<button
|
||||
onclick={() => (showCharts = false)}
|
||||
class="flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-all {!showCharts
|
||||
? 'bg-neon-blue/20 text-neon-blue'
|
||||
: 'text-white/60 hover:text-white'}"
|
||||
>
|
||||
<DollarSign class="h-4 w-4" />
|
||||
Round Details
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showCharts = true)}
|
||||
class="flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-all {showCharts
|
||||
? 'bg-neon-blue/20 text-neon-blue'
|
||||
: 'text-white/60 hover:text-white'}"
|
||||
>
|
||||
<BarChart3 class="h-4 w-4" />
|
||||
Economy Overview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showCharts}
|
||||
<!-- Economy Overview Charts -->
|
||||
<div class="space-y-6">
|
||||
<!-- Summary Stats -->
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<Card padding="lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-neon-blue/20">
|
||||
<BarChart3 class="h-5 w-5 text-neon-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-white/50">Total Rounds</div>
|
||||
<div class="text-3xl font-bold text-white">{totalRounds}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg" class="border-l-4 border-l-terrorist">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-terrorist/20">
|
||||
<TrendingUp class="h-5 w-5 text-terrorist" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-white/50">T Full Buys</div>
|
||||
<div class="text-3xl font-bold text-white">{fullBuyCount.t}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-red-400">{ecoCount.t} eco rounds</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg" class="border-l-4 border-l-ct">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-ct/20">
|
||||
<TrendingUp class="h-5 w-5 text-ct" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-white/50">CT Full Buys</div>
|
||||
<div class="text-3xl font-bold text-white">{fullBuyCount.ct}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-red-400">{ecoCount.ct} eco rounds</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Economy Flow Chart -->
|
||||
{#if economyFlowChartData}
|
||||
<Card padding="lg">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-neon-green/20">
|
||||
<DollarSign class="h-5 w-5 text-neon-green" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-white">Economy Flow</h2>
|
||||
<p class="text-sm text-white/50">Net-worth differential over time</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<LineChart
|
||||
data={economyFlowChartData}
|
||||
height={350}
|
||||
options={{
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: 'rgba(255, 255, 255, 0.05)' },
|
||||
ticks: { color: 'rgba(255, 255, 255, 0.5)' }
|
||||
},
|
||||
x: {
|
||||
grid: { color: 'rgba(255, 255, 255, 0.05)' },
|
||||
ticks: { color: 'rgba(255, 255, 255, 0.5)' }
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: { labels: { color: 'rgba(255, 255, 255, 0.7)' } }
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if totalRounds > halftimeRound}
|
||||
<div
|
||||
class="pointer-events-none absolute top-0 flex h-full items-center"
|
||||
style="left: {(halftimeRound / totalRounds) * 100}%"
|
||||
>
|
||||
<div class="h-full w-px bg-neon-purple/40"></div>
|
||||
<div
|
||||
class="absolute -top-1 left-1/2 -translate-x-1/2 rounded-md border border-neon-purple/30 bg-void-light px-2 py-1 text-xs font-medium text-neon-purple"
|
||||
>
|
||||
Half
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<!-- Equipment Value Chart -->
|
||||
{#if equipmentChartData}
|
||||
<Card padding="lg">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-neon-purple/20">
|
||||
<Wallet class="h-5 w-5 text-neon-purple" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-white">Equipment Value</h2>
|
||||
<p class="text-sm text-white/50">Total equipment value per team across rounds</p>
|
||||
</div>
|
||||
</div>
|
||||
<LineChart
|
||||
data={equipmentChartData}
|
||||
height={300}
|
||||
options={{
|
||||
scales: {
|
||||
y: {
|
||||
grid: { color: 'rgba(255, 255, 255, 0.05)' },
|
||||
ticks: { color: 'rgba(255, 255, 255, 0.5)' }
|
||||
},
|
||||
x: {
|
||||
grid: { color: 'rgba(255, 255, 255, 0.05)' },
|
||||
ticks: { color: 'rgba(255, 255, 255, 0.5)' }
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: { labels: { color: 'rgba(255, 255, 255, 0.7)' } }
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Round Details View -->
|
||||
<!-- Round Selector Header -->
|
||||
<Card padding="lg">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-white">Round Economy</h2>
|
||||
<p class="mt-1 text-sm text-white/50">
|
||||
Track team economy and buy patterns throughout the match
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={prevRound}
|
||||
disabled={selectedRound <= 1}
|
||||
class="rounded-lg border border-white/10 bg-white/5 p-2 transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
<ChevronLeft class="h-5 w-5 text-white/70" />
|
||||
</button>
|
||||
<div class="min-w-[100px] text-center">
|
||||
<span class="font-mono text-xl font-bold text-white">Round {selectedRound}</span>
|
||||
<span class="text-white/40"> / {totalRounds}</span>
|
||||
</div>
|
||||
<button
|
||||
onclick={nextRound}
|
||||
disabled={selectedRound >= totalRounds}
|
||||
class="rounded-lg border border-white/10 bg-white/5 p-2 transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
<ChevronRight class="h-5 w-5 text-white/70" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Economy Advantage Indicator -->
|
||||
<div class="flex items-center justify-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if economyAdvantage > 1000}
|
||||
<TrendingUp class="h-4 w-4 text-terrorist" />
|
||||
<span class="text-sm font-medium text-terrorist"
|
||||
>T Advantage: {formatMoney(Math.abs(economyAdvantage))}</span
|
||||
>
|
||||
{:else if economyAdvantage < -1000}
|
||||
<TrendingUp class="h-4 w-4 text-ct" />
|
||||
<span class="text-sm font-medium text-ct"
|
||||
>CT Advantage: {formatMoney(Math.abs(economyAdvantage))}</span
|
||||
>
|
||||
{:else}
|
||||
<Minus class="h-4 w-4 text-white/50" />
|
||||
<span class="text-sm font-medium text-white/50">Economy Even</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Round Timeline -->
|
||||
<div class="flex flex-wrap items-center justify-center gap-1">
|
||||
{#each { length: totalRounds } as _, index}
|
||||
{@const roundNum = index + 1}
|
||||
{@const isHalftime = roundNum === halftimeRound && totalRounds > halftimeRound}
|
||||
{@const isSelected = roundNum === selectedRound}
|
||||
{@const isFirstHalf = roundNum <= halftimeRound}
|
||||
{@const roundIsPistol = checkIsPistolRound(roundNum, halftimeRound)}
|
||||
|
||||
<button
|
||||
onclick={() => (selectedRound = roundNum)}
|
||||
class="relative flex h-8 w-8 items-center justify-center rounded-md text-xs font-bold transition-all
|
||||
{isSelected
|
||||
? 'bg-neon-blue text-white ring-2 ring-neon-blue/50'
|
||||
: roundIsPistol
|
||||
? 'bg-neon-purple/30 text-neon-purple hover:bg-neon-purple/40'
|
||||
: isFirstHalf
|
||||
? 'bg-terrorist/20 text-terrorist hover:bg-terrorist/30'
|
||||
: 'bg-ct/20 text-ct hover:bg-ct/30'}"
|
||||
title="Round {roundNum}{roundIsPistol ? ' (Pistol)' : ''}"
|
||||
>
|
||||
{roundNum}
|
||||
</button>
|
||||
|
||||
{#if isHalftime}
|
||||
<div
|
||||
class="mx-2 flex items-center gap-1 rounded-full bg-neon-purple/20 px-3 py-1 text-xs font-medium text-neon-purple"
|
||||
>
|
||||
<span>HT</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Team Economy Cards -->
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<!-- Team A (Terrorists) -->
|
||||
<Card padding="none" class="overflow-hidden border-l-4 border-l-terrorist">
|
||||
<!-- Team Header -->
|
||||
<div class="flex items-center justify-between bg-terrorist/10 px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-xl font-bold text-terrorist">Terrorists</h3>
|
||||
<span
|
||||
class={`rounded-full px-3 py-1 text-xs font-semibold ${teamABuyConfig.bgColor} ${teamABuyConfig.color}`}
|
||||
>
|
||||
{teamABuyConfig.label}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<span class={`text-xs ${teamAHealthConfig.color}`}>{teamAHealthConfig.label}</span>
|
||||
</div>
|
||||
<div class="font-mono text-lg font-bold text-neon-green">
|
||||
{formatMoney(teamAEconomy.totalBank)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Team Stats Summary -->
|
||||
<div class="grid grid-cols-3 gap-4 border-b border-white/10 bg-void/30 px-6 py-3">
|
||||
<div class="text-center">
|
||||
<div class="text-xs text-white/40">Total Bank</div>
|
||||
<div class="font-mono text-sm font-semibold text-neon-green">
|
||||
{formatMoney(teamAEconomy.totalBank)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-xs text-white/40">Equipment</div>
|
||||
<div class="font-mono text-sm font-semibold text-white">
|
||||
{formatMoney(teamAEconomy.totalEquipment)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-xs text-white/40">Spent</div>
|
||||
<div class="font-mono text-sm font-semibold text-neon-red">
|
||||
{formatMoney(teamAEconomy.totalSpent)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Player List -->
|
||||
<div class="divide-y divide-white/5">
|
||||
{#each teamAPlayers as player}
|
||||
<a
|
||||
href="/player/{player.player_id}"
|
||||
class="flex items-center gap-4 px-6 py-3 transition-colors hover:bg-white/5"
|
||||
>
|
||||
<!-- Avatar -->
|
||||
{#if player.avatar}
|
||||
<img
|
||||
src={player.avatar}
|
||||
alt={player.player_name || 'Player'}
|
||||
class="h-10 w-10 rounded-full border border-terrorist/30"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-terrorist/20 text-terrorist"
|
||||
>
|
||||
<DollarSign class="h-5 w-5" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Player Info -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate font-medium text-white">
|
||||
{player.player_name || `Player ${player.player_id}`}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-white/50">
|
||||
<span
|
||||
>Bank: <span class="text-neon-green"
|
||||
>{formatMoney(getPlayerBank(player))}</span
|
||||
></span
|
||||
>
|
||||
<span
|
||||
>Equip: <span class="text-white/70">{formatMoney(player.equipment)}</span
|
||||
></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spent -->
|
||||
<div class="text-right">
|
||||
<div class="text-xs text-white/40">Spent</div>
|
||||
<div
|
||||
class="font-mono text-sm font-semibold {player.spent > 0
|
||||
? 'text-neon-red'
|
||||
: 'text-white/30'}"
|
||||
>
|
||||
{player.spent > 0 ? `-${formatMoney(player.spent)}` : '$0'}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
{#if teamAPlayers.length === 0}
|
||||
<div class="px-6 py-8 text-center text-white/40">No player data available</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Team B (Counter-Terrorists) -->
|
||||
<Card padding="none" class="overflow-hidden border-l-4 border-l-ct">
|
||||
<!-- Team Header -->
|
||||
<div class="flex items-center justify-between bg-ct/10 px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-xl font-bold text-ct">Counter-Terrorists</h3>
|
||||
<span
|
||||
class={`rounded-full px-3 py-1 text-xs font-semibold ${teamBBuyConfig.bgColor} ${teamBBuyConfig.color}`}
|
||||
>
|
||||
{teamBBuyConfig.label}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<span class={`text-xs ${teamBHealthConfig.color}`}>{teamBHealthConfig.label}</span>
|
||||
</div>
|
||||
<div class="font-mono text-lg font-bold text-neon-green">
|
||||
{formatMoney(teamBEconomy.totalBank)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Team Stats Summary -->
|
||||
<div class="grid grid-cols-3 gap-4 border-b border-white/10 bg-void/30 px-6 py-3">
|
||||
<div class="text-center">
|
||||
<div class="text-xs text-white/40">Total Bank</div>
|
||||
<div class="font-mono text-sm font-semibold text-neon-green">
|
||||
{formatMoney(teamBEconomy.totalBank)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-xs text-white/40">Equipment</div>
|
||||
<div class="font-mono text-sm font-semibold text-white">
|
||||
{formatMoney(teamBEconomy.totalEquipment)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-xs text-white/40">Spent</div>
|
||||
<div class="font-mono text-sm font-semibold text-neon-red">
|
||||
{formatMoney(teamBEconomy.totalSpent)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Player List -->
|
||||
<div class="divide-y divide-white/5">
|
||||
{#each teamBPlayers as player}
|
||||
<a
|
||||
href="/player/{player.player_id}"
|
||||
class="flex items-center gap-4 px-6 py-3 transition-colors hover:bg-white/5"
|
||||
>
|
||||
<!-- Avatar -->
|
||||
{#if player.avatar}
|
||||
<img
|
||||
src={player.avatar}
|
||||
alt={player.player_name || 'Player'}
|
||||
class="h-10 w-10 rounded-full border border-ct/30"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-ct/20 text-ct"
|
||||
>
|
||||
<DollarSign class="h-5 w-5" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Player Info -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate font-medium text-white">
|
||||
{player.player_name || `Player ${player.player_id}`}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-white/50">
|
||||
<span
|
||||
>Bank: <span class="text-neon-green"
|
||||
>{formatMoney(getPlayerBank(player))}</span
|
||||
></span
|
||||
>
|
||||
<span
|
||||
>Equip: <span class="text-white/70">{formatMoney(player.equipment)}</span
|
||||
></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spent -->
|
||||
<div class="text-right">
|
||||
<div class="text-xs text-white/40">Spent</div>
|
||||
<div
|
||||
class="font-mono text-sm font-semibold {player.spent > 0
|
||||
? 'text-neon-red'
|
||||
: 'text-white/30'}"
|
||||
>
|
||||
{player.spent > 0 ? `-${formatMoney(player.spent)}` : '$0'}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
{#if teamBPlayers.length === 0}
|
||||
<div class="px-6 py-8 text-center text-white/40">No player data available</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Economy Legend -->
|
||||
<Card padding="md">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="text-center text-xs text-white/40">
|
||||
Buy Type Classification (based on avg equipment per player)
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-center gap-6 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class={`rounded-full px-2 py-0.5 text-xs font-semibold ${BUY_TYPE_CONFIG.pistol.bgColor} ${BUY_TYPE_CONFIG.pistol.color}`}
|
||||
>Pistol</span
|
||||
>
|
||||
<span class="text-white/50">Round 1 & {halftimeRound + 1}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class={`rounded-full px-2 py-0.5 text-xs font-semibold ${BUY_TYPE_CONFIG.eco.bgColor} ${BUY_TYPE_CONFIG.eco.color}`}
|
||||
>Eco</span
|
||||
>
|
||||
<span class="text-white/50">< $1,500</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class={`rounded-full px-2 py-0.5 text-xs font-semibold ${BUY_TYPE_CONFIG.force.bgColor} ${BUY_TYPE_CONFIG.force.color}`}
|
||||
>Force</span
|
||||
>
|
||||
<span class="text-white/50">$1,500 - $3,500 (T) / $4,000 (CT)</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class={`rounded-full px-2 py-0.5 text-xs font-semibold ${BUY_TYPE_CONFIG.full.bgColor} ${BUY_TYPE_CONFIG.full.color}`}
|
||||
>Full Buy</span
|
||||
>
|
||||
<span class="text-white/50">> $3,500 (T) / $4,000 (CT)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- No Data State -->
|
||||
<Card padding="lg">
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div class="mb-4 rounded-full bg-white/5 p-4">
|
||||
<Info class="h-12 w-12 text-white/30" />
|
||||
</div>
|
||||
<h3 class="mb-2 text-xl font-semibold text-white">Round Data Not Available</h3>
|
||||
<p class="max-w-md text-white/60">
|
||||
{#if !match.demo_parsed}
|
||||
The demo for this match is still being processed. Round-by-round economy data will be
|
||||
available once parsing is complete.
|
||||
{:else}
|
||||
Round economy data is not available for this match.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Crosshair, Target, AlertCircle, TrendingUp } from 'lucide-svelte';
|
||||
import { Crosshair, Target, AlertCircle, TrendingUp, Swords } from 'lucide-svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import DataTable from '$lib/components/data-display/DataTable.svelte';
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
// Get player names map from match data
|
||||
const playerNames = new Map(
|
||||
match.players?.map((p) => [p.id, { name: p.name, team_id: p.team_id }]) || []
|
||||
match.players?.map((p) => [p.id, { name: p.name, team_id: p.team_id, avatar: p.avatar }]) || []
|
||||
);
|
||||
|
||||
// Get unique team IDs
|
||||
@@ -40,6 +40,7 @@
|
||||
return {
|
||||
player_id: pw.player_id,
|
||||
player_name: playerInfo?.name || 'Unknown',
|
||||
player_avatar: playerInfo?.avatar || '',
|
||||
team_id: playerInfo?.team_id || 2,
|
||||
total_kills: totalKills,
|
||||
total_damage: totalDamage,
|
||||
@@ -64,29 +65,34 @@
|
||||
render: (value: unknown, row: PlayerWeapon) => {
|
||||
const strValue = value !== undefined ? String(value) : '';
|
||||
const teamClass = row.team_id === firstTeamId ? 'text-terrorist' : 'text-ct';
|
||||
return `<a href="/player/${row.player_id}" class="font-medium hover:underline ${teamClass}">${strValue}</a>`;
|
||||
const borderClass = row.team_id === firstTeamId ? 'border-terrorist/30' : 'border-ct/30';
|
||||
const bgClass = row.team_id === firstTeamId ? 'bg-terrorist/20' : 'bg-ct/20';
|
||||
const avatarHtml = row.player_avatar
|
||||
? `<img src="${row.player_avatar}" alt="${strValue}" class="h-8 w-8 rounded-full border ${borderClass}" />`
|
||||
: `<div class="flex h-8 w-8 items-center justify-center rounded-full ${bgClass} ${teamClass}"><svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path></svg></div>`;
|
||||
return `<a href="/player/${row.player_id}" class="flex items-center gap-3 font-medium hover:text-neon-blue transition-colors ${teamClass}">${avatarHtml}<span>${strValue}</span></a>`;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'top_weapon' as const,
|
||||
label: 'Top Weapon',
|
||||
label: 'Weapon of Choice',
|
||||
sortable: true,
|
||||
align: 'left' as const,
|
||||
class: 'font-medium'
|
||||
class: 'font-medium text-white'
|
||||
},
|
||||
{
|
||||
key: 'total_kills' as const,
|
||||
label: 'Total Kills',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
class: 'font-mono font-semibold'
|
||||
class: 'font-mono font-semibold text-white'
|
||||
},
|
||||
{
|
||||
key: 'total_damage' as const,
|
||||
label: 'Total Damage',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
class: 'font-mono',
|
||||
class: 'font-mono text-white/80',
|
||||
format: (v: unknown) => (v !== undefined ? (v as number).toLocaleString() : '0')
|
||||
},
|
||||
{
|
||||
@@ -94,7 +100,7 @@
|
||||
label: 'Total Hits',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
class: 'font-mono'
|
||||
class: 'font-mono text-white/80'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -111,7 +117,7 @@
|
||||
existing.kills += ws.kills;
|
||||
existing.damage += ws.damage;
|
||||
existing.hits += ws.hits;
|
||||
existing.headshot_pct = ws.headshot_pct || 0; // Use latest
|
||||
existing.headshot_pct = ws.headshot_pct || 0;
|
||||
} else {
|
||||
weaponAggregates.set(ws.weapon_name, {
|
||||
kills: ws.kills,
|
||||
@@ -129,19 +135,21 @@
|
||||
.sort((a, b) => b.kills - a.kills)
|
||||
.slice(0, 10);
|
||||
|
||||
// Weapon usage chart data
|
||||
// Weapon usage chart data with neon colors
|
||||
const weaponUsageData = {
|
||||
labels: topWeapons.map((w) => w.name),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Kills',
|
||||
data: topWeapons.map((w) => w.kills),
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.8)'
|
||||
backgroundColor: 'rgba(0, 212, 255, 0.7)',
|
||||
borderColor: '#00d4ff',
|
||||
borderWidth: 1
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Hit group distribution (aggregate across all weapons)
|
||||
// Hit group distribution with neon colors
|
||||
const hitGroupTotals = {
|
||||
head: 0,
|
||||
chest: 0,
|
||||
@@ -177,11 +185,11 @@
|
||||
hitGroupTotals.left_leg + hitGroupTotals.right_leg
|
||||
],
|
||||
backgroundColor: [
|
||||
'rgba(239, 68, 68, 0.8)', // Red for head
|
||||
'rgba(59, 130, 246, 0.8)', // Blue for chest
|
||||
'rgba(249, 115, 22, 0.8)', // Orange for stomach
|
||||
'rgba(34, 197, 94, 0.8)', // Green for arms
|
||||
'rgba(168, 85, 247, 0.8)' // Purple for legs
|
||||
'rgba(255, 51, 102, 0.8)', // neon-red for head
|
||||
'rgba(0, 212, 255, 0.8)', // neon-blue for chest
|
||||
'rgba(255, 215, 0, 0.8)', // neon-gold for stomach
|
||||
'rgba(0, 255, 136, 0.8)', // neon-green for arms
|
||||
'rgba(139, 92, 246, 0.8)' // neon-purple for legs
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -189,15 +197,20 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Match Weapons - CS2.WTF</title>
|
||||
<title>Match Weapons - teamflash.rip</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if !hasWeaponsData}
|
||||
<Card padding="lg">
|
||||
<div class="text-center">
|
||||
<AlertCircle class="mx-auto mb-4 h-16 w-16 text-warning" />
|
||||
<h2 class="mb-2 text-2xl font-bold text-base-content">No Weapons Data Available</h2>
|
||||
<p class="mb-4 text-base-content/60">Weapon statistics are not available for this match.</p>
|
||||
<AlertCircle
|
||||
class="mx-auto mb-4 h-16 w-16 text-neon-gold"
|
||||
style="filter: drop-shadow(0 0 15px rgba(255, 215, 0, 0.4));"
|
||||
/>
|
||||
<h2 class="mb-2 text-2xl font-bold text-white">No Weapons Data Available</h2>
|
||||
<p class="mb-4 text-white/60">
|
||||
Weapon statistics are not available for this match. The armory remains sealed.
|
||||
</p>
|
||||
<Badge variant="warning" size="lg">Weapons data unavailable</Badge>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -206,53 +219,125 @@
|
||||
<!-- Top Stats Summary -->
|
||||
<div class="grid gap-6 md:grid-cols-3">
|
||||
<Card padding="lg">
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<Crosshair class="h-5 w-5 text-primary" />
|
||||
<h3 class="font-semibold text-base-content">Total Kills</h3>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg bg-neon-blue/20"
|
||||
style="box-shadow: 0 0 15px rgba(0, 212, 255, 0.2);"
|
||||
>
|
||||
<Crosshair class="h-5 w-5 text-neon-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-white/50">Total Kills</div>
|
||||
<div
|
||||
class="font-mono text-3xl font-bold text-neon-blue"
|
||||
style="text-shadow: 0 0 15px rgba(0, 212, 255, 0.4);"
|
||||
>
|
||||
{topWeapons.reduce((sum, w) => sum + w.kills, 0)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="font-mono text-3xl font-bold text-primary">
|
||||
{topWeapons.reduce((sum, w) => sum + w.kills, 0)}
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-base-content/60">Across all weapons</div>
|
||||
<div class="mt-2 text-xs text-white/40">Across all weapons</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg">
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<Target class="h-5 w-5 text-success" />
|
||||
<h3 class="font-semibold text-base-content">Total Damage</h3>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg bg-neon-green/20"
|
||||
style="box-shadow: 0 0 15px rgba(0, 255, 136, 0.2);"
|
||||
>
|
||||
<Target class="h-5 w-5 text-neon-green" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-white/50">Total Damage</div>
|
||||
<div
|
||||
class="font-mono text-3xl font-bold text-neon-green"
|
||||
style="text-shadow: 0 0 15px rgba(0, 255, 136, 0.4);"
|
||||
>
|
||||
{topWeapons.reduce((sum, w) => sum + w.damage, 0).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="font-mono text-3xl font-bold text-success">
|
||||
{topWeapons.reduce((sum, w) => sum + w.damage, 0).toLocaleString()}
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-base-content/60">Across all weapons</div>
|
||||
<div class="mt-2 text-xs text-white/40">Across all weapons</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg">
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<TrendingUp class="h-5 w-5 text-warning" />
|
||||
<h3 class="font-semibold text-base-content">Total Hits</h3>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg bg-neon-gold/20"
|
||||
style="box-shadow: 0 0 15px rgba(255, 215, 0, 0.2);"
|
||||
>
|
||||
<TrendingUp class="h-5 w-5 text-neon-gold" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-white/50">Total Hits</div>
|
||||
<div
|
||||
class="font-mono text-3xl font-bold text-neon-gold"
|
||||
style="text-shadow: 0 0 15px rgba(255, 215, 0, 0.4);"
|
||||
>
|
||||
{topWeapons.reduce((sum, w) => sum + w.hits, 0).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="font-mono text-3xl font-bold text-warning">
|
||||
{topWeapons.reduce((sum, w) => sum + w.hits, 0).toLocaleString()}
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-base-content/60">Across all weapons</div>
|
||||
<div class="mt-2 text-xs text-white/40">Across all weapons</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Top Weapons Chart -->
|
||||
<Card padding="lg">
|
||||
<div class="mb-4">
|
||||
<h2 class="text-2xl font-bold text-base-content">Most Used Weapons</h2>
|
||||
<p class="text-sm text-base-content/60">Weapons ranked by total kills</p>
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg bg-neon-purple/20"
|
||||
style="box-shadow: 0 0 15px rgba(139, 92, 246, 0.2);"
|
||||
>
|
||||
<Swords class="h-5 w-5 text-neon-purple" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-white">The Arsenal Rankings</h2>
|
||||
<p class="text-sm text-white/50">
|
||||
Weapons ranked by total kills - The tools of destruction
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<BarChart data={weaponUsageData} height={300} />
|
||||
<BarChart
|
||||
data={weaponUsageData}
|
||||
height={300}
|
||||
options={{
|
||||
scales: {
|
||||
y: {
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.05)'
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgba(255, 255, 255, 0.5)'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.05)'
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgba(255, 255, 255, 0.5)'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: 'rgba(255, 255, 255, 0.7)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<!-- Hit Group Distribution -->
|
||||
<Card padding="lg">
|
||||
<div class="mb-4">
|
||||
<h2 class="text-2xl font-bold text-base-content">Hit Location Distribution</h2>
|
||||
<p class="text-sm text-base-content/60">Where shots landed across all weapons</p>
|
||||
<h2 class="text-2xl font-bold text-white">Hit Location Distribution</h2>
|
||||
<p class="text-sm text-white/50">
|
||||
Where shots landed across all weapons - Anatomy of aggression
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<PieChart data={hitGroupData} height={300} />
|
||||
@@ -260,40 +345,40 @@
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center gap-2">
|
||||
<div class="h-4 w-4 rounded bg-[rgba(239,68,68,0.8)]"></div>
|
||||
<span>Head</span>
|
||||
<div class="h-4 w-4 rounded bg-neon-red"></div>
|
||||
<span class="text-white/80">Head</span>
|
||||
</span>
|
||||
<span class="font-mono font-semibold">{hitGroupTotals.head}</span>
|
||||
<span class="font-mono font-semibold text-white">{hitGroupTotals.head}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center gap-2">
|
||||
<div class="h-4 w-4 rounded bg-[rgba(59,130,246,0.8)]"></div>
|
||||
<span>Chest</span>
|
||||
<div class="h-4 w-4 rounded bg-neon-blue"></div>
|
||||
<span class="text-white/80">Chest</span>
|
||||
</span>
|
||||
<span class="font-mono font-semibold">{hitGroupTotals.chest}</span>
|
||||
<span class="font-mono font-semibold text-white">{hitGroupTotals.chest}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center gap-2">
|
||||
<div class="h-4 w-4 rounded bg-[rgba(249,115,22,0.8)]"></div>
|
||||
<span>Stomach</span>
|
||||
<div class="h-4 w-4 rounded bg-neon-gold"></div>
|
||||
<span class="text-white/80">Stomach</span>
|
||||
</span>
|
||||
<span class="font-mono font-semibold">{hitGroupTotals.stomach}</span>
|
||||
<span class="font-mono font-semibold text-white">{hitGroupTotals.stomach}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center gap-2">
|
||||
<div class="h-4 w-4 rounded bg-[rgba(34,197,94,0.8)]"></div>
|
||||
<span>Arms</span>
|
||||
<div class="h-4 w-4 rounded bg-neon-green"></div>
|
||||
<span class="text-white/80">Arms</span>
|
||||
</span>
|
||||
<span class="font-mono font-semibold"
|
||||
<span class="font-mono font-semibold text-white"
|
||||
>{hitGroupTotals.left_arm + hitGroupTotals.right_arm}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center gap-2">
|
||||
<div class="h-4 w-4 rounded bg-[rgba(168,85,247,0.8)]"></div>
|
||||
<span>Legs</span>
|
||||
<div class="h-4 w-4 rounded bg-neon-purple"></div>
|
||||
<span class="text-white/80">Legs</span>
|
||||
</span>
|
||||
<span class="font-mono font-semibold"
|
||||
<span class="font-mono font-semibold text-white"
|
||||
>{hitGroupTotals.left_leg + hitGroupTotals.right_leg}</span
|
||||
>
|
||||
</div>
|
||||
@@ -305,8 +390,10 @@
|
||||
<!-- Player Weapons Table -->
|
||||
<Card padding="none">
|
||||
<div class="p-6">
|
||||
<h2 class="text-2xl font-bold text-base-content">Player Weapon Performance</h2>
|
||||
<p class="mt-1 text-sm text-base-content/60">Individual player weapon statistics</p>
|
||||
<h2 class="text-2xl font-bold text-white">Player Weapon Performance</h2>
|
||||
<p class="mt-1 text-sm text-white/50">
|
||||
Individual player weapon statistics - Who brought what to the fight
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DataTable data={sortedPlayerWeapons} columns={weaponColumns} striped hoverable />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,8 +27,9 @@ export const load: PageLoad = async ({ url }) => {
|
||||
playerId
|
||||
},
|
||||
meta: {
|
||||
title: 'Browse Matches - CS2.WTF',
|
||||
description: 'Browse and search through CS2 matchmaking games with detailed filters.'
|
||||
title: 'Browse Matches - teamflash.rip',
|
||||
description:
|
||||
'Browse CS2 matches and see who is blinding the competition (and their teammates).'
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -44,8 +45,9 @@ export const load: PageLoad = async ({ url }) => {
|
||||
nextPageTime: undefined,
|
||||
filters: { map, playerId },
|
||||
meta: {
|
||||
title: 'Browse Matches - CS2.WTF',
|
||||
description: 'Browse and search through CS2 matchmaking games with detailed filters.'
|
||||
title: 'Browse Matches - teamflash.rip',
|
||||
description:
|
||||
'Browse CS2 matches and see who is blinding the competition (and their teammates).'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { api } from '$lib/api';
|
||||
import type { PageLoad } from './$types';
|
||||
import type { PlayerMetaStats } from '$lib/types';
|
||||
import { calculateAllStats, type PlayerMatchStats } from '$lib/utils/playerStats';
|
||||
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
const playerId = params.id; // Keep as string to preserve uint64 precision
|
||||
@@ -10,10 +12,15 @@ export const load: PageLoad = async ({ params }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch player profile and recent matches in parallel
|
||||
const [profile, matchesData] = await Promise.all([
|
||||
// Fetch player profile, recent matches, and pre-aggregated meta stats in parallel
|
||||
// Note: Backend limits meta stats to max 10 items per category
|
||||
const [profile, matchesData, metaStats] = await Promise.all([
|
||||
api.players.getPlayerMeta(playerId),
|
||||
api.matches.getMatches({ player_id: playerId, limit: 20 })
|
||||
api.matches.getMatches({ player_id: playerId, limit: 20 }),
|
||||
api.players.getPlayerMetaStats(playerId, 10).catch((err): PlayerMetaStats | null => {
|
||||
console.error(`Failed to fetch player meta stats for ${playerId}:`, err);
|
||||
return null;
|
||||
})
|
||||
]);
|
||||
|
||||
// Fetch full match details with player stats for performance charts
|
||||
@@ -25,7 +32,7 @@ export const load: PageLoad = async ({ params }) => {
|
||||
const matchesWithDetails = await Promise.all(matchDetailsPromises);
|
||||
|
||||
// Extract player stats from each match
|
||||
const playerStats = matchesWithDetails
|
||||
const playerStats: PlayerMatchStats[] = matchesWithDetails
|
||||
.map((match) => {
|
||||
const playerData = match.players?.find((p) => p.id === playerId);
|
||||
if (!playerData) return null;
|
||||
@@ -48,13 +55,18 @@ export const load: PageLoad = async ({ params }) => {
|
||||
})
|
||||
.filter((stat): stat is NonNullable<typeof stat> => stat !== null);
|
||||
|
||||
// Calculate derived statistics (form, streaks, role detection, etc.)
|
||||
const calculatedStats = calculateAllStats(playerStats);
|
||||
|
||||
return {
|
||||
profile,
|
||||
recentMatches: matchesData.matches.slice(0, 4), // Show 4 in recent matches section
|
||||
playerStats, // Full stats for charts
|
||||
metaStats, // Pre-aggregated stats from backend (teammates, weapons, maps)
|
||||
calculatedStats, // Derived analytics (form, streaks, role, etc.)
|
||||
meta: {
|
||||
title: `${profile.name} - Player Profile | CS2.WTF`,
|
||||
description: `View ${profile.name}'s CS2 statistics, match history, and performance metrics.`
|
||||
title: `${profile.name} - Player Profile | teamflash.rip`,
|
||||
description: `View ${profile.name}'s CS2 statistics, flash history, and how often they blind their own team.`
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,41 +1,78 @@
|
||||
<script lang="ts">
|
||||
import { Search, TrendingUp } from 'lucide-svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Players - CS2.WTF</title>
|
||||
<title>Players - teamflash.rip</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="mb-2 text-4xl font-bold">Players</h1>
|
||||
<p class="text-base-content/60">Search and browse player profiles</p>
|
||||
<div class="relative bg-void">
|
||||
<!-- Decorative Background -->
|
||||
<div class="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<!-- Blur orbs -->
|
||||
<div class="absolute -left-40 top-20 h-80 w-80 rounded-full bg-neon-blue/10 blur-[100px]"></div>
|
||||
<div
|
||||
class="absolute -right-40 top-60 h-80 w-80 rounded-full bg-neon-purple/10 blur-[100px]"
|
||||
></div>
|
||||
<div
|
||||
class="absolute bottom-40 left-1/3 h-60 w-60 rounded-full bg-neon-gold/5 blur-[80px]"
|
||||
></div>
|
||||
<!-- Grid pattern -->
|
||||
<div
|
||||
class="absolute inset-0 opacity-20"
|
||||
style="background-image: linear-gradient(rgba(0, 212, 255, 0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(0, 212, 255, 0.03) 1px, transparent 1px); background-size: 60px 60px;"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<Card padding="lg" class="mb-8">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-base-content/40" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by Steam ID or player name..."
|
||||
class="input input-bordered w-full pl-10"
|
||||
/>
|
||||
<!-- Content -->
|
||||
<div class="container relative z-10 mx-auto px-4 py-8">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<h1
|
||||
class="mb-2 text-4xl font-bold text-white"
|
||||
style="text-shadow: 0 0 30px rgba(0, 212, 255, 0.5);"
|
||||
>
|
||||
Players
|
||||
</h1>
|
||||
<p class="text-white/60">Search and browse player profiles</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Coming Soon -->
|
||||
<div
|
||||
class="flex min-h-[400px] items-center justify-center rounded-lg border-2 border-dashed border-base-300 bg-base-200/50"
|
||||
>
|
||||
<div class="text-center">
|
||||
<TrendingUp class="mx-auto mb-4 h-16 w-16 text-base-content/20" />
|
||||
<h2 class="mb-2 text-2xl font-bold text-base-content">Coming Soon</h2>
|
||||
<p class="text-base-content/60">Player search and profiles will be available in Phase 3</p>
|
||||
<div class="mt-6">
|
||||
<Badge variant="info">Phase 3 - In Development</Badge>
|
||||
<!-- Search Section -->
|
||||
<div class="mb-8 rounded-xl border border-white/10 bg-void-light p-6">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-white/40" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by Steam ID or player name..."
|
||||
class="w-full rounded-lg border border-neon-blue/30 bg-void px-4 py-3 pl-12 text-white placeholder-white/40 transition-all duration-300 focus:border-neon-blue focus:outline-none focus:ring-1 focus:ring-neon-blue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Coming Soon Section -->
|
||||
<div
|
||||
class="flex min-h-[400px] items-center justify-center rounded-xl border-2 border-dashed border-neon-blue/20 bg-void-light/50"
|
||||
>
|
||||
<div class="text-center">
|
||||
<TrendingUp
|
||||
class="mx-auto mb-4 h-16 w-16 text-neon-blue/30"
|
||||
style="filter: drop-shadow(0 0 15px rgba(0, 212, 255, 0.3));"
|
||||
/>
|
||||
<h2
|
||||
class="mb-2 text-2xl font-bold text-white"
|
||||
style="text-shadow: 0 0 20px rgba(0, 212, 255, 0.3);"
|
||||
>
|
||||
Coming Soon
|
||||
</h2>
|
||||
<p class="text-white/60">Player search and profiles will be available in Phase 3</p>
|
||||
<div class="mt-6">
|
||||
<span
|
||||
class="inline-flex items-center gap-2 rounded-full border border-neon-gold/30 bg-neon-gold/10 px-4 py-1.5 text-sm text-neon-gold"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 animate-pulse rounded-full bg-neon-gold"></span>
|
||||
Phase 3 - In Development
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,8 +7,9 @@ import type { PageLoad } from './$types';
|
||||
export const load: PageLoad = async () => {
|
||||
return {
|
||||
meta: {
|
||||
title: 'Search Players - CS2.WTF',
|
||||
description: 'Search and browse CS2 player profiles with detailed statistics.'
|
||||
title: 'Search Players - teamflash.rip',
|
||||
description:
|
||||
'Search CS2 players and expose their flash crime history. Know who to mute before the match.'
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,217 +1,360 @@
|
||||
<script lang="ts">
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import { Shield, Eye, Cookie, Server, Mail } from 'lucide-svelte';
|
||||
import { Shield, Eye, Cookie, Server, Mail, Lock, Users, Globe } from 'lucide-svelte';
|
||||
|
||||
const lastUpdated = new Date().toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Privacy Policy | CS2.WTF</title>
|
||||
<title>Privacy Policy | teamflash.rip</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Privacy policy for CS2.WTF - Learn how we collect, use, and protect your data."
|
||||
content="Privacy policy for teamflash.rip - Learn how we collect, use, and protect your data."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container mx-auto max-w-4xl space-y-8 px-4 py-12">
|
||||
<!-- Header -->
|
||||
<div class="text-center">
|
||||
<div class="mb-4 inline-flex rounded-full bg-primary/10 p-4">
|
||||
<Shield class="h-12 w-12 text-primary" />
|
||||
</div>
|
||||
<h1 class="mb-4 text-4xl font-bold text-base-content">Privacy Policy</h1>
|
||||
<p class="text-lg text-base-content/70">
|
||||
Last updated: {new Date().toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</p>
|
||||
<div class="relative bg-void">
|
||||
<!-- Decorative Background -->
|
||||
<div class="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div class="absolute -left-40 top-20 h-80 w-80 rounded-full bg-neon-blue/10 blur-[100px]"></div>
|
||||
<div
|
||||
class="absolute -right-40 top-60 h-80 w-80 rounded-full bg-neon-purple/10 blur-[100px]"
|
||||
></div>
|
||||
<div
|
||||
class="absolute bottom-40 left-1/3 h-60 w-60 rounded-full bg-neon-green/5 blur-[80px]"
|
||||
></div>
|
||||
<div
|
||||
class="absolute inset-0 opacity-20"
|
||||
style="background-image: linear-gradient(rgba(0, 212, 255, 0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(0, 212, 255, 0.03) 1px, transparent 1px); background-size: 60px 60px;"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Introduction -->
|
||||
<Card padding="lg">
|
||||
<h2 class="mb-4 text-2xl font-bold text-base-content">Introduction</h2>
|
||||
<p class="text-base-content/80">
|
||||
CS2.WTF ("we", "our", or "us") is committed to protecting your privacy. This Privacy Policy
|
||||
explains how we collect, use, and safeguard your information when you use our CS2 match
|
||||
statistics and analysis platform.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<!-- Data Collection -->
|
||||
<Card padding="lg">
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<Eye class="h-6 w-6 text-primary" />
|
||||
<h2 class="text-2xl font-bold text-base-content">Information We Collect</h2>
|
||||
<!-- Content -->
|
||||
<div class="legal-content container relative z-10 mx-auto max-w-4xl space-y-8 px-4 py-12">
|
||||
<!-- Header -->
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="mb-4 inline-flex rounded-full border border-neon-green/30 bg-neon-green/10 p-4"
|
||||
style="box-shadow: 0 0 30px rgba(0, 255, 136, 0.2);"
|
||||
>
|
||||
<Shield
|
||||
class="h-12 w-12 text-neon-green"
|
||||
style="filter: drop-shadow(0 0 10px rgba(0, 255, 136, 0.5));"
|
||||
/>
|
||||
</div>
|
||||
<h1
|
||||
class="mb-4 text-4xl font-bold text-white"
|
||||
style="text-shadow: 0 0 30px rgba(0, 212, 255, 0.5);"
|
||||
>
|
||||
Privacy Policy
|
||||
</h1>
|
||||
<p class="text-lg text-white/60">Last updated: {lastUpdated}</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 text-base-content/80">
|
||||
<div>
|
||||
<h3 class="mb-2 font-semibold text-base-content">Public Steam Data</h3>
|
||||
<p>
|
||||
We collect publicly available information from Steam profiles and CS2 match data,
|
||||
including:
|
||||
</p>
|
||||
<ul class="ml-6 mt-2 list-disc space-y-1">
|
||||
<li>Steam ID and profile information</li>
|
||||
<li>Match statistics and performance data</li>
|
||||
<li>In-game chat messages (from parsed demo files)</li>
|
||||
<li>VAC and game ban status</li>
|
||||
<!-- Introduction -->
|
||||
<div class="rounded-xl border border-white/10 bg-void-light p-6">
|
||||
<h2 class="mb-4 text-2xl font-bold text-white">Introduction</h2>
|
||||
<p class="text-white/70">
|
||||
teamflash.rip ("we", "our", or "us") is committed to protecting your privacy. This Privacy
|
||||
Policy explains how we collect, use, and safeguard your information when you use our CS2
|
||||
match statistics and flash crime analysis platform.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Data Collection -->
|
||||
<div class="rounded-xl border border-white/10 bg-void-light p-6">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<Eye
|
||||
class="h-6 w-6 text-neon-blue"
|
||||
style="filter: drop-shadow(0 0 8px rgba(0, 212, 255, 0.5));"
|
||||
/>
|
||||
<h2 class="text-2xl font-bold text-white">Information We Collect</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6 text-white/70">
|
||||
<div>
|
||||
<h3 class="mb-2 font-semibold text-white">Public Steam Data</h3>
|
||||
<p class="mb-2">
|
||||
We collect publicly available information from Steam profiles and CS2 match data,
|
||||
including:
|
||||
</p>
|
||||
<ul class="ml-4 space-y-1">
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-neon-blue"></span>
|
||||
Steam ID and profile information
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-neon-blue"></span>
|
||||
Match statistics and performance data
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-neon-blue"></span>
|
||||
In-game chat messages (from parsed demo files)
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-neon-blue"></span>
|
||||
VAC and game ban status
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-2 font-semibold text-white">Usage Data</h3>
|
||||
<p>
|
||||
We may collect information about how you interact with our service, including pages
|
||||
visited and features used.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-2 font-semibold text-white">Browser Storage</h3>
|
||||
<p>
|
||||
We use browser local storage to save your preferences (favorite players, recently
|
||||
visited players) locally on your device. This data never leaves your browser.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- How We Use Data -->
|
||||
<div class="rounded-xl border border-white/10 bg-void-light p-6">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<Server
|
||||
class="h-6 w-6 text-neon-purple"
|
||||
style="filter: drop-shadow(0 0 8px rgba(168, 85, 247, 0.5));"
|
||||
/>
|
||||
<h2 class="text-2xl font-bold text-white">How We Use Your Information</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-white/70">
|
||||
<p>We use collected information to:</p>
|
||||
<ul class="ml-4 space-y-1">
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-neon-purple"></span>
|
||||
Provide match statistics and performance analysis
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-neon-purple"></span>
|
||||
Track player performance over time
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-neon-purple"></span>
|
||||
Generate charts and visualizations
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-neon-purple"></span>
|
||||
Improve our service and user experience
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-neon-purple"></span>
|
||||
Detect and prevent abuse
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-2 font-semibold text-base-content">Usage Data</h3>
|
||||
<p>
|
||||
We may collect information about how you interact with our service, including pages
|
||||
visited and features used.
|
||||
</p>
|
||||
<!-- Cookies -->
|
||||
<div class="rounded-xl border border-white/10 bg-void-light p-6">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<Cookie
|
||||
class="h-6 w-6 text-neon-gold"
|
||||
style="filter: drop-shadow(0 0 8px rgba(255, 170, 0, 0.5));"
|
||||
/>
|
||||
<h2 class="text-2xl font-bold text-white">Cookies and Local Storage</h2>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-2 font-semibold text-base-content">Browser Storage</h3>
|
||||
<p>
|
||||
We use browser local storage to save your preferences (theme, favorite players, recently
|
||||
visited players) locally on your device. This data never leaves your browser.
|
||||
<div class="space-y-2 text-white/70">
|
||||
<p>We use browser local storage to:</p>
|
||||
<ul class="ml-4 space-y-1">
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-neon-gold"></span>
|
||||
Remember your favorite players
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-neon-gold"></span>
|
||||
Track recently visited player profiles
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-neon-gold"></span>
|
||||
Save navigation state for better UX
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mt-4">
|
||||
You can clear this data at any time through your browser settings. No tracking cookies are
|
||||
used.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- How We Use Data -->
|
||||
<Card padding="lg">
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<Server class="h-6 w-6 text-primary" />
|
||||
<h2 class="text-2xl font-bold text-base-content">How We Use Your Information</h2>
|
||||
<!-- Data Sharing -->
|
||||
<div class="rounded-xl border border-white/10 bg-void-light p-6">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<Users
|
||||
class="h-6 w-6 text-neon-blue"
|
||||
style="filter: drop-shadow(0 0 8px rgba(0, 212, 255, 0.5));"
|
||||
/>
|
||||
<h2 class="text-2xl font-bold text-white">Data Sharing and Disclosure</h2>
|
||||
</div>
|
||||
<div class="space-y-2 text-white/70">
|
||||
<p>We do not sell, trade, or rent your personal information to third parties.</p>
|
||||
<p>We may share information in the following circumstances:</p>
|
||||
<ul class="ml-4 space-y-1">
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-neon-blue"></span>
|
||||
When required by law or legal process
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-neon-blue"></span>
|
||||
To protect our rights, property, or safety
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-neon-blue"></span>
|
||||
With service providers who assist in operating our platform
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-base-content/80">
|
||||
<p>We use collected information to:</p>
|
||||
<ul class="ml-6 list-disc space-y-1">
|
||||
<li>Provide match statistics and performance analysis</li>
|
||||
<li>Track player performance over time</li>
|
||||
<li>Generate charts and visualizations</li>
|
||||
<li>Improve our service and user experience</li>
|
||||
<li>Detect and prevent abuse</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Cookies -->
|
||||
<Card padding="lg">
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<Cookie class="h-6 w-6 text-primary" />
|
||||
<h2 class="text-2xl font-bold text-base-content">Cookies and Local Storage</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-base-content/80">
|
||||
<p>We use browser local storage to:</p>
|
||||
<ul class="ml-6 list-disc space-y-1">
|
||||
<li>Save your theme preference (light/dark mode)</li>
|
||||
<li>Remember your favorite players</li>
|
||||
<li>Track recently visited player profiles</li>
|
||||
</ul>
|
||||
<p class="mt-4">
|
||||
You can clear this data at any time through your browser settings. No tracking cookies are
|
||||
used.
|
||||
<!-- Security -->
|
||||
<div class="rounded-xl border border-white/10 bg-void-light p-6">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<Lock
|
||||
class="h-6 w-6 text-neon-green"
|
||||
style="filter: drop-shadow(0 0 8px rgba(0, 255, 136, 0.5));"
|
||||
/>
|
||||
<h2 class="text-2xl font-bold text-white">Data Security</h2>
|
||||
</div>
|
||||
<p class="text-white/70">
|
||||
We implement reasonable security measures to protect your information. However, no method of
|
||||
transmission over the Internet is 100% secure. All Steam data displayed is already publicly
|
||||
available through Steam's Community features.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Data Sharing -->
|
||||
<Card padding="lg">
|
||||
<h2 class="mb-4 text-2xl font-bold text-base-content">Data Sharing and Disclosure</h2>
|
||||
<div class="space-y-2 text-base-content/80">
|
||||
<p>We do not sell, trade, or rent your personal information to third parties.</p>
|
||||
<p>We may share information in the following circumstances:</p>
|
||||
<ul class="ml-6 list-disc space-y-1">
|
||||
<li>When required by law or legal process</li>
|
||||
<li>To protect our rights, property, or safety</li>
|
||||
<li>With service providers who assist in operating our platform</li>
|
||||
</ul>
|
||||
<!-- Your Rights -->
|
||||
<div class="rounded-xl border border-white/10 bg-void-light p-6">
|
||||
<h2 class="mb-4 text-2xl font-bold text-white">Your Rights</h2>
|
||||
<div class="space-y-2 text-white/70">
|
||||
<p>You have the right to:</p>
|
||||
<ul class="ml-4 space-y-1">
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-neon-green"></span>
|
||||
Access the data we have about you
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-neon-green"></span>
|
||||
Request correction of inaccurate data
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-neon-green"></span>
|
||||
Request deletion of your data
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-neon-green"></span>
|
||||
Opt-out of data collection by not using our service
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mt-4">
|
||||
Since we only display publicly available Steam data, you can control what information is
|
||||
public through your Steam privacy settings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Security -->
|
||||
<Card padding="lg">
|
||||
<h2 class="mb-4 text-2xl font-bold text-base-content">Data Security</h2>
|
||||
<p class="text-base-content/80">
|
||||
We implement reasonable security measures to protect your information. However, no method of
|
||||
transmission over the Internet is 100% secure. All Steam data displayed is already publicly
|
||||
available through Steam's Community features.
|
||||
</p>
|
||||
</Card>
|
||||
<!-- Third-Party Services -->
|
||||
<div class="rounded-xl border border-white/10 bg-void-light p-6">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<Globe
|
||||
class="h-6 w-6 text-neon-purple"
|
||||
style="filter: drop-shadow(0 0 8px rgba(168, 85, 247, 0.5));"
|
||||
/>
|
||||
<h2 class="text-2xl font-bold text-white">Third-Party Services</h2>
|
||||
</div>
|
||||
<div class="space-y-2 text-white/70">
|
||||
<p>Our service may contain links to third-party websites, including:</p>
|
||||
<ul class="ml-4 space-y-1">
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-neon-purple"></span>
|
||||
Steam Community profiles
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-neon-purple"></span>
|
||||
Google Translate (for chat translation)
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mt-4">
|
||||
We are not responsible for the privacy practices of these third-party services. Please
|
||||
review their privacy policies.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Your Rights -->
|
||||
<Card padding="lg">
|
||||
<h2 class="mb-4 text-2xl font-bold text-base-content">Your Rights</h2>
|
||||
<div class="space-y-2 text-base-content/80">
|
||||
<p>You have the right to:</p>
|
||||
<ul class="ml-6 list-disc space-y-1">
|
||||
<li>Access the data we have about you</li>
|
||||
<li>Request correction of inaccurate data</li>
|
||||
<li>Request deletion of your data</li>
|
||||
<li>Opt-out of data collection by not using our service</li>
|
||||
</ul>
|
||||
<p class="mt-4">
|
||||
Since we only display publicly available Steam data, you can control what information is
|
||||
public through your Steam privacy settings.
|
||||
<!-- Children's Privacy -->
|
||||
<div class="rounded-xl border border-white/10 bg-void-light p-6">
|
||||
<h2 class="mb-4 text-2xl font-bold text-white">Children's Privacy</h2>
|
||||
<p class="text-white/70">
|
||||
Our service is not directed to children under 13. We do not knowingly collect personal
|
||||
information from children. If you believe we have collected information from a child, please
|
||||
contact us.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Third-Party Services -->
|
||||
<Card padding="lg">
|
||||
<h2 class="mb-4 text-2xl font-bold text-base-content">Third-Party Services</h2>
|
||||
<div class="space-y-2 text-base-content/80">
|
||||
<p>Our service may contain links to third-party websites, including:</p>
|
||||
<ul class="ml-6 list-disc space-y-1">
|
||||
<li>Steam Community profiles</li>
|
||||
<li>Google Translate (for chat translation)</li>
|
||||
</ul>
|
||||
<p class="mt-4">
|
||||
We are not responsible for the privacy practices of these third-party services. Please
|
||||
review their privacy policies.
|
||||
<!-- Changes -->
|
||||
<div class="rounded-xl border border-white/10 bg-void-light p-6">
|
||||
<h2 class="mb-4 text-2xl font-bold text-white">Changes to This Policy</h2>
|
||||
<p class="text-white/70">
|
||||
We may update this Privacy Policy from time to time. Changes will be posted on this page
|
||||
with an updated "Last updated" date. Continued use of the service after changes constitutes
|
||||
acceptance of the updated policy.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Children's Privacy -->
|
||||
<Card padding="lg">
|
||||
<h2 class="mb-4 text-2xl font-bold text-base-content">Children's Privacy</h2>
|
||||
<p class="text-base-content/80">
|
||||
Our service is not directed to children under 13. We do not knowingly collect personal
|
||||
information from children. If you believe we have collected information from a child, please
|
||||
contact us.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<!-- Changes -->
|
||||
<Card padding="lg">
|
||||
<h2 class="mb-4 text-2xl font-bold text-base-content">Changes to This Policy</h2>
|
||||
<p class="text-base-content/80">
|
||||
We may update this Privacy Policy from time to time. Changes will be posted on this page with
|
||||
an updated "Last updated" date. Continued use of the service after changes constitutes
|
||||
acceptance of the updated policy.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<!-- Contact -->
|
||||
<Card padding="lg">
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<Mail class="h-6 w-6 text-primary" />
|
||||
<h2 class="text-2xl font-bold text-base-content">Contact Us</h2>
|
||||
<!-- Contact -->
|
||||
<div class="rounded-xl border border-white/10 bg-void-light p-6">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<Mail
|
||||
class="h-6 w-6 text-neon-blue"
|
||||
style="filter: drop-shadow(0 0 8px rgba(0, 212, 255, 0.5));"
|
||||
/>
|
||||
<h2 class="text-2xl font-bold text-white">Contact Us</h2>
|
||||
</div>
|
||||
<p class="text-white/70">
|
||||
If you have questions about this Privacy Policy or our data practices, please visit our
|
||||
<a
|
||||
href="https://somegit.dev/CSGOWTF/csgowtf"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-neon-blue underline decoration-neon-blue/30 transition-colors hover:decoration-neon-blue"
|
||||
>
|
||||
GitHub repository
|
||||
</a>
|
||||
for more information or to report issues.
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-base-content/80">
|
||||
If you have questions about this Privacy Policy or our data practices, please visit our GitHub
|
||||
repository for more information or to report issues.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<!-- Footer Note -->
|
||||
<div class="text-center text-sm text-base-content/60">
|
||||
<p>
|
||||
CS2.WTF is not affiliated with Valve Corporation or Counter-Strike. All trademarks are
|
||||
property of their respective owners.
|
||||
</p>
|
||||
<!-- Footer Note -->
|
||||
<div class="text-center text-sm text-white/40">
|
||||
<p>
|
||||
teamflash.rip is not affiliated with Valve Corporation or Counter-Strike. All trademarks are
|
||||
property of their respective owners.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.legal-content :global(h1),
|
||||
.legal-content :global(h2),
|
||||
.legal-content :global(h3) {
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
sans-serif;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
const SITE_URL = 'https://cs2.wtf'; // Update with actual production URL
|
||||
const SITE_URL = 'https://teamflash.rip'; // Production URL
|
||||
|
||||
/**
|
||||
* Generate robots.txt for search engine crawlers
|
||||
|
||||
@@ -1,65 +1,49 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { matchesAPI } from '$lib/api/matches';
|
||||
|
||||
const SITE_URL = 'https://cs2.wtf'; // Update with actual production URL
|
||||
// Get backend API URL from environment variable (same as API proxy route)
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://api.csgow.tf';
|
||||
|
||||
/**
|
||||
* Generate XML sitemap for SEO
|
||||
* Includes static pages and dynamic match pages
|
||||
* Proxy sitemap.xml requests to the backend API
|
||||
*
|
||||
* The backend generates comprehensive sitemaps using gositemap library,
|
||||
* including all matches and players. This proxies that for SEO.
|
||||
*/
|
||||
export const GET: RequestHandler = async () => {
|
||||
try {
|
||||
// Static pages
|
||||
const staticPages = [
|
||||
{ url: '', priority: 1.0, changefreq: 'daily' }, // Home
|
||||
{ url: '/matches', priority: 0.9, changefreq: 'hourly' } // Matches listing
|
||||
];
|
||||
const response = await fetch(`${API_BASE_URL}/sitemap.xml`, {
|
||||
headers: {
|
||||
Accept: 'application/xml'
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch recent matches for dynamic URLs
|
||||
let matchUrls: { url: string; lastmod: string }[] = [];
|
||||
try {
|
||||
const matchesResponse = await matchesAPI.getMatches({ limit: 100 });
|
||||
matchUrls = matchesResponse.matches.map((match) => ({
|
||||
url: `/match/${match.match_id}`,
|
||||
lastmod: match.date || new Date().toISOString()
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch matches for sitemap:', error);
|
||||
if (!response.ok) {
|
||||
console.error(`Backend sitemap returned ${response.status}`);
|
||||
return new Response('Sitemap not available', {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Build XML sitemap
|
||||
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${staticPages
|
||||
.map(
|
||||
(page) => ` <url>
|
||||
<loc>${SITE_URL}${page.url}</loc>
|
||||
<lastmod>${new Date().toISOString()}</lastmod>
|
||||
<changefreq>${page.changefreq}</changefreq>
|
||||
<priority>${page.priority}</priority>
|
||||
</url>`
|
||||
)
|
||||
.join('\n')}
|
||||
${matchUrls
|
||||
.map(
|
||||
(match) => ` <url>
|
||||
<loc>${SITE_URL}${match.url}</loc>
|
||||
<lastmod>${match.lastmod}</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>`
|
||||
)
|
||||
.join('\n')}
|
||||
</urlset>`.trim();
|
||||
const xml = await response.text();
|
||||
|
||||
return new Response(sitemap, {
|
||||
return new Response(xml, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/xml',
|
||||
'Cache-Control': 'public, max-age=3600' // Cache for 1 hour
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error generating sitemap:', error);
|
||||
return new Response('Error generating sitemap', { status: 500 });
|
||||
console.error('Failed to fetch sitemap from backend:', error);
|
||||
return new Response('Sitemap temporarily unavailable', {
|
||||
status: 503,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
'Retry-After': '300' // Retry after 5 minutes
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
52
src/routes/sitemap/[id]/+server.ts
Normal file
52
src/routes/sitemap/[id]/+server.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
// Get backend API URL from environment variable (same as API proxy route)
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://api.csgow.tf';
|
||||
|
||||
/**
|
||||
* Proxy individual sitemap requests to the backend API
|
||||
*
|
||||
* The backend uses a sitemap index pattern where /sitemap.xml points to
|
||||
* individual sitemaps like /sitemap/0, /sitemap/1, etc.
|
||||
* This proxies those individual sitemaps for SEO.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
const { id } = params;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/sitemap/${id}`, {
|
||||
headers: {
|
||||
Accept: 'application/xml'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Backend sitemap/${id} returned ${response.status}`);
|
||||
return new Response('Sitemap not found', {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const xml = await response.text();
|
||||
|
||||
return new Response(xml, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/xml',
|
||||
'Cache-Control': 'public, max-age=3600' // Cache for 1 hour
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch sitemap/${id} from backend:`, error);
|
||||
return new Response('Sitemap temporarily unavailable', {
|
||||
status: 503,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
'Retry-After': '300' // Retry after 5 minutes
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
233
src/routes/terms/+page.svelte
Normal file
233
src/routes/terms/+page.svelte
Normal file
@@ -0,0 +1,233 @@
|
||||
<script lang="ts">
|
||||
import { FileText, AlertTriangle, Scale, Users, Ban, RefreshCw } from 'lucide-svelte';
|
||||
|
||||
const lastUpdated = new Date().toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Terms of Service | teamflash.rip</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Terms of Service for teamflash.rip - CS2 match analysis platform."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="relative bg-void">
|
||||
<!-- Decorative Background -->
|
||||
<div class="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div class="absolute -left-40 top-20 h-80 w-80 rounded-full bg-neon-blue/10 blur-[100px]"></div>
|
||||
<div
|
||||
class="absolute -right-40 top-60 h-80 w-80 rounded-full bg-neon-purple/10 blur-[100px]"
|
||||
></div>
|
||||
<div
|
||||
class="absolute inset-0 opacity-20"
|
||||
style="background-image: linear-gradient(rgba(0, 212, 255, 0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(0, 212, 255, 0.03) 1px, transparent 1px); background-size: 60px 60px;"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="legal-content container relative z-10 mx-auto max-w-4xl space-y-8 px-4 py-12">
|
||||
<!-- Header -->
|
||||
<div class="text-center">
|
||||
<div class="mb-4 inline-flex rounded-full border border-neon-blue/30 bg-neon-blue/10 p-4">
|
||||
<FileText class="h-12 w-12 text-neon-blue" />
|
||||
</div>
|
||||
<h1 class="mb-4 text-4xl font-bold text-white">Terms of Service</h1>
|
||||
<p class="text-lg text-white/60">Last updated: {lastUpdated}</p>
|
||||
</div>
|
||||
|
||||
<!-- Introduction -->
|
||||
<div class="rounded-xl border border-white/10 bg-void-light p-6">
|
||||
<h2 class="mb-4 text-2xl font-bold text-white">Agreement to Terms</h2>
|
||||
<p class="leading-relaxed text-white/70">
|
||||
By accessing or using teamflash.rip ("the Service"), you agree to be bound by these Terms of
|
||||
Service. If you do not agree to these terms, please do not use the Service. These terms
|
||||
apply to all visitors, users, and others who access the Service.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Use of Service -->
|
||||
<div class="rounded-xl border border-white/10 bg-void-light p-6">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<Users class="h-6 w-6 text-neon-blue" />
|
||||
<h2 class="text-2xl font-bold text-white">Use of Service</h2>
|
||||
</div>
|
||||
<div class="space-y-4 leading-relaxed text-white/70">
|
||||
<p>
|
||||
You agree to use the Service only for lawful purposes and in accordance with these Terms.
|
||||
You agree not to:
|
||||
</p>
|
||||
<ul class="ml-4 space-y-2">
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-neon-blue"></span>
|
||||
<span>Use the Service in any way that violates applicable laws or regulations</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-neon-blue"></span>
|
||||
<span>Attempt to gain unauthorized access to any part of the Service</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-neon-blue"></span>
|
||||
<span>Use automated systems to scrape or extract data without permission</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-neon-blue"></span>
|
||||
<span>Interfere with or disrupt the Service or servers connected to it</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-neon-blue"></span>
|
||||
<span>Use the Service to harass, abuse, or harm others</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Content -->
|
||||
<div class="rounded-xl border border-white/10 bg-void-light p-6">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<FileText class="h-6 w-6 text-neon-purple" />
|
||||
<h2 class="text-2xl font-bold text-white">User Content</h2>
|
||||
</div>
|
||||
<div class="space-y-4 leading-relaxed text-white/70">
|
||||
<p>
|
||||
When you upload demo files or otherwise submit content to the Service, you grant us a
|
||||
non-exclusive, worldwide, royalty-free license to use, store, and process that content
|
||||
solely for the purpose of providing the Service.
|
||||
</p>
|
||||
<p>
|
||||
You represent that you have the right to upload any demo files you submit and that doing
|
||||
so does not violate any third-party rights or applicable laws.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Intellectual Property -->
|
||||
<div class="rounded-xl border border-white/10 bg-void-light p-6">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<Scale class="h-6 w-6 text-neon-gold" />
|
||||
<h2 class="text-2xl font-bold text-white">Intellectual Property</h2>
|
||||
</div>
|
||||
<div class="space-y-4 leading-relaxed text-white/70">
|
||||
<p>
|
||||
The Service and its original content (excluding user-submitted content) are and will
|
||||
remain the property of teamflash.rip and its contributors. The Service is licensed under
|
||||
the GPL-3.0 license.
|
||||
</p>
|
||||
<p>
|
||||
Counter-Strike, CS2, Steam, and related trademarks are property of Valve Corporation.
|
||||
teamflash.rip is not affiliated with or endorsed by Valve Corporation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Disclaimers -->
|
||||
<div class="rounded-xl border border-white/10 bg-void-light p-6">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<AlertTriangle class="h-6 w-6 text-neon-gold" />
|
||||
<h2 class="text-2xl font-bold text-white">Disclaimers</h2>
|
||||
</div>
|
||||
<div class="space-y-4 leading-relaxed text-white/70">
|
||||
<p>
|
||||
The Service is provided "as is" and "as available" without warranties of any kind, either
|
||||
express or implied, including but not limited to implied warranties of merchantability,
|
||||
fitness for a particular purpose, or non-infringement.
|
||||
</p>
|
||||
<p>
|
||||
We do not warrant that the Service will be uninterrupted, secure, or error-free. Match
|
||||
statistics and analysis are provided for informational purposes only and may contain
|
||||
inaccuracies.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Limitation of Liability -->
|
||||
<div class="rounded-xl border border-white/10 bg-void-light p-6">
|
||||
<h2 class="mb-4 text-2xl font-bold text-white">Limitation of Liability</h2>
|
||||
<p class="leading-relaxed text-white/70">
|
||||
In no event shall teamflash.rip, its operators, or contributors be liable for any indirect,
|
||||
incidental, special, consequential, or punitive damages, including without limitation loss
|
||||
of profits, data, or use, arising out of or in connection with your use of the Service,
|
||||
whether based on warranty, contract, tort, or any other legal theory.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Termination -->
|
||||
<div class="rounded-xl border border-white/10 bg-void-light p-6">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<Ban class="h-6 w-6 text-neon-red" />
|
||||
<h2 class="text-2xl font-bold text-white">Termination</h2>
|
||||
</div>
|
||||
<p class="leading-relaxed text-white/70">
|
||||
We may terminate or suspend your access to the Service immediately, without prior notice or
|
||||
liability, for any reason, including if you breach these Terms. Upon termination, your right
|
||||
to use the Service will immediately cease.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Changes -->
|
||||
<div class="rounded-xl border border-white/10 bg-void-light p-6">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<RefreshCw class="h-6 w-6 text-neon-blue" />
|
||||
<h2 class="text-2xl font-bold text-white">Changes to Terms</h2>
|
||||
</div>
|
||||
<p class="leading-relaxed text-white/70">
|
||||
We reserve the right to modify or replace these Terms at any time. Material changes will be
|
||||
posted on this page with an updated date. Your continued use of the Service after changes
|
||||
constitutes acceptance of the new Terms.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Governing Law -->
|
||||
<div class="rounded-xl border border-white/10 bg-void-light p-6">
|
||||
<h2 class="mb-4 text-2xl font-bold text-white">Governing Law</h2>
|
||||
<p class="leading-relaxed text-white/70">
|
||||
These Terms shall be governed by and construed in accordance with applicable laws, without
|
||||
regard to conflict of law provisions. Any disputes arising from these Terms or the Service
|
||||
shall be resolved through good-faith negotiation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Contact -->
|
||||
<div class="rounded-xl border border-white/10 bg-void-light p-6">
|
||||
<h2 class="mb-4 text-2xl font-bold text-white">Contact Us</h2>
|
||||
<p class="leading-relaxed text-white/70">
|
||||
If you have questions about these Terms, please visit our
|
||||
<a
|
||||
href="https://somegit.dev/CSGOWTF/csgowtf"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-neon-blue underline decoration-neon-blue/30 transition-colors hover:decoration-neon-blue"
|
||||
>
|
||||
GitHub repository
|
||||
</a>
|
||||
or open an issue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer Note -->
|
||||
<div class="text-center text-sm text-white/40">
|
||||
<p>
|
||||
teamflash.rip is not affiliated with Valve Corporation or Counter-Strike. All trademarks are
|
||||
property of their respective owners.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.legal-content :global(h1),
|
||||
.legal-content :global(h2),
|
||||
.legal-content :global(h3) {
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
sans-serif;
|
||||
}
|
||||
</style>
|
||||
@@ -14,6 +14,18 @@ module.exports = {
|
||||
DEFAULT: '#5e98d9',
|
||||
light: '#7eaee5',
|
||||
dark: '#4a7ab3'
|
||||
},
|
||||
// Neon Esports colors
|
||||
neon: {
|
||||
blue: '#00d4ff',
|
||||
gold: '#ffd700',
|
||||
red: '#ff3366',
|
||||
green: '#00ff88',
|
||||
purple: '#8b5cf6'
|
||||
},
|
||||
void: {
|
||||
DEFAULT: '#0a0a0f',
|
||||
light: '#12121a'
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
@@ -34,6 +46,37 @@ module.exports = {
|
||||
'"Courier New"',
|
||||
'monospace'
|
||||
]
|
||||
},
|
||||
animation: {
|
||||
ticker: 'ticker 60s linear infinite',
|
||||
float: 'float 6s ease-in-out infinite',
|
||||
'glow-pulse': 'glow-pulse 2s ease-in-out infinite',
|
||||
'fade-up': 'fade-up 0.6s ease-out forwards'
|
||||
},
|
||||
keyframes: {
|
||||
ticker: {
|
||||
'0%': { transform: 'translateX(0)' },
|
||||
'100%': { transform: 'translateX(-50%)' }
|
||||
},
|
||||
float: {
|
||||
'0%, 100%': { transform: 'translateY(0)' },
|
||||
'50%': { transform: 'translateY(-10px)' }
|
||||
},
|
||||
'glow-pulse': {
|
||||
'0%, 100%': { opacity: '1' },
|
||||
'50%': { opacity: '0.5' }
|
||||
},
|
||||
'fade-up': {
|
||||
'0%': { opacity: '0', transform: 'translateY(20px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' }
|
||||
}
|
||||
},
|
||||
backgroundImage: {
|
||||
'grid-pattern':
|
||||
'linear-gradient(rgba(0, 212, 255, 0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(0, 212, 255, 0.03) 1px, transparent 1px)'
|
||||
},
|
||||
backgroundSize: {
|
||||
grid: '50px 50px'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user