48 Commits

Author SHA1 Message Date
49033560fa feat: CS2 format support, player tracking fixes, and homepage enhancements
- Add dynamic MR12/MR15 halftime calculation to RoundTimeline component
- Fix TrackPlayerModal API signature (remove shareCode, change isOpen to open binding)
- Update Player types for string IDs and add ban fields (vac_count, vac_date, game_ban_count, game_ban_date)
- Add target/rel props to Button component for external links
- Enhance homepage with processing matches indicator
- Update preferences store for string player IDs
- Document Phase 5 progress and TypeScript fixes in TODO.md

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 19:10:13 +01:00
3192180c60 feat: Overhaul ranking system to support CS2 dual-architecture
Based on comprehensive research, CS2 implements a bifurcated ranking system:
- Premier Mode: CS Rating (numerical ELO, 0-30,000+) - launched Aug 31, 2023
- Competitive/Wingman: Skill Groups (0-18, Silver I to Global Elite)
- Legacy CS:GO: Skill Groups only (pre-Sept 27, 2023)

Changes:
- Add game_mode field to Match type ('premier' | 'competitive' | 'wingman')
- Create rankingSystem.ts utility with smart detection logic:
  * Checks match date (CS:GO legacy vs CS2)
  * Checks game_mode (Premier vs Competitive/Wingman)
  * Falls back to rating value heuristic (0-18 vs 1000+)
- Update PremierRatingBadge to use new detection logic
- Pass match context from match detail page to badge component
- Update Match and schema documentation with dual-system details
- Add research.md documenting CS2's ranking system architecture

Key dates:
- August 31, 2023: Premier Mode and CS Rating launched
- September 27, 2023: CS2 officially replaced CS:GO

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 00:46:28 +01:00
05a6c10458 fix: Fix match detail data loading and add API transformation layer
- Update Zod schemas to match raw API response formats
- Create transformation layer (rounds, weapons, chat) to convert raw API to structured format
- Add player name mapping in transformers for better UX
- Fix Svelte 5 reactivity issues in chat page (replace $effect with $derived)
- Fix Chart.js compatibility with Svelte 5 state proxies using JSON serialization
- Add economy advantage chart with halftime perspective flip (WIP)
- Remove stray comment from details page
- Update layout to load match data first, then pass to API methods

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 00:37:41 +01:00
12115198b7 chore: Remove debug logging from PremierRatingBadge
Debug logging was added to troubleshoot rank detection logic.
Now that icons are working correctly, removing the console.log.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 23:44:07 +01:00
2e053a6388 fix: Constrain rank icon size and table row height
- Set Rating column table cells to fixed height (h-12 = 48px)
- Wrap icons in flex container for vertical centering
- Reduce icon size from 14x14 to 11x11 to fit within row height
- Add max-h-11 constraint to prevent overflow
- Add inline-block and align-middle to icon for proper alignment

This ensures all table rows remain the same height regardless of
whether they show rank icons or Premier rating badges.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 23:43:39 +01:00
973d188a26 fix: Increase rank icon sizes further for better readability
- sm: 10x10 → 14x14 (56px, used in scoreboard)
- md: 12x12 → 16x16 (64px)
- lg: 16x16 → 20x20 (80px)

Icons now prominently visible in Rating column.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 23:41:53 +01:00
afd1d7a822 fix: Increase rank icon sizes for better visibility
- sm: 6x6 → 10x10 (used in scoreboard tables)
- md: 8x8 → 12x12
- lg: 12x12 → 16x16

The icons were too small to see details at 6x6 pixels.
Now properly visible in the Rating column of match scoreboards.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 23:40:34 +01:00
68aada0c33 fix: Correct legacy rank detection - check rating range instead
The API returns CS:GO skill groups (0-18) in BOTH rank_old and rank_new
fields for legacy matches. Updated detection logic to:

- Check if rating (rank_new) is in 0-18 range (CS:GO skill groups)
- CS2 ratings are typically 1000-30000, so no overlap
- Use rating value for RankIcon display (contains current skill group)

Debug output showed:
- rating: 15-17 (CS:GO skill groups)
- oldRating: same values (15-17)
- Previous logic failed because rating was not 0

This fix properly detects and displays CS:GO rank icons for legacy matches.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 23:38:56 +01:00
f8cf85661e fix: Improve legacy rank detection logic and add debugging
- Update isLegacyRank check to properly detect CS:GO skill groups (0-18)
- Handle edge case where oldRating could be 0 (unranked)
- Add temporary debug logging to trace rank data values
- Fix detection for matches where rating is outside CS2 range

This will help identify if rank data is being passed correctly from the API.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 23:36:49 +01:00
668c32ed8a feat: Add CS:GO rank icons for legacy matches
Implemented rank icon display system for pre-CS2 matches:

New Components:
- RankIcon.svelte: Displays CS:GO skill group icons (0-18)
  - Supports sm/md/lg sizes
  - Shows appropriate rank icon based on skill group
  - Includes hover tooltips with rank names
  - Handles all 19 rank tiers (Silver I → Global Elite)

Updated Components:
- PremierRatingBadge: Now intelligently switches between:
  - CS:GO rank icons (when rank_old exists, rank_new doesn't)
  - Premier rating badge (when rank_new exists)
  - "Unranked" text (when neither exists)

Assets:
- Rank icons already present in static/images/rank_icons/
- Weapon icons already present in static/images/weapons/
- All icons in SVG format for crisp display at any size

Display Logic:
- Legacy matches (pre-Sept 2023): Show CS:GO rank icons
- Modern matches (CS2): Show Premier rating with trophy icon
- Automatically detects based on rank_old/rank_new fields

The scoreboard now displays the appropriate ranking visualization
based on match era, matching the original CSGO.WTF design.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 23:34:37 +01:00
78c87aaedd refactor: Redesign match hero with modern translucent panels
Implemented comprehensive visual overhaul based on UX feedback:

Background & Framing:
- Replace single gradient with dual-layer system for depth
- Add top-to-bottom gradient (30% → transparent → 40%) for framing
- Add left-to-right gradient (70% → 40% → 70%) for content focus
- Creates natural vignette effect that draws eye to center

Hero Info Panel:
- Wrap score and metadata in translucent panel (black/40 + backdrop-blur)
- Add subtle border (white/10) for definition
- Center-align and constrain width (max-w-3xl) for focused composition
- Cleaner hierarchy with reduced text shadows (rely on panel for contrast)

Typography & Layout:
- Increase map title to text-5xl, remove badge duplication
- Score numbers to text-6xl for prominence
- Team labels to text-xs with reduced opacity (70%)
- Metadata with bullet separators (•) for cleaner inline layout
- Smaller icons (3.5) and tighter spacing

Download Demo Button:
- Ghost style with translucent background (white/15)
- Subtle border (white/25) instead of solid primary color
- Hover effect (white/25) for interaction feedback
- Hide text on mobile (icon only) for space efficiency

Navigation:
- Reduce tabs background to 35% opacity with stronger backdrop-blur-lg
- Add border (white/10) for subtle definition
- Maintains readability while showing more background

Result: Modern, cinematic interface with improved visual hierarchy
and readability on both dark and bright map backgrounds.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 23:24:31 +01:00
4cc2b70dbc fix: Significantly improve text and button visibility on match hero
- Back button: solid black/60 background with backdrop-blur instead of ghost
- Download Demo button: solid primary background instead of outline
- Map name: triple-layer text shadow for maximum contrast
- Score labels: full white with strong shadows, uppercase styling
- Score numbers: triple shadow with glow effect (0.95/0.8/0.6 opacity layers)
- Colon separator: full white with strong shadow
- Metadata text/icons: strong text shadows and drop-shadow filters
- Tabs container: increased to black/70 with stronger backdrop-blur

All text elements now have multiple layers of shadows for readability
on bright map backgrounds like de_dust2.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 23:17:54 +01:00
a3955da7f2 fix: Improve text visibility on match hero section
- Strengthen background gradient overlay from 90/70/50% to 95/85/75% opacity
- Add stronger text shadows to score numbers (double shadow for depth)
- Increase team label opacity from 70% to 90%
- Increase metadata text from white/80 to white with drop-shadow
- Increase tabs background from black/30 to black/50
- Improve colon separator contrast

Fixes readability issues on bright maps like de_dust2.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 23:16:25 +01:00
eb68c5d00b fix: Add safe parsing for weapons and chat API endpoints
- Use parseMatchWeaponsSafe instead of parseMatchWeapons
- Use parseMatchChatSafe instead of parseMatchChat
- Throw clear error message when demo not parsed yet
- Prevents Zod validation errors from reaching page loaders
- Matches the pattern already used for rounds endpoint

This fixes Zod errors when navigating to match detail pages where
the demo hasn't been fully parsed yet by the backend.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 23:14:19 +01:00
05e6182bcf fix: Fix Svelte 5 reactivity issues in matches page and update API handling
- Fix toast notification imports: change from showToast to toast.success/error
- Remove hover preloading from app.html and Tabs component
- Fix match rounds API handling with safe parsing for incomplete data
- Fix pagination timestamp calculation (API returns Unix timestamp, not ISO string)
- Refactor matches page state management to fix reactivity issues:
  - Replace separate state variables with single matchesState object
  - Completely replace state object on updates to trigger reactivity
  - Fix infinite loop in intersection observer effect
  - Add keyed each blocks for proper list rendering
- Remove client-side filtering (temporarily) to isolate reactivity issues
- Add error state handling with nextPageTime in matches loader

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 23:11:50 +01:00
8f21b56223 feat: Add comprehensive utility statistics display
Display detailed utility usage including self-flashes, team flashes, smokes, and decoys.

## Changes

### Enhanced Statistics Calculation
- Added `selfFlashes`: Self-flash count tracking
- Added `teammatesBlinded`: Friendly fire flash tracking
- Added `smokesUsed`: Smoke grenade usage count
- Added `decoysUsed`: Decoy grenade usage count
- Added `flashesUsed`: Total flashbangs used

All stats aggregated from playerStats with per-match averages calculated.

### UI Enhancements
- Expanded Utility Effectiveness section from 4 to 8 cards
- Updated grid layout: 2 cols mobile, 3 cols desktop, 4 cols extra-wide
- Responsive design ensures cards wrap appropriately

### New Display Cards

**Self Flashes**
- Shows total times player flashed themselves
- Warning icon with yellow color
- Per-match average for context

**Team Flashes**
- Displays friendly fire flash incidents
- Users icon with error/red color
- Highlights potential coordination issues

**Smokes Used**
- Total smoke grenades deployed
- Target icon with neutral color
- Per-match usage rate

**Decoys Used**
- Total decoy grenades deployed
- Target icon with info/blue color
- Per-match usage pattern

## Implementation Details

The statistics are extracted from match-level utility data:
- `flash_total_self`: Self-flash incidents
- `flash_total_team`: Teammate flash incidents
- `ud_smoke`: Smoke grenade usage count
- `ud_decoy`: Decoy grenade usage count

Each stat card shows:
1. Total value across all matches
2. Per-match average (value / totalMatches)
3. Consistent icon and color scheme

**Utility Tracking Value:**
These metrics help players identify areas for improvement:
- Self-flashes indicate positioning/timing issues
- Team flashes highlight communication needs
- Smoke/decoy usage shows tactical utility engagement

This completes Phase 3 Feature 5 and ALL 15 MISSING FEATURES! 🎉

Full feature parity with original CS:GO WTF frontend now achieved.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 20:19:45 +01:00
248c4a8523 feat: Add comprehensive privacy policy page
Create detailed privacy policy explaining data collection and usage practices.

## New Page: /privacy

### Content Sections

**Introduction**
- Overview of CS2.WTF's commitment to privacy

**Information We Collect**
- Public Steam Data: Steam ID, match stats, chat messages, VAC status
- Usage Data: Page visits and feature interactions
- Browser Storage: Theme, favorites, recent players (localStorage only)

**How We Use Information**
- Provide match statistics and analysis
- Track performance over time
- Generate visualizations
- Improve service quality

**Cookies and Local Storage**
- Details on localStorage usage for preferences
- No tracking cookies
- User-controllable through browser settings

**Data Sharing**
- No selling/trading of personal information
- Only share when legally required or for safety
- May use service providers

**Security**
- Reasonable security measures implemented
- Acknowledgment of inherent internet risks
- Note that Steam data is already public

**Your Rights**
- Access, correction, and deletion rights
- Opt-out by not using service
- Control via Steam privacy settings

**Third-Party Services**
- Links to Steam profiles
- Google Translate integration
- Not responsible for third-party privacy practices

**Children's Privacy**
- Not directed at children under 13
- No knowingly collected children's data

**Policy Changes**
- May update with notice
- Continued use implies acceptance

**Contact**
- Direct users to GitHub for questions/issues

### UI Implementation
- Clean, readable layout with Card components
- Icons for each section (Shield, Eye, Cookie, Server, Mail)
- Responsive design optimized for all screen sizes
- Proper semantic HTML and accessibility
- SEO-optimized with meta tags
- Current date automatically displayed

The policy is comprehensive yet clear, covering all necessary legal bases while
explaining data practices in user-friendly language. Already linked from the
footer navigation.

This completes Phase 3 Feature 3 - privacy compliance established.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 20:17:33 +01:00
469f0df756 feat: Display win/loss/tie statistics on player profiles
Add comprehensive match record breakdown showing wins, losses, and ties.

## Changes

### Data Calculation (+ page.ts)
- Enhanced playerStats mapping to detect tied matches
- Calculate `isTie` by comparing team scores (score_team_a === score_team_b)
- Add `tied` boolean field alongside existing `won` field
- Ensure wins exclude tied matches for accurate statistics

### Statistics Display (+page.svelte)
- Added "Match Record" card to Career Statistics section
- Calculate wins, losses, and ties from playerStats
- Display in compact format: "XW / YL / ZT"
- Color-coded: wins (green), losses (red), ties (blue)
- Show total matches analyzed below the record

### UI Improvements
- Expanded Career Statistics grid from 4 to 5 columns
- Responsive: 2 columns on mobile, 5 on desktop
- Consistent card styling with other career stats
- Trophy icon for Match Record card

## Implementation Details

**Tie Detection Logic:**
```typescript
const isTie = match.score_team_a === match.score_team_b;
const won = !isTie &&
  ((playerData.team_id === 2 && match.score_team_a > match.score_team_b) ||
   (playerData.team_id === 3 && match.score_team_b > match.score_team_a));
```

**Record Format:**
- Wins: Green text, "W" suffix
- Losses: Red text, "L" suffix
- Ties: Blue text, "T" suffix
- Separators: Gray "/"

The statistics are calculated from the last 15 matches with full details,
providing accurate win/loss/tie breakdown for performance tracking.

This completes Phase 3 Feature 2 - match records now fully visible.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 20:15:54 +01:00
7e101ba274 feat: Add Steam profile link to player pages
Add direct link to Steam Community profile for easy access to player's Steam page.

## Changes

### UI Addition
- Added "Steam Profile" button to player page actions section
- Positioned alongside "Track Player" and "View All Matches" buttons
- Uses ExternalLink icon from lucide-svelte
- Ghost button variant for secondary action styling

### Link Implementation
- Opens Steam Community profile in new tab
- Uses player's Steam ID (uint64) to construct profile URL
- Format: `https://steamcommunity.com/profiles/{steamid64}`
- Includes `target="_blank"` and `rel="noopener noreferrer"` for security

### UX Improvements
- Changed actions container to use `flex-wrap` for responsive layout
- Buttons wrap on smaller screens to prevent overflow
- External link icon clearly indicates opening in new tab

**Security Note:** The `rel="noopener noreferrer"` attribute prevents:
- Potential security issues with window.opener access
- Referrer information leakage to external site

This provides users quick access to full Steam profile information including
inventory, game library, friends list, and other Steam-specific data not
available in CS2.WTF.

This completes Phase 3 Feature 1 - Steam profile integration added.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 20:13:31 +01:00
b59eebcddb feat: Add chat message translation feature
Enable translation of non-English chat messages to help users understand
international communications.

## Changes

### Translation Detection
- Added `mightNeedTranslation()` function to detect non-English text
- Checks for Cyrillic, Chinese, Japanese, Korean, and Arabic characters
- Uses Unicode range pattern matching for language detection

### Translation UI
- Added Languages icon from lucide-svelte
- Display translate button next to messages that contain non-English text
- Button shows icon only on mobile, "Translate" text on desktop
- Positioned using flexbox to prevent text wrapping issues

### Translation Functionality
- `translateMessage()` opens Google Translate in new popup window
- Auto-detects source language, translates to English
- Uses Google Translate's free web interface (no API key required)
- Opens in 800x600 popup for optimal translation viewing

## Implementation Details

The feature works by:
1. Scanning each message for non-ASCII character ranges
2. Showing translate button only for messages likely in foreign languages
3. Opening Google Translate web UI in popup when clicked
4. Preserving original message while providing translation access

**Why Google Translate Web Interface:**
- No API keys or authentication required
- Free and unlimited usage
- Familiar translation interface for users
- Supports all languages Google Translate offers
- Popup window keeps context while showing translation

This approach avoids the complexity and cost of translation APIs while
providing full-featured translation capabilities to users.

This completes Phase 2 Feature 4 and ALL Phase 2 features! 🎉

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 20:12:09 +01:00
d4d7015df6 feat: Add SEO sitemap and robots.txt generation
Implement dynamic sitemap.xml and robots.txt for search engine optimization.

## New Files

### src/routes/sitemap.xml/+server.ts
- Generate XML sitemap with all indexable pages
- Include static pages (home, matches list)
- Dynamically fetch and include recent 100 matches
- Set appropriate priority and changefreq for each URL type
- Cache response for 1 hour to reduce server load

### src/routes/robots.txt/+server.ts
- Allow all crawlers to index the site
- Point crawlers to sitemap.xml location
- Set crawl-delay of 1 second to be polite to server
- Cache response for 1 day

## Implementation Details

**Sitemap Structure:**
- Static pages: Priority 0.9-1.0, updated daily
- Match pages: Priority 0.7, updated weekly
- Fetches up to 100 most recent matches from API
- Uses match date as lastmod timestamp for accurate indexing

**SEO Benefits:**
- Helps search engines discover all match pages efficiently
- Provides crawlers with update frequency hints
- Improves indexing of dynamic content
- Reduces unnecessary crawling with robots.txt directives

The sitemap automatically stays current by fetching recent matches on each
request. The 1-hour cache balances freshness with server performance.

Note: Player profile pages not included in sitemap due to lack of bulk listing
API endpoint. Individual player pages will still be indexed via internal links.

This completes Phase 2 Feature 3 - site now properly configured for SEO.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 20:10:07 +01:00
b1284bad71 feat: Add team average Premier rating badges to match header
Display average Premier rating for each team in match statistics cards.

## Changes

### Average Rating Calculation
- Calculate average rank_new across all ranked players on each team
- Filter out unranked players (rating = 0 or null/undefined)
- Round to nearest integer for clean display

### UI Display
- Add PremierRatingBadge below team name in statistics cards
- Small size badge with trophy icon
- Automatically styled with tier colors (gray/blue/purple/pink/red/gold)
- Only show badge if team has at least one ranked player

## Implementation Details

Extended `calcTeamStats()` function to compute `avgRating` by:
1. Filtering players with valid rank_new > 0
2. Computing average if any ranked players exist
3. Returning undefined if no players are ranked

The PremierRatingBadge component handles all tier styling automatically based
on the rating value using the existing formatPremierRating utility.

Team badges provide quick visual indication of match skill level and help
identify skill disparities between teams.

This completes Phase 2 Feature 2 - team ratings now prominently displayed.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 20:07:59 +01:00
05ef985851 feat: Complete scoreboard with avatar, score, and player color indicators
Enhance match details scoreboard with additional visual information for better
player identification and context.

## Changes

### Avatar Column
- Display player profile images in first column (40x40px)
- Rounded style with border for consistent appearance
- Non-sortable for visual continuity

### Score Column
- Show in-game score for each player
- Sortable to identify top performers
- Monospace font for numeric alignment

### Player Color Indicators
- Add colored dot next to player names
- Map CS2 player colors (green, yellow, purple, blue, orange, grey) to hex values
- Visual indicator helps identify specific players during match review

## Implementation Details

Created `playerColors` mapping object to convert CS2's player color names to
hex color codes for display. Updated Player name column render function to
include inline colored dot element.

All columns maintain existing team color styling (terrorist/CT) for consistency.

Note: MVP and K/D ratio columns already existed in scoreboard.

This completes Phase 2 Feature 1 - scoreboard now provides comprehensive player
information at a glance.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 20:05:51 +01:00
ae7d880bc1 feat: Add recently visited players tracking to home page
Implement localStorage-based player visit tracking with display on home page.

## New Features

### Recently Visited Players Component
- **RecentPlayers.svelte**: Grid display of up to 10 recently visited players
- **Responsive Layout**: 1-4 columns based on screen size
- **Player Cards**: Avatar, name, and time since last visit
- **Remove Functionality**: Individual player removal with X button
- **Auto-show/hide**: Component only renders when players have been visited

### Player Visit Tracking
- **recentPlayers utility**: localStorage management functions
  - `getRecentPlayers()`: Retrieve sorted list by most recent
  - `addRecentPlayer()`: Add/update player visit with timestamp
  - `removeRecentPlayer()`: Remove specific player from list
  - `clearRecentPlayers()`: Clear entire history
- **Auto-tracking**: Player profile page automatically records visits on mount
- **Smart deduplication**: Visiting same player updates timestamp, no duplicates
- **Max limit**: Maintains up to 10 most recent players

### Time Display
- Relative time formatting: "Just now", "5m ago", "2h ago", "3d ago"
- Real-time updates when component mounts
- Human-readable timestamps

### UX Features
- **Hover Effects**: Border highlights and shadow on card hover
- **Team Colors**: Player names inherit team colors from profiles
- **Remove on Hover**: X button appears only on hover for clean interface
- **Click Protection**: Remove button prevents navigation when clicked
- **Footer Info**: Shows count of displayed players

## Implementation Details

- **localStorage Key**: `cs2wtf_recent_players`
- **Data Structure**: Array of `{id, name, avatar, visitedAt}` objects
- **Sort Order**: Most recently visited first
- **SSR Safe**: All localStorage operations check for browser environment
- **Error Handling**: Try-catch wraps all storage operations with console errors

## Integration

- Added to home page between hero and featured matches
- Integrates seamlessly with existing layout and styling
- No data fetching required - pure client-side functionality
- Persists across sessions via localStorage

This completes Phase 1 (6/6) - all critical features now implemented!

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 19:43:52 +01:00
2215cab77f feat: Add comprehensive weapons statistics tab for matches
Implement detailed weapon performance tracking and visualization.

## New Features

### Weapons Tab
- Added "Weapons" tab to match layout navigation
- Created `/match/[id]/weapons` route with server-side data loading
- Displays weapon statistics for all players in the match

### Statistics Displayed

**Overview Cards:**
- Total kills across all weapons
- Total damage dealt
- Total hits landed

**Charts & Visualizations:**
- Bar chart: Top 10 most-used weapons by kills
- Pie chart: Hit location distribution (head, chest, stomach, arms, legs)
- Legend with exact hit counts for each body part

**Player Performance Table:**
- Player name (with team color coding)
- Top weapon for each player
- Total kills per player
- Total damage per player
- Total hits per player
- Sortable columns for easy comparison

### Technical Implementation

- **Data Loading**: Server-side fetch of weapons data via `getMatchWeapons()` API
- **Type Safety**: Full TypeScript types with WeaponStats, PlayerWeaponStats, MatchWeaponsResponse
- **Error Handling**: Graceful fallback when weapons data unavailable
- **Aggregation**: Weapon stats aggregated across all players for match-wide insights
- **Team Colors**: Player names colored by team (terrorist/CT)

### UX Improvements

- Empty state with helpful message when no weapons data exists
- Responsive grid layouts for cards and charts
- Consistent styling with existing tabs
- Interactive data table with hover states and sorting

This completes Phase 1 feature 5 of 6 - comprehensive weapon performance analysis
gives players insight into their weapon choices and accuracy.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 19:39:38 +01:00
7d642b0be3 feat: Add VAC/ban status column to match scoreboard
Add per-player VAC and game ban status display in match details scoreboard.

## Changes

- **Types & Schema**: Add vac and game_ban optional boolean fields to MatchPlayer
- **Transformer**: Extract vac and game_ban from legacy player.vac and player.game_ban
- **UI**: Add "Status" column to details table showing VAC/BAN badges
- **Mocks**: Update mock player data with ban status fields

## Implementation Details

The backend API provides per-player ban status in match details via the player
object (player.vac and player.game_ban). These were previously not being extracted
by the transformer.

The new Status column displays:
- Red "VAC" badge if player has VAC ban
- Red "BAN" badge if player has game ban
- Both badges if player has both bans
- "-" if player has no bans

Users can identify banned players at a glance in the scoreboard, improving
transparency and match context.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 19:34:39 +01:00
8f3b652740 feat: Implement Phase 1 critical features and fix API integration
This commit completes the first phase of feature parity implementation and
resolves all API integration issues to match the backend API format.

## API Integration Fixes

- Remove all hardcoded default values from transformers (tick_rate, kast, player_count, steam_updated)
- Update TypeScript types to make fields optional where backend doesn't guarantee them
- Update Zod schemas to validate optional fields correctly
- Fix mock data to match real API response format (plain arrays, not wrapped objects)
- Update UI components to handle undefined values with proper fallbacks
- Add comprehensive API documentation for Match and Player endpoints

## Phase 1 Features Implemented (3/6)

### 1. Player Tracking System 
- Created TrackPlayerModal.svelte with auth code input
- Integrated track/untrack player API endpoints
- Added UI for providing optional share code
- Displays tracked status on player profiles
- Full validation and error handling

### 2. Share Code Parsing 
- Created ShareCodeInput.svelte component
- Added to matches page for easy match submission
- Real-time validation of share code format
- Parse status feedback with loading states
- Auto-redirect to match page on success

### 3. VAC/Game Ban Status 
- Added VAC and game ban count/date fields to Player type
- Display status badges on player profile pages
- Show ban count and date when available
- Visual indicators using DaisyUI badge components

## Component Improvements

- Modal.svelte: Added Svelte 5 Snippet types, actions slot support
- ThemeToggle.svelte: Removed deprecated svelte:component usage
- Tooltip.svelte: Fixed type safety with Snippet type
- All new components follow Svelte 5 runes pattern ($state, $derived, $bindable)

## Type Safety & Linting

- Fixed all ESLint errors (any types → proper types)
- Fixed form label accessibility issues
- Replaced error: any with error: unknown + proper type guards
- Added Snippet type imports where needed
- Updated all catch blocks to use instanceof Error checks

## Static Assets

- Migrated all files from public/ to static/ directory per SvelteKit best practices
- Moved 200+ map icons, screenshots, and other assets
- Updated all import paths to use /images/ (served from static/)

## Documentation

- Created IMPLEMENTATION_STATUS.md tracking all 15 missing features
- Updated API.md with optional field annotations
- Created MATCHES_API.md with comprehensive endpoint documentation
- Added inline comments marking optional vs required fields

## Testing

- Updated mock fixtures to remove default values
- Fixed mock handlers to return plain arrays like real API
- Ensured all components handle undefined gracefully

## Remaining Phase 1 Tasks

- [ ] Add VAC status column to match scoreboard
- [ ] Create weapons statistics tab for matches
- [ ] Implement recently visited players on home page

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 19:31:18 +01:00
a861b1c1b6 fix: Fix player profile loading with API transformer and improve UI layout
- Add LegacyPlayerProfile transformer to handle API response format mismatch
- Transform avatar hashes to full Steam CDN URLs
- Map team IDs correctly (API 1/2 -> Schema 2/3)
- Calculate aggregate stats (avg_kills, avg_deaths, win_rate) from matches
- Reduce featured matches on homepage from 6 to 3
- Show 4 recent matches on player profile instead of 10
- Display recent matches in 4-column grid on desktop (side-by-side)

Fixes "Player not found" error for all player profiles.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 00:43:50 +01:00
62bfdc8090 fix: Fix match page SSR, tab errors, and table consistency
- Enable SSR for match pages by detecting server vs client context in API client
- Fix 500 errors on economy, chat, and details tabs by adding data loaders
- Handle unparsed matches gracefully with "Match Not Parsed" messages
- Fix dynamic team ID detection instead of hardcoding team IDs 2/3
- Fix DataTable component to properly render HTML in render functions
- Add fixed column widths to tables for visual consistency
- Add meta titles to all tab page loaders
- Fix Svelte 5 $derived syntax errors
- Fix ESLint errors (unused imports, any types, reactive state)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 00:27:47 +01:00
7d8e3a6de0 feat: Add sorting and result filtering to matches page
Adds client-side sorting and filtering capabilities:

Sorting options:
- Date (newest/oldest)
- Duration (shortest/longest)
- Score difference (close games/blowouts)
- Toggle ascending/descending order

Result filters:
- All matches
- Wins only (Team A won)
- Losses only (Team B won)
- Ties only

Features:
- Reactive $derived computed matches list
- Shows filtered count vs total matches
- Empty state when no matches match filters
- Clear filter button when results are empty
- Works seamlessly with pagination (Load More)

Completes Phase 5.2 advanced features from TODO.md.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 23:45:21 +01:00
8093d4d308 feat: Implement Flashes Tab with real flash effectiveness data
Replaces placeholder with fully functional flash analysis tab showing:
- Summary stats: total enemies blinded, flash assists, blind time
- Team comparison cards (T vs CT)
- Flash effectiveness leaderboard (sortable DataTable)
- Per-team detailed flash stats tables
- Average blind duration per flash calculation
- Self-flash and team-flash tracking

Data includes:
- Enemies blinded count
- Average blind duration (in seconds)
- Flash assists (kills while enemy blinded)
- Teammates flashed (accidents)
- Self-flashed count

Completes Phase 5.7 from TODO.md (Flash Tab implementation).

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 23:42:50 +01:00
f583ff54a9 fix: Remove Number() conversions that corrupt uint64 IDs
JavaScript's Number type cannot accurately represent uint64 values exceeding
Number.MAX_SAFE_INTEGER (2^53-1). Converting these IDs to numbers causes
precision loss and API errors.

Root cause found:
- match/[id]/+layout.ts: `Number(params.id)` corrupted match IDs
- player/[id]/+page.ts: `Number(params.id)` corrupted player IDs

Example of the bug:
- URL param: "3638078243082338615" (correct)
- After Number(): 3638078243082339000 (rounded!)
- API response: "Match 3638078243082339000 not found"

Changes:
- Remove Number() conversions in route loaders
- Keep params.id as string throughout the application
- Update API functions to only accept string (not string | number)
- Update MatchesQueryParams.player_id type to string
- Add comprehensive transformers for legacy API responses
- Transform player stats: duo→mk_2, triple→mk_3, steamid64→id
- Build full Steam avatar URLs
- Make share_code optional (not always present)

This ensures uint64 IDs maintain full precision from URL → API → response.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 23:38:37 +01:00
43c50084c6 feat: Implement Load More pagination for matches page
- Add pagination state management (matches, hasMore, nextPageTime)
- Create loadMore() function to fetch and append next page of results
- Replace placeholder "pagination coming soon" with functional Load More button
- Add loading spinner during pagination requests
- Show total matches count and "all loaded" message when complete
- Use $effect to reset pagination state when filters change

Completes Phase 5.2 pagination requirement from TODO.md.
Users can now browse through large match lists efficiently.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 23:17:33 +01:00
ea61061530 fix: Add API response transformer for legacy CSGOW.TF format
- Create transformers.ts to convert legacy API format to new schema
- Transform score array [a, b] to score_team_a/score_team_b fields
- Convert Unix timestamps to ISO strings
- Map legacy field names (parsed, vac, game_ban) to new names
- Update matches API to use transformer with proper types
- Handle empty map names gracefully in homepage
- Limit featured matches to exactly 6 items

Fixes homepage data display issue where API format mismatch prevented
matches from rendering. API returns legacy CSGO:WTF format while frontend
expects new CS2.WTF schema.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 23:14:28 +01:00
8b73a68a6b feat: Add player profile performance charts and visualizations
Implemented comprehensive performance analysis for player profiles with interactive charts
and detailed statistics visualization.

Key Features:
- Performance Trend Chart (K/D and KAST over last 15 matches)
- Map Performance Chart (win rate per map with color coding)
- Utility Effectiveness Stats (flash assists, enemies blinded, HE/flame damage)
- Responsive charts using Chart.js LineChart and BarChart components

Technical Updates:
- Enhanced page loader to fetch 15 detailed matches with player stats
- Fixed DataTable Svelte 5 compatibility and type safety
- Updated MatchCard and PlayerCard to use PlayerMeta properties
- Proper error handling and typed data structures

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 21:49:36 +01:00
274f5b3b53 fix: Configure Vite proxy to eliminate CORS issues in development
Implemented a comprehensive CORS proxy solution that works with both
local and remote backends during development.

## Changes

### Vite Configuration (vite.config.ts)
- Use loadEnv() to properly read VITE_API_BASE_URL from .env
- Configure proxy to forward /api/* requests to backend
- Add detailed logging for proxy requests and responses
- Support changeOrigin, rewrite, secure=false, and websockets

### API Client (src/lib/api/client.ts)
- In development: Always use /api prefix (proxied)
- In production: Use direct VITE_API_BASE_URL
- Add console logging to show proxy configuration in dev mode
- Automatic detection of environment (DEV vs PROD)

### Error Handling (route loaders)
- Fix console.error() calls that caused TypeError with circular refs
- Use error.message instead of logging full error objects
- Affects: +page.ts, matches/+page.ts

### Documentation
- docs/LOCAL_DEVELOPMENT.md: Complete rewrite with proxy explanation
  - Quick start guide for both production API and local backend
  - Detailed proxy flow diagrams
  - Comprehensive troubleshooting section
  - Clear examples and logs

- docs/CORS_PROXY.md: Technical deep-dive on proxy implementation
  - How the proxy works internally
  - Configuration options explained
  - Testing procedures
  - Common issues and solutions

- .env.example: Updated with proxy documentation

## How It Works

Development Flow:
1. Frontend makes request: /api/matches
2. Vite proxy intercepts and forwards to: ${VITE_API_BASE_URL}/matches
3. Backend responds (no CORS headers needed)
4. Proxy returns response to frontend (same-origin)

Production Flow:
1. Frontend makes request directly to: https://api.csgow.tf/matches
2. Backend responds with CORS headers
3. Browser allows request (CORS enabled on backend)

## Benefits
 No CORS errors in development
 Works with local backend (localhost:8000)
 Works with remote backend (api.csgow.tf)
 Simple configuration (just set VITE_API_BASE_URL)
 Detailed logging for debugging
 Production build unaffected (direct requests)

## Testing
Verified with production API:
- curl https://api.csgow.tf/matches ✓
- Dev server proxy logs show successful forwarding ✓
- Browser Network tab shows /api/* requests ✓
- No CORS errors in console ✓

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 21:34:26 +01:00
e81be2cf68 fix: Use svelte:component for dynamic icon rendering in ThemeToggle
Replace invalid {@const} usage with proper <svelte:component this={...}> syntax.
The {@const} tag must be an immediate child of specific block structures,
not directly inside regular HTML elements.
2025-11-04 21:19:28 +01:00
523136ffbc feat: Implement Phase 5 match detail tabs with charts and data visualization
This commit implements significant portions of Phase 5 (Feature Delivery) including:

Chart Components (src/lib/components/charts/):
- LineChart.svelte: Line charts with Chart.js integration
- BarChart.svelte: Vertical/horizontal bar charts with stacking
- PieChart.svelte: Pie/Doughnut charts with legend
- All charts use Svelte 5 runes ($effect) for reactivity
- Responsive design with customizable options

Data Display Components (src/lib/components/data-display/):
- DataTable.svelte: Generic sortable, filterable table component
- TypeScript generics support for type safety
- Custom formatters and renderers
- Sort indicators and column alignment options

Match Detail Pages:
- Match layout with header, tabs, and score display
- Economy tab: Equipment value charts, buy type classification, round-by-round table
- Details tab: Multi-kill distribution charts, team performance, top performers
- Chat tab: Chronological messages with filtering, search, and round grouping

Additional Components:
- SearchBar, ThemeToggle (layout components)
- MatchCard, PlayerCard (domain components)
- Modal, Skeleton, Tabs, Tooltip (UI components)
- Player profile page with stats and recent matches

Dependencies:
- Installed chart.js for data visualization
- Created Svelte 5 compatible chart wrappers

Phase 4 marked as complete, Phase 5 at 50% completion.
Flashes and Damage tabs deferred for future implementation.

Note: Minor linting warnings to be addressed in follow-up commit.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 21:17:32 +01:00
24b990ac62 feat: Implement Phase 4 - Application Architecture & Routing
Phase 4 establishes the core application structure with SvelteKit routing,
data loading, error handling, and state management.

## Routing & Data Loading
- Created root layout load function (+layout.ts) with app version and feature flags
- Implemented comprehensive error boundary (+error.svelte) with status-based messages
- Added page loaders for homepage, matches, players, and about routes
- Homepage loader fetches featured matches via API with error fallback
- Matches loader supports URL query parameters (map, player_id, limit)

## State Management (Svelte Stores)
- preferences.ts: User settings with localStorage persistence
  * Theme selection (cs2dark, cs2light, auto)
  * Favorite players tracking
  * Advanced stats toggle, date format preferences
- search.ts: Search state with recent searches (localStorage)
- toast.ts: Toast notification system with auto-dismiss
  * Success, error, warning, info types
  * Configurable duration and dismissibility

## UI Components
- Toast.svelte: Individual notification with Lucide icons
- ToastContainer.svelte: Fixed top-right toast display
- Integrated ToastContainer into root layout

## Fixes
- Fixed Svelte 5 deprecation warnings (removed <svelte:component> in runes mode)
- Updated homepage to use PageData from loader
- Added proper type safety across all load functions

## Testing
- Type check: 0 errors, 0 warnings
- Production build: Successful
- All Phase 4 core objectives completed

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 20:47:49 +01:00
09ce400cd7 docs: mark Phase 3 as complete in TODO
Phase 3 (Domain Modeling & Data Layer) is now 100% complete:
-  TypeScript interfaces for all data models
-  Zod schemas with runtime validation
-  API client with error handling and cancellation
-  MSW mock handlers for testing
- Real-time features deferred to Phase 5

Now starting Phase 4: Application Architecture & Routing

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 20:37:20 +01:00
d811efc394 feat: complete Phase 3 - Domain Modeling & Data Layer
Implements comprehensive type system, runtime validation, API client,
and testing infrastructure for CS2.WTF.

TypeScript Interfaces (src/lib/types/):
- Match.ts: Match, MatchPlayer, MatchListItem types
- Player.ts: Player, PlayerMeta, PlayerProfile types
- RoundStats.ts: Round economy and performance data
- Weapon.ts: Weapon statistics with hit groups (HitGroup, WeaponType enums)
- Message.ts: Chat messages with filtering support
- api.ts: API responses, errors, and APIException class
- Complete type safety with strict null checks

Zod Schemas (src/lib/schemas/):
- Runtime validation for all data models
- Schema parsers with safe/unsafe variants
- Special handling for backend typo (looses → losses)
- Share code validation regex
- CS2-specific validations (rank 0-30000, MR12 rounds)

API Client (src/lib/api/):
- client.ts: Axios-based HTTP client with error handling
  - Request cancellation support (AbortController)
  - Automatic retry logic for transient failures
  - Timeout handling (10s default)
  - Typed APIException errors
- players.ts: Player endpoints (profile, meta, track/untrack, search)
- matches.ts: Match endpoints (parse, details, weapons, rounds, chat, search)
- Zod validation on all API responses

MSW Mock Handlers (src/mocks/):
- fixtures.ts: Comprehensive mock data for testing
- handlers/players.ts: Mock player API endpoints
- handlers/matches.ts: Mock match API endpoints
- browser.ts: Browser MSW worker for development
- server.ts: Node MSW server for Vitest tests
- Realistic responses with delays and pagination
- Safe integer IDs to avoid precision loss

Configuration:
- .env.example: Complete environment variable documentation
- src/vite-env.d.ts: Vite environment type definitions
- All strict TypeScript checks passing (0 errors, 0 warnings)

Features:
- Cancellable requests for search (prevent race conditions)
- Data normalization (backend typo handling)
- Comprehensive error types (NetworkError, Timeout, etc.)
- Share code parsing and validation
- Pagination support for players and matches
- Mock data for offline development and testing

Build Status: ✓ Production build successful
Type Check: ✓ 0 errors, 0 warnings
Lint: ✓ All checks passed
Phase 3 Completion: 100%

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 20:31:20 +01:00
66aea51c39 docs: mark Phase 1 and Phase 2 as complete in TODO
Phase 1 (Technical Foundations) - 100% complete:
- Node.js 20+ with .nvmrc, package manager locked to npm
- SvelteKit 2.0 + Svelte 5 with all dependencies installed
- TypeScript strict mode configured
- Tailwind CSS + DaisyUI with CS2 themes
- Complete dev tooling: ESLint, Prettier, Husky, Stylelint
- Vitest and Playwright test frameworks configured
- Vite with path aliases and optimizations
- Woodpecker CI pipeline updated

Phase 2 (Design System) - 100% complete:
- Comprehensive design documentation (docs/DESIGN.md)
- CS2-inspired DaisyUI themes (cs2dark, cs2light)
- Responsive layout guidelines and motion specs
- Core UI components implemented (Button, Badge, Card, Header, Footer)
- Accessibility guidelines documented and enforced

Now starting Phase 3: Domain Modeling & Data Layer

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 20:11:39 +01:00
153c0e9f13 feat: implement CS2-inspired design system and UI components
This commit delivers a comprehensive design system and component library
inspired by Counter-Strike 2's tactical aesthetic.

Design System:
- Created docs/DESIGN.md with complete design language documentation
- CS2-inspired color palette: T-side orange (#d4a74a), CT-side blue (#5e98d9)
- Dark-first approach with tactical, data-dense layouts
- Typography scale, spacing system, and animation guidelines

Component Library:
- Button component: 4 variants (primary, secondary, ghost, danger), 3 sizes
- Badge component: 7 variants including team-specific badges
- Card component: 3 variants (default, elevated, interactive)
- Header component: Responsive navigation with mobile menu
- Footer component: Site-wide footer with organized link sections

Pages:
- Redesigned homepage with hero section, featured matches, features grid, CTA
- Created placeholder pages: /matches, /players, /about
- All pages follow CS2 aesthetic with proper component usage

Technical Fixes:
- Fixed Svelte 5 snippet syntax errors (removed incorrect render prop pattern)
- Fixed Card component accessibility (conditional button/div rendering)
- Removed invalid CSS border-border class from app.css
- Ensured zero TypeScript errors and warnings

Build Status: ✓ Verified with 0 errors, 0 warnings

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 20:07:05 +01:00
288438a953 feat: complete Phase 1 - Technical Foundations for CS2.WTF rewrite
Initialize SvelteKit project with complete modern web development stack:

## Core Framework
- SvelteKit 2.0 with Svelte 5 and TypeScript strict mode
- Vite 5 for blazing fast dev server and builds
- Node.js 20 standardized via .nvmrc

## Styling & Theming
- Tailwind CSS 3.4 with utility-first approach
- DaisyUI 4.0 with custom CS2 themes (cs2dark/cs2light)
- CS2-branded color palette (T-side orange, CT-side blue)
- PostCSS for CSS processing

## Code Quality & Tooling
- ESLint 8 with TypeScript + Svelte plugins
- Prettier 3 with Svelte + Tailwind plugins
- Stylelint 16 for CSS linting
- Husky 9 + lint-staged for pre-commit hooks
- TypeScript 5.3 with all strict flags enabled

## Testing Infrastructure
- Vitest 1.0 for unit/component tests with jsdom
- Playwright 1.40 for E2E tests (3 browsers)
- Testing Library for component testing
- MSW 2.0 for API mocking
- Coverage thresholds set to 80%

## Project Structure
- Organized src/ with lib/, routes/, mocks/, tests/
- Component directories: layout, ui, charts, match, player
- Path aliases configured: $lib, $components, $stores, $types, $api
- Separate test directories: unit, integration, e2e

## CI/CD & Deployment
- Updated Woodpecker CI pipeline with quality gates
- Pipeline steps: install → lint → type-check → test → build
- Deploy targets: master (prod), dev (staging), cs2-port (preview)

## Documentation
- Comprehensive README.md with setup guide
- API.md with complete backend documentation (12 endpoints)
- TODO.md updated with Phase 0 & 1 completion
- Environment variables template (.env.example)

## Development Experience
- Hot module reloading configured
- Dev server running on port 5173
- All npm scripts defined for dev, test, build workflows
- Pre-commit hooks prevent broken code commits

Project is now ready for feature development (Phase 2+).

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 19:54:35 +01:00
0404188d4d Document CSGOWTFD backend API and update domain modeling plans
Reflect backend audit findings in TODO.md:
- 12 REST endpoints documented for player, match, matches, and sitemap
- Data models aligned with backend schemas (Match, Player, MatchPlayer,
  etc.)
- CS2 compatibility confirmed with Premier rating support (0-30000)

Add comprehensive API documentation covering:
- Endpoint specifications and response structures
- Integration guide with TypeScript examples
- Error handling and caching strategies
- CS2 migration notes for rank system and MR12 changes
2025-11-04 19:32:08 +01:00
366bfbeb54 Update TODO with detailed rewrite plan and CS2 specifics 2025-11-04 19:25:15 +01:00
be89c68f89 Add TODO document for CS2.WTF rewrite 2025-11-04 18:35:57 +01:00
9ab7ee91ea refactor!: Clear out legacy code for rewrite. 2025-11-04 18:35:46 +01:00
558 changed files with 127862 additions and 119507 deletions

View File

@@ -1,3 +0,0 @@
> 1%
last 2 versions
not dead

View File

@@ -1,10 +0,0 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
[*.{js,json,yml}]
charset = utf-8
indent_style = space
indent_size = 2

80
.env.example Normal file
View File

@@ -0,0 +1,80 @@
# CS2.WTF Environment Configuration
# Copy this file to .env for local development
# DO NOT commit .env to version control
# ============================================
# API Configuration
# ============================================
# Backend API Base URL
# Development: Vite proxy forwards /api to this URL (default: http://localhost:8000)
# Production: Set to your actual backend URL (e.g., https://api.csgow.tf)
# Note: In development, the frontend uses /api and Vite proxies to this URL
VITE_API_BASE_URL=http://localhost:8000
# API request timeout in milliseconds
# Default: 10000 (10 seconds)
VITE_API_TIMEOUT=10000
# ============================================
# Feature Flags
# ============================================
# Enable live match updates (polling/WebSocket)
# Default: false
VITE_ENABLE_LIVE_MATCHES=false
# Enable analytics tracking
# Default: true (respects user consent)
VITE_ENABLE_ANALYTICS=true
# Enable debug mode (verbose logging, dev tools)
# Default: false
VITE_DEBUG_MODE=false
# ============================================
# Analytics & Tracking (Optional)
# ============================================
# Plausible Analytics
# Only required if analytics is enabled
# VITE_PLAUSIBLE_DOMAIN=cs2.wtf
# VITE_PLAUSIBLE_API_HOST=https://plausible.io
# Umami Analytics (alternative)
# VITE_UMAMI_WEBSITE_ID=your-website-id
# VITE_UMAMI_SRC=https://analytics.example.com/script.js
# ============================================
# Experimental Features
# ============================================
# Enable WebGL-based heatmaps (high performance)
# Default: false (use Canvas fallback)
# VITE_ENABLE_WEBGL_HEATMAPS=false
# Enable MSW API mocking in development
# Useful for frontend development without backend
# Default: false
# VITE_ENABLE_MSW_MOCKING=false
# ============================================
# Build Configuration
# ============================================
# App version (auto-populated from package.json)
# VITE_APP_VERSION=2.0.0
# Build timestamp (auto-populated during build)
# VITE_BUILD_TIMESTAMP=2024-11-04T12:00:00Z
# ============================================
# SSR/Deployment (Advanced)
# ============================================
# Public base URL for the application
# Used for canonical URLs, sitemaps, etc.
# PUBLIC_BASE_URL=https://cs2.wtf
# Origin whitelist for CORS (if handling API in same domain)
# PUBLIC_CORS_ORIGINS=https://cs2.wtf,https://www.cs2.wtf

View File

@@ -1,17 +0,0 @@
module.exports = {
root: true,
env: {
node: true
},
'extends': [
'plugin:vue/vue3-essential',
'eslint:recommended'
],
parserOptions: {
parser: '@babel/eslint-parser'
},
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
}
}

304
.gitignore vendored
View File

@@ -1,286 +1,52 @@
# Created by https://www.toptal.com/developers/gitignore/api/webstorm+all,yarn,windows,linux,node,vuejs .DS_Store
# Edit at https://www.toptal.com/developers/gitignore?templates=webstorm+all,yarn,windows,linux,node,vuejs node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
### Linux ###
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
### Node ###
# Logs # Logs
logs logs
*.log *.log
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html) # Editor directories and files
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json .vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Runtime data # Build artifacts
pids dist
*.pid dist-ssr
*.seed *.local
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover # Test coverage
lib-cov
# Coverage directory used by tools like istanbul
coverage coverage
*.lcov *.lcov
# nyc test coverage
.nyc_output .nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) # Playwright
.grunt /test-results/
/playwright-report/
# Bower dependency directory (https://bower.io/) /playwright/.cache/
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
.env.production
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
### Node Patch ###
# Serverless Webpack directories
.webpack/
### Vuejs ###
# Recommended template: Node.gitignore
dist/
npm-debug.log
yarn-error.log
### WebStorm+all ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
### WebStorm+all Patch ###
# Ignores the whole .idea folder and all .iml files
# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360
.idea/
# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023
*.iml
modules.xml
.idea/misc.xml
*.ipr
# Sonarlint plugin
.idea/sonarlint
### Windows ###
# Windows thumbnail cache files
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
### yarn ###
# https://yarnpkg.com/advanced/qa#which-files-should-be-gitignored
.yarn/*
!.yarn/releases
!.yarn/plugins
!.yarn/sdks
!.yarn/versions
# if you are NOT using Zero-installs, then:
# comment the following lines
#!.yarn/cache
# and uncomment the following lines
.pnp.*
# End of https://www.toptal.com/developers/gitignore/api/webstorm+all,yarn,windows,linux,node,vuejs
# Vercel
.vercel
# Temporary files
.tmp
tmp
*.tmp

4
.husky/pre-commit Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
20.11.0

30
.prettierignore Normal file
View File

@@ -0,0 +1,30 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock
# Build artifacts
dist/
.vercel/
.netlify/
.output/
# Generated files
src-tauri/target/
**/.svelte-kit/
# IDE
.vscode/
.idea/
# Logs
*.log

17
.prettierrc.json Normal file
View File

@@ -0,0 +1,17 @@
{
"useTabs": true,
"tabWidth": 2,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"semi": true,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}

6
.stylelintignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
build/
.svelte-kit/
dist/
**/*.js
**/*.ts

13
.stylelintrc.cjs Normal file
View File

@@ -0,0 +1,13 @@
module.exports = {
extends: ['stylelint-config-standard'],
rules: {
'at-rule-no-unknown': [
true,
{
ignoreAtRules: ['tailwind', 'apply', 'variants', 'responsive', 'screen', 'layer']
}
],
'selector-class-pattern': null,
'custom-property-pattern': null
}
};

1
.tool-versions Normal file
View File

@@ -0,0 +1 @@
nodejs 20.11.0

View File

@@ -1,36 +1,76 @@
pipeline: pipeline:
install dependencies: install:
image: node:19 image: node:20
commands: commands:
- yarn install --immutable - npm ci
pull: true
lint:
image: node:20
commands:
- npm run lint
depends_on:
- install
pull: true
type-check:
image: node:20
commands:
- npm run check
depends_on:
- install
pull: true
test:
image: node:20
commands:
- npm run test
depends_on:
- install
pull: true pull: true
build: build:
image: node:19 image: node:20
commands: commands:
- yarn build - npm run build
secrets: [ vue_app_api_url, vue_app_track_url, vue_app_track_id, vue_app_tracking ] environment:
- VITE_API_BASE_URL=https://api.csgow.tf
secrets:
- vite_plausible_domain
- vite_sentry_dsn
depends_on:
- lint
- type-check
- test
pull: true pull: true
# E2E tests (optional - can be resource intensive)
# test-e2e:
# image: mcr.microsoft.com/playwright:v1.40.0-jammy
# commands:
# - npm run test:e2e
# depends_on:
# - build
deploy: deploy:
image: cschlosser/drone-ftps image: cschlosser/drone-ftps
settings: settings:
hostname: hostname:
from_secret: ftp_host from_secret: ftp_host
src_dir: "/dist/" src_dir: '/build/'
clean_dir: true clean_dir: true
secrets: [ ftp_username, ftp_password ] secrets: [ftp_username, ftp_password]
when: when:
branch: master branch: master
event: [ push, tag ] event: [push, tag]
status: success status: success
deploy (dev): deploy-dev:
image: cschlosser/drone-ftps image: cschlosser/drone-ftps
settings: settings:
hostname: hostname:
from_secret: ftp_host from_secret: ftp_host
src_dir: "/dist/" src_dir: '/build/'
clean_dir: true clean_dir: true
secrets: secrets:
- source: ftp_username_dev - source: ftp_username_dev
@@ -41,3 +81,20 @@ pipeline:
branch: dev branch: dev
event: [push, tag] event: [push, tag]
status: success status: success
deploy-cs2:
image: cschlosser/drone-ftps
settings:
hostname:
from_secret: ftp_host_cs2
src_dir: '/build/'
clean_dir: true
secrets:
- source: ftp_username_cs2
target: ftp_username
- source: ftp_password_cs2
target: ftp_password
when:
branch: cs2-port
event: [push]
status: success

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +0,0 @@
nodeLinker: node-modules
plugins:
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
spec: "@yarnpkg/plugin-interactive-tools"
yarnPath: .yarn/releases/yarn-3.4.1.cjs

326
README.md
View File

@@ -1,28 +1,314 @@
# CSGOW.TF # CS2.WTF
[![Vue3](https://img.shields.io/badge/created%20with-Vue3-%2342b883?style=flat-square)](https://vuejs.org/) [![SvelteKit](https://img.shields.io/badge/SvelteKit-5.0-FF3E00?style=flat-square&logo=svelte)](https://kit.svelte.dev/)
[![Go](https://img.shields.io/badge/created%20with-Go-%2379d4fd?style=flat-square)](https://go.dev/) [![TypeScript](https://img.shields.io/badge/TypeScript-5.3-3178C6?style=flat-square&logo=typescript)](https://www.typescriptlang.org/)
[![Tailwind CSS](https://img.shields.io/badge/Tailwind-3.4-06B6D4?style=flat-square&logo=tailwindcss)](https://tailwindcss.com/)
[![GPL3](https://img.shields.io/badge/licence-GPL3-%23007ec6?style=flat-square)](https://git.harting.dev/CSGOWTF/csgowtf/src/branch/master/LICENSE) [![GPL3](https://img.shields.io/badge/licence-GPL3-%23007ec6?style=flat-square)](https://git.harting.dev/CSGOWTF/csgowtf/src/branch/master/LICENSE)
[![Liberapay](https://img.shields.io/badge/donate%20on-LiberaPay-%23f6c915?style=flat-square)](https://liberapay.com/CSGOWTF/) [![Liberapay](https://img.shields.io/badge/donate%20on-LiberaPay-%23f6c915?style=flat-square)](https://liberapay.com/CSGOWTF/)
[![Liberapay patrons](https://img.shields.io/liberapay/patrons/csgowtf?style=flat-square)](https://liberapay.com/CSGOWTF/) [![status-badge](https://ci.somegit.dev/api/badges/CSGOWTF/csgowtf/status.svg?branch=cs2-port)](https://ci.somegit.dev/CSGOWTF/csgowtf)
[![Website](https://img.shields.io/website?down_message=down&label=csgow.tf&style=flat-square&up_message=up&url=https%3A%2F%2Fcsgow.tf)](https://csgow.tf/)
<!--[![Typescript](https://img.shields.io/badge/created%20with-typescript-%233178c6?style=flat-square)](https://www.typescriptlang.org/)-->
[![status-badge](https://ci.somegit.dev/api/badges/CSGOWTF/csgowtf/status.svg?branch=master)](https://ci.somegit.dev/CSGOWTF/csgowtf)
### Statistics for CS:GO matchmaking matches. **Statistics for CS2 matchmaking matches** - A complete rewrite of CSGOW.TF with modern web technologies.
--- ---
## Backend ## 🚀 Quick Start
This is the frontend to the [csgowtfd](https://git.harting.dev/CSGOWTF/csgowtfd) backend.
## Tips on how to contribute ### Prerequisites
- If you are implementing or fixing an issue, please comment on the issue so work is not duplicated.
- If you want to implement a new feature, create an issue first describing the issue, so we know about it. - **Node.js** ≥ 18.0.0 (v20.11.0 recommended - see `.nvmrc`)
- Don't commit unnecessary changes to the codebase or debugging code. - **npm** or **yarn**
- Write meaningful commits or squash them.
- Please try to follow the code style of the rest of the codebase. ### Installation
- Only make pull requests to the dev branch.
- Only implement one feature per pull request to keep it easy to understand. ```bash
- Expect comments or questions on your pull request from the project maintainers. We try to keep the code as consistent and maintainable as possible. # Clone the repository
- Each pull request should come from a new branch in your fork, it should have a meaningful name. git clone https://somegit.dev/CSGOWTF/csgowtf.git
cd csgowtf
# Switch to the cs2-port branch
git checkout cs2-port
# Install dependencies
npm install
# Copy environment variables
cp .env.example .env
# Start development server
npm run dev
```
The app will be available at `http://localhost:5173`
---
## 📦 Tech Stack
### Core Framework
- **SvelteKit 2.0** - Full-stack framework with SSR/SSG
- **Svelte 5** - Reactive UI framework
- **TypeScript 5.3** - Type safety (strict mode)
- **Vite 5** - Build tool and dev server
### Styling
- **Tailwind CSS 3.4** - Utility-first CSS framework
- **DaisyUI 4.0** - Component library with CS2 custom themes
- **PostCSS** - CSS processing
### Data & State
- **Axios** - HTTP client for API requests
- **Zod** - Runtime type validation and parsing
- **Svelte Stores** - State management
### Testing
- **Vitest** - Unit and component testing
- **Playwright** - End-to-end testing
- **Testing Library** - Component testing utilities
- **MSW** - API mocking
### Code Quality
- **ESLint** - Linting (TypeScript + Svelte)
- **Prettier** - Code formatting
- **Stylelint** - CSS linting
- **Husky** - Git hooks
- **lint-staged** - Pre-commit linting
---
## 🛠️ Development
### Available Scripts
```bash
# Development
npm run dev # Start dev server
npm run dev -- --host # Expose to network
# Type Checking
npm run check # Run type check
npm run check:watch # Type check in watch mode
# Linting & Formatting
npm run lint # Run ESLint + Prettier check
npm run lint:fix # Auto-fix linting issues
npm run format # Format code with Prettier
# Testing
npm run test # Run unit tests
npm run test:watch # Run tests in watch mode
npm run test:coverage # Generate coverage report
npm run test:e2e # Run E2E tests (headless)
npm run test:e2e:ui # Run E2E tests with UI
npm run test:e2e:debug # Debug E2E tests
# Building
npm run build # Build for production
npm run preview # Preview production build
```
### Project Structure
```
csgowtf/
├── src/
│ ├── lib/
│ │ ├── api/ # API client & endpoints
│ │ ├── components/ # Reusable Svelte components
│ │ │ ├── layout/ # Header, Footer, Nav
│ │ │ ├── ui/ # Base UI components
│ │ │ ├── charts/ # Data visualization
│ │ │ ├── match/ # Match-specific components
│ │ │ └── player/ # Player-specific components
│ │ ├── stores/ # Svelte stores (state)
│ │ ├── types/ # TypeScript types
│ │ ├── utils/ # Helper functions
│ │ └── i18n/ # Internationalization
│ ├── routes/ # SvelteKit routes (pages)
│ ├── mocks/ # MSW mock handlers
│ ├── tests/ # Test setup
│ ├── app.html # HTML shell
│ └── app.css # Global styles
├── tests/
│ ├── unit/ # Unit tests
│ ├── integration/ # Integration tests
│ └── e2e/ # E2E tests
├── docs/ # Documentation
│ ├── API.md # Backend API reference
│ └── TODO.md # Project roadmap
├── public/ # Static assets
└── static/ # Additional static files
```
---
## 🎨 Features
### Current (Phase 1 - ✅ 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)
- ✅ CI/CD pipeline (Woodpecker)
- ✅ Backend API documented
### Planned (See `docs/TODO.md` for details)
- 🏠 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)
---
## 🔗 Backend
This frontend connects to the [csgowtfd](https://somegit.dev/CSGOWTF/csgowtfd) backend.
- **Language**: Go
- **Framework**: Gin
- **Database**: PostgreSQL
- **Cache**: Redis
- **API Docs**: See `docs/API.md`
Default API endpoint: `http://localhost:8000`
---
## 🧪 Testing
### Unit & Component Tests
```bash
# Run all tests
npm run test
# Watch mode for TDD
npm run test:watch
# Generate coverage report
npm run test:coverage
```
### End-to-End Tests
```bash
# Run E2E tests (headless)
npm run test:e2e
# Run with Playwright UI
npm run test:e2e:ui
# Debug mode
npm run test:e2e:debug
```
---
## 🚢 Deployment
### Build for Production
```bash
npm run build
```
The built app will be in the `build/` directory, ready to be deployed to any Node.js hosting platform.
### Environment Variables
See `.env.example` for all available configuration options:
- `VITE_API_BASE_URL` - Backend API URL
- `VITE_API_TIMEOUT` - API request timeout
- `VITE_ENABLE_LIVE_MATCHES` - Feature flag for live matches
- `VITE_ENABLE_ANALYTICS` - Feature flag for analytics
### CI/CD
Woodpecker CI automatically builds and deploys:
- **`master`** branch → Production
- **`dev`** branch → Development/Staging
- **`cs2-port`** branch → CS2 Preview (during rewrite)
---
## 🤝 Contributing
We welcome contributions! Please follow these guidelines:
### Before You Start
- Check existing issues or create one describing your feature/fix
- Comment on the issue to avoid duplicate work
- Fork the repository and create a feature branch
### Code Standards
- Follow TypeScript strict mode (no `any` types)
- Write tests for new features
- Follow existing code style (enforced by ESLint/Prettier)
- Keep components under 300 lines
- Write meaningful commit messages (Conventional Commits)
### Pull Request Process
1. Create a feature branch: `feature/your-feature-name`
2. Make your changes and commit with clear messages
3. Run linting and tests: `npm run lint && npm run test`
4. Push to your fork and create a PR to the `cs2-port` branch
5. Ensure CI passes and address review feedback
### Git Workflow
- Branch naming: `feature/`, `fix/`, `refactor/`, `docs/`
- Commit messages: `feat:`, `fix:`, `docs:`, `test:`, `refactor:`
- Only one feature/fix per PR
- Squash commits before merging
---
## 📚 Documentation
- **API Reference**: [`docs/API.md`](docs/API.md) - Complete backend API documentation
- **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/)
- **DaisyUI**: [daisyui.com](https://daisyui.com/)
---
## 📄 License
[GPL-3.0](LICENSE) © CSGOW.TF Team
---
## 💖 Support
If you find this project helpful, consider supporting us:
[![Liberapay](https://img.shields.io/badge/donate%20on-LiberaPay-%23f6c915?style=for-the-badge)](https://liberapay.com/CSGOWTF/)
---
## 🔗 Links
- **Website**: [csgow.tf](https://csgow.tf) (legacy CS:GO version)
- **Backend**: [csgowtfd](https://somegit.dev/CSGOWTF/csgowtfd)
- **Issues**: [Report a bug](https://somegit.dev/CSGOWTF/csgowtf/issues)
---
**Status**: 🚧 **Phase 1 Complete** - Active rewrite for CS2 support

1154
TODO.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +0,0 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

1087
docs/API.md Normal file

File diff suppressed because it is too large Load Diff

393
docs/CORS_PROXY.md Normal file
View File

@@ -0,0 +1,393 @@
# API Proxying with SvelteKit Server Routes
This document explains how API requests are proxied to the backend using SvelteKit server routes.
## Why Use Server Routes?
The CS2.WTF frontend uses **SvelteKit server routes** to proxy API requests to the backend. This approach provides several benefits:
-**Works in all environments**: Development, preview, and production
-**No CORS issues**: Requests are server-side
-**Single code path**: Same behavior everywhere
-**Flexible backend switching**: Change one environment variable
-**Future-proof**: Can add caching, rate limiting, auth later
-**Better security**: Backend URL not exposed to client
## Architecture
### Request Flow
```
Browser → /api/matches → SvelteKit Server Route → Backend → Response
```
**Detailed Flow**:
```
1. Browser: GET http://localhost:5173/api/matches?limit=20
2. SvelteKit: Routes to src/routes/api/[...path]/+server.ts
3. Server Handler: Reads VITE_API_BASE_URL environment variable
4. Backend Call: GET https://api.csgow.tf/matches?limit=20
5. Backend: Returns JSON response
6. Server Handler: Forwards response to browser
7. Browser: Receives response (no CORS issues!)
```
**SSR (Server-Side Rendering) Flow**:
```
1. Page Load: +page.ts calls api.matches.getMatches()
2. API Client: Detects import.meta.env.SSR === true
3. Direct Call: GET https://api.csgow.tf/matches?limit=20
4. Backend: Returns JSON response
5. SSR: Renders page with data
```
**Note**: SSR bypasses the SvelteKit route and calls the backend directly because relative URLs (`/api`) don't work during server-side rendering.
### Key Components
**1. SvelteKit Server Route** (`src/routes/api/[...path]/+server.ts`)
- Catch-all route that matches `/api/*`
- Forwards requests to backend
- Supports GET, POST, DELETE methods
- Handles errors gracefully
**2. API Client** (`src/lib/api/client.ts`)
- Browser: Uses `/api` base URL (routes to SvelteKit)
- SSR: Uses `VITE_API_BASE_URL` directly (bypasses SvelteKit route)
- Automatically detects environment with `import.meta.env.SSR`
**3. Environment Variable** (`.env`)
- `VITE_API_BASE_URL` controls which backend to use
- Switch between local and production easily
## Configuration
### Environment Variables
**`.env`**:
```env
# Production API (default)
VITE_API_BASE_URL=https://api.csgow.tf
# Local backend (for development)
# VITE_API_BASE_URL=http://localhost:8000
```
**Switching Backends**:
```bash
# Use production API
echo "VITE_API_BASE_URL=https://api.csgow.tf" > .env
npm run dev
# Use local backend
echo "VITE_API_BASE_URL=http://localhost:8000" > .env
npm run dev
```
### Server Route Implementation
**File**: `src/routes/api/[...path]/+server.ts`
```typescript
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://api.csgow.tf';
export const GET: RequestHandler = async ({ params, url }) => {
const path = params.path; // e.g., "matches"
const queryString = url.search; // e.g., "?limit=20"
const backendUrl = `${API_BASE_URL}/${path}${queryString}`;
try {
const response = await fetch(backendUrl);
const data = await response.json();
return json(data);
} catch (err) {
throw error(503, 'Unable to connect to backend');
}
};
```
### API Client Configuration
**File**: `src/lib/api/client.ts`
```typescript
// Simple, single configuration
const API_BASE_URL = '/api';
// Always routes to SvelteKit server routes
// No environment detection needed
```
## Testing the Setup
### 1. Check Environment Variable
```bash
cat .env
# Should show:
VITE_API_BASE_URL=https://api.csgow.tf
# or
VITE_API_BASE_URL=http://localhost:8000
```
### 2. Start Development Server
```bash
npm run dev
# Server starts on http://localhost:5173
```
### 3. Check Network Requests
Open DevTools → Network tab:
- ✅ Requests go to `/api/matches`, `/api/player/123`, etc.
- ✅ Status should be `200 OK`
- ✅ No CORS errors in console
### 4. Test Both Backends
**Test Production API**:
```bash
# Set production API
echo "VITE_API_BASE_URL=https://api.csgow.tf" > .env
# Start dev server
npm run dev
# Visit http://localhost:5173/matches
# Should load matches from production API
```
**Test Local Backend**:
```bash
# Start local backend first
cd ../csgowtfd
go run main.go
# In another terminal, set local API
echo "VITE_API_BASE_URL=http://localhost:8000" > .env
# Start dev server
npm run dev
# Visit http://localhost:5173/matches
# Should load matches from local backend
```
## Common Issues
### Issue 1: 503 Service Unavailable
**Symptom**: API requests return 503 error
**Possible Causes**:
1. Backend is not running
2. Wrong `VITE_API_BASE_URL` in `.env`
3. Network connectivity issues
**Fix**:
```bash
# Check .env file
cat .env
# If using local backend, make sure it's running
curl http://localhost:8000/matches
# If using production API, check connectivity
curl https://api.csgow.tf/matches
# Restart dev server after changing .env
npm run dev
```
### Issue 2: 404 Not Found
**Symptom**: `/api/*` routes return 404
**Cause**: SvelteKit server route file missing or not loaded
**Fix**:
```bash
# Check file exists
ls src/routes/api/[...path]/+server.ts
# If missing, create it
mkdir -p src/routes/api/'[...path]'
# Then create +server.ts file
# Restart dev server
npm run dev
```
### Issue 3: Environment Variable Not Loading
**Symptom**: Server route uses wrong backend URL
**Cause**: Changes to `.env` require server restart
**Fix**:
```bash
# Stop dev server (Ctrl+C)
# Update .env
echo "VITE_API_BASE_URL=http://localhost:8000" > .env
# Start dev server again
npm run dev
```
### Issue 4: CORS Errors Still Appearing
**Symptom**: Browser console shows CORS errors
**Cause**: API client is not using `/api` prefix
**Fix**:
Check `src/lib/api/client.ts`:
```typescript
// Should be:
const API_BASE_URL = '/api';
// Not:
const API_BASE_URL = 'https://api.csgow.tf'; // ❌ Wrong
```
## How It Works Compared to Vite Proxy
### Old Approach (Vite Proxy)
```
Development:
Browser → /api → Vite Proxy → Backend
Production:
Browser → Backend (direct, different code path)
```
**Problems**:
- Two different code paths (dev vs prod)
- Proxy only works in development
- SSR has to bypass proxy
- Complex configuration
### New Approach (SvelteKit Server Routes)
```
All Environments:
Browser → /api → SvelteKit Route → Backend
```
**Benefits**:
- Single code path
- Works in dev, preview, and production
- Consistent behavior everywhere
- Simpler configuration
## Adding Features
### Add Request Caching
**File**: `src/routes/api/[...path]/+server.ts`
```typescript
const cache = new Map<string, { data: any; expires: number }>();
export const GET: RequestHandler = async ({ params, url }) => {
const cacheKey = `${params.path}${url.search}`;
// Check cache
const cached = cache.get(cacheKey);
if (cached && Date.now() < cached.expires) {
return json(cached.data);
}
// Fetch from backend
const data = await fetch(`${API_BASE_URL}/${params.path}${url.search}`).then((r) => r.json());
// Cache for 5 minutes
cache.set(cacheKey, {
data,
expires: Date.now() + 5 * 60 * 1000
});
return json(data);
};
```
### Add Rate Limiting
```typescript
import { rateLimit } from '$lib/server/rateLimit';
export const GET: RequestHandler = async ({ request, params, url }) => {
// Check rate limit
await rateLimit(request);
// Continue with normal flow...
};
```
### Add Authentication
```typescript
export const GET: RequestHandler = async ({ request, params, url }) => {
// Get auth token from cookie
const token = request.headers.get('cookie')?.includes('auth_token');
// Forward to backend with auth
const response = await fetch(backendUrl, {
headers: {
Authorization: `Bearer ${token}`
}
});
// ...
};
```
## Summary
| Feature | Vite Proxy | SvelteKit Routes |
| --------------------- | ---------- | ---------------- |
| Works in dev | ✅ | ✅ |
| Works in production | ❌ | ✅ |
| Single code path | ❌ | ✅ |
| Can add caching | ❌ | ✅ |
| Can add rate limiting | ❌ | ✅ |
| Can add auth | ❌ | ✅ |
| SSR compatible | ❌ | ✅ |
**SvelteKit server routes provide a production-ready, maintainable solution for API proxying that works in all environments.**

587
docs/DESIGN.md Normal file
View File

@@ -0,0 +1,587 @@
# CS2.WTF Design System
A modern, tactical design language inspired by Counter-Strike 2's in-game aesthetics.
---
## 🎨 Design Philosophy
### Core Principles
1. **Tactical & Data-Dense**: Inspired by CS2's HUD - information at a glance
2. **Dark-First**: Gaming-optimized dark theme as default
3. **Team Identity**: Leverage T-side (orange) and CT-side (blue) throughout
4. **Performance**: Smooth animations, no bloat
5. **Accessible**: WCAG 2.1 AA compliant
### Visual Language
- **Sharp Corners**: Minimal border radius (2-4px) for tactical feel
- **Neon Accents**: Subtle glows on interactive elements
- **Grid-Based**: 8px base unit for consistent spacing
- **Monospace Numbers**: Stats feel more tactical
- **Depth Through Layers**: Elevated cards with subtle shadows
---
## 🎨 Color Palette
### Brand Colors
```css
/* Primary (CT Blue) */
--ct-blue: #5e98d9;
--ct-blue-light: #7eaee5;
--ct-blue-dark: #4a7ab3;
/* Secondary (T Orange) */
--t-orange: #d4a74a;
--t-orange-light: #e5c674;
--t-orange-dark: #b38a3a;
/* Accent (Success Green) */
--accent-green: #36d399;
```
### Base Colors (Dark Theme)
```css
/* Backgrounds */
--bg-primary: #0f172a; /* Slate 900 - Main background */
--bg-secondary: #1e293b; /* Slate 800 - Card background */
--bg-tertiary: #334155; /* Slate 700 - Hover states */
/* Text */
--text-primary: #e2e8f0; /* Slate 200 - Main text */
--text-secondary: #94a3b8; /* Slate 400 - Muted text */
--text-tertiary: #64748b; /* Slate 500 - Disabled text */
/* Borders */
--border-default: #334155; /* Slate 700 */
--border-accent: #475569; /* Slate 600 - Hover */
```
### Semantic Colors
```css
/* Status */
--success: #36d399; /* Win, positive stats */
--warning: #fbbd23; /* Neutral, info */
--error: #f87272; /* Loss, negative stats */
--info: #3abff8; /* Information, CT-related */
```
---
## 📐 Typography
### Font Families
**Primary (UI Text):**
```css
font-family:
'Inter',
system-ui,
-apple-system,
sans-serif;
```
**Monospace (Stats & Numbers):**
```css
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
```
### Type Scale
```css
/* Display */
--text-6xl: 3.75rem; /* 60px - Hero headings */
--text-5xl: 3rem; /* 48px - Page titles */
--text-4xl: 2.25rem; /* 36px - Section headers */
/* Headings */
--text-3xl: 1.875rem; /* 30px - Card titles */
--text-2xl: 1.5rem; /* 24px - Subsection headers */
--text-xl: 1.25rem; /* 20px - Large body */
/* Body */
--text-lg: 1.125rem; /* 18px - Prominent text */
--text-base: 1rem; /* 16px - Default body */
--text-sm: 0.875rem; /* 14px - Small text */
--text-xs: 0.75rem; /* 12px - Captions */
```
### Font Weights
```css
--font-normal: 400;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
```
---
## 🏗️ Layout
### Spacing System (8px Grid)
```css
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-3: 0.75rem; /* 12px */
--space-4: 1rem; /* 16px */
--space-6: 1.5rem; /* 24px */
--space-8: 2rem; /* 32px */
--space-12: 3rem; /* 48px */
--space-16: 4rem; /* 64px */
--space-24: 6rem; /* 96px */
```
### Container Widths
```css
--container-sm: 640px; /* Mobile landscape */
--container-md: 768px; /* Tablet */
--container-lg: 1024px; /* Desktop */
--container-xl: 1280px; /* Large desktop */
--container-2xl: 1536px; /* Extra large */
```
### Breakpoints
```css
/* Mobile first approach */
sm: 640px /* Tablet */
md: 768px /* Small desktop */
lg: 1024px /* Desktop */
xl: 1280px /* Large desktop */
2xl: 1536px /* Extra large */
```
---
## 🎭 Components
### Cards
**Default Card:**
```css
background: var(--bg-secondary);
border: 1px solid var(--border-default);
border-radius: 4px;
padding: 1.5rem;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
```
**Elevated Card:**
```css
box-shadow:
0 10px 15px -3px rgb(0 0 0 / 0.2),
0 4px 6px -4px rgb(0 0 0 / 0.1);
```
**Interactive Card (Hover):**
```css
transition: all 0.2s ease;
&:hover {
border-color: var(--ct-blue);
box-shadow: 0 0 0 2px rgb(94 152 217 / 0.2);
transform: translateY(-2px);
}
```
### Buttons
**Primary (CT Blue):**
```css
background: var(--ct-blue);
color: white;
padding: 0.75rem 1.5rem;
border-radius: 4px;
font-weight: 600;
transition: all 0.2s;
&:hover {
background: var(--ct-blue-dark);
box-shadow: 0 0 20px rgb(94 152 217 / 0.3);
}
```
**Secondary (T Orange):**
```css
background: var(--t-orange);
color: white;
/* Similar styling */
```
**Ghost:**
```css
background: transparent;
border: 1px solid var(--border-default);
color: var(--text-primary);
&:hover {
background: var(--bg-tertiary);
border-color: var(--ct-blue);
}
```
### Badges
**Team Badge:**
```css
/* T-Side */
background: rgb(212 167 74 / 0.1);
color: var(--t-orange-light);
border: 1px solid var(--t-orange-dark);
/* CT-Side */
background: rgb(94 152 217 / 0.1);
color: var(--ct-blue-light);
border: 1px solid var(--ct-blue-dark);
```
**Status Badge:**
```css
/* Win */
background: rgb(54 211 153 / 0.1);
color: var(--success);
/* Loss */
background: rgb(248 114 114 / 0.1);
color: var(--error);
```
---
## 🌊 Animations
### Transitions
```css
/* Standard */
transition: all 0.2s ease;
/* Slow */
transition: all 0.3s ease;
/* Fast */
transition: all 0.15s ease;
```
### Keyframes
**Fade In:**
```css
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
```
**Pulse (Live Indicator):**
```css
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
```
**Glow:**
```css
@keyframes glow {
0%,
100% {
box-shadow: 0 0 10px rgb(94 152 217 / 0.3);
}
50% {
box-shadow: 0 0 20px rgb(94 152 217 / 0.5);
}
}
```
---
## 🎯 Iconography
**Icon Library:** Lucide Icons (clean, modern, consistent)
**Icon Sizes:**
```css
--icon-xs: 16px;
--icon-sm: 20px;
--icon-md: 24px;
--icon-lg: 32px;
--icon-xl: 48px;
```
**Icon Colors:**
- Default: `text-slate-400`
- Active: `text-primary` or `text-secondary`
- Success: `text-success`
- Error: `text-error`
---
## 📊 Data Visualization
### Chart Colors
**Team Performance:**
- T-Side: `#d4a74a`
- CT-Side: `#5e98d9`
**Heatmaps:**
- Low: `#334155` (Slate 700)
- Medium: `#f59e0b` (Amber 500)
- High: `#ef4444` (Red 500)
**Line Charts:**
- Primary line: `#5e98d9`
- Secondary line: `#d4a74a`
- Grid: `rgb(51 65 85 / 0.3)`
### Tables
**Header:**
```css
background: var(--bg-primary);
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
color: var(--text-secondary);
```
**Row:**
```css
border-bottom: 1px solid var(--border-default);
&:hover {
background: var(--bg-tertiary);
}
```
**Stats (Numbers):**
```css
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
```
---
## ♿ Accessibility
### Focus States
```css
&:focus-visible {
outline: 2px solid var(--ct-blue);
outline-offset: 2px;
}
```
### Color Contrast
- Text on dark bg: Minimum 4.5:1 (WCAG AA)
- Large text: Minimum 3:1
- UI components: Minimum 3:1
### Motion
```css
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
```
---
## 🎮 CS2-Specific Elements
### Rank Display
- Show Premier rating (0-30,000) with color coding
- Bronze: `#cd7f32`
- Silver: `#c0c0c0`
- Gold: `#ffd700`
- Legend: `#9b59b6`
### Map Thumbnails
- 16:9 aspect ratio
- Slight overlay gradient (bottom to top)
- Map name in bottom-left corner
### Weapon Icons
- Monochrome with subtle glow
- Size: 32x32px default
- Color: Match rarity (Consumer White, Mil-Spec Blue, etc.)
### Kill Feed
```css
.kill-feed-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.5rem;
background: rgb(15 23 42 / 0.9);
border-left: 2px solid var(--t-orange);
}
```
---
## 📱 Responsive Design
### Mobile (< 768px)
- Stack layouts vertically
- Reduce padding/spacing by 25%
- Hide secondary information
- Larger tap targets (min 44x44px)
- Bottom navigation for main actions
### Tablet (768px - 1024px)
- Two-column layouts
- Collapsible sidebar
- Touch-optimized interactions
### Desktop (> 1024px)
- Three-column layouts where appropriate
- Hover states and tooltips
- Keyboard shortcuts
- Dense data tables
---
## 🎨 Example Compositions
### Hero Section (Homepage)
```
┌─────────────────────────────────────────┐
│ │
│ CS2.WTF (Large Logo) │
│ Statistics for CS2 Matches │
│ │
│ [Search Match] [Browse Players] │
│ │
│ Featured Matches (Carousel) ────> │
│ │
└─────────────────────────────────────────┘
```
### Match Card
```
┌─────────────────────────────────────┐
│ de_inferno 13 - 10 LIVE │
│ ─────────────────────────────────── │
│ 👤 Player1 24K 18D ⭐⭐⭐ │
│ 👤 Player2 21K 20D ⭐⭐ │
│ ... │
│ ─────────────────────────────────── │
│ 📅 2 hours ago ⏱️ 42:33 │
└─────────────────────────────────────┘
```
### Stats Table
```
┌──────────────────────────────────────────────┐
│ PLAYER K D A HS% ADR RATING │
├──────────────────────────────────────────────┤
│ 👤 Player1 24 18 6 50% 98 1.32 🥇 │
│ 👤 Player2 21 20 8 48% 87 1.12 │
│ 👤 Player3 19 22 5 44% 82 0.98 │
└──────────────────────────────────────────────┘
```
---
## 🚀 Performance Guidelines
- Lazy load images and charts
- Use CSS transforms for animations (GPU-accelerated)
- Debounce search inputs (300ms)
- Virtual scrolling for large tables (> 100 rows)
- Optimize bundle size (< 200KB initial)
---
## 📝 Naming Conventions
### CSS Classes
```css
/* Component */
.match-card {
}
/* Element */
.match-card__header {
}
/* Modifier */
.match-card--featured {
}
/* State */
.match-card.is-active {
}
```
### Tailwind Utilities
Prefer utility classes for spacing, colors, and common patterns:
```html
<div class="rounded-lg bg-base-200 p-6 shadow-lg"></div>
```
---
**Last Updated**: 2025-11-04
**Status**: Active Development

View File

@@ -0,0 +1,480 @@
# CS2.WTF Feature Implementation Status
**Last Updated:** 2025-11-12
**Branch:** cs2-port
**Status:** In Progress (~70% Complete)
## Overview
This document tracks the implementation status of missing features from the original CS:GO WTF frontend that need to be ported to the new CS2.WTF SvelteKit application.
---
## Phase 1: Critical Features (HIGH PRIORITY)
### ✅ 1. Player Tracking System
**Status:** COMPLETED
- ✅ Added `tracked` field to Player type
- ✅ Updated player schema validation
- ✅ Updated API transformer to pass through `tracked` field
- ✅ Created `TrackPlayerModal.svelte` component
- Auth code input
- Optional share code input
- Track/Untrack functionality
- Help text with instructions
- Loading states and error handling
- ✅ Integrated modal into player profile page
- ✅ Added tracking status indicator button
- ✅ Connected to API endpoints: `POST /player/:id/track` and `DELETE /player/:id/track`
**Files Modified:**
- `src/lib/types/Player.ts`
- `src/lib/schemas/player.schema.ts`
- `src/lib/api/transformers.ts`
- `src/routes/player/[id]/+page.svelte`
**Files Created:**
- `src/lib/components/player/TrackPlayerModal.svelte`
---
### ✅ 2. Match Share Code Parsing
**Status:** COMPLETED
- ✅ Created `ShareCodeInput.svelte` component
- Share code input with validation
- Submit button with loading state
- Parse status feedback (parsing/success/error)
- Auto-redirect to match page on success
- Help text with instructions
- ✅ Added component to matches page
- ✅ Connected to API endpoint: `GET /match/parse/:sharecode`
- ✅ Share code format validation
**Files Created:**
- `src/lib/components/match/ShareCodeInput.svelte`
**Files Modified:**
- `src/routes/matches/+page.svelte`
---
### ✅ 3. VAC/Game Ban Status Display (Player Profile)
**Status:** COMPLETED
- ✅ Added VAC ban badge with count and date
- ✅ Added Game ban badge with count and date
- ✅ Styled with error/warning colors
- ✅ Displays on player profile header
- ✅ Shows ban dates when available
**Files Modified:**
- `src/routes/player/[id]/+page.svelte`
---
### 🔄 4. VAC Status Column on Match Scoreboard
**Status:** NOT STARTED
**TODO:**
- Add VAC status indicator column to scoreboard in `src/routes/match/[id]/+page.svelte`
- Add VAC status indicator to details tab table
- Style with red warning icon for players with VAC bans
- Tooltip with ban date on hover
**Files to Modify:**
- `src/routes/match/[id]/+page.svelte`
- `src/routes/match/[id]/details/+page.svelte`
---
### 🔄 5. Weapons Statistics Tab
**Status:** NOT STARTED
**Requires:**
- New tab on match detail page
- Component to display weapon statistics
- Hitgroup visualization (similar to old HitgroupPuppet.vue)
- Weapon breakdown table with kills, damage, hits per weapon
- API endpoint already exists: `GET /match/:id/weapons`
- API method already exists: `matchesAPI.getMatchWeapons()`
**TODO:**
- Create `src/routes/match/[id]/weapons/+page.svelte`
- Create `src/routes/match/[id]/weapons/+page.ts` (load function)
- Create `src/lib/components/match/WeaponStats.svelte`
- Create `src/lib/components/match/HitgroupVisualization.svelte`
- Update match layout tabs to include weapons tab
**Estimated Effort:** 8-16 hours
---
### 🔄 6. Recently Visited Players (Home Page)
**Status:** NOT STARTED
**Requires:**
- localStorage tracking of visited player profiles
- Display on home page as cards
- Delete/clear functionality
- Limit to last 6-10 players
**TODO:**
- Create utility functions for localStorage management
- Create `src/lib/components/player/RecentlyVisitedPlayers.svelte`
- Add to home page (`src/routes/+page.svelte`)
- Track player visits in player profile page
- Add to preferences store
**Estimated Effort:** 4-6 hours
---
## Phase 2: Important Features (MEDIUM-HIGH PRIORITY)
### 🔄 7. Complete Scoreboard Columns
**Status:** NOT STARTED
**Missing Columns:**
- Player avatars (Steam avatar images)
- Color indicators (in-game player colors)
- In-game score column
- MVP stars column
- K/D ratio column (separate from K/D difference)
- Multi-kill indicators on scoreboard (currently only in Details tab)
**TODO:**
- Update `src/routes/match/[id]/+page.svelte` scoreboard table
- Add avatar column with Steam profile images
- Add color-coded player indicators
- Add Score, MVP, K/D ratio columns
- Move multi-kill indicators to scoreboard or add as tooltips
**Estimated Effort:** 6-8 hours
---
### 🔄 8. Sitemap Generation
**Status:** NOT STARTED
**Requires:**
- Dynamic sitemap generation based on players and matches
- XML sitemap endpoint
- Sitemap index for pagination
- robots.txt configuration
**TODO:**
- Create `src/routes/sitemap.xml/+server.ts`
- Create `src/routes/sitemap/[id]/+server.ts`
- Implement sitemap generation logic
- Add robots.txt to static folder
- Connect to backend sitemap endpoints if they exist
**Estimated Effort:** 6-8 hours
---
### 🔄 9. Team Average Rank Badges (Match Header)
**Status:** NOT STARTED
**Requires:**
- Calculate average Premier rating per team
- Display in match header/layout
- Show tier badges for each team
- Rank change indicators
**TODO:**
- Add calculation logic in `src/routes/match/[id]/+layout.svelte`
- Create component for team rank display
- Style with tier colors
**Estimated Effort:** 3-4 hours
---
### 🔄 10. Chat Message Translation
**Status:** NOT STARTED
**Requires:**
- Translation API integration (Google Translate, DeepL, or similar)
- Translate button on each chat message
- Language detection
- Cache translations
**TODO:**
- Choose translation API provider
- Add API key configuration
- Create translation service in `src/lib/services/translation.ts`
- Update `src/routes/match/[id]/chat/+page.svelte`
- Add translate button to chat messages
- Handle loading and error states
**Estimated Effort:** 8-12 hours
---
## Phase 3: Polish & Nice-to-Have (MEDIUM-LOW PRIORITY)
### 🔄 11. Steam Profile Links
**Status:** NOT STARTED
**TODO:**
- Add Steam profile link to player name on player profile page
- Add links to scoreboard player names
- Support for vanity URLs
- Open in new tab
**Files to Modify:**
- `src/routes/player/[id]/+page.svelte`
- `src/routes/match/[id]/+page.svelte`
- `src/routes/match/[id]/details/+page.svelte`
**Estimated Effort:** 2-3 hours
---
### 🔄 12. Win/Loss/Tie Statistics
**Status:** NOT STARTED
**TODO:**
- Display total wins, losses, ties on player profile
- Calculate win rate from these totals
- Add to player stats cards section
**Files to Modify:**
- `src/routes/player/[id]/+page.svelte`
**Estimated Effort:** 1-2 hours
---
### 🔄 13. Privacy Policy Page
**Status:** NOT STARTED
**TODO:**
- Create `src/routes/privacy-policy/+page.svelte`
- Write privacy policy content
- Add GDPR compliance information
- Link from footer
**Estimated Effort:** 2-4 hours
---
### 🔄 14. Player Color Indicators (Scoreboard)
**Status:** NOT STARTED
**TODO:**
- Display in-game player colors on scoreboard
- Color-code player rows or names
- Match CS2 color scheme (green/yellow/purple/blue/orange)
**Files to Modify:**
- `src/routes/match/[id]/+page.svelte`
**Estimated Effort:** 1-2 hours
---
### 🔄 15. Additional Utility Statistics
**Status:** NOT STARTED
**Missing Stats:**
- Self-flash statistics
- Smoke grenade usage
- Decoy grenade usage
- Team flash statistics
**TODO:**
- Display in match details or player profile
- Add to utility effectiveness section
**Estimated Effort:** 2-3 hours
---
## Feature Parity Comparison
### What's BETTER in Current Implementation ✨
- Modern SvelteKit architecture with TypeScript
- Superior filtering and search functionality
- Data export (CSV/JSON)
- Better data visualizations (Chart.js)
- Premier rating system (CS2-specific)
- Dark/light theme toggle
- Infinite scroll
- Better responsive design
### What's Currently Missing ⚠️
- Weapon statistics page (high impact)
- Complete scoreboard columns (medium impact)
- Recently visited players (medium impact)
- Sitemap/SEO (medium impact)
- Chat translation (low-medium impact)
- Various polish features (low impact)
---
## Estimated Remaining Effort
### By Priority
| Priority | Tasks Remaining | Est. Hours | Status |
| ------------------- | --------------- | --------------- | ---------------- |
| Phase 1 (Critical) | 3 | 16-30 hours | 50% Complete |
| Phase 2 (Important) | 4 | 23-36 hours | 0% Complete |
| Phase 3 (Polish) | 5 | 8-14 hours | 0% Complete |
| **TOTAL** | **12** | **47-80 hours** | **25% Complete** |
### Overall Project Status
- **Completed:** 3 critical features
- **In Progress:** API cleanup and optimization
- **Remaining:** 12 features across 3 phases
- **Estimated Completion:** 2-3 weeks of full-time development
---
## Next Steps
### Immediate (This Session)
1. ✅ Player tracking UI - DONE
2. ✅ Share code parsing UI - DONE
3. ✅ VAC/ban status display (profile) - DONE
4. ⏭️ VAC status on scoreboard - NEXT
5. ⏭️ Weapons statistics tab - NEXT
6. ⏭️ Recently visited players - NEXT
### Short Term (Next Session)
- Complete remaining Phase 1 features
- Start Phase 2 features (scoreboard completion, sitemap)
### Medium Term
- Complete Phase 2 features
- Begin Phase 3 polish features
### Long Term
- Full feature parity with old frontend
- Additional CS2-specific features
- Performance optimizations
---
## Testing Checklist
### Completed Features
- [x] Player tracking modal opens and closes
- [x] Player tracking modal validates auth code input
- [x] Track/untrack API calls work
- [x] Tracking status updates after track/untrack
- [x] Share code input validates format
- [x] Share code parsing submits to API
- [x] Parse status feedback displays correctly
- [x] Redirect to match page after successful parse
- [x] VAC/ban badges display on player profile
- [x] VAC/ban dates show when available
### TODO Testing
- [ ] VAC status displays on scoreboard
- [ ] Weapons tab loads and displays data
- [ ] Hitgroup visualization renders correctly
- [ ] Recently visited players tracked correctly
- [ ] Recently visited players display on home page
- [ ] All Phase 2 and 3 features
---
## Known Issues
### Current
- None
### Potential
- Translation API rate limiting (once implemented)
- Sitemap generation performance with large datasets
- Weapons tab may need pagination for long matches
---
## Notes
### Architecture Decisions
- Using SvelteKit server routes for API proxying (no CORS issues)
- Transformers pattern for legacy API format conversion
- Component-based approach for reusability
- TypeScript + Zod for type safety
### API Endpoints Used
-`POST /player/:id/track`
-`DELETE /player/:id/track`
-`GET /match/parse/:sharecode`
- ⏭️ `GET /match/:id/weapons` (available but not used yet)
- ⏭️ `GET /player/:id/meta` (available but not optimized yet)
---
## Contributors
- Initial Analysis: Claude (Anthropic AI)
- Implementation: In Progress
- Testing: Pending
---
**For questions or updates, refer to the main project README.md**

335
docs/LOCAL_DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,335 @@
# Local Development Setup
This guide will help you set up the CS2.WTF frontend for local development.
## Prerequisites
- **Node.js**: v18.x or v20.x (check with `node --version`)
- **npm**: v9.x or higher (comes with Node.js)
- **Backend API**: Either local csgowtfd service OR access to production API
## Quick Start
### 1. Install Dependencies
```bash
npm install
```
### 2. Environment Configuration
The `.env` file already exists in the project. You can use it as-is or modify it:
**Option A: Use Production API** (Recommended for frontend development)
```env
# Use the live production API - no local backend needed
VITE_API_BASE_URL=https://api.csgow.tf
VITE_API_TIMEOUT=10000
VITE_DEBUG_MODE=true
VITE_ENABLE_ANALYTICS=false
```
**Option B: Use Local Backend** (For full-stack development)
```env
# Use local backend (requires csgowtfd running on port 8000)
VITE_API_BASE_URL=http://localhost:8000
VITE_API_TIMEOUT=10000
VITE_DEBUG_MODE=true
VITE_ENABLE_ANALYTICS=false
```
### 3. Start the Development Server
```bash
npm run dev
```
The frontend will be available at `http://localhost:5173`
You should see output like:
```
VITE v5.x.x ready in xxx ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose
```
### 4. (Optional) Start Local Backend
Only needed if using `VITE_API_BASE_URL=http://localhost:8000`:
```bash
# In the csgowtfd repository
cd ../csgowtfd
go run cmd/csgowtfd/main.go
```
Or use Docker:
```bash
docker-compose up csgowtfd
```
## How SvelteKit API Routes Work
All API requests go through **SvelteKit server routes** which proxy to the backend. This works consistently in all environments.
### Request Flow (All Environments)
```
1. Browser makes request to: http://localhost:5173/api/matches
2. SvelteKit routes to: src/routes/api/[...path]/+server.ts
3. Server handler reads VITE_API_BASE_URL environment variable
4. Server fetches from backend: ${VITE_API_BASE_URL}/matches
5. Backend responds
6. Server handler forwards response to browser
```
### Benefits
-**No CORS errors** - All requests are server-side
-**Works in all environments** - Dev, preview, and production
-**Single code path** - Same behavior everywhere
-**Easy backend switching** - Change one environment variable
-**Future-proof** - Can add caching, rate limiting, auth later
-**Backend URL not exposed** - Hidden from client
### Switching Between Backends
Simply update `.env` and restart the dev server:
```bash
# Use production API
echo "VITE_API_BASE_URL=https://api.csgow.tf" > .env
npm run dev
# Use local backend
echo "VITE_API_BASE_URL=http://localhost:8000" > .env
npm run dev
```
### Development vs Production
| Mode | Request Flow | Backend URL From |
| -------------------------------- | ---------------------------------------------- | ------------------------------ |
| **Development** (`npm run dev`) | Browser → `/api/*` → SvelteKit Route → Backend | `.env``VITE_API_BASE_URL` |
| **Production** (`npm run build`) | Browser → `/api/*` → SvelteKit Route → Backend | Build-time `VITE_API_BASE_URL` |
**Note**: The flow is identical in both modes - this is the key advantage over the old Vite proxy approach.
## Troubleshooting
### No Data Showing / Network Errors
**Problem**: Frontend loads but shows no matches, players show "Failed to load" errors.
**Solutions**:
1. **Check what backend you're using**:
```bash
# Look at your .env file
cat .env | grep VITE_API_BASE_URL
```
2. **If using production API** (`https://api.csgow.tf`):
```bash
# Test if production API is accessible
curl https://api.csgow.tf/matches?limit=1
```
Should return JSON data. If not, production API may be down.
3. **If using local backend** (`http://localhost:8000`):
```bash
# Test if local backend is running
curl http://localhost:8000/matches?limit=1
```
If you get "Connection refused", start the backend service.
4. **Check browser console**:
- Open DevTools → Console tab
- Look for `[API Route]` error messages from the server route handler
- Network tab should show requests to `/api/*` (not external URLs)
- Check if requests return 503 (backend unreachable) or 500 (server error)
5. **Check server logs**:
- Look at the terminal running `npm run dev`
- Server route errors will appear with `[API Route] Error fetching...`
- This will show you the exact backend URL being requested
6. **Restart dev server**:
```bash
# Stop dev server (Ctrl+C)
npm run dev
```
### CORS Errors (Should Never Happen)
CORS errors should be impossible with SvelteKit server routes since all requests are server-side.
**If you somehow see CORS errors:**
- This means the API client is bypassing the `/api` routes
- Check that `src/lib/api/client.ts` has `API_BASE_URL = '/api'`
- Verify `src/routes/api/[...path]/+server.ts` exists
- Clear cache and restart:
```bash
rm -rf .svelte-kit
npm run dev
```
### Port Already in Use
If port 5173 is already in use:
```bash
# Vite will automatically try the next available port
npm run dev
# Or specify a custom port
npm run dev -- --port 3000
```
### Backend Connection Issues
If the backend is on a different host/port, update `.env`:
```env
# Custom backend location
VITE_API_BASE_URL=http://192.168.1.100:8080
```
Then restart the dev server.
## Development Workflow
### 1. Make Changes
Edit files in `src/`. The dev server has hot module replacement (HMR):
- Component changes reload instantly
- Route changes reload the page
- Store changes reload affected components
### 2. Type Checking
Run TypeScript type checking:
```bash
npm run check # Check once
npm run check:watch # Watch mode
```
### 3. Linting
```bash
npm run lint # Check for issues
npm run lint:fix # Auto-fix issues
npm run format # Run Prettier
```
### 4. Testing
```bash
# Unit tests
npm run test # Run once
npm run test:watch # Watch mode
npm run test:coverage # Generate coverage report
# E2E tests
npm run test:e2e # Headless
npm run test:e2e:ui # Playwright UI
```
## API Endpoints
The backend provides these endpoints (see `docs/API.md` for full details):
- `GET /matches` - List all matches
- `GET /match/:id` - Get match details
- `GET /match/:id/rounds` - Get round economy data
- `GET /match/:id/weapons` - Get weapon statistics
- `GET /match/:id/chat` - Get chat messages
- `GET /player/:id` - Get player profile
### How Requests Work
**All Environments** (dev, preview, production):
```
Frontend code: api.matches.getMatches()
API Client: GET /api/matches
SvelteKit Route: src/routes/api/[...path]/+server.ts
Server Handler: GET ${VITE_API_BASE_URL}/matches
Response: ← Data returned to frontend
```
The request flow is identical in all environments. The only difference is which backend URL `VITE_API_BASE_URL` points to:
- Development: Usually `https://api.csgow.tf` (production API)
- Local full-stack: `http://localhost:8000` (local backend)
- Production: `https://api.csgow.tf` (or custom backend URL)
## Mock Data (Alternative: No Backend)
If you want to develop without any backend (local or production), enable MSW mocking:
1. Update `.env`:
```env
VITE_ENABLE_MSW_MOCKING=true
```
2. Restart dev server
The app will use mock data from `src/mocks/handlers/`.
**Note**: Mock data is limited and may not reflect all features. **Production API is recommended** for most development work.
## Building for Production
```bash
# Build
npm run build
# Preview production build locally
npm run preview
```
The preview server runs on `http://localhost:4173` and uses the production API configuration.
## Environment Variables Reference
| Variable | Default | Description |
| -------------------------- | ----------------------- | ---------------------------- |
| `VITE_API_BASE_URL` | `http://localhost:8000` | Backend API base URL |
| `VITE_API_TIMEOUT` | `10000` | Request timeout (ms) |
| `VITE_ENABLE_LIVE_MATCHES` | `false` | Enable live match polling |
| `VITE_ENABLE_ANALYTICS` | `false` | Enable analytics tracking |
| `VITE_DEBUG_MODE` | `false` | Enable debug logging |
| `VITE_ENABLE_MSW_MOCKING` | `false` | Use mock data instead of API |
## Getting Help
- **Frontend Issues**: Check browser console for errors
- **API Issues**: Check backend logs and proxy output in terminal
- **Type Errors**: Run `npm run check` for detailed messages
- **Build Issues**: Delete `.svelte-kit/` and `node_modules/`, then `npm install`
## Next Steps
- Read `TODO.md` for current development status
- Check `docs/DESIGN.md` for design system documentation
- Review `docs/API.md` for complete API reference
- See `README.md` for project overview

460
docs/MATCHES_API.md Normal file
View File

@@ -0,0 +1,460 @@
# Matches API Endpoint Documentation
This document provides detailed information about the matches API endpoints used by CS2.WTF to retrieve match data from the backend CSGOWTFD service.
## Overview
The matches API provides access to Counter-Strike 2 match data including match listings, detailed match statistics, and related match information such as weapons, rounds, and chat data.
## Base URL
All endpoints are relative to the API base URL: `https://api.csgow.tf`
During development, requests are proxied through `/api` to avoid CORS issues.
## Authentication
No authentication is required for read operations. All match data is publicly accessible.
## Rate Limiting
The API does not currently enforce rate limiting, but clients should implement reasonable request throttling to avoid overwhelming the service.
## Endpoints
### 1. Get Matches List
Retrieves a paginated list of matches.
**Endpoint**: `GET /matches`
**Alternative**: `GET /matches/next/:time`
**Parameters**:
- `time` (path, optional): Unix timestamp for pagination (use with `/matches/next/:time`)
- Query parameters:
- `limit` (optional): Number of matches to return (default: 50, max: 100)
- `map` (optional): Filter by map name (e.g., `de_inferno`)
- `player_id` (optional): Filter by player Steam ID
**Response** (200 OK):
**IMPORTANT**: This endpoint returns a **plain array**, not an object with properties.
```json
[
{
"match_id": "3589487716842078322",
"map": "de_inferno",
"date": 1730487900,
"score": [13, 10],
"duration": 2456,
"match_result": 1,
"max_rounds": 24,
"parsed": true,
"vac": false,
"game_ban": false
}
]
```
**Field Descriptions**:
- `match_id`: Unique match identifier (uint64 as string)
- `map`: Map name (can be empty string if not parsed)
- `date`: Unix timestamp (seconds since epoch)
- `score`: Array with two elements `[team_a_score, team_b_score]`
- `duration`: Match duration in seconds
- `match_result`: 0 = tie, 1 = team_a win, 2 = team_b win
- `max_rounds`: Maximum rounds (24 for MR12, 30 for MR15)
- `parsed`: Whether the demo has been parsed
- `vac`: Whether any player has a VAC ban
- `game_ban`: Whether any player has a game ban
**Pagination**:
- The API returns a plain array of matches, sorted by date (newest first)
- To get the next page, use the `date` field from the **last match** in the array
- Request `/matches/next/{timestamp}` where `{timestamp}` is the Unix timestamp
- Continue until the response returns fewer matches than your `limit` parameter
- Example: If you request `limit=20` and get back 15 matches, you've reached the end
### 2. Get Match Details
Retrieves detailed information about a specific match including player statistics.
**Endpoint**: `GET /match/{match_id}`
**Parameters**:
- `match_id` (path): The unique match identifier (uint64 as string)
**Response** (200 OK):
```json
{
"match_id": "3589487716842078322",
"share_code": "CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX",
"map": "de_inferno",
"date": "2024-11-01T18:45:00Z",
"score_team_a": 13,
"score_team_b": 10,
"duration": 2456,
"match_result": 1,
"max_rounds": 24,
"demo_parsed": true,
"vac_present": false,
"gameban_present": false,
"tick_rate": 64.0, // Optional: not always provided by API
"players": [
{
"id": "765611980123456",
"name": "Player1",
"avatar": "https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg",
"team_id": 2,
"kills": 24,
"deaths": 18,
"assists": 6,
"headshot": 12,
"mvp": 3,
"score": 56,
"kast": 78, // Optional: not always provided by API
"rank_old": 18500,
"rank_new": 18650,
"dmg_enemy": 2450,
"dmg_team": 120,
"flash_assists": 4,
"flash_duration_enemy": 15.6,
"flash_total_enemy": 8,
"ud_he": 450,
"ud_flames": 230,
"ud_flash": 5,
"ud_smoke": 3,
"avg_ping": 25.5,
"color": "yellow"
}
]
}
```
### 3. Get Match Weapons
Retrieves weapon statistics for all players in a match.
**Endpoint**: `GET /match/{match_id}/weapons`
**Parameters**:
- `match_id` (path): The unique match identifier
**Response** (200 OK):
```json
{
"match_id": 3589487716842078322,
"weapons": [
{
"player_id": 765611980123456,
"weapon_stats": [
{
"eq_type": 17,
"weapon_name": "AK-47",
"kills": 12,
"damage": 1450,
"hits": 48,
"hit_groups": {
"head": 8,
"chest": 25,
"stomach": 8,
"left_arm": 3,
"right_arm": 2,
"left_leg": 1,
"right_leg": 1
},
"headshot_pct": 16.7
}
]
}
]
}
```
### 4. Get Match Rounds
Retrieves round-by-round statistics for a match.
**Endpoint**: `GET /match/{match_id}/rounds`
**Parameters**:
- `match_id` (path): The unique match identifier
**Response** (200 OK):
```json
{
"match_id": 3589487716842078322,
"rounds": [
{
"round": 1,
"winner": 2,
"win_reason": "elimination",
"players": [
{
"round": 1,
"player_id": 765611980123456,
"bank": 800,
"equipment": 650,
"spent": 650,
"kills_in_round": 2,
"damage_in_round": 120
}
]
}
]
}
```
### 5. Get Match Chat
Retrieves chat messages from a match.
**Endpoint**: `GET /match/{match_id}/chat`
**Parameters**:
- `match_id` (path): The unique match identifier
**Response** (200 OK):
```json
{
"match_id": 3589487716842078322,
"messages": [
{
"player_id": 765611980123456,
"player_name": "Player1",
"message": "nice shot!",
"tick": 15840,
"round": 8,
"all_chat": true,
"timestamp": "2024-11-01T19:12:34Z"
}
]
}
```
### 6. Parse Match from Share Code
Initiates parsing of a match from a CS:GO/CS2 share code.
**Endpoint**: `GET /match/parse/{sharecode}`
**Parameters**:
- `sharecode` (path): The CS:GO/CS2 match share code
**Response** (200 OK):
```json
{
"match_id": "3589487716842078322",
"status": "parsing",
"message": "Demo download and parsing initiated",
"estimated_time": 120
}
```
## Data Models
### Match
```typescript
interface Match {
match_id: string; // Unique match identifier (uint64 as string)
share_code?: string; // CS:GO/CS2 share code (optional)
map: string; // Map name (e.g., "de_inferno")
date: string; // Match date and time (ISO 8601)
score_team_a: number; // Final score for team A
score_team_b: number; // Final score for team B
duration: number; // Match duration in seconds
match_result: number; // Match result: 0 = tie, 1 = team_a win, 2 = team_b win
max_rounds: number; // Maximum rounds (24 for MR12, 30 for MR15)
demo_parsed: boolean; // Whether the demo has been successfully parsed
vac_present: boolean; // Whether any player has a VAC ban
gameban_present: boolean; // Whether any player has a game ban
tick_rate?: number; // Server tick rate (64 or 128) - optional, not always provided by API
players?: MatchPlayer[]; // Array of player statistics (optional)
}
```
### MatchPlayer
```typescript
interface MatchPlayer {
id: string; // Player Steam ID (uint64 as string)
name: string; // Player display name
avatar: string; // Steam avatar URL
team_id: number; // Team ID: 2 = T side, 3 = CT side
kills: number; // Kills
deaths: number; // Deaths
assists: number; // Assists
headshot: number; // Headshot kills
mvp: number; // MVP stars earned
score: number; // In-game score
kast?: number; // KAST percentage (0-100) - optional, not always provided by API
rank_old?: number; // Premier rating before match (0-30000)
rank_new?: number; // Premier rating after match (0-30000)
dmg_enemy?: number; // Damage to enemies
dmg_team?: number; // Damage to teammates
flash_assists?: number; // Flash assist count
flash_duration_enemy?: number; // Total enemy blind time
flash_total_enemy?: number; // Enemies flashed count
ud_he?: number; // HE grenade damage
ud_flames?: number; // Molotov/Incendiary damage
ud_flash?: number; // Flash grenades used
ud_smoke?: number; // Smoke grenades used
avg_ping?: number; // Average ping
color?: string; // Player color
}
```
### MatchListItem
```typescript
interface MatchListItem {
match_id: string; // Unique match identifier (uint64 as string)
map: string; // Map name
date: string; // Match date and time (ISO 8601)
score_team_a: number; // Final score for team A
score_team_b: number; // Final score for team B
duration: number; // Match duration in seconds
demo_parsed: boolean; // Whether the demo has been successfully parsed
player_count?: number; // Number of players in the match - optional, not provided by API
}
```
## Error Handling
All API errors follow a consistent format:
```json
{
"error": "Error message",
"code": 404,
"details": {
"match_id": "3589487716842078322"
}
}
```
### Common HTTP Status Codes
- `200 OK`: Request successful
- `400 Bad Request`: Invalid parameters
- `404 Not Found`: Resource not found
- `500 Internal Server Error`: Server error
## Implementation Notes
### Pagination
The matches API implements cursor-based pagination using timestamps:
1. Initial request to `/matches` returns a plain array of matches (sorted newest first)
2. Extract the `date` field from the **last match** in the array
3. Request `/matches/next/{timestamp}` to get older matches
4. Continue until the response returns fewer matches than your `limit` parameter
5. The API does **not** provide `has_more` or `next_page_time` fields - you must calculate these yourself
### Data Transformation
The frontend application transforms legacy API responses to a modern schema-validated format:
- Unix timestamps are converted to ISO strings
- Avatar hashes are converted to full URLs (if provided)
- Team IDs are normalized (1/2 → 2/3 if needed)
- Score arrays `[team_a, team_b]` are split into separate fields
- Field names are mapped: `parsed``demo_parsed`, `vac``vac_present`, `game_ban``gameban_present`
- Missing fields are provided with defaults (e.g., `tick_rate: 64`)
### Steam ID Handling
All Steam IDs and Match IDs are handled as strings to preserve uint64 precision. Never convert these to numbers as it causes precision loss.
## Examples
### Fetching Matches with Pagination
```javascript
// Initial request - API returns a plain array
const matches = await fetch('/api/matches?limit=20').then((r) => r.json());
// matches is an array: [{ match_id, map, date, ... }, ...]
console.log(`Loaded ${matches.length} matches`);
// Get the timestamp of the last match for pagination
if (matches.length > 0) {
const lastMatch = matches[matches.length - 1];
const lastTimestamp = lastMatch.date; // Unix timestamp
// Fetch next page using the timestamp
const moreMatches = await fetch(`/api/matches/next/${lastTimestamp}?limit=20`).then((r) =>
r.json()
);
console.log(`Loaded ${moreMatches.length} more matches`);
// Check if we've reached the end
if (moreMatches.length < 20) {
console.log('Reached the end of matches');
}
}
```
### Complete Pagination Loop
```javascript
async function loadAllMatches(limit = 50) {
let allMatches = [];
let hasMore = true;
let lastTimestamp = null;
while (hasMore) {
// Build URL based on whether we have a timestamp
const url = lastTimestamp
? `/api/matches/next/${lastTimestamp}?limit=${limit}`
: `/api/matches?limit=${limit}`;
// Fetch matches
const matches = await fetch(url).then((r) => r.json());
// Add to collection
allMatches.push(...matches);
// Check if there are more
if (matches.length < limit) {
hasMore = false;
} else {
// Get timestamp of last match for next iteration
lastTimestamp = matches[matches.length - 1].date;
}
}
return allMatches;
}
```
### Filtering Matches by Map
```javascript
const response = await fetch('/api/matches?map=de_inferno&limit=20');
const data = await response.json();
```
### Filtering Matches by Player
```javascript
const response = await fetch('/api/matches?player_id=765611980123456&limit=20');
const data = await response.json();
```

54
eslint.config.js Normal file
View File

@@ -0,0 +1,54 @@
import js from '@eslint/js';
import ts from 'typescript-eslint';
import svelte from 'eslint-plugin-svelte';
import prettier from 'eslint-config-prettier';
import globals from 'globals';
/** @type {import('eslint').Linter.FlatConfig[]} */
export default [
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs['flat/recommended'],
prettier,
...svelte.configs['flat/prettier'],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
}
},
{
files: ['**/*.svelte'],
languageOptions: {
parserOptions: {
parser: ts.parser
}
}
},
{
ignores: [
'build/',
'.svelte-kit/',
'dist/',
'node_modules/',
'**/*.cjs',
'*.config.js',
'*.config.ts'
]
},
{
rules: {
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_'
}
],
'@typescript-eslint/no-explicit-any': 'error',
'svelte/no-at-html-tags': 'warn'
}
}
];

9106
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,46 +1,79 @@
{ {
"name": "csgowtf", "name": "cs2wtf",
"version": "1.0.9", "version": "2.0.0",
"description": "Statistics for CS2 matchmaking matches",
"private": true, "private": true,
"type": "module",
"scripts": { "scripts": {
"serve": "vue-cli-service serve", "dev": "vite dev",
"build": "vue-cli-service build --mode production", "build": "vite build",
"lint": "vue-cli-service lint" "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"lint:fix": "prettier --write . && eslint --fix .",
"format": "prettier --write .",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug",
"prepare": "husky"
}, },
"dependencies": { "dependencies": {
"@fontsource/open-sans": "^4.5.14", "@sveltejs/kit": "^2.0.0",
"@fontsource/orbitron": "^4.5.11", "axios": "^1.6.0",
"@popperjs/core": "^2.11.6", "chart.js": "^4.5.1",
"axios": "^1.3.4", "svelte": "^5.0.0",
"bootstrap": "^5.2.3", "zod": "^3.22.0"
"core-js": "^3.29.0",
"dotenv-webpack": "^8.0.1",
"echarts": "^5.4.1",
"fork-awesome": "^1.2.0",
"http-status-codes": "^2.2.0",
"iso-639-1": "^2.1.15",
"jquery": "^3.6.3",
"luxon": "^3.2.1",
"string-sanitizer": "^2.0.2",
"vue": "^3.2.47",
"vue-matomo": "^4.2.0",
"vue-router": "^4.1.6",
"vue3-cookies": "^1.0.6",
"vuex": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.21.0", "@playwright/test": "^1.40.0",
"@babel/eslint-parser": "^7.19.1", "@sveltejs/adapter-auto": "^3.0.0",
"@vue/cli-plugin-babel": "~5.0.8", "@sveltejs/adapter-node": "^5.0.0",
"@vue/cli-plugin-eslint": "~5.0.8", "@sveltejs/vite-plugin-svelte": "^4.0.0",
"@vue/cli-plugin-router": "~5.0.8", "@testing-library/jest-dom": "^6.0.0",
"@vue/cli-plugin-vuex": "~5.0.8", "@testing-library/svelte": "^5.0.0",
"@vue/cli-service": "~5.0.8", "@types/node": "^20.10.0",
"@vue/compiler-sfc": "^3.2.47", "@typescript-eslint/eslint-plugin": "^7.0.0",
"eslint": "^8.35.0", "@typescript-eslint/parser": "^7.0.0",
"eslint-plugin-vue": "^9.9.0", "@vitest/coverage-v8": "^1.0.0",
"sass": "^1.58.3", "autoprefixer": "^10.4.0",
"sass-loader": "^13.2.0" "daisyui": "^4.0.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.35.0",
"globals": "^15.0.0",
"husky": "^9.0.0",
"jsdom": "^24.0.0",
"lint-staged": "^15.0.0",
"lucide-svelte": "^0.400.0",
"msw": "^2.0.0",
"postcss": "^8.4.0",
"prettier": "^3.2.0",
"prettier-plugin-svelte": "^3.1.0",
"prettier-plugin-tailwindcss": "^0.5.0",
"stylelint": "^16.0.0",
"stylelint-config-standard": "^36.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^3.4.0",
"tslib": "^2.6.0",
"typescript": "^5.3.0",
"typescript-eslint": "^8.0.0",
"vite": "^5.0.0",
"vitest": "^1.0.0"
}, },
"packageManager": "yarn@3.4.1" "lint-staged": {
"*.{js,ts,svelte}": [
"prettier --write",
"eslint --fix"
],
"*.{json,css,md}": [
"prettier --write"
]
},
"engines": {
"node": ">=18.0.0"
}
} }

36
playwright.config.ts Normal file
View File

@@ -0,0 +1,36 @@
import type { PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = {
webServer: {
command: 'npm run build && npm run preview',
port: 4173,
reuseExistingServer: !process.env.CI
},
testDir: 'tests/e2e',
testMatch: /(.+\.)?(test|spec)\.[jt]s/,
use: {
baseURL: 'http://localhost:4173',
screenshot: 'only-on-failure',
trace: 'retain-on-failure'
},
projects: [
{
name: 'chromium',
use: { browserName: 'chromium' }
},
{
name: 'firefox',
use: { browserName: 'firefox' }
},
{
name: 'webkit',
use: { browserName: 'webkit' }
}
],
reporter: process.env.CI ? 'github' : 'html',
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined
};
export default config;

6
postcss.config.cjs Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

View File

@@ -1,66 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta content="IE=edge" http-equiv="X-UA-Compatible">
<meta content="width=device-width,initial-scale=1.0" name="viewport">
<meta content="Track your CSGO matches and see your match details."
name="description">
<meta content="index, follow, archive"
name="robots">
<meta content="Track your CSGO matches and see your match details."
property="st:section">
<meta content="csgoWTF - Open source CSGO data platform"
name="twitter:title">
<meta content="Track your CSGO matches and see your match details."
name="twitter:description">
<meta content="summary_large_image"
name="twitter:card">
<meta content="https://csgow.tf/"
property="og:url">
<meta content="csgoWTF - Open source CSGO data platform"
property="og:title">
<meta content="Track your CSGO matches and see your match details."
property="og:description">
<meta content="website"
property="og:type">
<meta content="en_US"
property="og:locale">
<meta content="csgoWTF - Open source CSGO data platform"
property="og:site_name">
<meta content="https://csgow.tf/images/logo.png"
name="twitter:image">
<meta content="https://csgow.tf/images/logo.png"
property="og:image">
<meta content="1024"
property="og:image:width">
<meta content="526"
property="og:image:height">
<meta content="https://csgow.tf/images/logo.png"
property="og:image:secure_url">
<link href="<%= BASE_URL %>images/apple-touch-icon.png" rel="apple-touch-icon" sizes="180x180">
<link href="<%= BASE_URL %>images/favicon-32x32.png" rel="icon" sizes="32x32" type="image/png">
<link href="<%= BASE_URL %>images/favicon-16x16.png" rel="icon" sizes="16x16" type="image/png">
<link href="<%= BASE_URL %>site.webmanifest" rel="manifest">
<link rel="preconnect" href="https://steamcdn-a.akamaihd.net" crossorigin>
<link rel="dns-prefetch" href="https://steamcdn-a.akamaihd.net">
<link rel="preconnect" href="https://api.csgow.tf" crossorigin>
<link rel="dns-prefetch" href="https://api.csgow.tf">
<link rel="preconnect" href="https://piwik.harting.hosting" crossorigin>
<link rel="dns-prefetch" href="https://piwik.harting.hosting">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled.
Please enable it to continue.</strong>
</noscript>
<div id="app" class="d-flex flex-column min-vh-100"></div>
<!-- built files will be auto injected -->
</body>
</html>

View File

@@ -1 +0,0 @@
{"name":"","short_name":"","icons":[{"src":"/images/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/images/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

35
research.md Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,69 +0,0 @@
<template>
<img alt="" class="bg-img" src="">
<header>
<CompNav/>
</header>
<main>
<div :style="{height: offset + 'px'}"/>
<InfoModal/>
<router-view name="main"/>
</main>
<footer class="mt-auto">
<CompFooter/>
</footer>
<CookieConsentBtn id="cookie-btn"/>
</template>
<script>
import {onMounted, ref} from "vue";
import InfoModal from "@/components/InfoModal";
import CompFooter from "@/components/CompFooter";
import CompNav from "@/components/CompNav";
export default {
components: {CompNav, CompFooter, InfoModal},
setup() {
const offset = ref(0)
const setOffset = () => {
return document.getElementsByTagName('nav')[0].clientHeight
}
const setBgHeight = () => {
document.querySelector('.bg-img').style.height = document.documentElement.clientHeight + 'px'
}
window.onresize = () => {
offset.value = setOffset()
setBgHeight()
}
onMounted(() => {
offset.value = setOffset()
setBgHeight()
})
return {offset}
}
}
</script>
<style lang="scss">
@font-face {
font-family: "Obitron";
}
.bg-img {
z-index: -1;
position: fixed;
width: 100%;
object-fit: cover;
overflow: hidden;
}
#cookie-btn {
position: fixed;
bottom: 30px;
right: 20px;
}
</style>

131
src/app.css Normal file
View File

@@ -0,0 +1,131 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* CS2 Custom Font */
@font-face {
font-family: 'CS Regular';
src:
url('/fonts/cs_regular.woff2') format('woff2'),
url('/fonts/cs_regular.ttf') format('truetype');
font-weight: normal;
font-style: normal;
font-display: swap;
}
@layer base {
:root {
/* Default to dark theme */
color-scheme: dark;
}
body {
@apply bg-base-100 text-base-content;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
sans-serif;
font-feature-settings:
'rlig' 1,
'calt' 1;
}
/* CS2 Font for headlines only */
h1,
h2,
h3,
h4,
h5,
h6 {
font-family:
'CS Regular',
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
sans-serif;
}
}
@layer components {
/* Custom scrollbar */
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: theme('colors.base-300') transparent;
}
.scrollbar-thin::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: theme('colors.base-300');
border-radius: 4px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: theme('colors.base-content');
}
/* Loading skeleton */
.skeleton {
@apply animate-pulse rounded bg-base-300;
}
/* Team colors */
.team-t {
@apply text-terrorist;
}
.team-ct {
@apply text-ct;
}
.bg-team-t {
@apply bg-terrorist;
}
.bg-team-ct {
@apply bg-ct;
}
}
@layer utilities {
/* Animations */
.animate-fade-in {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-slide-in {
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
}

15
src/app.html Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<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" />
%sveltekit.head%
</head>
<body>
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -1,63 +0,0 @@
<template>
<div class="details-site">
<div class="multi-kills">
<h3 class="text-center mt-2">Multi-Kills</h3>
<MultiKillsChart/>
</div>
<!-- <hr>-->
<!-- <div class="spray">-->
<!-- <h3 class="text-center">Spray</h3>-->
<!-- <SprayGraph :spray="data.spray"/>-->
<!-- </div>-->
</div>
</template>
<script>
import MultiKillsChart from "@/components/MultiKillsChart";
import {useStore} from "vuex";
import {onMounted, reactive} from "vue";
import {GetWeaponDmg} from "@/utils";
export default {
name: "CompDetails",
components: {MultiKillsChart},
setup() {
const store = useStore()
const data = reactive({
spray: [],
})
const getWeaponDamage = async () => {
const resData = await GetWeaponDmg(store, store.state.matchDetails.match_id)
if (resData !== null) {
data.spray = resData.spray
}
}
onMounted(() => {
getWeaponDamage()
})
return {data}
}
}
</script>
<style lang="scss" scoped>
.details-site {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
h3 {
margin-bottom: 1rem;
}
hr {
width: 100%;
border: 1px solid white;
}
}
</style>

View File

@@ -1,48 +0,0 @@
<template>
<div class="footer bg-secondary text-center pt-4 pb-2">
<div class="text">
<p class="fs-6">Made with <i class="fa fa-heart text-warning" aria-hidden="true"></i>, <span
style="color: #41b883">Vue.js</span> and<a aria-label="Gitea" class="text-warning ms-2"
href="https://somegit.dev/CSGOWTF"
target="_blank">
<i aria-hidden="true" class="fa fa-gitea"></i>
</a></p>
<div class="d-flex justify-content-center align-items-center gap-4">
<p><a class="text-decoration-none text-warning"
href="https://somegit.dev/CSGOWTF/csgowtf/issues"
target="_blank">Issue Tracker</a></p>
<p class="text-muted">Version {{ version }}</p>
<p>
<a class="text-decoration-none text-warning" href="/privacy-policy">Privacy Policy</a>
</p>
</div>
</div>
</div>
</template>
<script>
export default {
name: "CompFooter",
setup() {
const version = process.env.VUE_APP_VERSION
return {version}
}
}
</script>
<style lang="scss" scoped>
.footer {
.fa-gitea:hover {
color: #609926 !important;
}
.fa-heart:hover {
color: red !important;
}
p {
font-size: .85rem;
}
}
</style>

View File

@@ -1,360 +0,0 @@
<template>
<nav class="navbar navbar-expand-md navbar-dark fixed-top">
<div class="container">
<router-link class="navbar-brand" to="/" @click="closeNav('mainNav')">
<img alt="logo-nav"
class="logo-nav"
src="/images/logo.svg">
</router-link>
<button aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation" class="navbar-toggler"
data-bs-target="#mainNav" data-bs-toggle="collapse" type="button">
<span class="navbar-toggler-icon"></span>
</button>
<div id="mainNav" class="collapse navbar-collapse navbar-nav justify-content-between">
<ul class="list-unstyled">
<li class="nav-item">
<router-link class="nav-link" to="/matches" @click="closeNav('mainNav')">
Matches
</router-link>
</li>
</ul>
<form id="searchform" class="d-flex" @keydown.enter.prevent="parseSearch" @submit.prevent="parseSearch">
<label for="search">
<i class="fa fa-search"></i>
</label>
<input id="search" v-model="data.searchInput" aria-label="Search"
autocomplete="off"
class="form-control bg-transparent border-0"
placeholder="SteamID64, Profile Link or Custom URL"
title="SteamID64, Profile Link or Custom URL"
type="search">
<button
id="search-button"
class="btn border-2 btn-outline-info"
type="button"
@click="parseSearch"
>
Search!
</button>
</form>
</div>
</div>
</nav>
</template>
<script>
import {reactive} from "vue";
import {useStore} from 'vuex'
import {closeNav, GetUser, GoToPlayer} from '@/utils'
import {StatusCodes as STATUS} from "http-status-codes";
export default {
name: 'CompNav',
setup() {
const store = useStore()
const data = reactive({
searchInput: '',
})
const parseSearch = async () => {
const input = data.searchInput
const customUrlPattern = 'https://steamcommunity.com/id/'
const profileUrlPattern = 'https://steamcommunity.com/profiles/'
const id64Pattern = /^\d{17}$/
const vanityPattern = /^[A-Za-z0-9-_]{3,32}$/
store.commit({
type: 'changeVanityUrl',
id: ''
})
store.commit({
type: 'changeId64',
id: ''
})
if (data.searchInput !== '') {
if (id64Pattern.test(input)) {
store.commit({
type: 'changeId64',
id: input
})
} else if (input.match(customUrlPattern)) {
store.commit({
type: 'changeVanityUrl',
id: input.split('/')[4].split('?')[0]
})
} else if (input.match(profileUrlPattern)) {
const tmp = input.split('/')[4].split('?')[0]
if (id64Pattern.test(tmp)) {
store.commit({
type: 'changeId64',
id: tmp
})
}
} else {
store.commit({
type: 'changeVanityUrl',
id: input
})
}
if (store.state.vanityUrl && !vanityPattern.test(store.state.vanityUrl)) {
store.commit({
type: 'changeInfoState',
data: {
statuscode: STATUS.NOT_ACCEPTABLE,
message: 'Only alphanumeric symbols, "_", and "-", between 3-32 characters',
type: 'warning'
}
})
store.commit({
type: 'changeVanityUrl',
id: ''
})
data.searchInput = ''
}
if (store.state.id64 !== '' || store.state.vanityUrl !== '') {
const resData = await GetUser(store, store.state.vanityUrl || store.state.id64)
if (resData !== null) {
data.searchInput = ''
document.activeElement.blur()
store.commit({
type: 'changePlayerDetails',
data: resData
})
if (store.state.vanityUrl) {
closeNav('mainNav')
GoToPlayer(store.state.vanityUrl)
} else if (store.state.id64) {
closeNav('mainNav')
GoToPlayer(store.state.id64)
}
}
}
}
}
document.addEventListener('click', (e) => {
if (!e.target.attributes.id)
closeNav('mainNav')
})
return {
data, parseSearch, closeNav
}
}
}
</script>
<style lang="scss" scoped>
.navbar-dark .navbar-brand:hover,
.navbar-dark .navbar-brand:focus {
color: var(--bs-warning);
}
nav {
max-width: 100vw;
width: 100vw;
height: 70px;
background: rgba(16, 18, 26, .9);
box-shadow: 0 1px 10px 0 #111;
z-index: 2;
vertical-align: center !important;
.navbar-brand {
img {
width: 75px;
height: auto;
}
&:focus-visible {
outline: none;
box-shadow: 0 4px 2px -2px var(--bs-warning);
}
&:hover {
color: var(--bs-warning);
}
}
ul li {
font-size: 1.5rem;
font-weight: lighter;
margin: 22px 0 0 10px;
cursor: pointer;
transition: 100ms ease-in-out;
.nav-link {
text-decoration: none;
color: white !important;
.router-link-exact-active {
box-shadow: 0 4px 2px -2px var(--bs-warning);
}
&:focus-visible {
outline: none;
box-shadow: 0 4px 2px -2px var(--bs-warning);
}
&:hover {
color: #bdbdbd !important;
transition: 250ms ease-in-out;
cursor: pointer;
box-shadow: 0 4px 2px -2px var(--bs-warning);
}
}
}
form {
position: relative;
svg {
width: 24px;
height: 24px;
fill: currentColor;
}
label {
padding-top: 6px;
font-size: 1.4rem;
}
input[type="search"] {
min-width: 300px;
max-width: 300px;
&:focus {
box-shadow: 0 4px 2px -2px rgba(95, 120, 146, 0.59);
transition: .2s ease-in-out;
transform: scale(.975);
}
&::placeholder {
color: #aaa;
font-size: .9rem;
}
}
.alert {
position: absolute;
right: 0;
top: 55px;
}
}
}
@media screen and (max-width: 410px) {
form {
margin-left: auto !important;
margin-right: auto !important;
input[type="search"] {
margin-left: 0 !important;
max-width: 60vw !important;
min-width: 60vw !important;
}
}
}
@media screen and (max-width: 455px) and (min-width: 410px) {
form {
margin-left: auto !important;
margin-right: auto !important;
input[type="search"] {
margin-left: 0 !important;
max-width: 65vw !important;
min-width: 65vw !important;
}
}
}
@media screen and (max-width: 610px) and (min-width: 456px) {
form {
margin-left: auto !important;
margin-right: auto !important;
input[type="search"] {
margin-left: 0 !important;
max-width: 68vw !important;
min-width: 68vw !important;
}
}
}
@media screen and (max-width: 768px) {
nav {
button {
outline: 1px solid var(--bs-primary);
margin-left: auto;
float: right;
&:focus {
box-shadow: none;
outline: 1px solid var(--bs-primary);
}
}
.navbar-collapse {
background: var(--bs-secondary);
border-radius: 5px;
border: 1px solid var(--bs-primary)
}
#mainNav {
ul {
display: flex;
flex-direction: column;
text-align: center;
width: 100%;
li {
line-height: 1;
padding: 0 0 20px 0;
border-bottom: 1px solid rgba(255, 255, 255, .1);
}
}
form {
max-width: 87vw;
margin-left: -40px;
label {
display: none;
}
input[type="search"] {
margin-bottom: 15px;
margin-left: 37px;
max-width: 400px;
min-width: 400px;
font-size: 1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&::placeholder {
background: var(--bs-body-bg);
}
}
button {
margin-left: 10px;
display: block;
margin-top: -2px;
height: 40px;
}
}
}
}
}
</style>

View File

@@ -1,56 +0,0 @@
<template>
<div class="damage-site">
<div class="total-damage">
<h3 class="text-center mt-2">Total Damage</h3>
<TotalDamage/>
</div>
<div class="hitgroup">
<!-- <h3 class="text-center">Damage by Hitgroup</h3>-->
<HitgroupPuppet :equipment_map="data.equipment_map" :stats="data.stats" />
</div>
</div>
</template>
<script>
import HitgroupPuppet from '@/components/HitgroupPuppet'
import TotalDamage from "@/components/TotalDamage"
import {onMounted, reactive} from "vue";
import {useStore} from "vuex";
import {GetWeaponDmg} from "@/utils";
export default {
name: "DamageSite.vue",
components: {HitgroupPuppet, TotalDamage},
setup() {
const store = useStore()
const data = reactive({
equipment_map: {},
stats: [],
})
const getWeaponDamage = async () => {
const resData = await GetWeaponDmg(store, store.state.matchDetails.match_id)
if (resData !== null) {
data.equipment_map = resData.equipment_map
data.stats = resData.stats
}
}
onMounted(() => {
getWeaponDamage()
})
return {data}
}
}
</script>
<style scoped>
.damage-site {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
</style>

View File

@@ -1,276 +0,0 @@
<template>
<div class="economy">
<h3 class="text-center mt-2">Economy</h3>
<div class="flexbreak"></div>
<div id="economy-graph"></div>
</div>
</template>
<script>
import {GetPlayerValue} from "@/utils";
import {useStore} from "vuex";
import {onBeforeMount, onMounted, onUnmounted, reactive, ref, watch} from "vue";
import * as echarts from 'echarts/core';
import {
GridComponent,
MarkAreaComponent,
TitleComponent,
TooltipComponent,
VisualMapComponent
} from 'echarts/components';
import {LineChart} from 'echarts/charts';
import {UniversalTransition} from 'echarts/features';
import {CanvasRenderer} from 'echarts/renderers';
export default {
name: "EqValueGraph",
setup() {
const store = useStore()
let myChart1, max_rounds
let valueList = []
let dataList = []
const width = ref(window.innerWidth >= 800 && window.innerWidth <= 1200 ? window.innerWidth : window.innerWidth < 800 ? 800 : 1200)
const height = ref(width.value * 1 / 3)
const data = reactive({
rounds: {},
team: [],
eq_team_1: [],
eq_team_2: [],
eq_team_player_1: [],
eq_team_player_2: [],
})
const getTeamPlayer = (stats, team) => {
let arr = []
for (let i = (team - 1) * 5; i < team * 5; i++) {
arr.push(stats[i].player.steamid64)
}
return arr
}
const parseObject = async () => {
data.rounds = await GetPlayerValue(store, store.state.matchDetails.match_id)
if (data.rounds === null)
data.rounds = {}
for (const round in data.rounds) {
for (const player in data.rounds[round]) {
for (let p in data.team[0]) {
if (data.team[0][p] === player) {
data.eq_team_player_1.push({
round: round,
player: player,
eq: (data.rounds[round][player][0] + data.rounds[round][player][2])
})
}
}
for (let p in data.team[1]) {
if (data.team[1][p] === player) {
data.eq_team_player_2.push({
round: round,
player: player,
eq: (data.rounds[round][player][0] + data.rounds[round][player][2])
})
}
}
}
}
}
const sumArr = (arr) => {
return arr.reduce((acc, current) => ({
...acc,
[current.round]: (acc[current.round] || 0) + current.eq
}), {})
}
const BuildGraphData = (team_1, team_2, max_rounds) => {
let newArr = []
const half_point = max_rounds / 2 - 1
for (let round in team_1) {
if (round <= half_point) {
newArr.push(team_1[round] - team_2[round])
} else
newArr.push(team_2[round] - team_1[round])
}
return newArr
}
const optionGen = (dataList, valueList) => {
return {
// Make gradient line here
visualMap: [
{
show: false,
type: 'continuous',
seriesIndex: 0,
color: ['#3a6e99', '#c3a235'],
},
],
tooltip: {
trigger: 'axis',
formatter: 'Round <b>{b0}</b><br />{a0} <b>{c0}</b>',
},
xAxis: [
{
type: 'category',
data: dataList,
}
],
yAxis: [
{},
],
grid: [
{
bottom: '10%'
},
{
top: '0%'
},
{
right: '0%'
},
{
left: '0%'
}
],
series: [
{
name: 'Net-Worth',
type: 'line',
lineStyle: {
width: 4
},
showSymbol: false,
data: valueList,
markArea: {
data: [
[
{
name: 'Half-Point',
xAxis: max_rounds / 2 - 1,
label: {
color: 'white'
},
},
{
xAxis: max_rounds / 2
}
]
],
itemStyle: {
color: 'rgba(200,200,200, 0.3)'
}
}
},
],
}
}
const disposeCharts = () => {
if (myChart1 != null && myChart1 !== '' && myChart1 !== undefined) {
myChart1.dispose()
}
}
const buildCharts = () => {
disposeCharts()
myChart1 = echarts.init(document.getElementById('economy-graph'), {}, {
width: width.value,
height: height.value
})
myChart1.setOption(optionGen(dataList, valueList))
}
onBeforeMount(() => {
max_rounds = store.state.matchDetails.max_rounds ? store.state.matchDetails.max_rounds : 30
})
onMounted(() => {
if (store.state.matchDetails.stats) {
echarts.use([
TitleComponent,
TooltipComponent,
GridComponent,
VisualMapComponent,
LineChart,
CanvasRenderer,
UniversalTransition,
MarkAreaComponent
]);
data.team.push(getTeamPlayer(store.state.matchDetails.stats, 1))
data.team.push(getTeamPlayer(store.state.matchDetails.stats, 2))
parseObject()
}
})
onUnmounted(() => {
disposeCharts()
})
watch(() => data.rounds, () => {
data.eq_team_1 = sumArr(data.eq_team_player_1)
data.eq_team_2 = sumArr(data.eq_team_player_2)
valueList = BuildGraphData(data.eq_team_1, data.eq_team_2, max_rounds)
dataList = Array.from(Array(valueList.length + 1).keys())
dataList.shift()
buildCharts()
})
window.onresize = () => {
if (window.innerWidth > 1200) {
width.value = 1200
}
if (window.innerWidth <= 1200 && window.innerWidth >= 800) {
width.value = window.innerWidth - 20
}
if (window.innerWidth < 800) {
width.value = 800
}
height.value = width.value * 1 / 3
buildCharts()
}
}
}
</script>
<style lang="scss" scoped>
.economy {
display: flex;
flex-wrap: wrap;
flex-direction: column;
justify-content: center;
align-items: center;
margin: 0 auto 3rem;
h3 {
margin-bottom: -1rem;
z-index: 2;
}
}
@media (max-width: 1200px) {
h3 {
margin-left: 2rem;
}
}
@media (max-width: 800px) and (min-width: 1199px) {
#economy-graph {
overflow: scroll;
}
}
</style>

View File

@@ -1,244 +0,0 @@
<template>
<div class="player-flash">
<h3 class="text-center mt-2">Flash</h3>
<div class="flex-break"></div>
<div class="toggle-btn">
<div @click="toggleShow">
<table class="table table-borderless text-muted">
<tr>
<td>
<span class="text-uppercase float-end" :class="toggle === 'duration' ? 'text-warning' : ''">Duration</span>
</td>
<td class="text-center">
<i id="toggle-off" class="fa fa-toggle-off show"></i>
<i id="toggle-on" class="fa fa-toggle-on"></i>
</td>
<td>
<span class="text-uppercase float-start" :class="toggle === 'total' ? 'text-warning' : ''">Count</span>
</td>
</tr>
</table>
</div>
</div>
<div class="flex-break"></div>
<div id="flash-chart-1"></div>
<div id="flash-chart-2"></div>
</div>
</template>
<script>
import * as echarts from 'echarts/core';
import {GridComponent, LegendComponent, TooltipComponent} from 'echarts/components';
import {BarChart} from 'echarts/charts';
import {CanvasRenderer} from 'echarts/renderers';
import {onMounted, onUnmounted, ref, watch} from "vue";
import {checkStatEmpty, getPlayerArr} from "@/utils";
import {useStore} from "vuex";
export default {
name: "FlashChart",
setup() {
const store = useStore()
const toggle = ref('duration')
let myChart1, myChart2
const color = ['#bb792c', '#9bd270', '#eac42a']
const width = ref(window.innerWidth <= 600 ? window.innerWidth : 600)
const height = ref(width.value * 2 / 3)
const toggleShow = () => {
const offBtn = document.getElementById('toggle-off')
const onBtn = document.getElementById('toggle-on')
if (offBtn.classList.contains('show')) {
offBtn.classList.remove('show')
onBtn.classList.add('show')
toggle.value = 'total'
} else if (onBtn.classList.contains('show')) {
onBtn.classList.remove('show')
offBtn.classList.add('show')
toggle.value = 'duration'
}
}
const valueArr = (stats, team, toggle, prop) => {
if (['team', 'enemy', 'self'].indexOf(prop) > -1) {
let arr = []
for (let i = (team - 1) * 5; i < team * 5; i++) {
arr.push(checkStatEmpty(Function('return(function(stats, i){ return stats[i].flash.' + toggle.value + '.' + prop + '})')()(stats, i)).toFixed(2))
}
arr.reverse()
return arr
}
}
const setOptions = (id, color) => {
return {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
shadowStyle: {
shadowBlur: 2,
shadowColor: 'rgba(255, 255, 255, .3)'
}
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'value',
boundaryGap: [0, 0.01]
},
yAxis: {
type: 'category',
data: getPlayerArr(store.state.matchDetails.stats, id, true)
},
color: color,
series: [
{
name: 'Enemy',
type: 'bar',
data: valueArr(store.state.matchDetails.stats, id, toggle, 'enemy'),
},
{
name: 'Team',
type: 'bar',
data: valueArr(store.state.matchDetails.stats, id, toggle, 'team'),
},
{
name: 'Self',
type: 'bar',
data: valueArr(store.state.matchDetails.stats, id, toggle, 'self'),
}
]
}
}
const disposeCharts = () => {
if (myChart1 != null && myChart1 !== '' && myChart1 !== undefined) {
myChart1.dispose()
}
if (myChart2 != null && myChart2 !== '' && myChart2 !== undefined) {
myChart2.dispose()
}
}
const buildCharts = () => {
disposeCharts()
myChart1 = echarts.init(document.getElementById('flash-chart-1'), {}, {
width: width.value,
height: height.value
});
myChart1.setOption(setOptions(1, color));
myChart2 = echarts.init(document.getElementById('flash-chart-2'), {}, {
width: width.value,
height: height.value
});
myChart2.setOption(setOptions(2, color));
}
onMounted(() => {
if (store.state.matchDetails.stats) {
echarts.use([
TooltipComponent,
GridComponent,
LegendComponent,
BarChart,
CanvasRenderer
]);
buildCharts()
}
})
onUnmounted(() => {
disposeCharts()
})
watch(() => toggle.value, () => {
buildCharts()
})
window.onresize = () => {
if (window.innerWidth <= 600) {
width.value = window.innerWidth - 20
height.value = width.value * 2 / 3
buildCharts()
}
}
return {toggleShow, toggle}
}
}
</script>
<style lang="scss" scoped>
.player-flash {
display: flex;
flex-wrap: wrap;
margin-bottom: 1rem;
.flex-break {
flex-basis: 100%;
height: 0;
}
h3 {
margin: 1rem auto -1rem;
}
.toggle-btn {
margin: 0 auto;
cursor: pointer;
table {
margin-top: 1rem;
td {
font-size: .8rem;
}
td:first-child,
td:last-child {
max-width: 80px;
width: 80px;
}
td:nth-child(2) {
max-width: 30px;
width: 30px;
}
}
.fa {
display: none;
&.show {
display: initial;
}
}
}
#flash-chart-1,
#flash-chart-2 {
flex-basis: 50%;
}
}
@media (max-width: 1200px) {
.player-flash {
justify-content: center;
align-items: center;
padding: 0;
margin: 0;
}
}
</style>

View File

@@ -1,564 +0,0 @@
<template>
<div class="hitgroup pt-2">
<div class="d-flex flex-lg-nowrap flex-wrap justify-content-center gap-4">
<div class="d-flex flex-column justify-content-center align-items-center w-auto">
<div class="select-group mb-4">
<select v-if="store.state.playersArr" v-model="data.selectPlayer" class="form-select">
<option value="All">All</option>
<option value="Team 1">Team 1</option>
<option value="Team 2">Team 2</option>
<option disabled></option>
<option v-for="(value, index) in props.stats" :key="index"
:value="Object.keys(value).toString() === store.state.playersArr[index].player.steamid64 ? store.state.playersArr[index].player : ''">
{{
Object.keys(value).toString() === store.state.playersArr[index].player.steamid64 ? store.state.playersArr[index].player.name : ''
}}
</option>
</select>
<select v-if="data.selectPlayer !== ''" :key="data.selectPlayer" v-model="data.selectWeapon"
class="form-select">
<option class="select-hr" value="All">All</option>
<option disabled></option>
<option v-for="(value, index) in processPlayerWeapon()" :key="index" :value="value">
<!-- This is here, because weapons are not always named correctly -->
<!-- {{ Object.values(value).toString().charAt(0).toUpperCase() + Object.values(value).toString().slice(1) }}-->
{{ Object.values(value).toString() }}
</option>
</select>
</div>
<div id="hitgroup-puppet"/>
</div>
<div v-if="data.weaponDmg"
id="bar-graph"
class="w-auto"
:style="{
minWidth: dmgWidth + 'px'
}">
<table class="table table-borderless">
<tr v-for="(value, index) in data.weaponDmg" :key="index">
<td v-if="index < 10 && (data.selectWeapon === 'All' || Object.keys(data.selectWeapon).toString() === Object.keys(value).toString())"
style="width: 100px">
<img :alt="Object.values(value).toString()"
:src="DisplayWeapon(parseInt(Object.keys(value)[0]))"/>
</td>
<td v-if="index < 10 && (data.selectWeapon === 'All' || Object.keys(data.selectWeapon).toString() === Object.keys(value).toString())">
<span :style="{
width: (processWeaponDmg(Object.keys(value).toString()) / processWeaponDmg(Object.keys(data.weaponDmg[0]).toString()) * 100).toFixed(0) + '%',
backgroundColor: 'orangered',
display: 'block',
}"
class="rounded"
>
<span>{{ processWeaponDmg(Object.keys(value).toString()) }}</span>
</span>
</td>
</tr>
</table>
</div>
</div>
</div>
</template>
<script>
import * as echarts from 'echarts/core';
import {GeoComponent, TooltipComponent, VisualMapComponent} from 'echarts/components';
import {MapChart} from 'echarts/charts';
import {CanvasRenderer} from 'echarts/renderers';
import {onMounted, onUnmounted, reactive, ref, watch} from "vue";
import {useStore} from "vuex";
import {DisplayWeapon} from '@/utils'
import $ from 'jquery'
export default {
name: "HitgroupPuppet.vue",
props: {
equipment_map: {
type: Object,
required: true,
},
stats: {
type: Array,
required: true
}
},
setup(props) {
const store = useStore()
const data = reactive({
selectPlayer: 'All',
selectWeapon: 'All',
eq_map: [],
weaponDmg: []
})
let myChart1
const getWindowWidth = () => {
const windowWidth = window.innerWidth
if (windowWidth <= 750)
return windowWidth
else
return 650
}
const setDmgWidth = () => {
const windowWidth = getWindowWidth()
if (windowWidth >= 500)
return 500
else
return windowWidth - 10
}
const dmgWidth = ref(setDmgWidth())
const setHeight = () => {
const windowWidth = getWindowWidth()
if (windowWidth >= 751)
return windowWidth * 3 / 7.5
else if (windowWidth >= 501 && windowWidth <= 750)
return windowWidth * 3 / 6.5
else
return windowWidth * 3 / 5.5
}
const width = ref(getWindowWidth())
const height = ref(setHeight())
const processWeaponDmg = (id) => {
let value = ''
data.weaponDmg.forEach(w => {
if (Object.keys(w).toString() === id) {
value = Object.values(w).toString()
}
})
return value
}
const processPlayerWeapon = () => {
let arr = []
if (data.selectPlayer === 'All') {
props.stats.forEach(player => {
Object.values(player).forEach(enemies => {
Object.values(enemies).forEach(weapons => {
Object.values(weapons).forEach(weapon => {
arr.push(weapon[0])
})
})
})
})
} else if (data.selectPlayer === 'Team 1') {
props.stats.forEach(player => {
store.state.playersArr.forEach(p => {
if (p.player.steamid64 === Object.keys(player).toString() && p.team_id === 1)
Object.values(player).forEach(enemies => {
Object.values(enemies).forEach(weapons => {
Object.values(weapons).forEach(weapon => {
arr.push(weapon[0])
})
})
})
})
})
} else if (data.selectPlayer === 'Team 2') {
props.stats.forEach(player => {
store.state.playersArr.forEach(p => {
if (p.player.steamid64 === Object.keys(player).toString() && p.team_id === 2)
Object.values(player).forEach(enemies => {
Object.values(enemies).forEach(weapons => {
Object.values(weapons).forEach(weapon => {
arr.push(weapon[0])
})
})
})
})
})
} else {
props.stats.forEach(player => {
if (Object.keys(player).toString() === data.selectPlayer.steamid64) {
Object.values(player).forEach(enemies => {
Object.values(enemies).forEach(weapons => {
Object.values(weapons).forEach(weapon => {
arr.push(weapon[0])
})
})
})
}
})
}
const unique = arr.filter((a, b) => arr.indexOf(a) === b && a < 400)
let arr2 = []
unique.forEach(w => {
for (let weapon in props.equipment_map) {
if (parseInt(w) === parseInt(weapon)) {
let obj = {}
obj[w] = props.equipment_map[weapon]
arr2.push(obj)
}
}
})
return arr2
}
const processDmg = (by = 'hitgroup') => {
let arr = []
if (data.selectPlayer && data.selectWeapon) {
switch (data.selectPlayer) {
case "All":
props.stats.forEach(player => {
Object.values(player).forEach(enemies => {
Object.values(enemies).forEach(weapons => {
Object.values(weapons).forEach(weapon => {
// 0: weapon
// 1: hitgroup
// 2: dmg
if (weapon) {
if (by === 'hitgroup') {
if (Object.values(weapon)[0] === parseInt(Object.keys(data.selectWeapon).toString())) {
let obj = {}
obj[weapon[1]] = weapon[2]
arr.push(obj)
} else if (data.selectWeapon === 'All') {
let obj = {}
obj[weapon[1]] = weapon[2]
arr.push(obj)
}
} else if (by === 'weapon') {
if (Object.values(weapon)[0] === parseInt(Object.keys(data.selectWeapon).toString())) {
let obj = {}
obj[weapon[0]] = weapon[2]
arr.push(obj)
} else if (data.selectWeapon === 'All') {
let obj = {}
obj[weapon[0]] = weapon[2]
arr.push(obj)
}
}
}
})
})
})
})
break;
case "Team 1":
props.stats.forEach(player => {
store.state.playersArr.forEach(p => {
if (p.player.steamid64 === Object.keys(player).toString() && p.team_id === 1)
Object.values(player).forEach(enemies => {
Object.values(enemies).forEach(weapons => {
Object.values(weapons).forEach(weapon => {
// 0: weapon
// 1: hitgroup
// 2: dmg
if (weapon) {
if (by === 'hitgroup') {
if (Object.values(weapon)[0] === parseInt(Object.keys(data.selectWeapon).toString())) {
let obj = {}
obj[weapon[1]] = weapon[2]
arr.push(obj)
} else if (data.selectWeapon === 'All') {
let obj = {}
obj[weapon[1]] = weapon[2]
arr.push(obj)
}
} else if (by === 'weapon') {
if (Object.values(weapon)[0] === parseInt(Object.keys(data.selectWeapon).toString())) {
let obj = {}
obj[weapon[0]] = weapon[2]
arr.push(obj)
} else if (data.selectWeapon === 'All') {
let obj = {}
obj[weapon[0]] = weapon[2]
arr.push(obj)
}
}
}
})
})
})
})
})
break;
case "Team 2":
props.stats.forEach(player => {
store.state.playersArr.forEach(p => {
if (p.player.steamid64 === Object.keys(player).toString() && p.team_id === 2)
Object.values(player).forEach(enemies => {
Object.values(enemies).forEach(weapons => {
Object.values(weapons).forEach(weapon => {
// 0: weapon
// 1: hitgroup
// 2: dmg
if (weapon) {
if (by === 'hitgroup') {
if (Object.values(weapon)[0] === parseInt(Object.keys(data.selectWeapon).toString())) {
let obj = {}
obj[weapon[1]] = weapon[2]
arr.push(obj)
} else if (data.selectWeapon === 'All') {
let obj = {}
obj[weapon[1]] = weapon[2]
arr.push(obj)
}
} else if (by === 'weapon') {
if (Object.values(weapon)[0] === parseInt(Object.keys(data.selectWeapon).toString())) {
let obj = {}
obj[weapon[0]] = weapon[2]
arr.push(obj)
} else if (data.selectWeapon === 'All') {
let obj = {}
obj[weapon[0]] = weapon[2]
arr.push(obj)
}
}
}
})
})
})
})
})
break;
default:
props.stats.forEach(player => {
if (Object.keys(player).toString() === data.selectPlayer.steamid64) {
Object.values(player).forEach(enemies => {
Object.values(enemies).forEach(weapons => {
Object.values(weapons).forEach(weapon => {
// 0: weapon
// 1: hitgroup
// 2: dmg
if (weapon) {
if (by === 'hitgroup') {
if (Object.values(weapon)[0] === parseInt(Object.keys(data.selectWeapon).toString())) {
let obj = {}
obj[weapon[1]] = weapon[2]
arr.push(obj)
} else if (data.selectWeapon === 'All') {
let obj = {}
obj[weapon[1]] = weapon[2]
arr.push(obj)
}
} else if (by === 'weapon') {
if (Object.values(weapon)[0] === parseInt(Object.keys(data.selectWeapon).toString())) {
let obj = {}
obj[weapon[0]] = weapon[2]
arr.push(obj)
} else if (data.selectWeapon === 'All') {
let obj = {}
obj[weapon[0]] = weapon[2]
arr.push(obj)
}
}
}
})
})
})
}
})
break;
}
} else {
arr = []
}
if (by === 'hitgroup') {
buildCharts(sumDmgArr(arr))
} else if (by === 'weapon') {
data.weaponDmg = sumDmgArr(arr, 'weapon')
}
}
const sumDmgArr = (arr, by = 'hitgroup') => {
let holder = {};
arr.forEach(function (d) {
// eslint-disable-next-line no-prototype-builtins
if (holder.hasOwnProperty(parseInt(Object.keys(d).toString()))) {
holder[parseInt(Object.keys(d).toString())] = holder[parseInt(Object.keys(d).toString())] + parseInt(Object.values(d).toString());
} else {
holder[parseInt(Object.keys(d).toString())] = parseInt(Object.values(d).toString());
}
});
let arr2 = [];
if (by === 'hitgroup') {
for (let i = 1; i < 8; i++) {
if (holder[i] !== undefined) {
arr2.push(holder[i])
} else {
arr2.push(0)
}
}
} else if (by === 'weapon') {
for (let i = 1; i < 312; i++) {
if (holder[i] !== undefined) {
let obj = {}
obj[i] = holder[i]
arr2.push(obj)
}
}
arr2.sort((a, b) => {
return Object.values(b).toString() - Object.values(a).toString()
})
}
return arr2
}
const getMax = (arr) => {
let max = 0
for (let i = 0; i < 7; i++) {
if (arr[i] > max)
max = arr[i]
}
return max
}
const optionGen = (arr = []) => {
return {
tooltip: {},
visualMap: {
left: 'center',
bottom: '5%',
textStyle: {
color: 'white',
},
min: 0,
max: getMax(arr) || 100,
orient: 'horizontal',
realtime: true,
calculable: true,
inRange: {
color: ['#00ff00', '#db6e00', '#cf0000']
}
},
series: [
{
name: 'Hitgroup',
type: 'map',
map: 'hitgroup-puppet',
top: '0%',
emphasis: {
label: {
show: false
}
},
selectedMode: false,
data: [
{name: 'Head', value: arr[0] || 0},
{name: 'Chest', value: arr[1] || 0},
{name: 'Stomach', value: arr[2] || 0},
{name: 'Left Arm', value: arr[3] || 0},
{name: 'Right Arm', value: arr[4] || 0},
{name: 'Left Foot', value: arr[5] || 0},
{name: 'Right Foot', value: arr[6] || 0}
]
}
]
}
}
const disposeCharts = () => {
if (myChart1 != null && myChart1 !== '' && myChart1 !== undefined) {
myChart1.dispose()
}
}
const buildCharts = (arr) => {
disposeCharts()
myChart1 = echarts.init(document.getElementById('hitgroup-puppet'), {}, {width: 300, height: 500})
const url = '/images/icons/hitgroup-puppet.svg'
$.get(url, function (svg) {
echarts.registerMap('hitgroup-puppet', {svg: svg})
myChart1.setOption(optionGen(arr));
})
}
onMounted(() => {
if (store.state.matchDetails.stats) {
echarts.use([
TooltipComponent,
VisualMapComponent,
GeoComponent,
MapChart,
CanvasRenderer
]);
buildCharts()
watch(() => props.stats, () => {
processDmg()
processDmg('weapon')
processPlayerWeapon()
})
}
})
onUnmounted(() => {
disposeCharts()
})
window.onresize = () => {
if (window.innerWidth <= 750) {
width.value = getWindowWidth() - 20
height.value = setHeight()
dmgWidth.value = setDmgWidth()
}
buildCharts()
}
watch(() => data.selectPlayer, () => {
data.selectWeapon = 'All'
processPlayerWeapon()
processDmg()
processDmg('weapon')
})
watch(() => data.selectWeapon, () => {
processDmg()
processDmg('weapon')
})
return {props, data, store, dmgWidth, processPlayerWeapon, processWeaponDmg, DisplayWeapon}
}
}
</script>
<style lang="scss" scoped>
.select-group {
display: flex;
flex-direction: row;
gap: 1rem;
.form-select {
background: var(--bs-secondary);
color: var(--bs-primary);
width: 250px;
}
}
@media (max-width: 600px) {
.select-group {
flex-wrap: wrap;
justify-content: center;
align-items: center;
}
}
</style>

View File

@@ -1,88 +0,0 @@
<template>
<div v-if="infos.data" id="modal">
<div v-for="(info, id) in infos.data" :key="id" class="custom-modal">
<div :class="info.type === 'error'
? 'bg-danger text-white'
: info.type === 'warning'
? 'bg-warning text-secondary'
: info.type === 'success'
? 'bg-success text-white'
: 'bg-secondary text-white'"
class="card">
<div class="card-body d-flex justify-content-between">
<span class="info-text">{{ info.message }}</span>
<button aria-label="Close" class="btn-close" type="button" @click="closeModal(id)"/>
</div>
</div>
</div>
</div>
</template>
<script>
import {useStore} from "vuex";
import {onMounted, reactive} from "vue";
export default {
name: "InfoModal",
setup() {
const store = useStore()
const infos = reactive({
data: []
})
const closeModal = (id) => {
store.commit('removeInfoState', id)
}
onMounted(() => {
store.subscribe(((mutation, state) => {
if (mutation.type === 'changeInfoState') {
infos.data = state.info
setTimeout(() => {
closeModal(store.state.info.length - 1)
}, 5000)
}
}))
})
return {infos, closeModal}
}
}
</script>
<style lang="scss" scoped>
#modal {
--height: 56px;
.card {
z-index: 10;
position: absolute;
right: 1rem;
opacity: .8;
width: min(100vw - 2rem, 50ch);
height: var(--height);
.btn-close {
background-color: white;
opacity: .5;
}
.info-text {
font-size: .8rem;
}
}
@for $i from 1 through 10 {
.custom-modal:nth-of-type(#{$i}) {
.card {
@if $i == 1 {
margin: 1rem 0;
} @else {
margin-top: calc(#{$i}rem + (#{$i} - 1) * var(--height));
}
}
}
}
}
</style>

View File

@@ -1,274 +0,0 @@
<template>
<div class="container w-50">
<TranslateChatButton
v-if="data.chat.length > 0"
:translated="data.translatedText.length > 0"
class="translate-btn"
@translated="handleTranslatedText"
/>
<div v-if="data.chat.length > 0" class="chat-history mt-2">
<table id="chat" :style="`max-width: ${data.clientWidth}px; width: ${data.clientWidth}px`" class="table table-borderless">
<tbody>
<tr v-for="(m, id) in data.chat" :key="id">
<td class="td-time">
{{ ConvertTickToTime(m.tick, m.tick_rate) }}
</td>
<td class="td-avatar">
<img :class="'team-color-' + m.color"
:src="constructAvatarUrl(m.avatar)"
alt="Player avatar"
class="avatar">
</td>
<td :class="m.startSide === 1 ? 'text-info' : 'text-warning'"
class="td-name d-flex"
@click="GoToPlayer(m.steamid64)">
<span>
<i v-if="m.tracked" class="fa fa-dot-circle-o text-success tracked" title="Tracked user"/>
<span :class="(m.vac && FormatVacDate(m.vac_date, store.state.matchDetails.date) !== '')
|| (!m.vac && m.game_ban && FormatVacDate(m.game_ban_date, store.state.matchDetails.date) !== '')
? 'ban-shadow'
: ''"
:title="!m.vac && m.game_ban
? 'Game-banned: ' + FormatVacDate(m.game_ban_date, store.state.matchDetails.date)
: m.vac && !m.game_ban
? 'Vac-banned: ' + FormatVacDate(m.vac_date, store.state.matchDetails.date)
: ''">
{{ m.player }}
</span>
</span>
</td>
<td class="td-icon">
<i class="fa fa-caret-right"/>
<span v-if="!m.all_chat" class="ms-1">
(team)
</span>
</td>
<td class="td-message">
{{ data.translatedText.length === 0 ? m.message : data.originalChat[id].message }}
<span v-if="m.translated_from"
:class="m.translated_from ? 'text-success' : ''"
:title="`Translated from ${ISO6391.getName(m.translated_from)}`"
class="ms-2 helpicon">
<br/>
{{ m.message }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else>
<h3>No chat available</h3>
</div>
</div>
</template>
<script>
import {useStore} from "vuex";
import {onMounted, reactive} from "vue";
import {constructAvatarUrl, ConvertTickToTime, FormatVacDate, GetChatHistory, GoToPlayer, truncate} from "@/utils";
import TranslateChatButton from "@/components/TranslateChatButton";
import ISO6391 from 'iso-639-1'
export default {
name: "MatchChatHistory",
components: {TranslateChatButton},
setup() {
const store = useStore()
const data = reactive({
chat: [],
translatedText: [],
originalChat: [],
clientWidth: 0
})
const handleTranslatedText = async (e) => {
const [res, toggle] = await e
if (res !== null) {
if (toggle === 'translated') {
data.translatedText = await setPlayer(sortChatHistory(res, true))
data.chat = data.translatedText
} else if (toggle === 'original') {
data.chat = data.originalChat
}
}
}
const getChatHistory = async () => {
const resData = await GetChatHistory(store, store.state.matchDetails.match_id)
if (resData !== null) {
data.chat = await setPlayer(sortChatHistory(resData))
data.originalChat = data.chat
}
}
const sortChatHistory = (res = {}, translated = false) => {
let arr = []
if (res !== {}) {
Object.keys(res).forEach(i => {
res[i].forEach(o => {
let obj = Object.assign({
player: i,
tick: o.tick,
all_chat: o.all_chat,
message: o.message,
translated_from: translated ? o.translated_from : null,
translated_to: translated ? o.translated_to : null
})
arr.push(obj)
})
})
}
arr.sort((a, b) => a.tick - b.tick)
return arr
}
const setPlayer = async (chat) => {
let arr = []
for (const o of chat) {
for (const p of store.state.matchDetails.stats) {
if (o.player === p.player.steamid64) {
const obj = Object.assign({
player: truncate(p.player.name, 20),
steamid64: p.player.steamid64,
avatar: p.player.avatar,
color: p.color,
startSide: p.team_id,
tracked: p.player.tracked,
vac: p.player.vac,
vac_date: p.player.vac_date,
game_ban: p.player.game_ban,
game_ban_date: p.player.game_ban_date,
tick: o.tick,
tick_rate: store.state.matchDetails.tick_rate && store.state.matchDetails.tick_rate !== -1 ? store.state.matchDetails.tick_rate : 64,
all_chat: o.all_chat,
message: o.message,
translated_from: o.translated_from,
translated_to: o.translated_to
})
arr.push(obj)
}
}
}
return arr
}
const sizeTable = () => {
if (document.documentElement.clientWidth <= 768) {
data.clientWidth = document.documentElement.clientWidth - 32
} else {
data.clientWidth = 700
}
}
window.onresize = () => {
sizeTable()
}
onMounted(() => {
getChatHistory()
sizeTable()
})
return {
data,
store,
ISO6391,
constructAvatarUrl,
GoToPlayer,
ConvertTickToTime,
FormatVacDate,
handleTranslatedText
}
}
}
</script>
<style lang="scss" scoped>
.container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.translate-btn {
margin-top: .5rem;
}
td {
padding: .5rem;
}
.td-time {
width: 80px;
}
.td-avatar {
width: 30px;
.avatar {
width: 20px;
height: 20px;
border-radius: 50%;
}
}
.td-name {
width: 200px;
max-width: 200px;
cursor: pointer;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.tracked {
font-size: .8rem;
margin-right: .2rem;
}
.ban-shadow {
color: red;
text-shadow: 0 0 1rem orangered;
}
}
.td-icon {
width: 20px;
.fa-caret-right {
font-size: 1rem;
}
}
.td-message {
width: 400px !important;
}
@media screen and (max-width: 768px) {
.container {
justify-content: flex-start;
align-items: flex-start;
margin-left: 1rem;
}
.td-name {
width: 120px !important;
max-width: 120px !important;
}
.td-message {
width: auto !important;
}
}
@media screen and (max-width: 576px) {
.container {
margin-left: 0;
}
.td-avatar {
display: none;
}
}
</style>

View File

@@ -1,532 +0,0 @@
<template>
<div v-if="props.matches.length === 0" id="matches-placeholder">
<span v-for="i in 20" :key="i" :class="i % 2 === 1 ? 'placeholder-wave' : 'placeholder-wave-alt'"
class="placeholder col-12"></span>
</div>
<div v-else id="matches">
<table class="table table-borderless">
<thead class="border-bottom">
<tr>
<th class="text-center map" scope="col">Map</th>
<th class="text-center rank" scope="col">Rank</th>
<th class="text-center length" scope="col" title="Match Length">
<img alt="Match length" class="match-len helpicon" src="/images/icons/timer_both.svg">
</th>
<th class="text-center score" scope="col">Score</th>
<th v-if="!props.explore" class="text-center kills" scope="col">K</th>
<th v-if="!props.explore" class="text-center assists" scope="col">A</th>
<th v-if="!props.explore" class="text-center deaths" scope="col">D</th>
<th v-if="!props.explore" class="text-center kdiff helptext" scope="col" title="Kill-to-death difference">+/-</th>
<th v-if="!props.explore" class="text-center hltv helptext" scope="col" title="HLTV 1.0 Rating">Rating</th>
<th class="text-center duration" scope="col">Duration</th>
<th class="date" scope="col">Date</th>
</tr>
</thead>
<tbody>
<tr v-for="match in props.matches"
:key="match.match_id"
:class="props.colorFront ? (GetWinLoss(match.match_result, match.stats.team_id) + (match.vac || match.game_ban ? ' ban' : '')) : (match.vac || match.game_ban ? ' matches_ban' : '')"
:title="match.vac ? 'VAC-banned player in this game' : match.game_ban ? 'Game-banned player in this game' : ''"
class="match default"
@click="GoToMatch(match.match_id)"
>
<td class="td-map text-center">
<i v-if="match.parsed" class="fa fa-bar-chart parsed helpicon"
title="Demo has been parsed for additional data"></i>
<i v-if="!match.parsed && MatchNotParsedTime(match.date)" class="fa fa-hourglass-half not-yet-parsed helpicon"
title="Match has not been parsed yet"></i>
<img v-if="match.map !== ''"
:alt="match.map"
:src="'/images/map_icons/map_icon_' + match.map + '.svg'"
:title="FixMapName(match.map)"
class="map-icon">
<i v-else class="fa fa-question-circle-o map-not-found" title="Match not parsed"></i>
</td>
<td class="td-rank text-center">
<img v-if="props.explore"
:alt="DisplayRank(Math.floor(match.avg_rank || 0))[1]"
:src="DisplayRank(Math.floor(match.avg_rank || 0))[0]"
:title="DisplayRank(Math.floor(match.avg_rank || 0))[1]" class="rank-icon">
<img v-else
:alt="DisplayRank(match.stats.rank?.new)[1]"
:class="match.stats.rank?.new > match.stats.rank?.old ? 'uprank' : match.stats.rank?.new < match.stats.rank?.old ? 'downrank' : ''"
:src="DisplayRank(match.stats.rank?.new)[0]"
:title="DisplayRank(match.stats.rank?.new)[1]" class="rank-icon">
</td>
<td class="td-length text-center">
<img v-if="match.max_rounds === 30 || !match.max_rounds"
alt="Match long"
class="match-len"
src="/images/icons/timer_long.svg"
title="Long Match">
<img v-if="match.max_rounds === 16"
alt="Match short"
class="match-len"
src="/images/icons/timer_short.svg"
title="Short Match">
</td>
<td class="td-score text-center fw-bold">
<span
:class="match.match_result === 1 ? 'text-success' : match.match_result === 0 ? 'text-warning' : 'text-danger'">{{
match.score[0]
}}</span> - <span
:class="match.match_result === 2 ? 'text-success' : match.match_result === 0 ? 'text-warning' : 'text-danger'">{{
match.score[1]
}}</span>
</td>
<td v-if="match.stats" class="td-kills text-center">
{{ match.stats.kills ? match.stats.kills : "0" }}
</td>
<td v-if="match.stats" class="td-assists text-center">
{{ match.stats.assists ? match.stats.assists : "0" }}
</td>
<td v-if="match.stats" class="td-deaths text-center">
{{ match.stats.deaths ? match.stats.deaths : "0" }}
</td>
<td v-if="match.stats"
:class="(match.stats.kills ? match.stats.kills : 0) - (match.stats.deaths ? match.stats.deaths : 0) >= 0 ? 'text-success' : 'text-danger'"
class="td-plus text-center">
{{
(match.stats.kills ? match.stats.kills : 0) - (match.stats.deaths ? match.stats.deaths : 0)
}}
</td>
<td v-if="match.stats"
:class="GetHLTV_1(
match.stats.kills,
match.score[0] + match.score[1],
match.stats.deaths,
match.stats.multi_kills?.duo,
match.stats.multi_kills?.triple,
match.stats.multi_kills?.quad,
match.stats.multi_kills?.pent) >= 1 ? 'text-success' : 'text-warning'"
class="td-hltv text-center fw-bold">
{{
GetHLTV_1(
match.stats.kills,
match.score[0] + match.score[1],
match.stats.deaths,
match.stats.multi_kills?.duo,
match.stats.multi_kills?.triple,
match.stats.multi_kills?.quad,
match.stats.multi_kills?.pent)
}}
</td>
<td :title="FormatFullDuration(match.duration)" class="td-duration text-center">
{{ FormatDuration(match.duration) }}
</td>
<td :title="FormatFullDate(match.date)" class="td-date">
{{ FormatDate(match.date) }}
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script>
import {
DisplayRank,
FixMapName,
FormatDate,
FormatDuration,
FormatFullDate,
FormatFullDuration,
GetHLTV_1,
GetWinLoss,
GoToMatch,
MatchNotParsedTime
} from "@/utils";
export default {
name: "MatchesTable",
props: {
colorFront: {
type: Boolean,
required: false,
default: false
},
matches: {
type: Array,
required: false
},
explore: {
type: Boolean,
required: false,
default: false
}
},
setup(props) {
return {
props,
FormatDate,
FormatFullDate,
FormatDuration,
FormatFullDuration,
GetHLTV_1,
GetWinLoss,
GoToMatch,
MatchNotParsedTime,
DisplayRank,
FixMapName
}
}
}
</script>
<style lang="scss" scoped>
#matches-placeholder {
.placeholder {
height: 78px;
margin: 1px 0;
}
}
table {
margin-bottom: 0;
tr {
th {
line-height: 1;
}
td {
line-height: 60px;
font-size: 1rem;
}
th:last-child, td:last-child {
text-align: right;
width: 150px;
}
td {
vertical-align: middle;
}
.map {
padding-left: 3rem;
}
.match-len {
width: 18px;
height: 18px;
}
.td-map {
position: relative;
padding-left: 3rem;
text-align: left !important;
width: 50px;
.parsed {
position: absolute;
left: 7px;
bottom: 23px;
color: var(--bs-warning);
font-size: 1.7rem;
}
.not-yet-parsed {
position: absolute;
left: 10px;
bottom: 25px;
color: darkgrey;
font-size: 1.7rem;
}
.map-not-found {
position: absolute;
top: 4px;
left: 48px;
font-size: 4.35rem;
color: rgba(255, 193, 7, .86);
}
img {
width: 60px;
height: auto;
}
}
.td-rank {
img {
width: 70px;
height: auto;
.rank-icon {
height: 35px;
}
}
}
.td-score {
font-size: 1.2rem;
}
.td-date, .date {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.match {
$first: rgb(0, 0, 0);
$last: rgb(0, 0, 0);
$win: false;
$loss: false;
$draw: false;
$ban: false;
&.default {
background: linear-gradient(to right,
rgba($first, 0.2) 0%,
rgba($first, 0.1) 15%,
rgba(0, 0, 0, 0.4) 30%,
rgba(0, 0, 0, 0.4) 70%,
rgba($last, 0.6) 80%,
rgba($last, 0.6) 100%
);
&:hover {
background: linear-gradient(to right,
rgba($first, 0.3) 0%,
rgba($first, 0.2) 15%,
rgba(0, 0, 0, 0.5) 30%,
rgba(0, 0, 0, 0.5) 70%,
rgba($last, 0.7) 80%,
rgba($last, 0.7) 100%
);
}
}
&.win {
$first: rgb(0, 255, 0);
background: linear-gradient(to right,
rgba($first, 0.2) 0%,
rgba($first, 0.1) 15%,
rgba(0, 0, 0, 0.4) 30%,
rgba(0, 0, 0, 0.4) 70%,
rgba($last, 0.6) 80%,
rgba($last, 0.6) 100%
);
&:hover {
background: linear-gradient(to right,
rgba($first, 0.3) 0%,
rgba($first, 0.2) 15%,
rgba(0, 0, 0, 0.5) 30%,
rgba(0, 0, 0, 0.5) 70%,
rgba($last, 0.7) 80%,
rgba($last, 0.7) 100%
);
}
}
&.draw {
$first: rgb(255, 255, 0);
background: linear-gradient(to right,
rgba($first, 0.2) 0%,
rgba($first, 0.1) 15%,
rgba(0, 0, 0, 0.4) 30%,
rgba(0, 0, 0, 0.4) 70%,
rgba($last, 0.6) 80%,
rgba($last, 0.6) 100%
);
&:hover {
background: linear-gradient(to right,
rgba($first, 0.3) 0%,
rgba($first, 0.2) 15%,
rgba(0, 0, 0, 0.5) 30%,
rgba(0, 0, 0, 0.5) 70%,
rgba($last, 0.7) 80%,
rgba($last, 0.7) 100%
);
}
}
&.loss {
$first: rgb(255, 0, 0);
background: linear-gradient(to right,
rgba($first, 0.2) 0%,
rgba($first, 0.1) 15%,
rgba(0, 0, 0, 0.4) 30%,
rgba(0, 0, 0, 0.4) 70%,
rgba($last, 0.6) 80%,
rgba($last, 0.6) 100%
);
&:hover {
background: linear-gradient(to right,
rgba($first, 0.3) 0%,
rgba($first, 0.2) 15%,
rgba(0, 0, 0, 0.5) 30%,
rgba(0, 0, 0, 0.5) 70%,
rgba($last, 0.7) 80%,
rgba($last, 0.7) 100%
);
}
}
&.ban {
$last: rgb(93, 3, 3);
background: linear-gradient(to right,
rgba($first, 0.2) 0%,
rgba($first, 0.1) 15%,
rgba(0, 0, 0, 0.4) 30%,
rgba(0, 0, 0, 0.4) 70%,
rgba($last, 0.6) 80%,
rgba($last, 0.6) 100%
);
&:hover {
background: linear-gradient(to right,
rgba($first, 0.3) 0%,
rgba($first, 0.2) 15%,
rgba(0, 0, 0, 0.5) 30%,
rgba(0, 0, 0, 0.5) 70%,
rgba($last, 0.7) 80%,
rgba($last, 0.7) 100%
);
}
}
&.matches_ban {
$first: rgb(0, 0, 0);
$last: rgb(93, 3, 3);
background: linear-gradient(to right,
rgba($first, 0.2) 0%,
rgba($first, 0.1) 15%,
rgba(0, 0, 0, 0.4) 30%,
rgba(0, 0, 0, 0.4) 70%,
rgba($last, 0.6) 80%,
rgba($last, 0.6) 100%
);
&:hover {
background: linear-gradient(to right,
rgba($first, 0.3) 0%,
rgba($first, 0.2) 15%,
rgba(0, 0, 0, 0.5) 30%,
rgba(0, 0, 0, 0.5) 70%,
rgba($last, 0.7) 80%,
rgba($last, 0.7) 100%
);
}
}
border-bottom: 1px solid rgba(73, 73, 73, 0.73);
&:last-child {
border: none;
}
&:hover {
cursor: pointer;
}
}
}
@media screen and (max-width: 400px) {
table tr {
.map-icon {
margin-left: 0 !important;
}
.map {
padding: 0.5rem !important;
}
.td-map {
padding: 0 1rem !important;
.parsed {
display: none;
}
.not-yet-parsed {
display: none;
}
}
}
}
@media screen and (max-width: 768px) {
.map-icon {
margin-left: -1.32em !important;
}
.td-map {
position: relative;
width: 35px !important;
.parsed {
position: absolute;
left: .3rem !important;
}
.not-yet-parsed {
position: absolute;
left: .3rem !important;
}
img {
width: 35px !important;
height: auto;
}
}
.td-rank img {
width: 50px !important;
height: auto;
max-width: 50px !important;
margin-left: -0.5rem !important;
}
.td-score {
font-size: .7rem !important;
//width: 110px !important;
}
.td-date {
font-size: .8rem !important;
}
.kills, .deaths, .assists, .kdiff, .duration, .hltv, .length,
.td-kills, .td-deaths, .td-assists, .td-plus, .td-duration, .td-hltv, .td-length {
display: none;
}
}
@media screen and (max-width: 992px) {
.avatar {
width: 100px !important;
height: 100px !important;
}
.trackme-btn {
top: 25px;
}
.map, .td-map {
padding-left: 4rem !important;
}
}
@media screen and (max-width: 1200px) {
.td-plus, .kdiff {
display: none;
}
.td-rank img {
width: 60px !important;
height: auto;
max-width: 60px;
}
.td-map img {
width: 50px !important;
height: auto;
}
.td-score {
font-size: 1.1rem !important;
width: 130px !important;
}
}
</style>

View File

@@ -1,183 +0,0 @@
<template>
<div class="charts">
<div id="multi-kills-chart-1"></div>
<div id="multi-kills-chart-2"></div>
</div>
</template>
<script>
import * as echarts from 'echarts/core';
import {GridComponent, TooltipComponent, VisualMapComponent} from 'echarts/components';
import {HeatmapChart} from 'echarts/charts';
import {CanvasRenderer} from 'echarts/renderers';
import {onMounted, onUnmounted, ref} from "vue";
import {checkStatEmpty, getPlayerArr} from "../utils";
import {useStore} from "vuex";
export default {
name: "MultiKillsChart",
setup() {
const store = useStore()
const multiKills = ['2k', '3k', '4k', '5k']
let myChart1, myChart2
const width = ref(window.innerWidth <= 500 ? window.innerWidth : 500)
const height = ref(width.value)
const multiKillArr = (stats, team) => {
let arr = []
for (let i = (team - 1) * 5; i < team * 5; i++) {
for (let j = 0; j < multiKills.length; j++) {
if (j === 0)
arr.push([i % 5, j, checkStatEmpty(stats[i].multi_kills.duo) === 0 ? null : stats[i].multi_kills.duo])
if (j === 1)
arr.push([i % 5, j, checkStatEmpty(stats[i].multi_kills.triple) === 0 ? null : stats[i].multi_kills.triple])
if (j === 2)
arr.push([i % 5, j, checkStatEmpty(stats[i].multi_kills.quad) === 0 ? null : stats[i].multi_kills.quad])
if (j === 3)
arr.push([i % 5, j, checkStatEmpty(stats[i].multi_kills.pent) === 0 ? null : stats[i].multi_kills.pent])
}
}
return arr
}
const getMax = (stats, team) => {
let max = 0
for (let i = (team - 1) * 5; i < team * 5; i++) {
if (stats[i].multi_kills.duo > max)
max = stats[i].multi_kills.duo
if (stats[i].multi_kills.triple > max)
max = stats[i].multi_kills.triple
if (stats[i].multi_kills.quad > max)
max = stats[i].multi_kills.quad
if (stats[i].multi_kills.pent > max)
max = stats[i].multi_kills.pent
}
return max
}
const optionGen = (team) => {
return {
tooltip: {},
grid: {
height: '65%',
top: '0%',
bottom: '10%'
},
xAxis: {
type: 'category',
data: getPlayerArr(store.state.matchDetails.stats, team, true).reverse(),
splitArea: {
show: true
},
axisLabel: {
fontSize: 14,
color: 'white',
rotate: 50
}
},
yAxis: {
type: 'category',
data: multiKills,
splitArea: {
show: true
},
axisLabel: {
color: 'white'
}
},
visualMap: {
min: 0,
max: getMax(store.state.matchDetails.stats, team),
calculable: true,
orient: 'horizontal',
left: 'center',
bottom: '5%',
textStyle: {
color: 'white'
}
},
series: [
{
type: 'heatmap',
data: multiKillArr(store.state.matchDetails.stats, team),
label: {
fontSize: 14,
show: true
},
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
}
]
}
}
const disposeCharts = () => {
if (myChart1 != null && myChart1 !== '' && myChart1 !== undefined) {
myChart1.dispose()
}
if (myChart2 != null && myChart2 !== '' && myChart2 !== undefined) {
myChart2.dispose()
}
}
const buildCharts = () => {
disposeCharts()
myChart1 = echarts.init(document.getElementById('multi-kills-chart-1'), {}, {
width: width.value,
height: height.value
});
myChart1.setOption(optionGen(1));
myChart2 = echarts.init(document.getElementById('multi-kills-chart-2'), {}, {
width: width.value,
height: height.value
});
myChart2.setOption(optionGen(2));
}
onMounted(() => {
if (store.state.matchDetails.stats) {
echarts.use([
TooltipComponent,
GridComponent,
VisualMapComponent,
HeatmapChart,
CanvasRenderer
]);
buildCharts()
}
})
onUnmounted(() => {
disposeCharts()
})
window.onresize = () => {
if (window.innerWidth <= 500) {
width.value = window.innerWidth - 20
height.value = width.value
buildCharts()
}
}
}
}
</script>
<style lang="scss" scoped>
.charts {
display: flex;
#multi-kills-chart-1,
#multi-kills-chart-2 {
flex-basis: 50%;
}
}
</style>

View File

@@ -1,373 +0,0 @@
<template>
<div class="side-info">
<div v-if="props.player_meta.most_mates" class="side-info-box most-played-with">
<div class="heading">
<h5>Most played with</h5>
</div>
<hr>
<ul v-for="mate in props.player_meta.most_mates" :key="mate.player.steamid64" class="list-unstyled">
<li @click="GoToPlayer(mate.player.vanity_url || mate.player.steamid64)">
<span class="start">
<img :class="mate.player.tracked ? 'tracked' : ''" :src="constructAvatarUrl(mate.player.avatar)"
:title="mate.player.tracked ? 'Tracked' : ''" alt="Player avatar">
<span class="text">{{ mate.player.name }}</span>
</span>
<span class="end">
{{ mate.total }}
</span>
</li>
</ul>
</div>
<div v-else-if="mostMatesLoading" class="side-info-box most-played-with">
<div class="heading">
<h5>Most played with</h5>
</div>
<hr>
<ul class="list-unstyled placeholder-glow">
<li class="placeholder col-11"></li>
</ul>
</div>
<div v-if="props.player_meta.best_mates" class="side-info-box best-mate">
<div class="heading">
<h5>Best Mate <span class="text-muted">(by winrate)</span></h5>
</div>
<hr>
<ul v-for="mate in props.player_meta.best_mates" :key="mate.player.steamid64" class="list-unstyled">
<li @click="GoToPlayer(mate.player.vanity_url || mate.player.steamid64)">
<span class="start">
<img :class="mate.player.tracked ? 'tracked' : ''" :src="constructAvatarUrl(mate.player.avatar)"
:title="mate.player.tracked ? 'Tracked' : ''" alt="Player avatar">
<span class="text">{{ mate.player.name }}</span>
</span>
<span class="end">
{{ mate.win_rate ? (mate.win_rate * 100).toFixed(0) : 0 }} %
<span v-if="mate.total" class="total text-muted">({{ mate.total }})</span>
</span>
</li>
</ul>
</div>
<div v-else-if="bestMatesLoading" class="side-info-box best-mate">
<div class="heading">
<h5>Best Mate <span class="text-muted">(by winrate)</span></h5>
</div>
<hr>
<ul class="list-unstyled placeholder-glow">
<li class="placeholder col-11"></li>
</ul>
</div>
<div v-if="props.player_meta.eq_map && props.player_meta.weapon_dmg" class="side-info-box preferred-weapons">
<div class="heading">
<h5>Weapons <span class="text-muted">(by dmg)</span></h5>
</div>
<hr>
<ul v-for="(id, key) in data.best_weapons" :key="id[0]" class="list-unstyled">
<li>
<span class="start">
<span class="text">{{ id[0] }}</span>
</span>
<span :title="id[0] + ' - ' + id[1] + ' dmg'" class="end">
<span :class="'dmg-chart-' + key">
{{ id[1] }}
</span>
</span>
</li>
</ul>
{{ setDmgGraphWidth() }}
</div>
<div v-else-if="weaponsLoading" class="side-info-box preferred-weapons">
<div class="heading">
<h5>Weapons <span class="text-muted">(by dmg)</span></h5>
</div>
<hr>
<ul class="list-unstyled placeholder-glow">
<li class="placeholder col-11"></li>
</ul>
</div>
<div v-if="props.player_meta.win_maps" class="side-info-box best-map">
<div class="heading">
<h5>Best Map <span class="text-muted">(by winrate)</span></h5>
</div>
<hr>
<ul v-for="map in data.best_maps" :key="map[0]" class="list-unstyled">
<li>
<span class="start">
<img :src="'/images/map_icons/map_icon_' + map[0] + '.svg'" alt="Player avatar">
<span class="text">{{ FixMapName(map[0]) }}</span>
</span>
<span class="end">
{{ (map[1] * 100).toFixed(0) }} %
<span v-if="props.player_meta.total_maps[map[0]]"
class="total text-muted">({{ props.player_meta.total_maps[map[0]] }})</span>
</span>
</li>
</ul>
</div>
<div v-else-if="mapsLoading" class="side-info-box best-map">
<div class="heading">
<h5>Best Map <span class="text-muted">(by winrate)</span></h5>
</div>
<hr>
<ul class="list-unstyled placeholder-glow">
<li class="placeholder col-11"></li>
</ul>
</div>
</div>
</template>
<script>
import {constructAvatarUrl, FixMapName, GoToPlayer, sortObjectValue} from "@/utils";
import {reactive, ref, watch} from "vue";
export default {
name: "PlayerSideInfo",
props: {
player_meta: {
type: Object,
required: true
}
},
setup(props) {
const displayCounter = 3
const mostMatesLoading = ref(true)
const bestMatesLoading = ref(true)
const weaponsLoading = ref(true)
const mapsLoading = ref(true)
const data = reactive({
best_maps: [],
best_weapons_tmp: [],
best_weapons: []
})
const mapWeaponDamage = () => {
if (props.player_meta.eq_map && props.player_meta.weapon_dmg) {
Object.keys(props.player_meta.eq_map).forEach((key) => {
for (const id in props.player_meta.weapon_dmg) {
Object.keys(props.player_meta.weapon_dmg[id]).forEach((k) => {
if (k === 'eq') {
if (props.player_meta.weapon_dmg[id][k] === key * 1) {
data.best_weapons_tmp.push([props.player_meta.eq_map[key], props.player_meta.weapon_dmg[id]['dmg']])
}
}
})
}
})
data.best_weapons_tmp.sort((a, b) => {
return b[1] - a[1]
})
data.best_weapons = data.best_weapons_tmp
data.best_weapons_tmp = []
}
}
const setDmgGraphWidth = () => {
setTimeout(() => {
let weaponsContainer
const dmg100 = ref(0)
const dmg = ref(0)
for (let i = 0; i <= 4; i++) {
weaponsContainer = document.querySelector('.dmg-chart-' + i)
if (weaponsContainer !== null) {
if (i === 0) {
dmg100.value = weaponsContainer.innerHTML * 1
weaponsContainer.style.width = '100%'
}
dmg.value = weaponsContainer.innerHTML * 1
weaponsContainer.style.width = dmg.value * 100 / dmg100.value + '%'
}
}
}, 100)
}
watch(() => props.player_meta, () => {
mapWeaponDamage()
data.best_maps = sortObjectValue(props.player_meta.win_maps, 'desc')
if (data.best_maps.length > displayCounter)
data.best_maps.splice(displayCounter, data.best_maps.length - displayCounter)
if (!props.player_meta.most_mates) {
mostMatesLoading.value = false
}
if (!props.player_meta.best_mates) {
bestMatesLoading.value = false
}
if (!props.player_meta.win_maps) {
mapsLoading.value = false
}
if (!props.player_meta.eq_map || !props.player_meta.weapon_dmg) {
weaponsLoading.value = false
}
})
return {
props,
data,
weaponsLoading,
mapsLoading,
mostMatesLoading,
bestMatesLoading,
setDmgGraphWidth,
GoToPlayer,
constructAvatarUrl,
FixMapName
}
}
}
</script>
<style lang="scss" scoped>
.side-info {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
height: auto;
margin-top: 30px;
.placeholder {
height: 25px;
padding: 0 10px !important;
margin: 14px auto !important;
border-radius: 5px;
}
.side-info-box {
width: 100%;
height: auto;
background: rgba(20, 20, 20, .8);
border: 1px solid rgba(white, .3);
border-radius: 5px;
}
ol, ul, dl {
margin-bottom: 0;
}
.best-mate,
.preferred-weapons,
.most-played-with,
.best-map {
.heading {
display: flex;
align-items: center;
justify-content: center;
height: 30px;
h5 {
font-size: 1rem;
margin: 0;
padding: 0;
}
}
hr {
margin: 0 0 5px 0;
border-color: rgba(white, .3);
}
ul li {
line-height: 25px;
font-size: .9rem;
padding: 0 10px;
margin: 10px 0;
cursor: pointer;
display: flex;
justify-content: space-between;
gap: 1rem;
.start {
width: 50%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.tracked {
font-size: .8rem;
margin-right: 5px;
}
img {
width: 25px;
height: 25px;
border-radius: 50%;
margin-right: 5px;
margin-left: 5px;
&.tracked {
border: 2px solid var(--bs-success);
}
}
}
.end {
display: flex;
width: 45%;
justify-content: flex-end;
align-items: flex-end;
}
}
}
.best-map, .best-mate {
ul li {
.start {
width: 75%;
}
.end {
.total {
padding-left: 5px;
}
}
}
}
.preferred-weapons,
.best-map {
ul li {
cursor: default;
}
}
.preferred-weapons {
.end {
position: relative;
@for $i from 0 through 3 {
.dmg-chart-#{$i} {
position: absolute;
background: rgba(150, 50, 50, 1);
border-radius: 15px;
color: transparent;
user-select: none;
cursor: help;
&:hover {
background: rgba(220, 50, 50, 1);
}
}
}
}
}
}
</style>

View File

@@ -1,311 +0,0 @@
<template>
<div class="scoreboard">
<table>
<caption>
<div v-if="store.state.matchDetails.max_rounds === 16" id="short-match">
<div class="team-1">
<div class="score-text">
<span v-if="store.state.matchDetails.score[0] < 10"
:style="store.state.matchDetails.score[0] < 10 ? 'margin-left: -10px;' : ''"
class="hidden">0</span><span
:class="store.state.matchDetails.score[0] === 9 ? 'text-success' : store.state.matchDetails.score[0] === 8 ? 'text-warning' : 'text-danger'">{{
store.state.matchDetails.score[0]
}}</span>
</div>
<img alt="CT logo" src="/images/icons/ct_logo.svg">
<img alt="T logo" src="/images/icons/t_logo.svg">
</div>
<div class="team-2">
<div class="score-text">
<span v-if="store.state.matchDetails.score[1] < 10"
:style="store.state.matchDetails.score[1] < 10 ? 'margin-left: -10px;' : ''"
class="hidden">0</span><span
:class="store.state.matchDetails.score[1] === 9 ? 'text-success' : store.state.matchDetails.score[1] === 8 ? 'text-warning' : 'text-danger'">{{
store.state.matchDetails.score[1]
}}</span>
</div>
<img alt="T logo" src="/images/icons/t_logo.svg">
<img alt="CT logo" src="/images/icons/ct_logo.svg">
</div>
</div>
<div v-if="store.state.matchDetails.max_rounds === 30 || !store.state.matchDetails.max_rounds" id="long-match">
<div class="team-1">
<div class="score-text">
<span v-if="store.state.matchDetails.score[0] < 10"
:style="store.state.matchDetails.score[0] < 10 ? 'margin-left: -10px;' : ''"
class="hidden">0</span><span
:class="store.state.matchDetails.match_result === 1 ? 'text-success' : store.state.matchDetails.match_result === 0 ? 'text-warning' : 'text-danger'">{{
store.state.matchDetails.score[0]
}}</span>
</div>
<img alt="CT logo" src="/images/icons/ct_logo.svg">
<img alt="T logo" src="/images/icons/t_logo.svg">
</div>
<div class="team-2">
<div class="score-text">
<span v-if="store.state.matchDetails.score[1] < 10"
:style="store.state.matchDetails.score[1] < 10 ? 'margin-left: -10px;' : ''"
class="hidden">0</span><span
:class="store.state.matchDetails.match_result === 2 ? 'text-success' : store.state.matchDetails.match_result === 0 ? 'text-warning' : 'text-danger'">{{
store.state.matchDetails.score[1]
}}</span>
</div>
<img alt="T logo" src="/images/icons/t_logo.svg">
<img alt="CT logo" src="/images/icons/ct_logo.svg">
</div>
</div>
</caption>
<thead>
<tr>
<th class="player__vac"></th>
<th class="player__avatar"></th>
<th class="player__name"></th>
<th class="player__rank"></th>
<th class="player__kills">K</th>
<th class="player__assist">A</th>
<th class="player__deaths">D</th>
<th class="player__diff helptext" title="Kill death difference">+/-</th>
<th class="player__kd">K/D</th>
<th v-if="store.state.matchDetails.parsed" class="player__adr helptext" title="Average damage per round">
ADR
</th>
<th class="player__hs helptext" title="Percentage of kills with a headshot">HS%</th>
<th class="player__rating helptext" title="Estimated HLTV Rating 1.0">Rating</th>
<th class="player__mvp helptext" title="Most valuable player">MVP</th>
<th class="player__score">Score</th>
</tr>
</thead>
<tbody>
<tr v-for="player in teamStats(1)"
:key="player.player.steamid64"
class="team-1">
<ScoreTeamPlayer :assists="player.assists"
:avatar="player.player.avatar"
:color="player.color"
:deaths="player.deaths"
:dmg="player.dmg?.enemy"
:game_ban="player.player.game_ban"
:game_ban_date="player.player.game_ban_date"
:hs="player.headshot"
:kdiff="player.kills - player.deaths"
:kills="player.kills"
:mk_duo="player.multi_kills?.duo"
:mk_pent="player.multi_kills?.pent"
:mk_quad="player.multi_kills?.quad"
:mk_triple="player.multi_kills?.triple"
:mvp="player.mvp"
:name="player.player.name"
:parsed="store.state.matchDetails.parsed"
:player_score="player.score"
:rank_new="player.rank?.new"
:rank_old="player.rank?.old"
:rounds_played="store.state.matchDetails.score.reduce((a, b) => a + b)"
:steamid64="player.player.steamid64"
:tracked="player.player.tracked"
:vac="player.player.vac"
:vac_date="player.player.vac_date"
/>
</tr>
<tr class="hr_outer">
<td colspan="14"></td>
</tr>
<tr class="hr">
<td colspan="14"></td>
</tr>
<tr class="hr_outer">
<td colspan="14"></td>
</tr>
<tr v-for="player in teamStats(2)"
:key="player.player.steamid64"
class="team-2">
<ScoreTeamPlayer :assists="player.assists"
:avatar="player.player.avatar"
:color="player.color"
:deaths="player.deaths"
:dmg="player.dmg?.enemy"
:game_ban="player.player.game_ban"
:game_ban_date="player.player.game_ban_date"
:hs="player.headshot"
:kdiff="player.kills - player.deaths"
:kills="player.kills"
:mk_duo="player.multi_kills?.duo"
:mk_pent="player.multi_kills?.pent"
:mk_quad="player.multi_kills?.quad"
:mk_triple="player.multi_kills?.triple"
:mvp="player.mvp"
:name="player.player.name"
:parsed="store.state.matchDetails.parsed"
:player_score="player.score"
:rank_new="player.rank?.new"
:rank_old="player.rank?.old"
:rounds_played="store.state.matchDetails.score.reduce((a, b) => a + b)"
:steamid64="player.player.steamid64"
:tracked="player.player.tracked"
:vac="player.player.vac"
:vac_date="player.player.vac_date"
/>
</tr>
</tbody>
</table>
</div>
</template>
<script>
import ScoreTeamPlayer from '@/components/ScoreTeamPlayer.vue'
import {useStore} from "vuex";
export default {
name: 'ScoreTeam',
components: {ScoreTeamPlayer},
setup() {
const store = useStore()
const teamStats = (team) => {
let arr = []
if (team === 1) {
arr = []
for (let i = 0; i < 5; i++) {
arr.push(store.state.matchDetails.stats[i])
}
} else if (team === 2) {
arr = []
for (let i = 5; i < store.state.matchDetails.stats.length; i++) {
arr.push(store.state.matchDetails.stats[i])
}
}
return arr
}
return {store, teamStats}
}
}
</script>
<style lang="scss" scoped>
.scoreboard {
margin: 1rem 0;
}
hr {
width: 900px;
}
table {
width: 900px;
text-align: center;
caption {
position: relative;
color: white;
caption-side: top;
padding: 0;
z-index: 0;
.hidden {
color: transparent;
user-select: none;
}
.score-text {
position: relative;
}
.team-1,
.team-2 {
position: absolute;
font-size: 3rem;
opacity: .8;
margin-left: -100px;
img {
position: absolute;
width: 30px;
height: 30px;
margin-top: 22px;
margin-left: 10px;
&:first-child {
z-index: 1;
}
&:last-child {
margin-left: 30px;
z-index: 0 !important;
}
}
}
.team-1 {
top: 85px;
.score-text {
margin-left: 5px;
}
}
.team-2 {
top: 180px;
.score-text {
top: 150px;
margin-left: 5px;
}
}
}
tbody {
position: relative;
z-index: 1;
}
tr.team-1, tr.team-2 {
height: 40px;
}
td {
padding: 5px 10px;
}
.hr {
td {
height: 1px;
padding: 0;
background: white;
}
}
.hr_outer {
height: 15px;
}
.player__vac {
width: 20px;
}
}
@media (max-width: 1200px) {
.scoreboard {
margin-left: 65px;
}
}
@media (max-width: 991px) {
.scoreboard {
margin-left: 2px;
caption {
display: none;
}
}
}
</style>

View File

@@ -1,268 +0,0 @@
<template>
<td class="player__vac">
<div v-if="!props.vac && !props.game_ban" class="vac-placeholder"></div>
<img v-if="props.vac && FormatVacDate(props.vac_date, store.state.matchDetails.date) !== ''"
:title="'Vac-banned: ' + FormatVacDate(props.vac_date, store.state.matchDetails.date)"
alt="VAC-Ban"
src="/images/icons/vac_banned.svg">
<img v-if="!props.vac && props.game_ban && FormatVacDate(props.game_ban_date, store.state.matchDetails.date) !== ''"
:title="'Game-banned: ' + FormatVacDate(props.game_ban_date, store.state.matchDetails.date)"
alt="Game-Ban"
src="/images/icons/game_banned.svg">
</td>
<td>
<img :class="'team-color-' + props.color" :src="constructAvatarUrl(props.avatar)" alt="Player avatar"
class="player__avatar">
</td>
<td class="player__name" @click="GoToPlayer(props.steamid64)">
<i v-if="props.tracked" class="fa fa-dot-circle-o text-success tracked" title="Tracked user"></i>
{{ props.name }}
<i class="fa fa-external-link"></i>
</td>
<td v-if="props.parsed" class="player__rank">
<img :alt="DisplayRank(props.rank_old)[1]"
:class="props.rank_new > props.rank_old ? 'uprank' : props.rank_new < props.rank_old ? 'downrank' : ''"
:src="DisplayRank(props.rank_old)[0]"
:title="props.rank_new > props.rank_old ? 'Uprank to ' + DisplayRank(props.rank_new)[1] : props.rank_new < props.rank_old ? 'Downrank to ' + DisplayRank(props.rank_new)[1] : DisplayRank(props.rank_old)[1]">
</td>
<td v-if="!props.parsed" class="rank-placeholder"></td>
<td class="player__kills">
{{ props.kills }}
</td>
<td class="player__assist">
{{ props.assists }}
</td>
<td class="player__deaths">
{{ props.deaths }}
</td>
<td :class="props.kdiff >= 0 ? 'text-success' : 'text-danger'" class="player__kdiff">
{{ props.kdiff }}
</td>
<td class="player__kd">
{{
(props.kills > 0 && props.deaths > 0) ? (props.kills / props.deaths).toFixed(2) : (props.kills > 0 && props.deaths === 0) ? props.kills : 0.00
}}
</td>
<td v-if="props.parsed" class="player__adr">
{{ (props.dmg / props.rounds_played).toFixed(2) }}
</td>
<td class="player__hs">
{{ (props.hs > 0 && props.kills > 0) ? (props.hs * 100 / props.kills).toFixed(0) + "%" : "0%" }}
</td>
<td class="player__rating">
{{
GetHLTV_1(props.kills, props.rounds_played, props.deaths, props.mk_duo, props.mk_triple, props.mk_quad, props.mk_pent)
}}
</td>
<td class="player__mvp">
{{ props.mvp }}
</td>
<td class="player__score">
{{ props.player_score }}
</td>
</template>
<script>
import {constructAvatarUrl, DisplayRank, FormatVacDate, GetHLTV_1, GoToPlayer} from "@/utils";
import {useStore} from "vuex";
export default {
name: 'ScoreTeamPlayer',
props: {
steamid64: {
type: String,
required: true,
default: ''
},
avatar: {
type: String,
required: true,
default: 'Avatar'
},
name: {
type: String,
required: true,
default: 'Name'
},
rank_old: {
type: Number,
required: true,
default: 0
},
rank_new: {
type: Number,
required: true,
default: 0
},
kills: {
type: Number,
required: true,
default: 0
},
assists: {
type: Number,
required: true,
default: 0
},
deaths: {
type: Number,
required: true,
default: 0
},
kdiff: {
type: Number,
required: true,
default: 0
},
hs: {
type: Number,
required: true,
default: 0
},
rounds_played: {
type: Number,
required: true,
default: 0
},
mk_duo: {
type: Number,
required: true,
default: 0
},
mk_triple: {
type: Number,
required: true,
default: 0
},
mk_quad: {
type: Number,
required: true,
default: 0
},
mk_pent: {
type: Number,
required: true,
default: 0
},
dmg: {
type: Number,
required: true,
default: 0
},
mvp: {
type: Number,
required: true,
default: 0
},
player_score: {
type: Number,
required: true,
default: 0
},
color: {
type: String,
required: true,
default: ''
},
tracked: {
type: Boolean,
required: true,
default: false
},
parsed: {
type: Boolean,
required: true,
default: false
},
vac: {
type: Boolean,
required: true,
default: false
},
vac_date: {
type: Number,
required: false,
default: 0
},
game_ban: {
type: Boolean,
required: true,
default: false
},
game_ban_date: {
type: Number,
required: false,
default: 0
}
},
setup(props) {
const store = useStore()
return {props, GetHLTV_1, GoToPlayer, DisplayRank, constructAvatarUrl, FormatVacDate, store}
}
}
</script>
<style lang="scss" scoped>
.player__vac,
.vac-placeholder {
width: 20px;
}
.player__vac {
img {
width: 20px;
height: 20px;
}
}
.player__avatar {
width: 30px;
height: 30px;
border-radius: 50%;
}
.player__name {
text-align: left;
width: 150px;
max-width: 150px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
.tracked {
font-size: .8rem;
}
.fa-external-link {
font-size: .8rem;
vertical-align: top;
}
}
.player__rank,
.rank-placeholder {
width: 100px;
img {
width: 60px;
height: auto;
}
}
.player__kills, .player__assist, .player__deaths, .player__kdiff, .player__mvp {
width: 40px;
}
.player__kd, .player__hs, .player__rating, .player__score {
width: 75px;
}
.player__adr {
width: 85px;
}
.player__rating {
border-radius: 25% 25%;
}
</style>

View File

@@ -1,26 +0,0 @@
<template>
<h3>This Graph will be available soon</h3>
</template>
<script>
import {watch} from "vue";
export default {
name: "SprayGraph",
props: {
spray: {
type: Object,
required: true
}
},
setup(props) {
watch(() => props.spray, () => {
// console.log(props.spray)
})
}
}
</script>
<style scoped lang="scss">
</style>

View File

@@ -1,191 +0,0 @@
<template>
<div class="player-dmg">
<div id="dmg-chart-1"></div>
<div id="dmg-chart-2"></div>
</div>
</template>
<script>
import * as echarts from 'echarts/core';
import {GridComponent, LegendComponent, TooltipComponent} from 'echarts/components';
import {BarChart} from 'echarts/charts';
import {CanvasRenderer} from 'echarts/renderers';
import {onMounted, onUnmounted, ref} from "vue";
import {checkStatEmpty, getPlayerArr} from "../utils";
import {useStore} from "vuex";
export default {
name: "FlashChart",
setup() {
const store = useStore()
let myChart1, myChart2
const getWindowWidth = () => {
const windowWidth = window.innerWidth
if (windowWidth <= 750)
return windowWidth
else
return 650
}
const setHeight = () => {
const windowWidth = getWindowWidth()
if (windowWidth >= 751)
return windowWidth * 3 / 7.5
else if (windowWidth >= 501 && windowWidth <= 750)
return windowWidth * 3 / 6.5
else
return windowWidth * 3 / 5.5
}
const width = ref(getWindowWidth())
const height = ref(setHeight())
const dataArr = (stats, team, prop) => {
if (['team', 'enemy', 'self'].indexOf(prop) > -1) {
let arr = []
for (let i = (team - 1) * 5; i < team * 5; i++) {
arr.push({
value: checkStatEmpty(Function('return(function(stats, i){ return stats[i].dmg.' + prop + '})')()(stats, i)) * (prop === 'enemy' ? 1 : -1),
itemStyle: {
color: prop === 'enemy' ? getComputedStyle(document.documentElement).getPropertyValue(`--csgo-${stats[i].color}`) : 'firebrick'
}
})
}
arr.reverse()
return arr
}
}
const optionGen = (team) => {
return {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
legend: {
show: false
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: [
{
type: 'value',
min: -300
}
],
yAxis: [
{
type: 'category',
axisTick: {
show: false
},
data: getPlayerArr(store.state.matchDetails.stats, team)
}
],
series: [
{
name: 'Team',
type: 'bar',
stack: 'Total',
label: {
show: true,
},
emphasis: {
focus: 'series'
},
data: dataArr(store.state.matchDetails.stats, team, 'team')
},
{
name: 'Enemy',
type: 'bar',
stack: 'Total',
label: {
show: true,
position: 'inside'
},
emphasis: {
focus: 'series'
},
data: dataArr(store.state.matchDetails.stats, team, 'enemy')
}
]
}
}
const disposeCharts = () => {
if (myChart1 != null && myChart1 !== '' && myChart1 !== undefined) {
myChart1.dispose()
}
if (myChart2 != null && myChart2 !== '' && myChart2 !== undefined) {
myChart2.dispose()
}
}
const buildCharts = () => {
disposeCharts()
myChart1 = echarts.init(document.getElementById('dmg-chart-1'), {}, {width: width.value, height: height.value});
myChart1.setOption(optionGen(1));
myChart2 = echarts.init(document.getElementById('dmg-chart-2'), {}, {width: width.value, height: height.value});
myChart2.setOption(optionGen(2));
}
onMounted(() => {
if (store.state.matchDetails.stats) {
echarts.use([
TooltipComponent,
GridComponent,
LegendComponent,
BarChart,
CanvasRenderer
]);
buildCharts()
}
})
onUnmounted(() => {
disposeCharts()
})
window.onresize = () => {
if (window.innerWidth <= 750) {
width.value = getWindowWidth() - 20
height.value = setHeight()
}
buildCharts()
}
}
}
</script>
<style lang="scss" scoped>
.player-dmg {
display: flex;
margin-bottom: 4rem;
#dmg-chart-1,
#dmg-chart-2 {
flex-basis: 50%;
}
}
@media (max-width: 1200px) {
.player-dmg {
flex-wrap: wrap;
justify-content: center;
align-items: center;
}
}
</style>

View File

@@ -1,117 +0,0 @@
<template>
<div class="toggle-btn text-muted">
<div @click.prevent="$emit('translated', handleBtnClick())"
class="d-flex">
<span class="text-center mx-2">
<i id="toggle-off" class="fa fa-toggle-off show"/>
<i id="toggle-on" class="fa fa-toggle-on"/>
</span>
<div>
<span :class="toggle === 'translated' ? 'text-warning' : ''"
class="float-start">
<span class="text-uppercase">Translate to {{data.browserLang}}</span>
<span class="loading-icon ms-2" title="Translating..">
<i class="fa fa-spinner fa-pulse fa-fw"/>
</span>
</span>
</div>
</div>
</div>
</template>
<script>
import {onMounted, reactive, ref} from "vue";
import ISO6391 from 'iso-639-1'
import {GetChatHistoryTranslated} from "@/utils";
import {useStore} from "vuex";
export default {
name: 'TranslateChatButton',
props: {
translated: {
type: Boolean,
required: true
}
},
setup() {
const store = useStore()
const data = reactive({
browserIsoCode: '',
browserLangCode: '',
browserLang: '',
})
const toggle = ref('original')
const setLanguageVariables = () => {
const navLangs = navigator.languages
data.browserIsoCode = navLangs.find((l) => l.length === 5)
data.browserLangCode = navLangs[0]
if (ISO6391.validate(data.browserLangCode)) {
data.browserLang = ISO6391.getNativeName(data.browserLangCode)
} else {
data.browserIsoCode = 'en-US'
data.browserLangCode = 'en'
data.browserLang = 'English'
}
}
const handleBtnClick = async () => {
let response
const refreshButton = document.querySelector('.loading-icon .fa-spinner')
refreshButton.classList.add('show')
toggleShow()
response = await GetChatHistoryTranslated(store, store.state.matchDetails.match_id)
if (refreshButton.classList.contains('show'))
refreshButton.classList.remove('show')
return [response, toggle.value]
}
const toggleShow = () => {
const offBtn = document.getElementById('toggle-off')
const onBtn = document.getElementById('toggle-on')
if (offBtn.classList.contains('show')) {
offBtn.classList.remove('show')
onBtn.classList.add('show')
toggle.value = 'translated'
} else if (onBtn.classList.contains('show')) {
onBtn.classList.remove('show')
offBtn.classList.add('show')
toggle.value = 'original'
}
}
onMounted(() => {
setLanguageVariables()
})
return {data, toggle, handleBtnClick}
},
}
</script>
<style lang="scss" scoped>
.toggle-btn {
margin: 0 auto;
cursor: pointer;
width: 100%;
.fa {
display: none;
font-size: 1.2rem;
vertical-align: middle;
&.show {
display: inline-block;
}
}
}
</style>

View File

@@ -1,169 +0,0 @@
<template>
<div :style="props.ud.flames || props.ud.flash || props.ud.he ? 'display: flex' : 'display: none'"
class="player-utility">
<div class="heading">
<img :src="props.avatar" alt="Player avatar" class="avatar">
<h4>{{ props.name }}</h4>
</div>
<div :id="'utility-chart-' + props.id"></div>
</div>
</template>
<script>
import * as echarts from 'echarts/core';
import {LegendComponent, TooltipComponent} from 'echarts/components';
import {PieChart} from 'echarts/charts';
import {LabelLayout} from 'echarts/features';
import {CanvasRenderer} from 'echarts/renderers';
import { TitleComponent } from 'echarts/components';
import {onMounted} from "vue";
export default {
name: "FlashChart",
props: {
id: {
type: Number,
default: 0,
required: true
},
avatar: {
type: String,
default: '',
required: true
},
name: {
type: String,
default: '',
required: true
},
ud: {
type: Object,
required: true
},
},
setup(props) {
onMounted(() => {
echarts.use([
TooltipComponent,
LegendComponent,
PieChart,
CanvasRenderer,
TitleComponent,
LabelLayout
]);
let myChart = echarts.init(document.getElementById(`utility-chart-${props.id}`), {}, {width: 500, height: 300});
let option
option = {
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)'
},
legend: {
show: false
},
series: [
{
name: 'Utility Damage',
type: 'pie',
radius: [0, '65%'],
avoidLabelOverlap: true,
itemStyle: {
borderRadius: 10,
borderColor: '#000',
borderWidth: 3
},
label: {
position: 'inside',
fontsize: 36,
fontWeight: 'bold'
},
labelLine: {
show: false
},
data: [
(props.ud.flames ? {
value: props.ud.flames ? props.ud.flames : null,
name: 'Flames',
itemStyle: {
color: '#FF4343FF'
}
} : {}),
(props.ud.he ? {
value: props.ud.he ? props.ud.he : null,
name: 'HE',
itemStyle: {
color: '#62c265'
}
} : {})
,
(props.ud.flash ? {
value: props.ud.flash ? props.ud.flash : null,
name: 'Flash',
itemStyle: {
color: '#18cff3'
}
} : {}),
(props.ud.smoke ? {
value: props.ud.smoke ? props.ud.smoke : null,
name: 'Smoke',
itemStyle: {
color: '#6e6b78'
}
} : {}),
(props.ud.decoy ? {
value: props.ud.decoy ? props.ud.decoy : null,
name: 'Decoy',
itemStyle: {
color: '#e28428'
}
} : {})
]
}
]
};
myChart.setOption(option);
})
return {props}
}
}
</script>
<style lang="scss" scoped>
.player-utility {
flex-direction: column;
align-items: center;
.heading {
display: flex;
margin-top: 10px;
margin-bottom: -30px;
.avatar {
width: 50px;
height: 50px;
border-radius: 50%;
margin-right: 20px;
color: #ff4343;
}
h4 {
margin-top: 7px;
}
}
p {
padding-top: 40px;
margin-bottom: -20px;
}
}
@for $i from 0 through 9 {
#utility-chart-#{$i} {
margin: 0;
}
}
</style>

View File

@@ -1,141 +0,0 @@
<template>
<div class="utility-chart-total" v-if="props.stats">
<div class="heading">
<h4>Total Utility Damage</h4>
</div>
<div id="utility-chart-total"></div>
<hr>
</div>
</template>
<script>
import * as echarts from 'echarts/core';
import {GridComponent, LegendComponent, TooltipComponent} from 'echarts/components';
import {BarChart} from 'echarts/charts';
import {CanvasRenderer} from 'echarts/renderers';
import {onMounted} from "vue";
export default {
name: "FlashChart",
props: {
stats: {
type: Object,
required: true
},
},
setup(props) {
const checkStatEmpty = (stat) => {
if (stat)
return stat
else
return 0
}
const seriesArr = (stats) => {
let arr = []
for (let i = 0; i < stats.length; i++) {
const sum = checkStatEmpty(stats[i].dmg.ud.flames) + checkStatEmpty(stats[i].dmg.ud.flash) + checkStatEmpty(stats[i].dmg.ud.he) + checkStatEmpty(stats[i].dmg.ud.smoke)
if (sum !== 0) {
arr.push({
name: stats[i].player.name,
type: 'bar',
stack: 'total',
label: {
show: true
},
emphasis: {
focus: 'series'
},
data: [sum]
})
}
}
arr.sort((a, b) => parseFloat(b.data[0]) - parseFloat(a.data[0]))
return arr
}
onMounted(() => {
echarts.use([
TooltipComponent,
GridComponent,
LegendComponent,
BarChart,
CanvasRenderer
]);
let myChart = echarts.init(document.getElementById('utility-chart-total'), {}, {width: 800, height: 200});
let option
option = {
tooltip: {
trigger: 'axis',
axisPointer: {
// Use axis to trigger tooltip
type: 'shadow' // 'shadow' as default; can also be 'line'
}
},
// color: ['#143147', '#39546c', '#617a94', '#89a2bd', '#b3cce8', '#eac65c', '#bd9d2c', '#917501', '#685000', '#412c00'],
// color: ['#003470', '#005a9b', '#0982c7', '#4bace5', '#90d3fe', '#febf4a', '#d7931c', '#ac6a01', '#804400', '#572000'],
// color: ['#888F98', '#10121A', '#1B2732', '#5F7892', '#C3A235'],
legend: {
textStyle: {
color: 'white'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'value'
},
yAxis: {
type: 'category',
data: ['Total']
},
aria: {
enabled: true,
show: true,
decal: {
show: true
}
},
series: seriesArr(props.stats)
};
myChart.setOption(option);
})
return {props}
}
}
</script>
<style lang="scss" scoped>
.utility-chart-total {
.heading {
display: flex;
margin-top: 10px;
margin-bottom: -30px;
h4 {
margin: 7px auto 0;
}
}
p {
padding-top: 40px;
margin-bottom: -20px;
}
}
#utility-chart-total {
margin: 40px 0;
}
</style>

View File

@@ -1,5 +0,0 @@
export const SHARECODE_REGEX = /^CSGO(?:-?[ABCDEFGHJKLMNOPQRSTUVWXYZabcdefhijkmnopqrstuvwxyz23456789]{5}){5}$/
export const AUTHCODE_REGEX = /^[ABCDEFGHJKLMNOPQRSTUVWXYZ23456789]{4}-[ABCDEFGHJKLMNOPQRSTUVWXYZ23456789]{5}-[ABCDEFGHJKLMNOPQRSTUVWXYZ23456789]{4}$/
export const NAV_HEIGHT = 70
export const FOOTER_HEIGHT = 200

187
src/lib/api/client.ts Normal file
View File

@@ -0,0 +1,187 @@
import axios from 'axios';
import type { AxiosInstance, AxiosRequestConfig, AxiosError } from 'axios';
import { APIException } from '$lib/types';
/**
* API Client Configuration
*
* Uses SvelteKit server routes (/api/[...path]/+server.ts) to proxy requests to the backend.
* This approach:
* - Works in all environments (dev, preview, production)
* - No CORS issues
* - Single code path for consistency
* - Can add caching, rate limiting, auth in the future
*
* Backend selection is controlled by VITE_API_BASE_URL environment variable:
* - Local development: VITE_API_BASE_URL=http://localhost:8000
* - Production: VITE_API_BASE_URL=https://api.csgow.tf
*
* Note: During SSR, we call the backend directly since relative URLs don't work server-side.
*/
function getAPIBaseURL(): string {
// During SSR, call backend API directly (relative URLs don't work server-side)
if (import.meta.env.SSR) {
return import.meta.env.VITE_API_BASE_URL || 'https://api.csgow.tf';
}
// In browser, use SvelteKit route
return '/api';
}
const API_BASE_URL = getAPIBaseURL();
const API_TIMEOUT = Number(import.meta.env?.VITE_API_TIMEOUT) || 10000;
/**
* Base API Client
* Provides centralized HTTP communication with error handling
*/
class APIClient {
private client: AxiosInstance;
private abortControllers: Map<string, AbortController>;
constructor() {
this.client = axios.create({
baseURL: API_BASE_URL,
timeout: API_TIMEOUT,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
}
});
this.abortControllers = new Map();
// Request interceptor
this.client.interceptors.request.use(
(config) => {
// Add request ID for tracking
const requestId = `${config.method}_${config.url}_${Date.now()}`;
config.headers['X-Request-ID'] = requestId;
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor for error handling
this.client.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
const apiError = this.handleError(error);
return Promise.reject(apiError);
}
);
}
/**
* Handle API errors and convert to APIException
*/
private handleError(error: AxiosError): APIException {
// Network error (no response from server)
if (!error.response) {
if (error.code === 'ECONNABORTED' || error.message.includes('timeout')) {
return APIException.timeout('Request timed out. Please try again.');
}
return APIException.networkError(
'Unable to connect to the server. Please check your internet connection.'
);
}
// Server responded with error status
const { status, data } = error.response;
return APIException.fromResponse(status, data);
}
/**
* GET request
*/
async get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.get<T>(url, config);
return response.data;
}
/**
* POST request
*/
async post<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.post<T>(url, data, config);
return response.data;
}
/**
* PUT request
*/
async put<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.put<T>(url, data, config);
return response.data;
}
/**
* DELETE request
*/
async delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.delete<T>(url, config);
return response.data;
}
/**
* Cancelable GET request
* Automatically cancels previous request with same key
*/
async getCancelable<T>(url: string, key: string, config?: AxiosRequestConfig): Promise<T> {
// Cancel previous request with same key
if (this.abortControllers.has(key)) {
this.abortControllers.get(key)?.abort();
}
// Create new abort controller
const controller = new AbortController();
this.abortControllers.set(key, controller);
try {
const response = await this.client.get<T>(url, {
...config,
signal: controller.signal
});
this.abortControllers.delete(key);
return response.data;
} catch (error) {
this.abortControllers.delete(key);
throw error;
}
}
/**
* Cancel a specific request by key
*/
cancelRequest(key: string): void {
const controller = this.abortControllers.get(key);
if (controller) {
controller.abort();
this.abortControllers.delete(key);
}
}
/**
* Cancel all pending requests
*/
cancelAllRequests(): void {
this.abortControllers.forEach((controller) => controller.abort());
this.abortControllers.clear();
}
/**
* Get base URL for constructing full URLs
*/
getBaseURL(): string {
return API_BASE_URL;
}
}
/**
* Singleton API client instance
*/
export const apiClient = new APIClient();
/**
* Export for testing/mocking
*/
export { APIClient };

30
src/lib/api/index.ts Normal file
View File

@@ -0,0 +1,30 @@
/**
* CS2.WTF API Client
* Central export for all API endpoints
*/
export { apiClient, APIClient } from './client';
export { playersAPI } from './players';
export { matchesAPI } from './matches';
/**
* Convenience re-exports
*/
export { APIException, APIErrorType } from '$lib/types';
// Import for combined API object
import { playersAPI } from './players';
import { matchesAPI } from './matches';
/**
* Combined API object for convenience
*/
export const api = {
players: playersAPI,
matches: matchesAPI
};
/**
* Default export
*/
export default api;

237
src/lib/api/matches.ts Normal file
View File

@@ -0,0 +1,237 @@
import { apiClient } from './client';
import {
parseMatchRoundsSafe,
parseMatchWeaponsSafe,
parseMatchChatSafe,
parseMatchParseResponse
} from '$lib/schemas';
import {
transformMatchesListResponse,
transformMatchDetail,
type LegacyMatchListItem,
type LegacyMatchDetail
} from './transformers';
import { transformRoundsResponse } from './transformers/roundsTransformer';
import { transformWeaponsResponse } from './transformers/weaponsTransformer';
import { transformChatResponse } from './transformers/chatTransformer';
import type {
Match,
MatchesListResponse,
MatchesQueryParams,
MatchParseResponse,
MatchRoundsResponse,
MatchWeaponsResponse,
MatchChatResponse
} from '$lib/types';
/**
* Match API endpoints
*/
export const matchesAPI = {
/**
* Parse match from share code
* @param shareCode - CS:GO/CS2 match share code
* @returns Parse status response
*/
async parseMatch(shareCode: string): Promise<MatchParseResponse> {
const url = `/match/parse/${shareCode}`;
const data = await apiClient.get<MatchParseResponse>(url);
// Validate with Zod schema
return parseMatchParseResponse(data);
},
/**
* Get match details with player statistics
* @param matchId - Match ID (uint64 as string)
* @returns Complete match data
*/
async getMatch(matchId: string): Promise<Match> {
const url = `/match/${matchId}`;
// API returns legacy format
const data = await apiClient.get<LegacyMatchDetail>(url);
// Transform legacy API response to new format
return transformMatchDetail(data);
},
/**
* Get match weapons statistics
* @param matchId - Match ID
* @param match - Optional match data for player name mapping
* @returns Weapon statistics for all players
* @throws Error if data is invalid or demo not parsed yet
*/
async getMatchWeapons(matchId: string | number, match?: Match): Promise<MatchWeaponsResponse> {
const url = `/match/${matchId}/weapons`;
const data = await apiClient.get<unknown>(url);
// Validate with Zod schema using safe parse
// This handles cases where the demo hasn't been parsed yet
const result = parseMatchWeaponsSafe(data);
if (!result.success) {
// If validation fails, it's likely the demo hasn't been parsed yet
throw new Error('Demo not parsed yet or invalid response format');
}
// Transform raw API response to structured format
return transformWeaponsResponse(result.data, String(matchId), match);
},
/**
* Get match round-by-round statistics
* @param matchId - Match ID
* @param match - Optional match data for player name mapping
* @returns Round statistics and economy data
* @throws Error if data is invalid or demo not parsed yet
*/
async getMatchRounds(matchId: string | number, match?: Match): Promise<MatchRoundsResponse> {
const url = `/match/${matchId}/rounds`;
const data = await apiClient.get<unknown>(url);
// Validate with Zod schema using safe parse
// This handles cases where the demo hasn't been parsed yet
const result = parseMatchRoundsSafe(data);
if (!result.success) {
// If validation fails, it's likely the demo hasn't been parsed yet
throw new Error('Demo not parsed yet or invalid response format');
}
// Transform raw API response to structured format
return transformRoundsResponse(result.data, String(matchId), match);
},
/**
* Get match chat messages
* @param matchId - Match ID
* @param match - Optional match data for player name mapping
* @returns Chat messages from the match
* @throws Error if data is invalid or demo not parsed yet
*/
async getMatchChat(matchId: string | number, match?: Match): Promise<MatchChatResponse> {
const url = `/match/${matchId}/chat`;
const data = await apiClient.get<unknown>(url);
// Validate with Zod schema using safe parse
// This handles cases where the demo hasn't been parsed yet
const result = parseMatchChatSafe(data);
if (!result.success) {
// If validation fails, it's likely the demo hasn't been parsed yet
throw new Error('Demo not parsed yet or invalid response format');
}
// Transform raw API response to structured format
return transformChatResponse(result.data, String(matchId), match);
},
/**
* Get paginated list of matches
*
* IMPORTANT: The API returns a plain array, not an object with properties.
* We must manually implement pagination by:
* 1. Requesting limit + 1 matches
* 2. Checking if we got more than limit (means there are more pages)
* 3. Extracting timestamp from last match for next page
*
* Pagination flow:
* - First call: GET /matches?limit=20 → returns array of up to 20 matches
* - Next call: GET /matches/next/{timestamp}?limit=20 → returns next 20 matches
* - Continue until response.length < limit (reached the end)
*
* @param params - Query parameters (filters, pagination)
* @param params.limit - Number of matches to return (default: 50)
* @param params.before_time - Unix timestamp for pagination (get matches before this time)
* @param params.map - Filter by map name (e.g., "de_inferno")
* @param params.player_id - Filter by player Steam ID
* @returns List of matches with pagination metadata
*/
async getMatches(params?: MatchesQueryParams): Promise<MatchesListResponse> {
const url = params?.before_time ? `/matches/next/${params.before_time}` : '/matches';
const limit = params?.limit || 50;
// CRITICAL: API returns a plain array, not a wrapped object
// NOTE: Backend has a hard limit of 20 matches per request
// We assume hasMore = true if we get exactly the limit we requested
const data = await apiClient.get<LegacyMatchListItem[]>(url, {
params: {
limit: limit,
map: params?.map,
player_id: params?.player_id
}
});
// Handle null or empty response
if (!data || !Array.isArray(data)) {
console.warn('[API] getMatches received null or invalid data');
return transformMatchesListResponse([], false, undefined);
}
// If we got exactly the limit, assume there might be more
// If we got less, we've reached the end
const hasMore = data.length === limit;
// Get the timestamp from the LAST match BEFORE transformation
// The legacy API format has `date` as a Unix timestamp (number)
const lastLegacyMatch = data.length > 0 ? data[data.length - 1] : undefined;
const nextPageTime = hasMore && lastLegacyMatch ? lastLegacyMatch.date : undefined;
// Transform legacy API response to new format
return transformMatchesListResponse(data, hasMore, nextPageTime);
},
/**
* Search matches (cancelable for live search)
* @param params - Search parameters
* @returns List of matching matches
*/
async searchMatches(params?: MatchesQueryParams): Promise<MatchesListResponse> {
const url = params?.before_time ? `/matches/next/${params.before_time}` : '/matches';
const limit = params?.limit || 20;
// API returns a plain array, not a wrapped object
// Backend has a hard limit of 20 matches per request
const data = await apiClient.getCancelable<LegacyMatchListItem[]>(url, 'match-search', {
params: {
limit: limit,
map: params?.map,
player_id: params?.player_id
}
});
// If we got exactly the limit, assume there might be more
const hasMore = data.length === limit;
// Get the timestamp from the LAST match BEFORE transformation
// The legacy API format has `date` as a Unix timestamp (number)
const lastLegacyMatch = data.length > 0 ? data[data.length - 1] : undefined;
const nextPageTime = hasMore && lastLegacyMatch ? lastLegacyMatch.date : undefined;
// Transform legacy API response to new format
return transformMatchesListResponse(data, hasMore, nextPageTime);
},
/**
* Get match by share code
* Convenience method that extracts match ID from share code if needed
* @param shareCodeOrId - Share code or match ID
* @returns Match data
*/
async getMatchByShareCode(shareCodeOrId: string): Promise<Match> {
// If it looks like a share code, parse it first
if (shareCodeOrId.startsWith('CSGO-')) {
const parseResult = await this.parseMatch(shareCodeOrId);
return this.getMatch(parseResult.match_id);
}
// Otherwise treat as match ID
return this.getMatch(shareCodeOrId);
}
};
/**
* Match API with default export
*/
export default matchesAPI;

127
src/lib/api/players.ts Normal file
View File

@@ -0,0 +1,127 @@
import { apiClient } from './client';
import { parsePlayer } from '$lib/schemas';
import type { Player, PlayerMeta, TrackPlayerResponse } from '$lib/types';
import { transformPlayerProfile, type LegacyPlayerProfile } from './transformers';
/**
* Player API endpoints
*/
export const playersAPI = {
/**
* Get player profile with match history
* @param steamId - Steam ID (uint64 as string to preserve precision)
* @param beforeTime - Optional Unix timestamp for pagination
* @returns Player profile with recent matches
*/
async getPlayer(steamId: string, beforeTime?: number): Promise<Player> {
const url = beforeTime ? `/player/${steamId}/next/${beforeTime}` : `/player/${steamId}`;
const data = await apiClient.get<Player>(url);
// Validate with Zod schema
return parsePlayer(data);
},
/**
* Get lightweight player metadata
* @param steamId - Steam ID (uint64 as string to preserve precision)
* @param limit - Number of recent matches to include (default: 10)
* @returns Player metadata
*/
async getPlayerMeta(steamId: string, limit = 10): Promise<PlayerMeta> {
// Use the /player/{id} endpoint which has the data we need
const url = `/player/${steamId}`;
const legacyData = await apiClient.get<LegacyPlayerProfile>(url);
// Transform legacy API format to our schema format
const transformedData = transformPlayerProfile(legacyData);
// Validate the player data
// parsePlayer throws on validation failure, so player is always defined if we reach this point
const player = parsePlayer(transformedData);
// Calculate aggregated stats from matches
const matches = player.matches || [];
const recentMatches = matches.slice(0, limit);
const totalKills = recentMatches.reduce((sum, m) => sum + (m.stats?.kills || 0), 0);
const totalDeaths = recentMatches.reduce((sum, m) => sum + (m.stats?.deaths || 0), 0);
const totalKast = recentMatches.reduce((sum, _m) => {
// KAST is a percentage, we need to calculate it
// For now, we'll use a placeholder
return sum + 0;
}, 0);
const wins = recentMatches.filter((m) => {
// match_result 1 = win, 2 = loss
return m.match_result === 1;
}).length;
const avgKills = recentMatches.length > 0 ? totalKills / recentMatches.length : 0;
const avgDeaths = recentMatches.length > 0 ? totalDeaths / recentMatches.length : 0;
const winRate = recentMatches.length > 0 ? wins / recentMatches.length : 0;
// Find the most recent match date
const lastMatchDate =
matches.length > 0 && matches[0] ? matches[0].date : new Date().toISOString();
// Transform to PlayerMeta format
const playerMeta: PlayerMeta = {
id: player.id, // Keep as string for uint64 precision
name: player.name,
avatar: player.avatar, // Already transformed by transformPlayerProfile
recent_matches: recentMatches.length,
last_match_date: lastMatchDate,
avg_kills: avgKills,
avg_deaths: avgDeaths,
avg_kast: recentMatches.length > 0 ? totalKast / recentMatches.length : 0, // Placeholder KAST calculation
win_rate: winRate,
vac_count: player.vac_count,
vac_date: player.vac_date,
game_ban_count: player.game_ban_count,
game_ban_date: player.game_ban_date,
tracked: player.tracked
};
return playerMeta;
},
/**
* Add player to tracking system
* @param steamId - Steam ID (uint64 as string to preserve precision)
* @param authCode - Steam authentication code
* @returns Success response
*/
async trackPlayer(steamId: string, authCode: string): Promise<TrackPlayerResponse> {
const url = `/player/${steamId}/track`;
return apiClient.post<TrackPlayerResponse>(url, { auth_code: authCode });
},
/**
* Remove player from tracking system
* @param steamId - Steam ID (uint64 as string to preserve precision)
* @returns Success response
*/
async untrackPlayer(steamId: string): Promise<TrackPlayerResponse> {
const url = `/player/${steamId}/track`;
return apiClient.delete<TrackPlayerResponse>(url);
},
/**
* Search players by name (cancelable)
* @param query - Search query
* @param limit - Maximum results
* @returns Array of player matches
*/
async searchPlayers(query: string, limit = 10): Promise<PlayerMeta[]> {
const url = `/players/search`;
const data = await apiClient.getCancelable<PlayerMeta[]>(url, 'player-search', {
params: { q: query, limit }
});
return data;
}
};
/**
* Player API with default export
*/
export default playersAPI;

335
src/lib/api/transformers.ts Normal file
View File

@@ -0,0 +1,335 @@
/**
* API Response Transformers
* Converts legacy CSGO:WTF API responses to the new CS2.WTF format
*
* IMPORTANT: The backend API returns data in a legacy format that differs from our TypeScript schemas.
* These transformers bridge that gap by:
* 1. Converting Unix timestamps to ISO 8601 strings
* 2. Splitting score arrays [team_a, team_b] into separate fields
* 3. Renaming fields (parsed → demo_parsed, vac → vac_present, etc.)
* 4. Constructing full avatar URLs from hashes
* 5. Normalizing team IDs (1/2 → 2/3)
*
* Always use these transformers before passing API data to Zod schemas or TypeScript types.
*/
import type { MatchListItem, MatchesListResponse, Match, MatchPlayer } from '$lib/types';
/**
* Legacy API match list item format (from api.csgow.tf)
*
* VERIFIED: This interface matches the actual API response from GET /matches
* Tested: 2025-11-12 via curl https://api.csgow.tf/matches?limit=2
*/
export interface LegacyMatchListItem {
match_id: string; // uint64 as string
map: string; // Can be empty string if not parsed
date: number; // Unix timestamp (seconds since epoch)
score: [number, number]; // [team_a_score, team_b_score]
duration: number; // Match duration in seconds
match_result: number; // 0 = tie, 1 = team_a win, 2 = team_b win
max_rounds: number; // 24 for MR12, 30 for MR15
parsed: boolean; // Whether demo has been parsed (NOT demo_parsed)
vac: boolean; // Whether any player has VAC ban (NOT vac_present)
game_ban: boolean; // Whether any player has game ban (NOT gameban_present)
}
/**
* Legacy API match detail format (from GET /match/:id)
*
* VERIFIED: This interface matches the actual API response
* Tested: 2025-11-12 via curl https://api.csgow.tf/match/3589487716842078322
*
* Note: Uses 'stats' array, not 'players' array
*/
export interface LegacyMatchDetail {
match_id: string;
share_code?: string;
map: string;
date: number; // Unix timestamp
score: [number, number]; // [team_a, team_b]
duration: number;
match_result: number;
max_rounds: number;
parsed: boolean; // NOT demo_parsed
vac: boolean; // NOT vac_present
game_ban: boolean; // NOT gameban_present
stats?: LegacyPlayerStats[]; // Player stats array
}
/**
* Legacy player stats format (nested within match detail)
*
* VERIFIED: Matches actual API response structure
* - Player info nested under 'player' object
* - Rank as object with 'old' and 'new' properties
* - Multi-kills as object with 'duo', 'triple', 'quad', 'ace'
* - Damage as object with 'enemy' and 'team'
* - Flash stats with nested 'duration' and 'total' objects
*/
export interface LegacyPlayerStats {
team_id: number;
kills: number;
deaths: number;
assists: number;
headshot: number;
mvp: number;
score: number;
player: {
steamid64: string;
name: string;
avatar: string;
vac: boolean;
game_ban: boolean;
vanity_url?: string;
};
rank: Record<string, unknown>;
multi_kills?: {
duo?: number;
triple?: number;
quad?: number;
ace?: number;
};
dmg?: Record<string, unknown>;
flash?: {
duration?: {
self?: number;
team?: number;
enemy?: number;
};
total?: {
self?: number;
team?: number;
enemy?: number;
};
};
}
/**
* Transform legacy match list item to new format
*
* Converts a single match from the API's legacy format to our schema format.
*
* Key transformations:
* - date: Unix timestamp → ISO 8601 string
* - score: [a, b] array → score_team_a, score_team_b fields
* - parsed → demo_parsed (rename)
*
* @param legacy - Match data from API in legacy format
* @returns Match data in schema-compatible format
*/
export function transformMatchListItem(legacy: LegacyMatchListItem): MatchListItem {
return {
match_id: legacy.match_id, // Keep as string to preserve uint64 precision
map: legacy.map || 'unknown', // Handle empty map names
date: new Date(legacy.date * 1000).toISOString(), // Convert Unix timestamp to ISO string
score_team_a: legacy.score[0],
score_team_b: legacy.score[1],
duration: legacy.duration,
demo_parsed: legacy.parsed // Rename: parsed → demo_parsed
};
}
/**
* Transform legacy matches list response to new format
*
* IMPORTANT: The API returns a plain array, NOT an object with properties.
* This function wraps the array and adds pagination metadata that we calculate ourselves.
*
* How pagination works:
* 1. API returns plain array: [match1, match2, ...]
* 2. We request limit + 1 to check if there are more matches
* 3. If we get > limit matches, hasMore = true
* 4. We extract timestamp from last match for next page: matches[length-1].date
*
* @param legacyMatches - Array of matches from API (already requested limit + 1)
* @param hasMore - Whether there are more matches available (calculated by caller)
* @param nextPageTime - Unix timestamp for next page (extracted from last match by caller)
* @returns Wrapped response with pagination metadata
*/
export function transformMatchesListResponse(
legacyMatches: LegacyMatchListItem[],
hasMore: boolean = false,
nextPageTime?: number
): MatchesListResponse {
return {
matches: legacyMatches.map(transformMatchListItem),
has_more: hasMore,
next_page_time: nextPageTime
};
}
/**
* Transform legacy player stats to new format
*/
export function transformPlayerStats(legacy: LegacyPlayerStats): MatchPlayer {
// Extract Premier rating from rank object
// API provides rank as { old: number, new: number }
const rankOld =
legacy.rank && typeof legacy.rank.old === 'number' ? (legacy.rank.old as number) : undefined;
const rankNew =
legacy.rank && typeof legacy.rank.new === 'number' ? (legacy.rank.new as number) : undefined;
return {
id: legacy.player.steamid64,
name: legacy.player.name,
avatar: `https://avatars.steamstatic.com/${legacy.player.avatar}_full.jpg`,
team_id: legacy.team_id,
kills: legacy.kills,
deaths: legacy.deaths,
assists: legacy.assists,
headshot: legacy.headshot,
mvp: legacy.mvp,
score: legacy.score,
// Premier rating (CS2: 0-30000)
rank_old: rankOld,
rank_new: rankNew,
// Multi-kills: map legacy names to new format
mk_2: legacy.multi_kills?.duo,
mk_3: legacy.multi_kills?.triple,
mk_4: legacy.multi_kills?.quad,
mk_5: legacy.multi_kills?.ace,
// Flash stats
flash_duration_self: legacy.flash?.duration?.self,
flash_duration_team: legacy.flash?.duration?.team,
flash_duration_enemy: legacy.flash?.duration?.enemy,
flash_total_self: legacy.flash?.total?.self,
flash_total_team: legacy.flash?.total?.team,
flash_total_enemy: legacy.flash?.total?.enemy,
// Ban status
vac: legacy.player.vac,
game_ban: legacy.player.game_ban
};
}
/**
* Transform legacy match detail to new format
*/
export function transformMatchDetail(legacy: LegacyMatchDetail): Match {
return {
match_id: legacy.match_id,
share_code: legacy.share_code || undefined,
map: legacy.map || 'unknown',
date: new Date(legacy.date * 1000).toISOString(),
score_team_a: legacy.score[0],
score_team_b: legacy.score[1],
duration: legacy.duration,
match_result: legacy.match_result,
max_rounds: legacy.max_rounds,
demo_parsed: legacy.parsed,
vac_present: legacy.vac,
gameban_present: legacy.game_ban,
players: legacy.stats?.map(transformPlayerStats)
};
}
/**
* Legacy player profile format from API
*/
export interface LegacyPlayerProfile {
steamid64: string;
name: string;
avatar: string; // Hash, not full URL
vac: boolean;
vac_date: number; // Unix timestamp
game_ban: boolean;
game_ban_date: number; // Unix timestamp
tracked: boolean;
match_stats?: {
win: number;
loss: number;
};
matches?: Array<{
match_id: string;
map: string;
date: number;
score: [number, number];
duration: number;
match_result: number;
max_rounds: number;
parsed: boolean;
vac: boolean;
game_ban: boolean;
stats: {
team_id: number;
kills: number;
deaths: number;
assists: number;
headshot: number;
mvp: number;
score: number;
rank: Record<string, unknown>;
multi_kills?: Record<string, number>;
dmg?: Record<string, unknown>;
};
}>;
}
/**
* Transform legacy player profile to schema-compatible format
*/
export function transformPlayerProfile(legacy: LegacyPlayerProfile) {
// Unix timestamp -62135596800 represents "no date" (year 0)
const hasVacDate = legacy.vac_date && legacy.vac_date > 0;
const hasGameBanDate = legacy.game_ban_date && legacy.game_ban_date > 0;
return {
id: legacy.steamid64,
name: legacy.name,
avatar: `https://avatars.steamstatic.com/${legacy.avatar}_full.jpg`,
vac_count: legacy.vac ? 1 : 0,
vac_date: hasVacDate ? new Date(legacy.vac_date * 1000).toISOString() : null,
game_ban_count: legacy.game_ban ? 1 : 0,
game_ban_date: hasGameBanDate ? new Date(legacy.game_ban_date * 1000).toISOString() : null,
tracked: legacy.tracked,
wins: legacy.match_stats?.win,
losses: legacy.match_stats?.loss,
matches: legacy.matches?.map((match) => {
// Extract Premier rating from rank object
const rankOld =
match.stats.rank && typeof match.stats.rank.old === 'number'
? (match.stats.rank.old as number)
: undefined;
const rankNew =
match.stats.rank && typeof match.stats.rank.new === 'number'
? (match.stats.rank.new as number)
: undefined;
return {
match_id: match.match_id,
map: match.map || 'unknown',
date: new Date(match.date * 1000).toISOString(),
score_team_a: match.score[0],
score_team_b: match.score[1],
duration: match.duration,
match_result: match.match_result,
max_rounds: match.max_rounds,
demo_parsed: match.parsed,
vac_present: match.vac,
gameban_present: match.game_ban,
stats: {
id: legacy.steamid64,
name: legacy.name,
avatar: `https://avatars.steamstatic.com/${legacy.avatar}_full.jpg`,
// Fix team_id: API returns 1/2, but schema expects min 2
// Map: 1 -> 2 (Terrorists), 2 -> 3 (Counter-Terrorists)
team_id:
match.stats.team_id === 1 ? 2 : match.stats.team_id === 2 ? 3 : match.stats.team_id,
kills: match.stats.kills,
deaths: match.stats.deaths,
assists: match.stats.assists,
headshot: match.stats.headshot,
mvp: match.stats.mvp,
score: match.stats.score,
// Premier rating (CS2: 0-30000)
rank_old: rankOld,
rank_new: rankNew,
mk_2: match.stats.multi_kills?.duo,
mk_3: match.stats.multi_kills?.triple,
mk_4: match.stats.multi_kills?.quad,
mk_5: match.stats.multi_kills?.ace
}
};
})
};
}

View File

@@ -0,0 +1,46 @@
import type { ChatAPIResponse } from '$lib/types/api/ChatAPIResponse';
import type { MatchChatResponse, Message, Match } from '$lib/types';
/**
* Transform raw chat API response into structured format
* @param rawData - Raw API response
* @param matchId - Match ID
* @param match - Match data with player information
* @returns Structured chat data
*/
export function transformChatResponse(
rawData: ChatAPIResponse,
matchId: string,
match?: Match
): MatchChatResponse {
const messages: Message[] = [];
// Create player ID to name mapping
const playerMap = new Map<string, string>();
if (match?.players) {
for (const player of match.players) {
playerMap.set(player.id, player.name);
}
}
// Flatten all player messages into a single array
for (const [playerId, playerMessages] of Object.entries(rawData)) {
const playerName = playerMap.get(playerId) || `Player ${playerId}`;
for (const message of playerMessages) {
messages.push({
...message,
player_id: Number(playerId),
player_name: playerName
});
}
}
// Sort by tick
messages.sort((a, b) => a.tick - b.tick);
return {
match_id: matchId,
messages
};
}

View File

@@ -0,0 +1,60 @@
import type { RoundsAPIResponse } from '$lib/types/api/RoundsAPIResponse';
import type { MatchRoundsResponse, RoundDetail, RoundStats, Match } from '$lib/types';
/**
* Transform raw rounds API response into structured format
* @param rawData - Raw API response
* @param matchId - Match ID
* @param match - Match data with player information
* @returns Structured rounds data
*/
export function transformRoundsResponse(
rawData: RoundsAPIResponse,
matchId: string,
match?: Match
): MatchRoundsResponse {
const rounds: RoundDetail[] = [];
// Create player ID to team mapping
const playerTeamMap = new Map<string, number>();
if (match?.players) {
for (const player of match.players) {
playerTeamMap.set(player.id, player.team_id);
}
}
// Convert object keys to sorted round numbers
const roundNumbers = Object.keys(rawData)
.map(Number)
.sort((a, b) => a - b);
for (const roundNum of roundNumbers) {
const roundData = rawData[String(roundNum)];
if (!roundData) continue;
const players: RoundStats[] = [];
// Convert player data
for (const [playerId, [bank, equipment, spent]] of Object.entries(roundData)) {
players.push({
round: roundNum + 1, // API uses 0-indexed, we use 1-indexed
bank,
equipment,
spent,
player_id: Number(playerId)
});
}
rounds.push({
round: roundNum + 1,
winner: 0, // TODO: Determine winner from data if available
win_reason: '', // TODO: Determine win reason if available
players
});
}
return {
match_id: matchId,
rounds
};
}

View File

@@ -0,0 +1,99 @@
import type { WeaponsAPIResponse } from '$lib/types/api/WeaponsAPIResponse';
import type { MatchWeaponsResponse, PlayerWeaponStats, WeaponStats, Match } from '$lib/types';
/**
* Transform raw weapons API response into structured format
* @param rawData - Raw API response
* @param matchId - Match ID
* @param match - Match data with player information
* @returns Structured weapons data
*/
export function transformWeaponsResponse(
rawData: WeaponsAPIResponse,
matchId: string,
match?: Match
): MatchWeaponsResponse {
const playerWeaponsMap = new Map<
string,
Map<number, { damage: number; hits: number; hitGroups: number[] }>
>();
// Create player ID to name mapping
const playerMap = new Map<string, string>();
if (match?.players) {
for (const player of match.players) {
playerMap.set(player.id, player.name);
}
}
// Process all stats
for (const roundStats of rawData.stats) {
for (const [attackerId, victims] of Object.entries(roundStats)) {
if (!playerWeaponsMap.has(attackerId)) {
playerWeaponsMap.set(attackerId, new Map());
}
const weaponsMap = playerWeaponsMap.get(attackerId)!;
for (const [_, hits] of Object.entries(victims)) {
for (const [eqType, hitGroup, damage] of hits) {
if (!weaponsMap.has(eqType)) {
weaponsMap.set(eqType, { damage: 0, hits: 0, hitGroups: [] });
}
const weaponStats = weaponsMap.get(eqType)!;
weaponStats.damage += damage;
weaponStats.hits++;
weaponStats.hitGroups.push(hitGroup);
}
}
}
}
// Convert to output format
const weapons: PlayerWeaponStats[] = [];
for (const [playerId, weaponsMap] of playerWeaponsMap.entries()) {
const playerName = playerMap.get(playerId) || `Player ${playerId}`;
const weapon_stats: WeaponStats[] = [];
for (const [eqType, stats] of weaponsMap.entries()) {
const hitGroupCounts = {
head: 0,
chest: 0,
stomach: 0,
left_arm: 0,
right_arm: 0,
left_leg: 0,
right_leg: 0
};
for (const hitGroup of stats.hitGroups) {
if (hitGroup === 1) hitGroupCounts.head++;
else if (hitGroup === 2) hitGroupCounts.chest++;
else if (hitGroup === 3) hitGroupCounts.stomach++;
else if (hitGroup === 4) hitGroupCounts.left_arm++;
else if (hitGroup === 5) hitGroupCounts.right_arm++;
else if (hitGroup === 6) hitGroupCounts.left_leg++;
else if (hitGroup === 7) hitGroupCounts.right_leg++;
}
weapon_stats.push({
eq_type: eqType,
weapon_name: rawData.equipment_map[String(eqType)] || `Weapon ${eqType}`,
kills: 0, // TODO: Calculate kills if needed
damage: stats.damage,
hits: stats.hits,
hit_groups: hitGroupCounts,
headshot_pct: hitGroupCounts.head > 0 ? (hitGroupCounts.head / stats.hits) * 100 : 0
});
}
weapons.push({
player_id: Number(playerId),
player_name: playerName,
weapon_stats
});
}
return {
match_id: matchId,
weapons
};
}

View File

@@ -0,0 +1,276 @@
<script lang="ts">
import { Bomb, Shield, Clock, Target, Skull } from 'lucide-svelte';
import Badge from '$lib/components/ui/Badge.svelte';
import Card from '$lib/components/ui/Card.svelte';
import type { RoundDetail } from '$lib/types/RoundStats';
let { rounds, maxRounds = 24 }: { rounds: RoundDetail[]; maxRounds?: number } = $props();
// Calculate halftime round based on max_rounds
// MR12 (24 rounds): halftime after round 12
// MR15 (30 rounds): halftime after round 15
const halftimeRound = $derived(maxRounds === 30 ? 15 : 12);
// State for hover/click details
let selectedRound = $state<number | null>(null);
// Helper to get win reason icon
const getWinReasonIcon = (reason: string) => {
const reasonLower = reason.toLowerCase();
if (reasonLower.includes('bomb') && reasonLower.includes('exploded')) return Bomb;
if (reasonLower.includes('defused')) return Shield;
if (reasonLower.includes('elimination')) return Skull;
if (reasonLower.includes('time')) return Clock;
if (reasonLower.includes('target')) return Target;
return null;
};
// Helper to get win reason display text
const getWinReasonText = (reason: string) => {
const reasonLower = reason.toLowerCase();
if (reasonLower.includes('bomb') && reasonLower.includes('exploded')) return 'Bomb Exploded';
if (reasonLower.includes('defused')) return 'Bomb Defused';
if (reasonLower.includes('elimination')) return 'Elimination';
if (reasonLower.includes('time')) return 'Time Expired';
if (reasonLower.includes('target')) return 'Target Saved';
return reason;
};
// Helper to format win reason for badge
const formatWinReason = (reason: string): string => {
const reasonLower = reason.toLowerCase();
if (reasonLower.includes('bomb') && reasonLower.includes('exploded')) return 'BOOM';
if (reasonLower.includes('defused')) return 'DEF';
if (reasonLower.includes('elimination')) return 'ELIM';
if (reasonLower.includes('time')) return 'TIME';
if (reasonLower.includes('target')) return 'SAVE';
return 'WIN';
};
// Toggle round selection
const toggleRound = (roundNum: number) => {
selectedRound = selectedRound === roundNum ? null : roundNum;
};
// Calculate team scores up to a given round
const getScoreAtRound = (roundNumber: number): { teamA: number; teamB: number } => {
let teamA = 0;
let teamB = 0;
for (let i = 0; i < roundNumber && i < rounds.length; i++) {
const round = rounds[i];
if (round && round.winner === 2) teamA++;
else if (round && round.winner === 3) teamB++;
}
return { teamA, teamB };
};
// Get selected round details
const selectedRoundData = $derived(
selectedRound ? rounds.find((r) => r.round === selectedRound) : null
);
</script>
<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
</p>
</div>
<!-- Timeline -->
<div class="relative">
<!-- Horizontal scroll container for mobile -->
<div class="overflow-x-auto pb-4">
<div class="min-w-max">
<!-- Round markers -->
<div class="flex gap-1">
{#each rounds as round (round.round)}
{@const isWinner2 = round.winner === 2}
{@const isWinner3 = round.winner === 3}
{@const isSelected = selectedRound === round.round}
{@const Icon = getWinReasonIcon(round.win_reason)}
{@const scoreAtRound = getScoreAtRound(round.round)}
<button
class="group relative flex flex-col items-center transition-all hover:scale-110"
style="width: 60px;"
onclick={() => toggleRound(round.round)}
aria-label={`Round ${round.round}`}
>
<!-- Round number -->
<div
class="mb-2 text-xs font-semibold transition-colors"
class:text-primary={isSelected}
class:opacity-60={!isSelected}
>
{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}
>
<!-- Win reason icon or T/CT badge -->
{#if Icon}
<Icon class={`h-5 w-5 ${isWinner2 ? 'text-terrorist' : 'text-ct'}`} />
{:else}
<span
class="text-sm font-bold"
class:text-terrorist={isWinner2}
class:text-ct={isWinner3}
>
{isWinner2 ? 'T' : 'CT'}
</span>
{/if}
<!-- Small win reason badge on bottom -->
<div
class="absolute -bottom-1 rounded px-1 py-0.5 text-[9px] font-bold leading-none"
class:bg-terrorist={isWinner2}
class:bg-ct={isWinner3}
class:text-white={true}
>
{formatWinReason(round.win_reason)}
</div>
</div>
<!-- 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"
></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"
>
<div class="text-xs font-semibold text-base-content">
Round {round.round}
</div>
<div class="mt-1 text-xs text-base-content/80">
Winner:
<span
class="font-bold"
class:text-terrorist={isWinner2}
class:text-ct={isWinner3}
>
{isWinner2 ? 'Terrorists' : 'Counter-Terrorists'}
</span>
</div>
<div class="mt-1 text-xs text-base-content/60">
{getWinReasonText(round.win_reason)}
</div>
<div class="mt-2 text-xs text-base-content/60">
Score: {scoreAtRound.teamA} - {scoreAtRound.teamB}
</div>
</div>
</button>
{/each}
</div>
<!-- Half marker (dynamic based on MR12/MR15) -->
{#if rounds.length > halftimeRound}
<div class="relative mt-2 flex gap-1">
<div
class="w-[60px] text-center"
style="margin-left: calc(60px * {halftimeRound} - 30px);"
>
<Badge variant="info" size="sm">Halftime</Badge>
</div>
</div>
{/if}
</div>
</div>
</div>
<!-- Selected Round Details -->
{#if selectedRoundData}
<div class="mt-6 border-t border-base-300 pt-6">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-xl font-bold text-base-content">
Round {selectedRoundData.round} Details
</h3>
<button
class="btn btn-ghost btn-sm"
onclick={() => (selectedRound = null)}
aria-label="Close details"
>
Close
</button>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div>
<div class="text-sm text-base-content/60">Winner</div>
<div
class="text-lg font-bold"
class:text-terrorist={selectedRoundData.winner === 2}
class:text-ct={selectedRoundData.winner === 3}
>
{selectedRoundData.winner === 2 ? 'Terrorists' : 'Counter-Terrorists'}
</div>
</div>
<div>
<div class="text-sm text-base-content/60">Win Reason</div>
<div class="text-lg font-semibold text-base-content">
{getWinReasonText(selectedRoundData.win_reason)}
</div>
</div>
</div>
<!-- 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>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr class="border-base-300">
<th>Player</th>
<th>Bank</th>
<th>Equipment</th>
<th>Spent</th>
{#if selectedRoundData.players.some((p) => p.kills_in_round !== undefined)}
<th>Kills</th>
{/if}
{#if selectedRoundData.players.some((p) => p.damage_in_round !== undefined)}
<th>Damage</th>
{/if}
</tr>
</thead>
<tbody>
{#each selectedRoundData.players as player}
<tr class="border-base-300">
<td class="font-medium"
>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>
{#if selectedRoundData.players.some((p) => p.kills_in_round !== undefined)}
<td class="font-mono">{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>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
</div>
{/if}
</Card>

View File

@@ -0,0 +1,130 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import {
Chart,
BarController,
BarElement,
LinearScale,
CategoryScale,
Title,
Tooltip,
Legend,
type ChartConfiguration
} from 'chart.js';
// Register Chart.js components
Chart.register(BarController, BarElement, LinearScale, CategoryScale, Title, Tooltip, Legend);
interface Props {
data: {
labels: string[];
datasets: Array<{
label: string;
data: number[];
backgroundColor?: string | string[];
borderColor?: string | string[];
borderWidth?: number;
}>;
};
options?: Partial<ChartConfiguration<'bar'>['options']>;
height?: number;
horizontal?: boolean;
class?: string;
}
let {
data,
options = {},
height = 300,
horizontal = false,
class: className = ''
}: Props = $props();
let canvas: HTMLCanvasElement;
let chart: Chart<'bar'> | null = null;
// Convert Svelte 5 $state proxy to plain object for Chart.js compatibility
// Using JSON parse/stringify to handle Svelte proxies that structuredClone can't handle
const plainData = $derived(JSON.parse(JSON.stringify(data)));
const defaultOptions: ChartConfiguration<'bar'>['options'] = {
responsive: true,
maintainAspectRatio: false,
indexAxis: horizontal ? 'y' : 'x',
plugins: {
legend: {
display: true,
position: 'top',
labels: {
color: 'rgb(156, 163, 175)',
font: {
family: 'Inter, system-ui, sans-serif',
size: 12
}
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
titleColor: '#fff',
bodyColor: '#fff',
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 1
}
},
scales: {
x: {
grid: {
color: 'rgba(156, 163, 175, 0.1)'
},
ticks: {
color: 'rgb(156, 163, 175)',
font: {
size: 11
}
}
},
y: {
grid: {
color: 'rgba(156, 163, 175, 0.1)'
},
ticks: {
color: 'rgb(156, 163, 175)',
font: {
size: 11
}
}
}
}
};
onMount(() => {
const ctx = canvas.getContext('2d');
if (ctx) {
chart = new Chart(ctx, {
type: 'bar',
data: plainData,
options: { ...defaultOptions, ...options }
});
}
});
onDestroy(() => {
if (chart) {
chart.destroy();
}
});
// Watch for data changes and update chart
$effect(() => {
if (chart && plainData) {
chart.data = plainData;
chart.options = { ...defaultOptions, ...options };
chart.update();
}
});
</script>
<div class="relative w-full {className}" style="height: {height}px">
<canvas bind:this={canvas}></canvas>
</div>

View File

@@ -0,0 +1,139 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import {
Chart,
LineController,
LineElement,
PointElement,
LinearScale,
CategoryScale,
Title,
Tooltip,
Legend,
Filler,
type ChartConfiguration
} from 'chart.js';
// Register Chart.js components
Chart.register(
LineController,
LineElement,
PointElement,
LinearScale,
CategoryScale,
Title,
Tooltip,
Legend,
Filler
);
interface Props {
data: {
labels: string[];
datasets: Array<{
label: string;
data: number[];
borderColor?: string;
backgroundColor?: string;
fill?: boolean;
tension?: number;
}>;
};
options?: Partial<ChartConfiguration<'line'>['options']>;
height?: number;
class?: string;
}
let { data, options = {}, height = 300, class: className = '' }: Props = $props();
let canvas: HTMLCanvasElement;
let chart: Chart<'line'> | null = null;
// Convert Svelte 5 $state proxy to plain object for Chart.js compatibility
// Using JSON parse/stringify to handle Svelte proxies that structuredClone can't handle
const plainData = $derived(JSON.parse(JSON.stringify(data)));
const defaultOptions: ChartConfiguration<'line'>['options'] = {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
display: true,
position: 'top',
labels: {
color: 'rgb(156, 163, 175)',
font: {
family: 'Inter, system-ui, sans-serif',
size: 12
}
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
titleColor: '#fff',
bodyColor: '#fff',
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 1
}
},
scales: {
x: {
grid: {
color: 'rgba(156, 163, 175, 0.1)'
},
ticks: {
color: 'rgb(156, 163, 175)',
font: {
size: 11
}
}
},
y: {
grid: {
color: 'rgba(156, 163, 175, 0.1)'
},
ticks: {
color: 'rgb(156, 163, 175)',
font: {
size: 11
}
}
}
}
};
onMount(() => {
const ctx = canvas.getContext('2d');
if (ctx) {
chart = new Chart(ctx, {
type: 'line',
data: plainData,
options: { ...defaultOptions, ...options }
});
}
});
onDestroy(() => {
if (chart) {
chart.destroy();
}
});
// Watch for data changes and update chart
$effect(() => {
if (chart && plainData) {
chart.data = plainData;
chart.options = { ...defaultOptions, ...options };
chart.update();
}
});
</script>
<div class="relative w-full {className}" style="height: {height}px">
<canvas bind:this={canvas}></canvas>
</div>

View File

@@ -0,0 +1,105 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import {
Chart,
DoughnutController,
ArcElement,
Title,
Tooltip,
Legend,
type ChartConfiguration
} from 'chart.js';
// Register Chart.js components
Chart.register(DoughnutController, ArcElement, Title, Tooltip, Legend);
interface Props {
data: {
labels: string[];
datasets: Array<{
label?: string;
data: number[];
backgroundColor?: string[];
borderColor?: string[];
borderWidth?: number;
}>;
};
options?: Partial<ChartConfiguration<'doughnut'>['options']>;
height?: number;
doughnut?: boolean;
class?: string;
}
let {
data,
options = {},
height = 300,
doughnut = true,
class: className = ''
}: Props = $props();
let canvas: HTMLCanvasElement;
let chart: Chart<'doughnut'> | null = null;
// Convert Svelte 5 $state proxy to plain object for Chart.js compatibility
// Using JSON parse/stringify to handle Svelte proxies that structuredClone can't handle
const plainData = $derived(JSON.parse(JSON.stringify(data)));
const defaultOptions: ChartConfiguration<'doughnut'>['options'] = {
responsive: true,
maintainAspectRatio: false,
cutout: doughnut ? '60%' : '0%',
plugins: {
legend: {
display: true,
position: 'bottom',
labels: {
color: 'rgb(156, 163, 175)',
font: {
family: 'Inter, system-ui, sans-serif',
size: 12
},
padding: 15
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
titleColor: '#fff',
bodyColor: '#fff',
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 1
}
}
};
onMount(() => {
const ctx = canvas.getContext('2d');
if (ctx) {
chart = new Chart(ctx, {
type: 'doughnut',
data: plainData,
options: { ...defaultOptions, ...options }
});
}
});
onDestroy(() => {
if (chart) {
chart.destroy();
}
});
// Watch for data changes and update chart
$effect(() => {
if (chart && plainData) {
chart.data = plainData;
chart.options = { ...defaultOptions, ...options };
chart.update();
}
});
</script>
<div class="relative w-full {className}" style="height: {height}px">
<canvas bind:this={canvas}></canvas>
</div>

View File

@@ -0,0 +1,131 @@
<script lang="ts" generics="T">
/* eslint-disable no-undef */
import { ArrowUp, ArrowDown } from 'lucide-svelte';
interface Column<T> {
key: keyof T;
label: string;
sortable?: boolean;
format?: (value: T[keyof T], row: T) => string;
render?: (value: T[keyof T], row: T) => unknown;
align?: 'left' | 'center' | 'right';
class?: string;
width?: string; // e.g., '200px', '30%', 'auto'
}
interface Props {
data: T[];
columns: Column<T>[];
class?: string;
striped?: boolean;
hoverable?: boolean;
compact?: boolean;
fixedLayout?: boolean; // Use table-layout: fixed for consistent column widths
}
let {
data,
columns,
class: className = '',
striped = false,
hoverable = true,
compact = false,
fixedLayout = false
}: Props = $props();
let sortKey = $state<keyof T | null>(null);
let sortDirection = $state<'asc' | 'desc'>('asc');
const handleSort = (column: Column<T>) => {
if (!column.sortable) return;
if (sortKey === column.key) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
sortKey = column.key;
sortDirection = 'asc';
}
};
const sortedData = $derived(
!sortKey
? data
: [...data].sort((a, b) => {
const aVal = a[sortKey as keyof T];
const bVal = b[sortKey as keyof T];
if (aVal === bVal) return 0;
const comparison = aVal < bVal ? -1 : 1;
return sortDirection === 'asc' ? comparison : -comparison;
})
);
const getValue = (row: T, column: Column<T>) => {
const value = row[column.key];
if (column.format) {
return column.format(value, row);
}
return value;
};
</script>
<div class="overflow-x-auto {className}">
<table
class="table"
class:table-zebra={striped}
class:table-xs={compact}
style={fixedLayout ? 'table-layout: fixed;' : ''}
>
<thead>
<tr>
{#each columns as column}
<th
class:cursor-pointer={column.sortable}
class:hover:bg-base-200={column.sortable}
class="text-{column.align || 'left'} {column.class || ''}"
style={column.width ? `width: ${column.width}` : ''}
onclick={() => handleSort(column)}
>
<div
class="flex items-center gap-2"
class:justify-end={column.align === 'right'}
class:justify-center={column.align === 'center'}
>
<span>{column.label}</span>
{#if column.sortable}
<div class="flex flex-col opacity-40">
<ArrowUp
class="h-3 w-3 {sortKey === column.key && sortDirection === 'asc'
? 'text-primary opacity-100'
: ''}"
/>
<ArrowDown
class="-mt-1 h-3 w-3 {sortKey === column.key && sortDirection === 'desc'
? 'text-primary opacity-100'
: ''}"
/>
</div>
{/if}
</div>
</th>
{/each}
</tr>
</thead>
<tbody>
{#each sortedData as row}
<tr class:hover={hoverable}>
{#each columns as column}
<td class="text-{column.align || 'left'} {column.class || ''}">
{#if column.render}
{@html column.render(row[column.key], row)}
{:else}
{getValue(row, column)}
{/if}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>

View File

@@ -0,0 +1,132 @@
<script lang="ts">
import { Github, Heart } from 'lucide-svelte';
const currentYear = new Date().getFullYear();
const links = {
main: [
{ name: 'Home', href: '/' },
{ name: 'Matches', href: '/matches' },
{ name: 'Players', href: '/players' },
{ name: 'API Docs', href: '/docs/api' }
],
about: [
{ name: 'About', href: '/about' },
{ name: 'FAQ', href: '/faq' },
{ name: 'Privacy', href: '/privacy' },
{ name: 'Terms', href: '/terms' }
],
resources: [
{ name: 'GitHub', href: 'https://somegit.dev/CSGOWTF/csgowtf', external: true },
{ name: 'Backend', href: 'https://somegit.dev/CSGOWTF/csgowtfd', external: true },
{
name: 'Donate',
href: 'https://liberapay.com/CSGOWTF/',
external: true
}
]
};
</script>
<footer class="border-t border-base-300 bg-base-100">
<div class="container 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>
<p class="mb-4 text-sm text-base-content/60">
Statistics for CS2 matchmaking matches. 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"
aria-label="GitHub"
>
<Github class="h-5 w-5" />
</a>
<a
href="https://liberapay.com/CSGOWTF/"
target="_blank"
rel="noopener noreferrer"
class="text-base-content/60 transition-colors hover:text-error"
aria-label="Support on Liberapay"
>
<Heart class="h-5 w-5" />
</a>
</div>
</div>
<!-- Links -->
<div>
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wider text-base-content/80">
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"
>
{link.name}
</a>
</li>
{/each}
</ul>
</div>
<div>
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wider text-base-content/80">
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"
>
{link.name}
</a>
</li>
{/each}
</ul>
</div>
<div>
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wider text-base-content/80">
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"
{...link.external ? { target: '_blank', rel: 'noopener noreferrer' } : {}}
>
{link.name}
</a>
</li>
{/each}
</ul>
</div>
</div>
<!-- Bottom -->
<div class="mt-12 border-t border-base-300 pt-8 text-center text-sm text-base-content/60">
<p>
© {currentYear} CSGOW.TF Team. Licensed under
<a href="/license" class="hover:text-primary">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.
</p>
</div>
</div>
</footer>

View File

@@ -0,0 +1,73 @@
<script lang="ts">
import { Menu, X } from 'lucide-svelte';
import SearchBar from './SearchBar.svelte';
import ThemeToggle from './ThemeToggle.svelte';
let mobileMenuOpen = $state(false);
const navigation = [
{ name: 'Home', href: '/' },
{ name: 'Matches', href: '/matches' },
{ name: 'Players', href: '/players' },
{ name: 'About', href: '/about' }
];
</script>
<header class="sticky top-0 z-50 w-full border-b border-base-300 bg-base-100/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">
<h1 class="text-2xl font-bold">
<span class="text-primary">CS2</span><span class="text-secondary">.WTF</span>
</h1>
</a>
<!-- Desktop Navigation -->
<nav class="hidden items-center gap-6 md:flex">
{#each navigation as item}
<a
href={item.href}
class="text-sm font-medium text-base-content/70 transition-colors hover:text-primary"
>
{item.name}
</a>
{/each}
</nav>
<!-- Search & Actions -->
<div class="flex items-center gap-2">
<SearchBar />
<ThemeToggle />
<!-- Mobile Menu Toggle -->
<button
class="btn btn-ghost btn-sm md:hidden"
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
aria-label="Toggle menu"
>
{#if mobileMenuOpen}
<X class="h-5 w-5" />
{:else}
<Menu class="h-5 w-5" />
{/if}
</button>
</div>
</div>
<!-- Mobile Navigation -->
{#if mobileMenuOpen}
<nav class="animate-fade-in border-t border-base-300 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"
onclick={() => (mobileMenuOpen = false)}
>
{item.name}
</a>
{/each}
</nav>
{/if}
</div>
</header>

View File

@@ -0,0 +1,116 @@
<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);
}
};
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"
>
<Search class="h-5 w-5" />
<span class="hidden md:inline">Search</span>
<kbd class="kbd kbd-sm hidden lg:inline-flex">
<Command class="h-3 w-3" />
K
</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,71 @@
<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

@@ -0,0 +1,99 @@
<script lang="ts">
import Badge from '$lib/components/ui/Badge.svelte';
import type { MatchListItem } from '$lib/types';
import { storeMatchesState } from '$lib/utils/navigation';
import { getMapBackground, formatMapName } from '$lib/utils/mapAssets';
interface Props {
match: MatchListItem;
loadedCount?: number;
}
let { match, loadedCount = 0 }: Props = $props();
const formattedDate = new Date(match.date).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
const mapName = formatMapName(match.map);
const mapBg = getMapBackground(match.map);
function handleClick() {
// Store navigation state before navigating
storeMatchesState(match.match_id, loadedCount);
}
function handleImageError(event: Event) {
const img = event.target as HTMLImageElement;
img.src = '/images/map_screenshots/default.webp';
}
</script>
<a
href={`/match/${match.match_id}`}
class="block transition-transform hover:scale-[1.02]"
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"
>
<!-- Map Header with Background Image -->
<div class="relative h-32 overflow-hidden">
<!-- Background Image -->
<img
src={mapBg}
alt={mapName}
class="absolute inset-0 h-full w-full object-cover"
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>
<!-- 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>
{#if match.demo_parsed}
<Badge variant="success" size="sm">Parsed</Badge>
{/if}
</div>
</div>
<!-- Match Info -->
<div class="p-4">
<!-- 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>
<!-- Meta -->
<div class="flex items-center justify-between text-sm text-base-content/60">
<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,155 @@
<script lang="ts">
import { Upload, Check, AlertCircle, Loader2 } from 'lucide-svelte';
import { matchesAPI } from '$lib/api/matches';
import { toast } from '$lib/stores/toast';
import { goto } from '$app/navigation';
let shareCode = $state('');
let isLoading = $state(false);
let parseStatus: 'idle' | 'parsing' | 'success' | 'error' = $state('idle');
let statusMessage = $state('');
let parsedMatchId = $state('');
// 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());
}
async function handleSubmit() {
const trimmedCode = shareCode.trim().toUpperCase();
if (!trimmedCode) {
toast.error('Please enter a share code');
return;
}
if (!isValidShareCode(trimmedCode)) {
toast.error('Invalid share code format');
parseStatus = 'error';
statusMessage = 'Share code must be in format: CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX';
return;
}
isLoading = true;
parseStatus = 'parsing';
statusMessage = 'Submitting share code for parsing...';
try {
const response = await matchesAPI.parseMatch(trimmedCode);
if (response.match_id) {
parsedMatchId = response.match_id;
parseStatus = 'success';
statusMessage =
response.message ||
'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);
} else {
parseStatus = 'error';
statusMessage = response.message || 'Failed to parse share code';
toast.error(statusMessage);
}
} catch (error: unknown) {
parseStatus = 'error';
statusMessage = error instanceof Error ? error.message : 'Failed to parse share code';
toast.error(statusMessage);
} finally {
isLoading = false;
}
}
function resetForm() {
shareCode = '';
parseStatus = 'idle';
statusMessage = '';
parsedMatchId = '';
}
</script>
<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>
</label>
<div class="flex gap-2">
<input
id="shareCode"
type="text"
placeholder="CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX"
class="input input-bordered flex-1"
bind:value={shareCode}
disabled={isLoading}
onkeydown={(e) => e.key === 'Enter' && handleSubmit()}
/>
<button
class="btn btn-primary"
onclick={handleSubmit}
disabled={isLoading || !shareCode.trim()}
>
{#if isLoading}
<Loader2 class="h-5 w-5 animate-spin" />
{:else}
<Upload class="h-5 w-5" />
{/if}
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>
</div>
<!-- Status Messages -->
{#if parseStatus !== 'idle'}
<div
class="alert {parseStatus === 'success'
? 'alert-success'
: parseStatus === 'error'
? 'alert-error'
: 'alert-info'}"
>
{#if parseStatus === 'parsing'}
<Loader2 class="h-6 w-6 shrink-0 animate-spin stroke-current" />
{:else if parseStatus === 'success'}
<Check class="h-6 w-6 shrink-0 stroke-current" />
{:else}
<AlertCircle class="h-6 w-6 shrink-0 stroke-current" />
{/if}
<div class="flex-1">
<p>{statusMessage}</p>
{#if parseStatus === 'success' && parsedMatchId}
<p class="mt-1 text-sm">Redirecting to match page...</p>
{/if}
</div>
{#if parseStatus !== 'parsing'}
<button class="btn btn-ghost btn-sm" 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>
</div>
</div>

View File

@@ -0,0 +1,73 @@
<script lang="ts">
import { User, TrendingUp, Target } from 'lucide-svelte';
import Badge from '$lib/components/ui/Badge.svelte';
import type { PlayerMeta } from '$lib/types';
interface Props {
player: PlayerMeta;
showStats?: boolean;
}
let { player, showStats = true }: Props = $props();
const kd =
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);
</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"
>
<!-- Header -->
<div class="bg-gradient-to-r from-primary/20 to-secondary/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>
<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>
</div>
</div>
</div>
{#if showStats}
<!-- Stats -->
<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" />
</div>
<div class="text-xl font-bold text-base-content">{kd}</div>
<div class="text-xs text-base-content/60">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" />
</div>
<div class="text-xl font-bold text-base-content">{winRate}%</div>
<div class="text-xs text-base-content/60">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" />
</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>
</div>
<!-- Footer -->
<div class="border-t border-base-300 bg-base-200 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>
</div>
</div>
{/if}
</a>

View File

@@ -0,0 +1,81 @@
<script lang="ts">
import { Clock, X } from 'lucide-svelte';
import Card from '$lib/components/ui/Card.svelte';
import { onMount } from 'svelte';
import {
getRecentPlayers,
removeRecentPlayer,
type RecentPlayer
} from '$lib/utils/recentPlayers';
let recentPlayers = $state<RecentPlayer[]>([]);
onMount(() => {
recentPlayers = getRecentPlayers();
});
function handleRemove(playerId: string) {
removeRecentPlayer(playerId);
recentPlayers = getRecentPlayers();
}
function formatTimeAgo(timestamp: number): string {
const now = Date.now();
const diff = now - timestamp;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
return `${days}d ago`;
}
</script>
{#if recentPlayers.length > 0}
<Card padding="lg">
<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>
</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"
>
<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"
/>
<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>
</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"
onclick={(e) => {
e.preventDefault();
handleRemove(player.id);
}}
aria-label="Remove from recent players"
>
<X class="h-3 w-3" />
</button>
</div>
{/each}
</div>
<div class="mt-4 text-center text-xs text-base-content/60">
Showing up to {recentPlayers.length} recently visited player{recentPlayers.length !== 1
? 's'
: ''}
</div>
</Card>
{/if}

View File

@@ -0,0 +1,180 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import { playersAPI } from '$lib/api/players';
import { toast } from '$lib/stores/toast';
interface Props {
playerId: string;
playerName: string;
isTracked: boolean;
open: boolean;
ontracked?: () => void;
onuntracked?: () => void;
}
let {
playerId,
playerName,
isTracked,
open = $bindable(),
ontracked,
onuntracked
}: Props = $props();
const dispatch = createEventDispatcher();
let authCode = $state('');
let isLoading = $state(false);
let error = $state('');
async function handleTrack() {
if (!authCode.trim()) {
error = 'Auth code is required';
return;
}
isLoading = true;
error = '';
try {
await playersAPI.trackPlayer(playerId, authCode);
toast.success('Player tracking activated successfully!');
open = false;
dispatch('tracked');
ontracked?.();
} catch (err: unknown) {
error = err instanceof Error ? err.message : 'Failed to track player';
toast.error(error);
} finally {
isLoading = false;
}
}
async function handleUntrack() {
isLoading = true;
error = '';
try {
await playersAPI.untrackPlayer(playerId);
toast.success('Player tracking removed successfully');
open = false;
dispatch('untracked');
onuntracked?.();
} catch (err: unknown) {
error = err instanceof Error ? err.message : 'Failed to untrack player';
toast.error(error);
} finally {
isLoading = false;
}
}
function handleClose() {
open = false;
authCode = '';
error = '';
}
</script>
<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">
{#if isTracked}
<p>Remove <strong>{playerName}</strong> from automatic match tracking.</p>
{:else}
<p>
Add <strong>{playerName}</strong> to the tracking system to automatically fetch new matches.
</p>
{/if}
</div>
</div>
<!-- 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>
</label>
<input
id="authCode"
type="text"
placeholder="Enter your auth code"
class="input input-bordered w-full"
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>
</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>
{/if}
<!-- Help Text -->
<div class="text-sm text-base-content/70">
<p class="mb-2 font-medium">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>Copy the code shown next to "Account:"</li>
</ol>
</div>
</div>
{#snippet actions()}
<button class="btn" onclick={handleClose} disabled={isLoading}>Cancel</button>
{#if isTracked}
<button class="btn btn-error" onclick={handleUntrack} disabled={isLoading}>
{#if isLoading}
<span class="loading loading-spinner loading-sm"></span>
{/if}
Untrack Player
</button>
{:else}
<button class="btn btn-primary" onclick={handleTrack} disabled={isLoading}>
{#if isLoading}
<span class="loading loading-spinner loading-sm"></span>
{/if}
Track Player
</button>
{/if}
{/snippet}
</Modal>

View File

@@ -0,0 +1,37 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
variant?: 'default' | 't-side' | 'ct-side' | 'success' | 'warning' | 'error' | 'info';
size?: 'sm' | 'md' | 'lg';
class?: string;
children: Snippet;
}
let { variant = 'default', size = 'md', class: className = '', children }: Props = $props();
const baseClasses = 'inline-flex items-center justify-center font-medium border rounded';
const variantClasses = {
default: 'bg-base-300/50 border-base-content/20 text-base-content',
't-side':
'bg-terrorist/10 border-terrorist/30 text-terrorist-light backdrop-blur-sm font-semibold',
'ct-side': 'bg-ct/10 border-ct/30 text-ct-light backdrop-blur-sm font-semibold',
success: 'bg-success/10 border-success/30 text-success',
warning: 'bg-warning/10 border-warning/30 text-warning',
error: 'bg-error/10 border-error/30 text-error',
info: 'bg-info/10 border-info/30 text-info'
};
const sizeClasses = {
sm: 'px-2 py-0.5 text-xs',
md: 'px-2.5 py-1 text-sm',
lg: 'px-3 py-1.5 text-base'
};
const classes = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`;
</script>
<span class={classes}>
{@render children()}
</span>

View File

@@ -0,0 +1,60 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
href?: string;
type?: 'button' | 'submit' | 'reset';
disabled?: boolean;
class?: string;
onclick?: () => void;
target?: string;
rel?: string;
children: Snippet;
}
let {
variant = 'primary',
size = 'md',
href,
type = 'button',
disabled = false,
class: className = '',
onclick,
target,
rel,
children
}: Props = $props();
const baseClasses =
'inline-flex items-center justify-center font-semibold transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-base-100 disabled:opacity-50 disabled:cursor-not-allowed';
const variantClasses = {
primary:
'bg-primary text-white hover:bg-primary-focus focus:ring-primary shadow-sm hover:shadow-lg hover:shadow-primary/30',
secondary:
'bg-secondary text-white hover:bg-secondary-focus focus:ring-secondary shadow-sm hover:shadow-lg hover:shadow-secondary/30',
ghost:
'bg-transparent border border-base-300 text-base-content hover:bg-base-300 hover:border-primary focus:ring-primary',
danger: 'bg-error text-white hover:bg-error/90 focus:ring-error shadow-sm hover:shadow-lg'
};
const sizeClasses = {
sm: 'px-3 py-1.5 text-sm rounded',
md: 'px-4 py-2 text-base rounded-md',
lg: 'px-6 py-3 text-lg rounded-lg'
};
const classes = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`;
</script>
{#if href}
<a {href} {target} {rel} class={classes} aria-disabled={disabled}>
{@render children()}
</a>
{:else}
<button {type} {disabled} {onclick} class={classes}>
{@render children()}
</button>
{/if}

View File

@@ -0,0 +1,49 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
variant?: 'default' | 'elevated' | 'interactive';
padding?: 'none' | 'sm' | 'md' | 'lg';
class?: string;
onclick?: () => void;
children: Snippet;
}
let {
variant = 'default',
padding = 'md',
class: className = '',
onclick,
children
}: Props = $props();
const baseClasses = 'bg-base-200 border border-base-300 rounded-md transition-all duration-200';
const variantClasses = {
default: 'shadow-sm',
elevated: 'shadow-lg shadow-black/10',
interactive:
'cursor-pointer hover:border-primary hover:shadow-lg hover:shadow-primary/20 hover:-translate-y-0.5'
};
const paddingClasses = {
none: '',
sm: 'p-3',
md: 'p-4',
lg: 'p-6'
};
const classes =
`${baseClasses} ${variantClasses[variant]} ${paddingClasses[padding]} ${className}` +
(onclick ? ' cursor-pointer' : '');
</script>
{#if onclick}
<button class={classes} {onclick}>
{@render children()}
</button>
{:else}
<div class={classes}>
{@render children()}
</div>
{/if}

View File

@@ -0,0 +1,102 @@
<script lang="ts">
import { X } from 'lucide-svelte';
import { fly, fade } from 'svelte/transition';
import type { Snippet } from 'svelte';
interface Props {
open?: boolean;
title?: string;
size?: 'sm' | 'md' | 'lg' | 'xl';
onClose?: () => void;
children?: Snippet;
actions?: Snippet;
}
let { open = $bindable(false), title, size = 'md', onClose, children, actions }: Props = $props();
const sizeClasses = {
sm: 'max-w-md',
md: 'max-w-2xl',
lg: 'max-w-4xl',
xl: 'max-w-6xl'
};
const handleClose = () => {
open = false;
onClose?.();
};
const handleBackdropClick = (e: MouseEvent) => {
if (e.target === e.currentTarget) {
handleClose();
}
};
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && open) {
handleClose();
}
};
</script>
<svelte:window onkeydown={handleKeydown} />
{#if open}
<div
class="fixed inset-0 z-50 flex items-center justify-center p-4"
transition:fade={{ duration: 200 }}
onclick={handleBackdropClick}
onkeydown={(e) => {
if (e.key === 'Escape') {
handleClose();
}
}}
role="dialog"
aria-modal="true"
aria-labelledby={title ? 'modal-title' : undefined}
tabindex="-1"
>
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm"></div>
<!-- Modal -->
<div
class="relative w-full {sizeClasses[size]} rounded-lg bg-base-100 shadow-xl"
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>
<button
class="btn btn-circle btn-ghost btn-sm"
onclick={handleClose}
aria-label="Close modal"
>
<X class="h-5 w-5" />
</button>
</div>
{:else}
<button
class="btn btn-circle btn-ghost btn-sm absolute right-4 top-4 z-10"
onclick={handleClose}
aria-label="Close modal"
>
<X class="h-5 w-5" />
</button>
{/if}
<!-- Content -->
<div class="p-6">
{@render children?.()}
</div>
<!-- Actions -->
{#if actions}
<div class="flex justify-end gap-2 border-t border-base-300 p-6">
{@render actions()}
</div>
{/if}
</div>
</div>
{/if}

View File

@@ -0,0 +1,96 @@
<script lang="ts">
import { formatPremierRating, getPremierRatingChange } from '$lib/utils/formatters';
import { usesSkillGroup } from '$lib/utils/rankingSystem';
import { Trophy, TrendingUp, TrendingDown } from 'lucide-svelte';
import RankIcon from './RankIcon.svelte';
import type { Match } from '$lib/types';
interface Props {
rating: number | undefined | null;
oldRating?: number | undefined | null;
/** Match data for determining ranking system (date + game_mode) */
match?: Pick<Match, 'date' | 'game_mode'>;
size?: 'sm' | 'md' | 'lg';
showTier?: boolean;
showChange?: boolean;
showIcon?: boolean;
class?: string;
}
let {
rating,
oldRating,
match,
size = 'md',
showTier = false,
showChange = false,
showIcon = true,
class: className = ''
}: Props = $props();
/**
* Determine if this rating should be displayed as a Skill Group (0-18)
* Uses the new ranking system detection logic based on:
* 1. Match date (CS:GO legacy vs CS2)
* 2. Game mode (Premier vs Competitive/Wingman)
* 3. Fallback heuristic (0-18 = Skill Group, >1000 = CS Rating)
*/
const shouldShowSkillGroup = $derived(
match
? usesSkillGroup(match, rating)
: rating !== null && rating !== undefined && rating >= 0 && rating <= 18
);
const tierInfo = $derived(formatPremierRating(rating));
const changeInfo = $derived(showChange ? getPremierRatingChange(oldRating, rating) : null);
const baseClasses = 'inline-flex items-center gap-1.5 border rounded-lg font-medium';
const sizeClasses = {
sm: 'px-2 py-0.5 text-xs',
md: 'px-3 py-1 text-sm',
lg: 'px-4 py-2 text-base'
};
const iconSizes = {
sm: 'h-3 w-3',
md: 'h-4 w-4',
lg: 'h-5 w-5'
};
const classes = $derived(
`${baseClasses} ${tierInfo.cssClasses} ${sizeClasses[size]} ${className}`
);
</script>
{#if shouldShowSkillGroup}
<!-- Show Skill Group icon (CS:GO legacy OR CS2 Competitive/Wingman mode) -->
<RankIcon skillGroup={rating} {size} class={className} />
{:else if !rating || rating === 0}
<!-- No rating available -->
<span class="text-sm text-base-content/50">Unranked</span>
{:else}
<!-- Show CS Rating for CS2 Premier mode -->
<div class={classes}>
{#if showIcon}
<Trophy class={iconSizes[size]} />
{/if}
<span>{tierInfo.formatted}</span>
{#if showTier}
<span class="opacity-75">({tierInfo.tier})</span>
{/if}
{#if showChange && changeInfo}
<span class="ml-1 flex items-center gap-0.5 {changeInfo.cssClasses}">
{#if changeInfo.isPositive}
<TrendingUp class={iconSizes[size]} />
{:else if changeInfo.change < 0}
<TrendingDown class={iconSizes[size]} />
{/if}
{changeInfo.display}
</span>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,74 @@
<script lang="ts">
/**
* CS:GO Skill Group Rank Icon Component
* Displays the appropriate rank icon based on skill group (0-18)
*/
interface Props {
/** CS:GO skill group (0-18) */
skillGroup: number | undefined | null;
size?: 'sm' | 'md' | 'lg';
showLabel?: boolean;
class?: string;
}
let { skillGroup, size = 'md', showLabel = false, class: className = '' }: Props = $props();
// Map skill groups to rank names
const rankNames: Record<number, string> = {
0: 'Unranked',
1: 'Silver I',
2: 'Silver II',
3: 'Silver III',
4: 'Silver IV',
5: 'Silver Elite',
6: 'Silver Elite Master',
7: 'Gold Nova I',
8: 'Gold Nova II',
9: 'Gold Nova III',
10: 'Gold Nova Master',
11: 'Master Guardian I',
12: 'Master Guardian II',
13: 'Master Guardian Elite',
14: 'Distinguished Master Guardian',
15: 'Legendary Eagle',
16: 'Legendary Eagle Master',
17: 'Supreme Master First Class',
18: 'The Global Elite'
};
const sizeClasses = {
sm: 'h-11 w-11 max-h-11',
md: 'h-16 w-16',
lg: 'h-20 w-20'
};
const labelSizeClasses = {
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base'
};
const iconPath = $derived(
skillGroup !== undefined && skillGroup !== null && skillGroup >= 0 && skillGroup <= 18
? `/images/rank_icons/skillgroup${skillGroup}.svg`
: '/images/rank_icons/skillgroup_none.svg'
);
const rankName = $derived(
skillGroup !== undefined && skillGroup !== null ? rankNames[skillGroup] || 'Unknown' : 'Unknown'
);
</script>
{#if showLabel}
<div class="inline-flex items-center gap-2 {className}">
<img src={iconPath} alt={rankName} class="{sizeClasses[size]} object-contain" />
<span class="font-medium {labelSizeClasses[size]}">{rankName}</span>
</div>
{:else}
<img
src={iconPath}
alt={rankName}
title={rankName}
class="{sizeClasses[size]} {className} inline-block object-contain align-middle"
/>
{/if}

View File

@@ -0,0 +1,26 @@
<script lang="ts">
interface Props {
variant?: 'text' | 'circular' | 'rectangular';
width?: string;
height?: string;
class?: string;
}
let { variant = 'rectangular', width, height, class: className = '' }: Props = $props();
const baseClasses = 'animate-pulse bg-base-300';
const variantClasses = {
text: 'rounded h-4',
circular: 'rounded-full',
rectangular: 'rounded'
};
const style = [width ? `width: ${width};` : '', height ? `height: ${height};` : '']
.filter(Boolean)
.join(' ');
</script>
<div class="{baseClasses} {variantClasses[variant]} {className}" {style} role="status">
<span class="sr-only">Loading...</span>
</div>

View File

@@ -0,0 +1,78 @@
<script lang="ts">
import { page } from '$app/stores';
interface Tab {
label: string;
href?: string;
value?: string;
disabled?: boolean;
}
interface Props {
tabs: Tab[];
activeTab?: string;
onTabChange?: (value: string) => void;
variant?: 'boxed' | 'bordered' | 'lifted';
size?: 'xs' | 'sm' | 'md' | 'lg';
class?: string;
}
let {
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 => {
if (tab.href) {
return $page.url.pathname === tab.href || $page.url.pathname.startsWith(tab.href + '/');
}
return activeTab === tab.value;
};
const handleTabClick = (tab: Tab) => {
if (tab.disabled) return;
if (tab.value && !tab.href) {
activeTab = tab.value;
onTabChange?.(tab.value);
}
};
const variantClass =
variant === 'boxed' ? 'tabs-boxed' : variant === 'lifted' ? 'tabs-lifted' : '';
const sizeClass =
size === 'xs' ? 'tabs-xs' : size === 'sm' ? 'tabs-sm' : size === 'lg' ? 'tabs-lg' : '';
</script>
<div role="tablist" class="tabs {variantClass} {sizeClass} {className}">
{#each tabs as tab}
{#if tab.href}
<a
href={tab.href}
role="tab"
class="tab"
class:tab-active={isActive(tab)}
class:tab-disabled={tab.disabled}
aria-disabled={tab.disabled}
>
{tab.label}
</a>
{:else}
<button
role="tab"
class="tab"
class:tab-active={isActive(tab)}
class:tab-disabled={tab.disabled}
disabled={tab.disabled}
onclick={() => handleTabClick(tab)}
>
{tab.label}
</button>
{/if}
{/each}
</div>

View File

@@ -0,0 +1,49 @@
<script lang="ts">
import { fly } from 'svelte/transition';
import { CheckCircle, XCircle, AlertTriangle, Info, X } from 'lucide-svelte';
import type { Toast } from '$lib/stores';
interface Props {
toast: Toast;
onDismiss: (id: string) => void;
}
let { toast, onDismiss }: Props = $props();
// Icon mapping
const icons = {
success: CheckCircle,
error: XCircle,
warning: AlertTriangle,
info: Info
};
// Color mapping for DaisyUI
const alertClasses = {
success: 'alert-success',
error: 'alert-error',
warning: 'alert-warning',
info: 'alert-info'
};
const IconComponent = icons[toast.type];
</script>
<div
role="alert"
class="alert {alertClasses[toast.type]} shadow-lg"
transition:fly={{ y: -20, duration: 300 }}
>
<IconComponent class="h-6 w-6" />
<span>{toast.message}</span>
{#if toast.dismissible}
<button
class="btn btn-circle btn-ghost btn-sm"
onclick={() => onDismiss(toast.id)}
aria-label="Dismiss notification"
>
<X class="h-4 w-4" />
</button>
{/if}
</div>

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import { toast } from '$lib/stores';
import Toast from './Toast.svelte';
</script>
<!-- Toast Container - Fixed position at top-right -->
<div class="toast toast-end toast-top z-50">
{#each $toast as toastItem (toastItem.id)}
<Toast toast={toastItem} onDismiss={toast.dismiss} />
{/each}
</div>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
text: string;
position?: 'top' | 'bottom' | 'left' | 'right';
children?: Snippet;
}
let { text, position = 'top', children }: Props = $props();
const positionClass = {
top: 'tooltip-top',
bottom: 'tooltip-bottom',
left: 'tooltip-left',
right: 'tooltip-right'
};
</script>
<div class="tooltip {positionClass[position]}" data-tip={text}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,79 @@
import { z } from 'zod';
import { matchListItemSchema } from './match.schema';
/**
* Zod schemas for API responses and error handling
*/
/** APIError schema */
export const apiErrorSchema = z.object({
error: z.string(),
message: z.string(),
status_code: z.number().int(),
timestamp: z.string().datetime().optional()
});
/** Generic APIResponse schema */
export const apiResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) =>
z.object({
data: dataSchema,
success: z.boolean(),
error: apiErrorSchema.optional()
});
/** MatchParseResponse schema */
export const matchParseResponseSchema = z.object({
match_id: z.string().min(1), // uint64 as string to preserve precision
status: z.enum(['parsing', 'queued', 'completed', 'error']),
message: z.string(),
estimated_time: z.number().int().positive().optional()
});
/** MatchParseStatus schema */
export const matchParseStatusSchema = z.object({
match_id: z.string().min(1), // uint64 as string to preserve precision
status: z.enum(['pending', 'parsing', 'completed', 'error']),
progress: z.number().int().min(0).max(100).optional(),
error_message: z.string().optional()
});
/** MatchesListResponse schema */
export const matchesListResponseSchema = z.object({
matches: z.array(matchListItemSchema),
next_page_time: z.number().int().optional(),
has_more: z.boolean(),
total_count: z.number().int().nonnegative().optional()
});
/** MatchesQueryParams schema */
export const matchesQueryParamsSchema = z.object({
limit: z.number().int().min(1).max(100).optional(),
map: z.string().optional(),
player_id: z.number().positive().optional(),
before_time: z.number().int().positive().optional()
});
/** TrackPlayerResponse schema */
export const trackPlayerResponseSchema = z.object({
success: z.boolean(),
message: z.string()
});
/** Parser functions */
export const parseAPIError = (data: unknown) => apiErrorSchema.parse(data);
export const parseMatchParseResponse = (data: unknown) => matchParseResponseSchema.parse(data);
export const parseMatchesList = (data: unknown) => matchesListResponseSchema.parse(data);
export const parseMatchesQueryParams = (data: unknown) => matchesQueryParamsSchema.parse(data);
export const parseTrackPlayerResponse = (data: unknown) => trackPlayerResponseSchema.parse(data);
/** Safe parser functions */
export const parseMatchesListSafe = (data: unknown) => matchesListResponseSchema.safeParse(data);
export const parseAPIErrorSafe = (data: unknown) => apiErrorSchema.safeParse(data);
/** Infer TypeScript types */
export type APIErrorSchema = z.infer<typeof apiErrorSchema>;
export type MatchParseResponseSchema = z.infer<typeof matchParseResponseSchema>;
export type MatchParseStatusSchema = z.infer<typeof matchParseStatusSchema>;
export type MatchesListResponseSchema = z.infer<typeof matchesListResponseSchema>;
export type MatchesQueryParamsSchema = z.infer<typeof matchesQueryParamsSchema>;
export type TrackPlayerResponseSchema = z.infer<typeof trackPlayerResponseSchema>;

116
src/lib/schemas/index.ts Normal file
View File

@@ -0,0 +1,116 @@
/**
* Central export for all Zod schemas
* Provides runtime validation for CS2.WTF data models
*/
// Match schemas
export {
matchSchema,
matchPlayerSchema,
matchListItemSchema,
parseMatch,
parseMatchSafe,
parseMatchPlayer,
parseMatchListItem,
type MatchSchema,
type MatchPlayerSchema,
type MatchListItemSchema
} from './match.schema';
// Player schemas
export {
playerSchema,
playerMetaSchema,
playerProfileSchema,
parsePlayer,
parsePlayerSafe,
parsePlayerMeta,
parsePlayerProfile,
normalizePlayerData,
type PlayerSchema,
type PlayerMetaSchema,
type PlayerProfileSchema
} from './player.schema';
// Round statistics schemas
export {
roundStatsSchema,
roundDetailSchema,
matchRoundsResponseSchema,
teamRoundStatsSchema,
parseRoundStats,
parseRoundDetail,
parseMatchRounds,
parseTeamRoundStats,
parseRoundStatsSafe,
parseMatchRoundsSafe,
type RoundStatsSchema,
type RoundDetailSchema,
type MatchRoundsResponseSchema,
type TeamRoundStatsSchema
} from './roundStats.schema';
// Weapon schemas
export {
weaponSchema,
hitGroupsSchema,
weaponStatsSchema,
playerWeaponStatsSchema,
matchWeaponsResponseSchema,
parseWeapon,
parseWeaponStats,
parsePlayerWeaponStats,
parseMatchWeapons,
parseWeaponSafe,
parseMatchWeaponsSafe,
type WeaponSchema,
type HitGroupsSchema,
type WeaponStatsSchema,
type PlayerWeaponStatsSchema,
type MatchWeaponsResponseSchema
} from './weapon.schema';
// Message/Chat schemas
export {
messageSchema,
matchChatResponseSchema,
enrichedMessageSchema,
chatFilterSchema,
chatStatsSchema,
parseMessage,
parseMatchChat,
parseEnrichedMessage,
parseChatFilter,
parseChatStats,
parseMessageSafe,
parseMatchChatSafe,
type MessageSchema,
type MatchChatResponseSchema,
type EnrichedMessageSchema,
type ChatFilterSchema,
type ChatStatsSchema
} from './message.schema';
// API schemas
export {
apiErrorSchema,
apiResponseSchema,
matchParseResponseSchema,
matchParseStatusSchema,
matchesListResponseSchema,
matchesQueryParamsSchema,
trackPlayerResponseSchema,
parseAPIError,
parseMatchParseResponse,
parseMatchesList,
parseMatchesQueryParams,
parseTrackPlayerResponse,
parseMatchesListSafe,
parseAPIErrorSafe,
type APIErrorSchema,
type MatchParseResponseSchema,
type MatchParseStatusSchema,
type MatchesListResponseSchema,
type MatchesQueryParamsSchema,
type TrackPlayerResponseSchema
} from './api.schema';

View File

@@ -0,0 +1,108 @@
import { z } from 'zod';
/**
* Zod schemas for Match data models
* Provides runtime validation and type safety
*/
/** MatchPlayer schema */
export const matchPlayerSchema = z.object({
id: z.string().min(1), // Steam ID uint64 as string to preserve precision
name: z.string().min(1),
avatar: z.string().url(),
team_id: z.number().int().min(2).max(3), // 2 = T, 3 = CT
// Performance metrics
kills: z.number().int().nonnegative(),
deaths: z.number().int().nonnegative(),
assists: z.number().int().nonnegative(),
headshot: z.number().int().nonnegative(),
mvp: z.number().int().nonnegative(),
score: z.number().int().nonnegative(),
kast: z.number().int().min(0).max(100).optional(),
// Rank (interpretation depends on game mode and date)
// Premier Mode: CS Rating (0-30000+), Competitive/Wingman: Skill Group (0-18)
rank_old: z.number().int().min(0).max(30000).optional(),
rank_new: z.number().int().min(0).max(30000).optional(),
// Damage
dmg_enemy: z.number().int().nonnegative().optional(),
dmg_team: z.number().int().nonnegative().optional(),
// Multi-kills
mk_2: z.number().int().nonnegative().optional(),
mk_3: z.number().int().nonnegative().optional(),
mk_4: z.number().int().nonnegative().optional(),
mk_5: z.number().int().nonnegative().optional(),
// Utility damage
ud_he: z.number().int().nonnegative().optional(),
ud_flames: z.number().int().nonnegative().optional(),
ud_flash: z.number().int().nonnegative().optional(),
ud_smoke: z.number().int().nonnegative().optional(),
ud_decoy: z.number().int().nonnegative().optional(),
// Flash statistics
flash_assists: z.number().int().nonnegative().optional(),
flash_duration_enemy: z.number().nonnegative().optional(),
flash_duration_team: z.number().nonnegative().optional(),
flash_duration_self: z.number().nonnegative().optional(),
flash_total_enemy: z.number().int().nonnegative().optional(),
flash_total_team: z.number().int().nonnegative().optional(),
flash_total_self: z.number().int().nonnegative().optional(),
// Other
crosshair: z.string().optional(),
color: z.enum(['green', 'yellow', 'purple', 'blue', 'orange', 'grey']).optional(),
avg_ping: z.number().nonnegative().optional(),
// Ban status
vac: z.boolean().optional(),
game_ban: z.boolean().optional()
});
/** Match schema */
export const matchSchema = z.object({
match_id: z.string().min(1), // uint64 as string to preserve precision
share_code: z
.string()
.regex(/^(CSGO-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5})?$/)
.optional(),
map: z.string().min(1),
date: z.string().datetime(),
score_team_a: z.number().int().nonnegative(),
score_team_b: z.number().int().nonnegative(),
duration: z.number().int().positive(),
match_result: z.number().int().min(0).max(2), // 0 = tie, 1 = team_a win, 2 = team_b win
max_rounds: z.number().int().positive(),
demo_parsed: z.boolean(),
vac_present: z.boolean(),
gameban_present: z.boolean(),
tick_rate: z.number().positive().optional(),
game_mode: z.enum(['premier', 'competitive', 'wingman']).optional(),
players: z.array(matchPlayerSchema).optional()
});
/** MatchListItem schema */
export const matchListItemSchema = z.object({
match_id: z.string().min(1), // uint64 as string to preserve precision
map: z.string().min(1),
date: z.string().datetime(),
score_team_a: z.number().int().nonnegative(),
score_team_b: z.number().int().nonnegative(),
duration: z.number().int().positive(),
demo_parsed: z.boolean(),
player_count: z.number().int().min(2).max(10).optional()
});
/** Parser functions for safe data validation */
export const parseMatch = (data: unknown) => matchSchema.parse(data);
export const parseMatchSafe = (data: unknown) => matchSchema.safeParse(data);
export const parseMatchPlayer = (data: unknown) => matchPlayerSchema.parse(data);
export const parseMatchListItem = (data: unknown) => matchListItemSchema.parse(data);
/** Infer TypeScript types from schemas */
export type MatchSchema = z.infer<typeof matchSchema>;
export type MatchPlayerSchema = z.infer<typeof matchPlayerSchema>;
export type MatchListItemSchema = z.infer<typeof matchListItemSchema>;

View File

@@ -0,0 +1,70 @@
import { z } from 'zod';
/**
* Zod schemas for Message/Chat data models
*/
/** Message schema */
export const messageSchema = z.object({
message: z.string(),
all_chat: z.boolean(),
tick: z.number().int().nonnegative(),
match_player_id: z.number().positive().optional(),
player_id: z.number().positive().optional(),
player_name: z.string().optional(),
round: z.number().int().positive().optional(),
timestamp: z.string().datetime().optional()
});
/** MatchChatResponse schema - matches actual API format */
// API returns: { "player_id": [{ message, all_chat, tick }, ...], ... }
export const matchChatResponseSchema = z.record(
z.string(), // player Steam ID as string key
z.array(messageSchema)
);
/** EnrichedMessage schema (with player data) */
export const enrichedMessageSchema = messageSchema.extend({
player_name: z.string().min(1),
player_avatar: z.string().url().optional(),
team_id: z.number().int().min(2).max(3).optional(),
round: z.number().int().positive()
});
/** ChatFilter schema */
export const chatFilterSchema = z.object({
player_id: z.number().positive().optional(),
chat_type: z.enum(['all', 'team', 'all_chat']).optional(),
round: z.number().int().positive().optional(),
search: z.string().optional()
});
/** ChatStats schema */
export const chatStatsSchema = z.object({
total_messages: z.number().int().nonnegative(),
team_chat_count: z.number().int().nonnegative(),
all_chat_count: z.number().int().nonnegative(),
messages_per_player: z.record(z.number().int().nonnegative()),
most_active_player: z.object({
player_id: z.number().positive(),
message_count: z.number().int().positive()
})
});
/** Parser functions */
export const parseMessage = (data: unknown) => messageSchema.parse(data);
export const parseMatchChat = (data: unknown) => matchChatResponseSchema.parse(data);
export const parseEnrichedMessage = (data: unknown) => enrichedMessageSchema.parse(data);
export const parseChatFilter = (data: unknown) => chatFilterSchema.parse(data);
export const parseChatStats = (data: unknown) => chatStatsSchema.parse(data);
/** Safe parser functions */
export const parseMessageSafe = (data: unknown) => messageSchema.safeParse(data);
export const parseMatchChatSafe = (data: unknown) => matchChatResponseSchema.safeParse(data);
/** Infer TypeScript types */
export type MessageSchema = z.infer<typeof messageSchema>;
export type MatchChatResponseSchema = z.infer<typeof matchChatResponseSchema>;
export type EnrichedMessageSchema = z.infer<typeof enrichedMessageSchema>;
export type ChatFilterSchema = z.infer<typeof chatFilterSchema>;
export type ChatStatsSchema = z.infer<typeof chatStatsSchema>;

View File

@@ -0,0 +1,89 @@
import { z } from 'zod';
import { matchSchema, matchPlayerSchema } from './match.schema';
/**
* Zod schemas for Player data models
*/
/** Player schema */
export const playerSchema = z.object({
id: z.string().min(1), // Steam ID uint64 as string to preserve precision
name: z.string().min(1),
avatar: z.string().url(),
vanity_url: z.string().optional(),
vanity_url_real: z.string().optional(),
steam_updated: z.string().datetime().optional(),
profile_created: z.string().datetime().optional(),
wins: z.number().int().nonnegative().optional(),
losses: z.number().int().nonnegative().optional(),
// Also support backend's typo "looses"
looses: z.number().int().nonnegative().optional(),
ties: z.number().int().nonnegative().optional(),
vac_count: z.number().int().nonnegative().optional(),
vac_date: z.string().datetime().nullable().optional(),
game_ban_count: z.number().int().nonnegative().optional(),
game_ban_date: z.string().datetime().nullable().optional(),
oldest_sharecode_seen: z.string().optional(),
tracked: z.boolean().optional(),
matches: z
.array(
matchSchema.extend({
stats: matchPlayerSchema
})
)
.optional()
});
/** Transform player data to normalize "looses" to "losses" */
export const normalizePlayerData = (data: z.infer<typeof playerSchema>) => {
if (data.looses !== undefined && data.losses === undefined) {
return { ...data, losses: data.looses };
}
return data;
};
/** PlayerMeta schema */
export const playerMetaSchema = z.object({
id: z.number().positive(),
name: z.string().min(1),
avatar: z.string().url(),
recent_matches: z.number().int().nonnegative(),
last_match_date: z.string().datetime(),
avg_kills: z.number().nonnegative(),
avg_deaths: z.number().nonnegative(),
avg_kast: z.number().nonnegative(),
win_rate: z.number().nonnegative()
});
/** PlayerProfile schema (extended with calculated stats) */
export const playerProfileSchema = playerSchema.extend({
total_matches: z.number().int().nonnegative(),
kd_ratio: z.number().nonnegative(),
win_rate: z.number().nonnegative(),
avg_headshot_pct: z.number().nonnegative(),
avg_kast: z.number().nonnegative(),
current_rating: z.number().int().min(0).max(30000).optional(),
peak_rating: z.number().int().min(0).max(30000).optional()
});
/** Parser functions */
export const parsePlayer = (data: unknown) => {
const parsed = playerSchema.parse(data);
return normalizePlayerData(parsed);
};
export const parsePlayerSafe = (data: unknown) => {
const result = playerSchema.safeParse(data);
if (result.success) {
return { ...result, data: normalizePlayerData(result.data) };
}
return result;
};
export const parsePlayerMeta = (data: unknown) => playerMetaSchema.parse(data);
export const parsePlayerProfile = (data: unknown) => playerProfileSchema.parse(data);
/** Infer TypeScript types */
export type PlayerSchema = z.infer<typeof playerSchema>;
export type PlayerMetaSchema = z.infer<typeof playerMetaSchema>;
export type PlayerProfileSchema = z.infer<typeof playerProfileSchema>;

View File

@@ -0,0 +1,70 @@
import { z } from 'zod';
/**
* Zod schemas for Round Statistics data models
*/
/** RoundStats schema */
export const roundStatsSchema = z.object({
round: z.number().int().positive(),
bank: z.number().int().nonnegative(),
equipment: z.number().int().nonnegative(),
spent: z.number().int().nonnegative(),
kills_in_round: z.number().int().nonnegative().optional(),
damage_in_round: z.number().int().nonnegative().optional(),
match_player_id: z.number().positive().optional(),
player_id: z.number().positive().optional()
});
/** RoundDetail schema (with player breakdown) */
export const roundDetailSchema = z.object({
round: z.number().int().positive(),
winner: z.number().int().min(2).max(3), // 2 = T, 3 = CT
win_reason: z.string(),
players: z.array(roundStatsSchema)
});
/** MatchRoundsResponse schema - matches actual API format */
// API returns: { "0": { "player_id": [bank, equipment, spent] }, "1": {...}, ... }
export const matchRoundsResponseSchema = z.record(
z.string(), // round number as string key
z.record(
z.string(), // player Steam ID as string key
z.tuple([
z.number().int().nonnegative(), // bank
z.number().int().nonnegative(), // equipment value
z.number().int().nonnegative() // spent
])
)
);
/** TeamRoundStats schema */
export const teamRoundStatsSchema = z.object({
round: z.number().int().positive(),
team_id: z.number().int().min(2).max(3),
total_bank: z.number().int().nonnegative(),
total_equipment: z.number().int().nonnegative(),
avg_equipment: z.number().nonnegative(),
total_spent: z.number().int().nonnegative(),
winner: z.number().int().min(2).max(3).optional(),
win_reason: z
.enum(['elimination', 'bomb_defused', 'bomb_exploded', 'time', 'target_saved'])
.optional(),
buy_type: z.enum(['eco', 'semi-eco', 'force', 'full']).optional()
});
/** Parser functions */
export const parseRoundStats = (data: unknown) => roundStatsSchema.parse(data);
export const parseRoundDetail = (data: unknown) => roundDetailSchema.parse(data);
export const parseMatchRounds = (data: unknown) => matchRoundsResponseSchema.parse(data);
export const parseTeamRoundStats = (data: unknown) => teamRoundStatsSchema.parse(data);
/** Safe parser functions */
export const parseRoundStatsSafe = (data: unknown) => roundStatsSchema.safeParse(data);
export const parseMatchRoundsSafe = (data: unknown) => matchRoundsResponseSchema.safeParse(data);
/** Infer TypeScript types */
export type RoundStatsSchema = z.infer<typeof roundStatsSchema>;
export type RoundDetailSchema = z.infer<typeof roundDetailSchema>;
export type MatchRoundsResponseSchema = z.infer<typeof matchRoundsResponseSchema>;
export type TeamRoundStatsSchema = z.infer<typeof teamRoundStatsSchema>;

View File

@@ -0,0 +1,81 @@
import { z } from 'zod';
/**
* Zod schemas for Weapon data models
*/
/** Weapon schema */
export const weaponSchema = z.object({
victim: z.number().positive(),
dmg: z.number().int().nonnegative(),
eq_type: z.number().int().positive(),
hit_group: z.number().int().min(0).max(7), // 0-7 hit groups
match_player_id: z.number().positive().optional()
});
/** Hit groups breakdown schema */
export const hitGroupsSchema = z.object({
head: z.number().int().nonnegative(),
chest: z.number().int().nonnegative(),
stomach: z.number().int().nonnegative(),
left_arm: z.number().int().nonnegative(),
right_arm: z.number().int().nonnegative(),
left_leg: z.number().int().nonnegative(),
right_leg: z.number().int().nonnegative()
});
/** WeaponStats schema */
export const weaponStatsSchema = z.object({
eq_type: z.number().int().positive(),
weapon_name: z.string().min(1),
kills: z.number().int().nonnegative(),
damage: z.number().int().nonnegative(),
hits: z.number().int().nonnegative(),
hit_groups: hitGroupsSchema,
headshot_pct: z.number().nonnegative().optional(),
accuracy: z.number().nonnegative().optional()
});
/** PlayerWeaponStats schema */
export const playerWeaponStatsSchema = z.object({
player_id: z.number().positive(),
weapon_stats: z.array(weaponStatsSchema)
});
/** MatchWeaponsResponse schema - matches actual API format */
// API returns: { equipment_map: { "1": "P2000", ... }, stats: [...] }
export const matchWeaponsResponseSchema = z.object({
equipment_map: z.record(z.string(), z.string()), // eq_type ID -> weapon name
stats: z.array(
z.record(
z.string(), // attacker Steam ID
z.record(
z.string(), // victim Steam ID
z.array(
z.tuple([
z.number().int().nonnegative(), // eq_type
z.number().int().min(0).max(7), // hit_group
z.number().int().nonnegative() // damage
])
)
)
)
)
});
/** Parser functions */
export const parseWeapon = (data: unknown) => weaponSchema.parse(data);
export const parseWeaponStats = (data: unknown) => weaponStatsSchema.parse(data);
export const parsePlayerWeaponStats = (data: unknown) => playerWeaponStatsSchema.parse(data);
export const parseMatchWeapons = (data: unknown) => matchWeaponsResponseSchema.parse(data);
/** Safe parser functions */
export const parseWeaponSafe = (data: unknown) => weaponSchema.safeParse(data);
export const parseMatchWeaponsSafe = (data: unknown) => matchWeaponsResponseSchema.safeParse(data);
/** Infer TypeScript types */
export type WeaponSchema = z.infer<typeof weaponSchema>;
export type HitGroupsSchema = z.infer<typeof hitGroupsSchema>;
export type WeaponStatsSchema = z.infer<typeof weaponStatsSchema>;
export type PlayerWeaponStatsSchema = z.infer<typeof playerWeaponStatsSchema>;
export type MatchWeaponsResponseSchema = z.infer<typeof matchWeaponsResponseSchema>;

12
src/lib/stores/index.ts Normal file
View File

@@ -0,0 +1,12 @@
/**
* Central export for all Svelte stores
*/
export { preferences } from './preferences';
export type { UserPreferences } from './preferences';
export { search, isSearchActive } from './search';
export type { SearchState } from './search';
export { toast } from './toast';
export type { Toast } from './toast';

View File

@@ -0,0 +1,100 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
/**
* User preferences store
* Persisted to localStorage
*/
export interface UserPreferences {
theme: 'cs2dark' | 'cs2light' | 'auto';
language: string;
favoriteMap?: string;
favoritePlayers: string[]; // Steam IDs as strings to preserve uint64 precision
showAdvancedStats: boolean;
dateFormat: 'relative' | 'absolute';
timezone: string;
}
const defaultPreferences: UserPreferences = {
theme: 'cs2dark',
language: 'en',
favoritePlayers: [],
showAdvancedStats: false,
dateFormat: 'relative',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
};
// Load preferences from localStorage
const loadPreferences = (): UserPreferences => {
if (!browser) return defaultPreferences;
try {
const stored = localStorage.getItem('cs2wtf-preferences');
if (stored) {
return { ...defaultPreferences, ...JSON.parse(stored) };
}
} catch (error) {
console.error('Failed to load preferences:', error);
}
return defaultPreferences;
};
// Create the store
const createPreferencesStore = () => {
const { subscribe, set, update } = writable<UserPreferences>(loadPreferences());
return {
subscribe,
set: (value: UserPreferences) => {
if (browser) {
localStorage.setItem('cs2wtf-preferences', JSON.stringify(value));
}
set(value);
},
update: (fn: (value: UserPreferences) => UserPreferences) => {
update((current) => {
const newValue = fn(current);
if (browser) {
localStorage.setItem('cs2wtf-preferences', JSON.stringify(newValue));
}
return newValue;
});
},
reset: () => {
if (browser) {
localStorage.removeItem('cs2wtf-preferences');
}
set(defaultPreferences);
},
// Convenience methods
setTheme: (theme: UserPreferences['theme']) => {
update((prefs) => ({ ...prefs, theme }));
},
setLanguage: (language: string) => {
update((prefs) => ({ ...prefs, language }));
},
addFavoritePlayer: (playerId: string) => {
update((prefs) => ({
...prefs,
favoritePlayers: [...new Set([...prefs.favoritePlayers, playerId])]
}));
},
removeFavoritePlayer: (playerId: string) => {
update((prefs) => ({
...prefs,
favoritePlayers: prefs.favoritePlayers.filter((id) => id !== playerId)
}));
},
toggleAdvancedStats: () => {
update((prefs) => ({
...prefs,
showAdvancedStats: !prefs.showAdvancedStats
}));
}
};
};
export const preferences = createPreferencesStore();

118
src/lib/stores/search.ts Normal file
View File

@@ -0,0 +1,118 @@
import { writable, derived } from 'svelte/store';
import { browser } from '$app/environment';
/**
* Search state store
* Manages search queries and recent searches
*/
export interface SearchState {
query: string;
recentSearches: string[];
filters: {
map?: string;
playerId?: number;
dateFrom?: string;
dateTo?: string;
};
}
const defaultState: SearchState = {
query: '',
recentSearches: [],
filters: {}
};
// Load recent searches from localStorage
const loadRecentSearches = (): string[] => {
if (!browser) return [];
try {
const stored = localStorage.getItem('cs2wtf-recent-searches');
if (stored) {
return JSON.parse(stored);
}
} catch (error) {
console.error('Failed to load recent searches:', error);
}
return [];
};
// Create the store
const createSearchStore = () => {
const { subscribe, set, update } = writable<SearchState>({
...defaultState,
recentSearches: loadRecentSearches()
});
return {
subscribe,
set,
update,
// Set search query
setQuery: (query: string) => {
update((state) => ({ ...state, query }));
},
// Clear search query
clearQuery: () => {
update((state) => ({ ...state, query: '' }));
},
// Add to recent searches (max 10)
addRecentSearch: (query: string) => {
if (!query.trim()) return;
update((state) => {
const recent = [query, ...state.recentSearches.filter((q) => q !== query)].slice(0, 10);
if (browser) {
localStorage.setItem('cs2wtf-recent-searches', JSON.stringify(recent));
}
return { ...state, recentSearches: recent };
});
},
// Clear recent searches
clearRecentSearches: () => {
if (browser) {
localStorage.removeItem('cs2wtf-recent-searches');
}
update((state) => ({ ...state, recentSearches: [] }));
},
// Set filters
setFilters: (filters: SearchState['filters']) => {
update((state) => ({ ...state, filters }));
},
// Update single filter
setFilter: (key: keyof SearchState['filters'], value: unknown) => {
update((state) => ({
...state,
filters: { ...state.filters, [key]: value }
}));
},
// Clear filters
clearFilters: () => {
update((state) => ({ ...state, filters: {} }));
},
// Reset entire search state
reset: () => {
set({ ...defaultState, recentSearches: loadRecentSearches() });
}
};
};
export const search = createSearchStore();
// Derived store: is search active?
export const isSearchActive = derived(
search,
($search) => $search.query.length > 0 || Object.keys($search.filters).length > 0
);

84
src/lib/stores/toast.ts Normal file
View File

@@ -0,0 +1,84 @@
import { writable } from 'svelte/store';
/**
* Toast notification store
* Manages temporary notifications to the user
*/
export interface Toast {
id: string;
message: string;
type: 'success' | 'error' | 'warning' | 'info';
duration?: number; // milliseconds, default 5000
dismissible?: boolean;
}
type ToastInput = Omit<Toast, 'id'>;
const createToastStore = () => {
const { subscribe, update } = writable<Toast[]>([]);
let nextId = 0;
const addToast = (toast: ToastInput) => {
const id = `toast-${++nextId}`;
const duration = toast.duration ?? 5000;
const dismissible = toast.dismissible ?? true;
const newToast: Toast = {
...toast,
id,
duration,
dismissible
};
update((toasts) => [...toasts, newToast]);
// Auto-dismiss after duration
if (duration > 0) {
setTimeout(() => {
removeToast(id);
}, duration);
}
return id;
};
const removeToast = (id: string) => {
update((toasts) => toasts.filter((t) => t.id !== id));
};
return {
subscribe,
// Add toast with specific type
success: (message: string, duration?: number) => {
return addToast({ message, type: 'success', duration });
},
error: (message: string, duration?: number) => {
return addToast({ message, type: 'error', duration });
},
warning: (message: string, duration?: number) => {
return addToast({ message, type: 'warning', duration });
},
info: (message: string, duration?: number) => {
return addToast({ message, type: 'info', duration });
},
// Add custom toast
add: addToast,
// Remove specific toast
dismiss: removeToast,
// Clear all toasts
clear: () => {
update(() => []);
}
};
};
export const toast = createToastStore();

Some files were not shown because too many files have changed in this diff Show More