21 Commits

Author SHA1 Message Date
f3d24e0286 docs: Consolidate backend API documentation and feature proposals
- Merge NEW_FEATURES.md into MISSING_BACKEND_API.md as "Future Feature Proposals"
- Remove redundant NEW_FEATURES.md file
- Add CLAUDE.md to .gitignore (local development config)
- Include pending frontend component updates

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 21:04:13 +01:00
22244e5ed7 docs: Comprehensive analysis of missing backend API data
Document all frontend expectations vs backend reality:
- Critical: KAST%, ADR, weapon kills (all show 0)
- High: Round winner/reason, damage stats
- Medium: Utility stats, per-round performance, loss bonus

Includes impact summary, code locations, expected formats,
and backend implementation checklist.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 20:42:08 +01:00
848dc95e77 fix: Improve match header UX and remove excessive page whitespace
- Shorten date format in match header (remove year)
- Change meta cards from grid to flex for better alignment
- Disable demo download for matches older than 4 weeks (Valve retention limit)
- Fix avg_rank card to only show valid CS Ratings (>1000)
- Remove min-h-screen from 8 pages to eliminate footer gap

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 20:27:20 +01:00
83caf1b858 fix: Align MVP badge and trophy icon inline with player names
Wrap player name, avatar, and badges in a flex container to ensure
MVP badge and team-top trophy icon appear inline rather than breaking
to a new line.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 20:15:15 +01:00
b988d141dd fix: Resolve avatar display issues on chat and player pages
Chat page:
- Fix player lookup using player_name instead of player_id to avoid
  JavaScript number precision loss with 64-bit Steam IDs
- Update player filter dropdown to use names instead of IDs

Player page (Teammates):
- Construct full avatar URLs from hash when backend returns hash format
- Handle both full URLs and hashes with startsWith check

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 20:11:00 +01:00
30b076bbec feat: Add player avatars to all match detail pages and player profile
- Add Steam avatars to match overview scoreboard (both teams)
- Add avatars to weapons page player table
- Add avatars to damage page player table and top dealer cards
- Add avatars to flashes page player table and Hall of Shame section
- Replace initial-based chat avatars with real Steam avatars
- Display actual Steam avatar on player profile page header

All avatar implementations include team-colored borders and fallback
to icons when avatar is unavailable.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 20:06:08 +01:00
1024ba839e docs: Update README with Phase 2 features and current status
- Document implemented match analysis features (rounds, economy, weapons, etc.)
- Add player profile features with Premier rating
- Reference MISSING_BACKEND_API.md for backend requirements
- Update status to Phase 2 Complete

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 20:00:13 +01:00
235ef65556 feat: Merge economy and rounds pages with unified economy utilities
- Create economyUtils.ts with team-aware buy type classification
  (CT has higher thresholds due to M4 cost)
- Add Economy Overview toggle to rounds page with charts
- Resolve player names/avatars in round economy display
- Remove standalone Economy tab (merged into Rounds)
- Document missing backend API data (round winner, win reason)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 19:58:06 +01:00
e27e9e8821 feat: Integrate player meta stats, match metadata, and sitemap proxies
- Add player meta stats API endpoint with teammates, weapons, and map data
- Display avg_rank badge on match cards and match detail page
- Add tick rate and demo download improvements to match layout
- Create sitemap proxy routes to backend for SEO
- Document backend data limitations in transformers (rounds/weapons)
- Fix 400 error: backend limits meta stats to max 10 items

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 18:42:44 +01:00
95b385c471 style: Redesign player profile page with neon esports aesthetic
- Add hero section with decorative blur orbs and grid pattern overlay
- Replace DaisyUI classes with neon design system (void backgrounds, neon accents)
- Add neon glow effects to avatar, stats, and section headers
- Add Combat Impact section with multi-kills (2K, 3K, 4K, Aces), MVPs, assists, ADR
- Add Rating Journey section with peak rating, current rating, delta, and range
- Add "Ace Hunter Detected" badge for players with 3+ aces
- Add "Flash Criminal Detected" badge for excessive team flashing
- Add empty state card when detailed match data unavailable (unparsed demos)
- Update chart colors to neon palette (blue for K/D, green for KAST)
- Reorganize utility stats with thematic descriptions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 18:13:37 +01:00
ee233bb6fb style: Redesign match detail pages with neon esports aesthetic
Complete overhaul of all 7 match sub-pages (Overview, Flashes, Economy,
Details, Weapons, Damage, Chat) with consistent neon design system.

Key changes:
- Update Card/Tabs components with void backgrounds and neon accents
- Add decorative blur orbs and grid pattern to match layout hero
- Convert DaisyUI classes to custom Tailwind with neon colors
- Update chart components with neon-themed tooltips and grid styling
- Add RoundTimeline neon glow on selection with void-themed tooltips

Puns added throughout:
- "Hall of Shame" for players who flash teammates more than enemies
- "Needs Therapy Award" for high team damage
- "MVP (Most Violent Player)" badge
- "The Poverty Round", "YOLO Buy" economy labels
- "Multi-Threat Level", "Can't Touch This", "Molotov Mixologist"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 17:54:29 +01:00
51112df979 feat: Add FAQ and Terms pages, redesign Privacy page
Create FAQ page with accordion-style Q&A sections and Terms of Service
page with comprehensive legal content. Redesign Privacy page with neon
styling. All legal pages use system font for headings to ensure
readability.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 17:30:40 +01:00
d6048fc264 style: Redesign error page and remove API docs from footer
Convert error page to neon esports aesthetic with gold theme for
warning states, animated flash icon, and proper button semantics.
Remove API Docs link from footer navigation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 17:24:59 +01:00
c92af5f377 style: Redesign about page with neon esports aesthetic
Convert the about page from DaisyUI to the custom neon design system
with decorative blur orbs, glowing icons, and contextual hover effects
on feature cards. External links now open in new tabs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 17:22:08 +01:00
8de8f1696f style: Redesign players page with neon esports aesthetic
Convert the players page and related components from DaisyUI to the
custom neon design system, matching the landing page and matches page
visual style. Adds decorative blur orbs, neon glow effects, and
consistent color semantics across PlayerCard, RecentPlayers, and
TrackPlayerModal components.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 17:18:01 +01:00
6dc12f0c35 feat: Redesign matches page with neon styling and UX improvements
- Convert matches page from DaisyUI to neon esports design system
- Add colored left borders to cards for instant win/loss/tie scanning
- Add player count badges and demo status icons to match cards
- Implement filter state preservation across navigation
- Add staggered card animations and skeleton loading states
- Add slide transition for filter panel
- Make cards compact with horizontal layout for better density
- Update grid to 4 columns on xl screens
- Style DataTable, ShareCodeInput with neon theme
- Add external link support to NeonButton

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 17:11:19 +01:00
cdc70403f9 refactor: Move search modal to layout root for proper z-index stacking
- Extract SearchModal component from SearchBar for root-level rendering
- Add isModalOpen state to search store with open/close methods
- Simplify SearchBar to trigger button only
- Update Modal with proper overflow handling and scroll-to-close
- Fix layout background to use void color

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 16:32:24 +01:00
d01e0d28f6 chore: Remove theme toggle and commit to dark mode
- Delete ThemeToggle.svelte component
- Remove theme toggle from Header
- Set permanent cs2dark theme in app.html
- Add darkreader-lock meta tag to prevent extension conflicts
- Add color-scheme: dark meta for browser hints
- Update theme-color to void (#0a0a0f)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 16:20:48 +01:00
a77082c400 style: Update Header and Footer to neon esports aesthetic
- Apply void dark background with neon-blue border accents
- Add neon text-shadow glow to logo (cyan + gold)
- Update nav links to hover:text-neon-blue with focus-visible states
- Add grid pattern overlay to footer
- Use neon-red for heart icon and donation hover
- Add motion-reduce support and accessible focus rings

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 16:15:35 +01:00
1ddda81d93 feat: Add neon esports landing page with WCAG accessibility
- Create HeroSection with animated search bar and stat counters
- Add LiveMatchTicker with auto-scrolling recent matches
- Build FlashLeaderboard "Wall of Shame" with podium display
- Implement FeatureShowcase with scroll-triggered animations
- Add NeonCTA call-to-action section with trust badges
- Create reusable NeonButton component with glow effects

Accessibility improvements:
- Add aria-labels, aria-hidden for decorative elements
- Implement focus-visible ring styles for keyboard navigation
- Support prefers-reduced-motion across all animations
- Use semantic HTML (article, nav, dl) for screen readers
- Improve color contrast ratios for WCAG compliance

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 16:10:13 +01:00
3383302225 feat: Rebrand from CS2.WTF to teamflash.rip with full pun mode
Complete site rebrand with flash-themed humor throughout:

- Update logo to "team" + "flash.rip" two-color design
- Add flash-themed error pages (404 = "You've Been Full-Blind")
- Revamp homepage hero with "Stop Flashing Your Teammates" tagline
- Update flash statistics page with playful labels ("Friendly Crimes", "Self-Inflicted L")
- Add loading messages store with flash-themed text
- Update all page meta titles and descriptions
- Update sitemap.xml and robots.txt with new domain
- Update package.json name and description

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 15:30:41 +01:00
82 changed files with 9296 additions and 2953 deletions

3
.gitignore vendored
View File

@@ -50,3 +50,6 @@ coverage
.tmp
tmp
*.tmp
# Claude Code
CLAUDE.md

1172
MISSING_BACKEND_API.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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": {

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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)

View File

@@ -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)
};
}

View File

@@ -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
});
}

View File

@@ -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,

View File

@@ -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}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>

View 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}

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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 = {

View File

@@ -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}

View 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}

View File

@@ -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)}
>

View 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.'
};

View File

@@ -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 }));
}
};
};

View File

@@ -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;
}
/**

View File

@@ -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>;
}

View File

@@ -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;
}
/**

View File

@@ -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 {

View 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()}`;
}

View File

@@ -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 {

View 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
};
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 />

View File

@@ -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.'
}
};
}

View File

@@ -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>

View File

@@ -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.'
}
};
};

View File

@@ -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
View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"
>

View File

@@ -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`
}
};
};

View File

@@ -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

View File

@@ -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>

View File

@@ -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`
}
};
};

View File

@@ -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">&lt; $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">&gt; $3,500 avg equipment</span>
</div>
</div>
</Card>
</div>
{/if}

View File

@@ -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`
}
};
};

View File

@@ -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>

View 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">&lt; $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">&gt; $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>

View File

@@ -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

View File

@@ -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

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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.'
}
};
};

View File

@@ -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>

View File

@@ -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

View File

@@ -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
}
});
}
};

View 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
}
});
}
};

View 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>

View File

@@ -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'
}
}
},