Compare commits
46 Commits
be89c68f89
...
49033560fa
| Author | SHA1 | Date | |
|---|---|---|---|
| 49033560fa | |||
| 3192180c60 | |||
| 05a6c10458 | |||
| 12115198b7 | |||
| 2e053a6388 | |||
| 973d188a26 | |||
| afd1d7a822 | |||
| 68aada0c33 | |||
| f8cf85661e | |||
| 668c32ed8a | |||
| 78c87aaedd | |||
| 4cc2b70dbc | |||
| a3955da7f2 | |||
| eb68c5d00b | |||
| 05e6182bcf | |||
| 8f21b56223 | |||
| 248c4a8523 | |||
| 469f0df756 | |||
| 7e101ba274 | |||
| b59eebcddb | |||
| d4d7015df6 | |||
| b1284bad71 | |||
| 05ef985851 | |||
| ae7d880bc1 | |||
| 2215cab77f | |||
| 7d642b0be3 | |||
| 8f3b652740 | |||
| a861b1c1b6 | |||
| 62bfdc8090 | |||
| 7d8e3a6de0 | |||
| 8093d4d308 | |||
| f583ff54a9 | |||
| 43c50084c6 | |||
| ea61061530 | |||
| 8b73a68a6b | |||
| 274f5b3b53 | |||
| e81be2cf68 | |||
| 523136ffbc | |||
| 24b990ac62 | |||
| 09ce400cd7 | |||
| d811efc394 | |||
| 66aea51c39 | |||
| 153c0e9f13 | |||
| 288438a953 | |||
| 0404188d4d | |||
| 366bfbeb54 |
80
.env.example
Normal file
80
.env.example
Normal 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
|
||||
52
.gitignore
vendored
Normal file
52
.gitignore
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Build artifacts
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Test coverage
|
||||
coverage
|
||||
*.lcov
|
||||
.nyc_output
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/playwright/.cache/
|
||||
|
||||
# Vercel
|
||||
.vercel
|
||||
|
||||
# Temporary files
|
||||
.tmp
|
||||
tmp
|
||||
*.tmp
|
||||
4
.husky/pre-commit
Executable file
4
.husky/pre-commit
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
||||
30
.prettierignore
Normal file
30
.prettierignore
Normal 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
17
.prettierrc.json
Normal 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
6
.stylelintignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
build/
|
||||
.svelte-kit/
|
||||
dist/
|
||||
**/*.js
|
||||
**/*.ts
|
||||
13
.stylelintrc.cjs
Normal file
13
.stylelintrc.cjs
Normal 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
1
.tool-versions
Normal file
@@ -0,0 +1 @@
|
||||
nodejs 20.11.0
|
||||
@@ -1,36 +1,76 @@
|
||||
pipeline:
|
||||
install dependencies:
|
||||
image: node:19
|
||||
install:
|
||||
image: node:20
|
||||
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
|
||||
|
||||
build:
|
||||
image: node:19
|
||||
image: node:20
|
||||
commands:
|
||||
- yarn build
|
||||
secrets: [ vue_app_api_url, vue_app_track_url, vue_app_track_id, vue_app_tracking ]
|
||||
- npm run build
|
||||
environment:
|
||||
- VITE_API_BASE_URL=https://api.csgow.tf
|
||||
secrets:
|
||||
- vite_plausible_domain
|
||||
- vite_sentry_dsn
|
||||
depends_on:
|
||||
- lint
|
||||
- type-check
|
||||
- test
|
||||
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:
|
||||
image: cschlosser/drone-ftps
|
||||
settings:
|
||||
hostname:
|
||||
from_secret: ftp_host
|
||||
src_dir: "/dist/"
|
||||
src_dir: '/build/'
|
||||
clean_dir: true
|
||||
secrets: [ ftp_username, ftp_password ]
|
||||
secrets: [ftp_username, ftp_password]
|
||||
when:
|
||||
branch: master
|
||||
event: [ push, tag ]
|
||||
event: [push, tag]
|
||||
status: success
|
||||
|
||||
deploy (dev):
|
||||
deploy-dev:
|
||||
image: cschlosser/drone-ftps
|
||||
settings:
|
||||
hostname:
|
||||
from_secret: ftp_host
|
||||
src_dir: "/dist/"
|
||||
src_dir: '/build/'
|
||||
clean_dir: true
|
||||
secrets:
|
||||
- source: ftp_username_dev
|
||||
@@ -41,3 +81,20 @@ pipeline:
|
||||
branch: dev
|
||||
event: [push, tag]
|
||||
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
|
||||
|
||||
326
README.md
326
README.md
@@ -1,28 +1,314 @@
|
||||
# CSGOW.TF
|
||||
# CS2.WTF
|
||||
|
||||
[](https://vuejs.org/)
|
||||
[](https://go.dev/)
|
||||
[](https://kit.svelte.dev/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://tailwindcss.com/)
|
||||
[](https://git.harting.dev/CSGOWTF/csgowtf/src/branch/master/LICENSE)
|
||||
[](https://liberapay.com/CSGOWTF/)
|
||||
[](https://liberapay.com/CSGOWTF/)
|
||||
[](https://csgow.tf/)
|
||||
<!--[](https://www.typescriptlang.org/)-->
|
||||
[](https://ci.somegit.dev/CSGOWTF/csgowtf)
|
||||
[](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
|
||||
This is the frontend to the [csgowtfd](https://git.harting.dev/CSGOWTF/csgowtfd) backend.
|
||||
## 🚀 Quick Start
|
||||
|
||||
## Tips on how to contribute
|
||||
- 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.
|
||||
- Don't commit unnecessary changes to the codebase or debugging code.
|
||||
- Write meaningful commits or squash them.
|
||||
- Please try to follow the code style of the rest of the codebase.
|
||||
- Only make pull requests to the dev branch.
|
||||
- Only implement one feature per pull request to keep it easy to understand.
|
||||
- Expect comments or questions on your pull request from the project maintainers. We try to keep the code as consistent and maintainable as possible.
|
||||
- Each pull request should come from a new branch in your fork, it should have a meaningful name.
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js** ≥ 18.0.0 (v20.11.0 recommended - see `.nvmrc`)
|
||||
- **npm** or **yarn**
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
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:
|
||||
|
||||
[](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
|
||||
|
||||
1087
docs/API.md
Normal file
1087
docs/API.md
Normal file
File diff suppressed because it is too large
Load Diff
393
docs/CORS_PROXY.md
Normal file
393
docs/CORS_PROXY.md
Normal 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
587
docs/DESIGN.md
Normal 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
|
||||
480
docs/IMPLEMENTATION_STATUS.md
Normal file
480
docs/IMPLEMENTATION_STATUS.md
Normal 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
335
docs/LOCAL_DEVELOPMENT.md
Normal 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
460
docs/MATCHES_API.md
Normal 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
54
eslint.config.js
Normal 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
9106
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
79
package.json
Normal file
79
package.json
Normal file
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"name": "cs2wtf",
|
||||
"version": "2.0.0",
|
||||
"description": "Statistics for CS2 matchmaking matches",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"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": {
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"axios": "^1.6.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"svelte": "^5.0.0",
|
||||
"zod": "^3.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.40.0",
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/adapter-node": "^5.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@testing-library/jest-dom": "^6.0.0",
|
||||
"@testing-library/svelte": "^5.0.0",
|
||||
"@types/node": "^20.10.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
"@typescript-eslint/parser": "^7.0.0",
|
||||
"@vitest/coverage-v8": "^1.0.0",
|
||||
"autoprefixer": "^10.4.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"
|
||||
},
|
||||
"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
36
playwright.config.ts
Normal 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
6
postcss.config.cjs
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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
35
research.md
Normal file
File diff suppressed because one or more lines are too long
131
src/app.css
Normal file
131
src/app.css
Normal 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
15
src/app.html
Normal 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>
|
||||
187
src/lib/api/client.ts
Normal file
187
src/lib/api/client.ts
Normal 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
30
src/lib/api/index.ts
Normal 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
237
src/lib/api/matches.ts
Normal 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
127
src/lib/api/players.ts
Normal 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
335
src/lib/api/transformers.ts
Normal 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
|
||||
}
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
46
src/lib/api/transformers/chatTransformer.ts
Normal file
46
src/lib/api/transformers/chatTransformer.ts
Normal 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
|
||||
};
|
||||
}
|
||||
60
src/lib/api/transformers/roundsTransformer.ts
Normal file
60
src/lib/api/transformers/roundsTransformer.ts
Normal 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
|
||||
};
|
||||
}
|
||||
99
src/lib/api/transformers/weaponsTransformer.ts
Normal file
99
src/lib/api/transformers/weaponsTransformer.ts
Normal 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
|
||||
};
|
||||
}
|
||||
276
src/lib/components/RoundTimeline.svelte
Normal file
276
src/lib/components/RoundTimeline.svelte
Normal 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>
|
||||
130
src/lib/components/charts/BarChart.svelte
Normal file
130
src/lib/components/charts/BarChart.svelte
Normal 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>
|
||||
139
src/lib/components/charts/LineChart.svelte
Normal file
139
src/lib/components/charts/LineChart.svelte
Normal 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>
|
||||
105
src/lib/components/charts/PieChart.svelte
Normal file
105
src/lib/components/charts/PieChart.svelte
Normal 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>
|
||||
131
src/lib/components/data-display/DataTable.svelte
Normal file
131
src/lib/components/data-display/DataTable.svelte
Normal 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>
|
||||
132
src/lib/components/layout/Footer.svelte
Normal file
132
src/lib/components/layout/Footer.svelte
Normal 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>
|
||||
73
src/lib/components/layout/Header.svelte
Normal file
73
src/lib/components/layout/Header.svelte
Normal 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>
|
||||
116
src/lib/components/layout/SearchBar.svelte
Normal file
116
src/lib/components/layout/SearchBar.svelte
Normal 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>
|
||||
71
src/lib/components/layout/ThemeToggle.svelte
Normal file
71
src/lib/components/layout/ThemeToggle.svelte
Normal 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>
|
||||
99
src/lib/components/match/MatchCard.svelte
Normal file
99
src/lib/components/match/MatchCard.svelte
Normal 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>
|
||||
155
src/lib/components/match/ShareCodeInput.svelte
Normal file
155
src/lib/components/match/ShareCodeInput.svelte
Normal 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>
|
||||
73
src/lib/components/player/PlayerCard.svelte
Normal file
73
src/lib/components/player/PlayerCard.svelte
Normal 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>
|
||||
81
src/lib/components/player/RecentPlayers.svelte
Normal file
81
src/lib/components/player/RecentPlayers.svelte
Normal 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}
|
||||
180
src/lib/components/player/TrackPlayerModal.svelte
Normal file
180
src/lib/components/player/TrackPlayerModal.svelte
Normal 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>
|
||||
37
src/lib/components/ui/Badge.svelte
Normal file
37
src/lib/components/ui/Badge.svelte
Normal 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>
|
||||
60
src/lib/components/ui/Button.svelte
Normal file
60
src/lib/components/ui/Button.svelte
Normal 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}
|
||||
49
src/lib/components/ui/Card.svelte
Normal file
49
src/lib/components/ui/Card.svelte
Normal 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}
|
||||
102
src/lib/components/ui/Modal.svelte
Normal file
102
src/lib/components/ui/Modal.svelte
Normal 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}
|
||||
96
src/lib/components/ui/PremierRatingBadge.svelte
Normal file
96
src/lib/components/ui/PremierRatingBadge.svelte
Normal 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}
|
||||
74
src/lib/components/ui/RankIcon.svelte
Normal file
74
src/lib/components/ui/RankIcon.svelte
Normal 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}
|
||||
26
src/lib/components/ui/Skeleton.svelte
Normal file
26
src/lib/components/ui/Skeleton.svelte
Normal 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>
|
||||
78
src/lib/components/ui/Tabs.svelte
Normal file
78
src/lib/components/ui/Tabs.svelte
Normal 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>
|
||||
49
src/lib/components/ui/Toast.svelte
Normal file
49
src/lib/components/ui/Toast.svelte
Normal 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>
|
||||
11
src/lib/components/ui/ToastContainer.svelte
Normal file
11
src/lib/components/ui/ToastContainer.svelte
Normal 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>
|
||||
22
src/lib/components/ui/Tooltip.svelte
Normal file
22
src/lib/components/ui/Tooltip.svelte
Normal 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>
|
||||
79
src/lib/schemas/api.schema.ts
Normal file
79
src/lib/schemas/api.schema.ts
Normal 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
116
src/lib/schemas/index.ts
Normal 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';
|
||||
108
src/lib/schemas/match.schema.ts
Normal file
108
src/lib/schemas/match.schema.ts
Normal 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>;
|
||||
70
src/lib/schemas/message.schema.ts
Normal file
70
src/lib/schemas/message.schema.ts
Normal 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>;
|
||||
89
src/lib/schemas/player.schema.ts
Normal file
89
src/lib/schemas/player.schema.ts
Normal 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>;
|
||||
70
src/lib/schemas/roundStats.schema.ts
Normal file
70
src/lib/schemas/roundStats.schema.ts
Normal 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>;
|
||||
81
src/lib/schemas/weapon.schema.ts
Normal file
81
src/lib/schemas/weapon.schema.ts
Normal 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
12
src/lib/stores/index.ts
Normal 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';
|
||||
100
src/lib/stores/preferences.ts
Normal file
100
src/lib/stores/preferences.ts
Normal 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
118
src/lib/stores/search.ts
Normal 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
84
src/lib/stores/toast.ts
Normal 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();
|
||||
166
src/lib/types/Match.ts
Normal file
166
src/lib/types/Match.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Match data model
|
||||
* Represents a complete CS2 match with metadata and optional player stats
|
||||
*/
|
||||
export interface Match {
|
||||
/** Unique match identifier (uint64 as string to preserve precision) */
|
||||
match_id: string;
|
||||
|
||||
/** CS:GO/CS2 share code (optional, may not always be available) */
|
||||
share_code?: string;
|
||||
|
||||
/** Map name (e.g., "de_inferno") */
|
||||
map: string;
|
||||
|
||||
/** Match date and time (ISO 8601) */
|
||||
date: string;
|
||||
|
||||
/** Final score for team A (T/CT side) */
|
||||
score_team_a: number;
|
||||
|
||||
/** Final score for team B (CT/T side) */
|
||||
score_team_b: number;
|
||||
|
||||
/** Match duration in seconds */
|
||||
duration: number;
|
||||
|
||||
/** Match result: 0 = tie, 1 = team_a win, 2 = team_b win */
|
||||
match_result: number;
|
||||
|
||||
/** Maximum rounds (24 for MR12, 30 for MR15) */
|
||||
max_rounds: number;
|
||||
|
||||
/** Whether the demo has been successfully parsed */
|
||||
demo_parsed: boolean;
|
||||
|
||||
/** Whether any player has a VAC ban */
|
||||
vac_present: boolean;
|
||||
|
||||
/** Whether any player has a game ban */
|
||||
gameban_present: boolean;
|
||||
|
||||
/** Server tick rate (64 or 128) - optional, not always provided by API */
|
||||
tick_rate?: number;
|
||||
|
||||
/**
|
||||
* Game mode: 'premier' | 'competitive' | 'wingman'
|
||||
* - Premier: Uses CS Rating (numerical ELO, 0-30,000+)
|
||||
* - Competitive: Uses Skill Groups (0-18, Silver I to Global Elite, per-map)
|
||||
* - Wingman: Uses Skill Groups (0-18, 2v2 mode)
|
||||
* Optional field - may not be present in legacy CS:GO matches
|
||||
*/
|
||||
game_mode?: 'premier' | 'competitive' | 'wingman';
|
||||
|
||||
/** Array of player statistics (optional, included in detailed match view) */
|
||||
players?: MatchPlayer[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal match information for lists
|
||||
*/
|
||||
export interface MatchListItem {
|
||||
match_id: string;
|
||||
map: string;
|
||||
date: string;
|
||||
score_team_a: number;
|
||||
score_team_b: number;
|
||||
duration: number;
|
||||
demo_parsed: boolean;
|
||||
player_count?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match player statistics
|
||||
* Player performance data for a specific match
|
||||
*/
|
||||
export interface MatchPlayer {
|
||||
/** Player Steam ID (uint64 as string to preserve precision) */
|
||||
id: string;
|
||||
|
||||
/** Player display name */
|
||||
name: string;
|
||||
|
||||
/** Steam avatar URL */
|
||||
avatar: string;
|
||||
|
||||
/** Team ID: 2 = T side, 3 = CT side */
|
||||
team_id: number;
|
||||
|
||||
// Performance metrics
|
||||
kills: number;
|
||||
deaths: number;
|
||||
assists: number;
|
||||
|
||||
/** Headshot kills */
|
||||
headshot: number;
|
||||
|
||||
/** MVP stars earned */
|
||||
mvp: number;
|
||||
|
||||
/** In-game score */
|
||||
score: number;
|
||||
|
||||
/** KAST percentage (0-100): Kill/Assist/Survive/Trade - optional, not always provided by API */
|
||||
kast?: number;
|
||||
|
||||
/** Average Damage per Round */
|
||||
adr?: number;
|
||||
|
||||
/** Headshot percentage */
|
||||
hs_percent?: number;
|
||||
|
||||
/**
|
||||
* Rank tracking - interpretation depends on game mode and date:
|
||||
*
|
||||
* CS2 (post-Sept 27, 2023):
|
||||
* - Premier Mode: rank_new = CS Rating (0-30,000+), rank_old = previous CS Rating
|
||||
* - Competitive/Wingman: rank_new = Skill Group (0-18), rank_old = previous Skill Group
|
||||
*
|
||||
* CS:GO Legacy (pre-Sept 27, 2023):
|
||||
* - rank_new = Skill Group (0-18), rank_old = previous Skill Group
|
||||
*/
|
||||
rank_old?: number;
|
||||
rank_new?: number;
|
||||
|
||||
// Damage statistics
|
||||
dmg_enemy?: number;
|
||||
dmg_team?: number;
|
||||
|
||||
// Multi-kill counts
|
||||
mk_2?: number; // Double kills
|
||||
mk_3?: number; // Triple kills
|
||||
mk_4?: number; // Quad kills
|
||||
mk_5?: number; // Aces
|
||||
|
||||
// Utility damage
|
||||
ud_he?: number; // HE grenade damage
|
||||
ud_flames?: number; // Molotov/Incendiary damage
|
||||
ud_flash?: number; // Flash grenades used
|
||||
ud_smoke?: number; // Smoke grenades used
|
||||
ud_decoy?: number; // Decoy grenades used
|
||||
|
||||
// Flash statistics
|
||||
flash_assists?: number;
|
||||
flash_duration_enemy?: number; // Total enemy blind time
|
||||
flash_duration_team?: number; // Total team blind time
|
||||
flash_duration_self?: number; // Self-flash time
|
||||
flash_total_enemy?: number; // Enemies flashed count
|
||||
flash_total_team?: number; // Teammates flashed count
|
||||
flash_total_self?: number; // Self-flash count
|
||||
|
||||
// Other
|
||||
crosshair?: string;
|
||||
color?: 'green' | 'yellow' | 'purple' | 'blue' | 'orange' | 'grey';
|
||||
avg_ping?: number;
|
||||
|
||||
// Ban status
|
||||
vac?: boolean; // Whether player has VAC ban
|
||||
game_ban?: boolean; // Whether player has game ban
|
||||
}
|
||||
|
||||
/**
|
||||
* Match with extended player details (full scoreboard)
|
||||
*/
|
||||
export type MatchWithPlayers = Match & {
|
||||
players: MatchPlayer[];
|
||||
};
|
||||
78
src/lib/types/Message.ts
Normal file
78
src/lib/types/Message.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Chat message data model
|
||||
* In-game chat messages from match demos
|
||||
*/
|
||||
export interface Message {
|
||||
/** Chat message text content */
|
||||
message: string;
|
||||
|
||||
/** true = all chat (both teams), false = team chat only */
|
||||
all_chat: boolean;
|
||||
|
||||
/** Game tick when message was sent */
|
||||
tick: number;
|
||||
|
||||
/** Reference to MatchPlayer ID */
|
||||
match_player_id?: number;
|
||||
|
||||
/** Player ID who sent the message */
|
||||
player_id?: number;
|
||||
|
||||
/** Player name (included in API response) */
|
||||
player_name?: string;
|
||||
|
||||
/** Round number when message was sent */
|
||||
round?: number;
|
||||
|
||||
/** Message timestamp (ISO 8601) */
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match chat response
|
||||
*/
|
||||
export interface MatchChatResponse {
|
||||
match_id: string | number;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat message with enhanced player data
|
||||
*/
|
||||
export interface EnrichedMessage extends Message {
|
||||
player_name: string;
|
||||
player_avatar?: string;
|
||||
team_id?: number;
|
||||
round: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat filter options
|
||||
*/
|
||||
export interface ChatFilter {
|
||||
/** Filter by player ID */
|
||||
player_id?: number;
|
||||
|
||||
/** Filter by chat type */
|
||||
chat_type?: 'all' | 'team' | 'all_chat';
|
||||
|
||||
/** Filter by round number */
|
||||
round?: number;
|
||||
|
||||
/** Search message content */
|
||||
search?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat statistics
|
||||
*/
|
||||
export interface ChatStats {
|
||||
total_messages: number;
|
||||
team_chat_count: number;
|
||||
all_chat_count: number;
|
||||
messages_per_player: Record<number, number>;
|
||||
most_active_player: {
|
||||
player_id: number;
|
||||
message_count: number;
|
||||
};
|
||||
}
|
||||
121
src/lib/types/Player.ts
Normal file
121
src/lib/types/Player.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { Match, MatchPlayer } from './Match';
|
||||
|
||||
/**
|
||||
* Player profile data model
|
||||
* Represents a Steam user with CS2 statistics
|
||||
*/
|
||||
export interface Player {
|
||||
/** Steam ID (uint64 as string to preserve precision) */
|
||||
id: string;
|
||||
|
||||
/** Steam display name */
|
||||
name: string;
|
||||
|
||||
/** Steam avatar URL */
|
||||
avatar: string;
|
||||
|
||||
/** Custom Steam profile URL */
|
||||
vanity_url?: string;
|
||||
|
||||
/** Actual vanity URL (may differ from vanity_url) */
|
||||
vanity_url_real?: string;
|
||||
|
||||
/** Last time Steam profile was updated (ISO 8601) - optional, not always provided by API */
|
||||
steam_updated?: string;
|
||||
|
||||
/** Steam account creation date (ISO 8601) */
|
||||
profile_created?: string;
|
||||
|
||||
/** Total competitive wins */
|
||||
wins?: number;
|
||||
|
||||
/**
|
||||
* Total competitive losses
|
||||
* Note: Backend has typo "looses", we map it to "losses"
|
||||
*/
|
||||
losses?: number;
|
||||
|
||||
/** Total ties */
|
||||
ties?: number;
|
||||
|
||||
/** Number of VAC bans on record */
|
||||
vac_count?: number;
|
||||
|
||||
/** Date of last VAC ban (ISO 8601) */
|
||||
vac_date?: string | null;
|
||||
|
||||
/** Number of game bans on record */
|
||||
game_ban_count?: number;
|
||||
|
||||
/** Date of last game ban (ISO 8601) */
|
||||
game_ban_date?: string | null;
|
||||
|
||||
/** Oldest match share code seen for this player */
|
||||
oldest_sharecode_seen?: string;
|
||||
|
||||
/** Whether this player is being tracked for automatic match updates */
|
||||
tracked?: boolean;
|
||||
|
||||
/** Recent matches with player statistics */
|
||||
matches?: PlayerMatch[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Player match entry (includes match + player stats)
|
||||
*/
|
||||
export interface PlayerMatch extends Match {
|
||||
/** Player's statistics for this match */
|
||||
stats: MatchPlayer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight player metadata for quick previews
|
||||
*/
|
||||
export interface PlayerMeta {
|
||||
/** Steam ID (string to preserve uint64 precision, consistent with Player) */
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
recent_matches: number;
|
||||
last_match_date: string;
|
||||
avg_kills: number;
|
||||
avg_deaths: number;
|
||||
avg_kast: number;
|
||||
win_rate: number;
|
||||
/** Number of VAC bans on record (optional) */
|
||||
vac_count?: number;
|
||||
/** Date of last VAC ban (ISO 8601, optional) */
|
||||
vac_date?: string | null;
|
||||
/** Number of game bans on record (optional) */
|
||||
game_ban_count?: number;
|
||||
/** Date of last game ban (ISO 8601, optional) */
|
||||
game_ban_date?: string | null;
|
||||
/** Whether this player is being tracked for automatic match updates (optional) */
|
||||
tracked?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Player profile with calculated aggregate statistics
|
||||
*/
|
||||
export interface PlayerProfile extends Player {
|
||||
/** Total matches played */
|
||||
total_matches: number;
|
||||
|
||||
/** Overall K/D ratio */
|
||||
kd_ratio: number;
|
||||
|
||||
/** Overall win rate percentage */
|
||||
win_rate: number;
|
||||
|
||||
/** Average headshot percentage */
|
||||
avg_headshot_pct: number;
|
||||
|
||||
/** Average KAST percentage */
|
||||
avg_kast: number;
|
||||
|
||||
/** Current CS2 Premier rating (0-30000) */
|
||||
current_rating?: number;
|
||||
|
||||
/** Peak CS2 Premier rating */
|
||||
peak_rating?: number;
|
||||
}
|
||||
85
src/lib/types/RoundStats.ts
Normal file
85
src/lib/types/RoundStats.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Round statistics data model
|
||||
* Economy and performance data for a single round
|
||||
*/
|
||||
export interface RoundStats {
|
||||
/** Round number (1-24 for MR12, 1-30 for MR15) */
|
||||
round: number;
|
||||
|
||||
/** Money available at round start */
|
||||
bank: number;
|
||||
|
||||
/** Value of equipment purchased/held */
|
||||
equipment: number;
|
||||
|
||||
/** Total money spent this round */
|
||||
spent: number;
|
||||
|
||||
/** Kills achieved in this round */
|
||||
kills_in_round?: number;
|
||||
|
||||
/** Damage dealt in this round */
|
||||
damage_in_round?: number;
|
||||
|
||||
/** Reference to MatchPlayer ID */
|
||||
match_player_id?: number;
|
||||
|
||||
/** Player ID for this round data */
|
||||
player_id?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Team round statistics
|
||||
* Aggregated economy data for a team in a round
|
||||
*/
|
||||
export interface TeamRoundStats {
|
||||
round: number;
|
||||
team_id: number; // 2 = T, 3 = CT
|
||||
|
||||
/** Total team money at round start */
|
||||
total_bank: number;
|
||||
|
||||
/** Total equipment value */
|
||||
total_equipment: number;
|
||||
|
||||
/** Average equipment value per player */
|
||||
avg_equipment: number;
|
||||
|
||||
/** Total money spent */
|
||||
total_spent: number;
|
||||
|
||||
/** Round winner (2 = T, 3 = CT) */
|
||||
winner?: number;
|
||||
|
||||
/** Win reason */
|
||||
win_reason?: 'elimination' | 'bomb_defused' | 'bomb_exploded' | 'time' | 'target_saved';
|
||||
|
||||
/** Buy type classification */
|
||||
buy_type?: 'eco' | 'semi-eco' | 'force' | 'full';
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete match rounds data
|
||||
*/
|
||||
export interface MatchRoundsData {
|
||||
match_id: number;
|
||||
rounds: RoundStats[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Round details with player breakdown
|
||||
*/
|
||||
export interface RoundDetail {
|
||||
round: number;
|
||||
winner: number;
|
||||
win_reason: string;
|
||||
players: RoundStats[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete match rounds response
|
||||
*/
|
||||
export interface MatchRoundsResponse {
|
||||
match_id: string | number;
|
||||
rounds: RoundDetail[];
|
||||
}
|
||||
148
src/lib/types/Weapon.ts
Normal file
148
src/lib/types/Weapon.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Weapon statistics data model
|
||||
* Tracks weapon usage, damage, and hit locations
|
||||
*/
|
||||
export interface Weapon {
|
||||
/** Player ID of the victim who was hit/killed */
|
||||
victim: number;
|
||||
|
||||
/** Damage dealt with this hit */
|
||||
dmg: number;
|
||||
|
||||
/** Weapon equipment type ID */
|
||||
eq_type: number;
|
||||
|
||||
/** Hit location group (1=head, 2=chest, 3=stomach, 4=left_arm, 5=right_arm, 6=left_leg, 7=right_leg) */
|
||||
hit_group: number;
|
||||
|
||||
/** Reference to MatchPlayer ID */
|
||||
match_player_id?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Weapon performance statistics for a player
|
||||
*/
|
||||
export interface WeaponStats {
|
||||
/** Weapon equipment type ID */
|
||||
eq_type: number;
|
||||
|
||||
/** Weapon display name */
|
||||
weapon_name: string;
|
||||
|
||||
/** Total kills with this weapon */
|
||||
kills: number;
|
||||
|
||||
/** Total damage dealt */
|
||||
damage: number;
|
||||
|
||||
/** Total hits landed */
|
||||
hits: number;
|
||||
|
||||
/** Hit group distribution */
|
||||
hit_groups: {
|
||||
head: number;
|
||||
chest: number;
|
||||
stomach: number;
|
||||
left_arm: number;
|
||||
right_arm: number;
|
||||
left_leg: number;
|
||||
right_leg: number;
|
||||
};
|
||||
|
||||
/** Headshot percentage */
|
||||
headshot_pct?: number;
|
||||
|
||||
/** Accuracy percentage (hits / shots) if available */
|
||||
accuracy?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Player weapon statistics
|
||||
*/
|
||||
export interface PlayerWeaponStats {
|
||||
player_id: number;
|
||||
player_name?: string;
|
||||
weapon_stats: WeaponStats[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Match weapons response
|
||||
*/
|
||||
export interface MatchWeaponsResponse {
|
||||
match_id: string | number;
|
||||
weapons: PlayerWeaponStats[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hit group enumeration
|
||||
*/
|
||||
export enum HitGroup {
|
||||
Generic = 0,
|
||||
Head = 1,
|
||||
Chest = 2,
|
||||
Stomach = 3,
|
||||
LeftArm = 4,
|
||||
RightArm = 5,
|
||||
LeftLeg = 6,
|
||||
RightLeg = 7
|
||||
}
|
||||
|
||||
/**
|
||||
* Weapon type enumeration
|
||||
* Equipment type IDs from CS2
|
||||
*/
|
||||
export enum WeaponType {
|
||||
// Pistols
|
||||
Glock = 1,
|
||||
USP = 2,
|
||||
P2000 = 3,
|
||||
P250 = 4,
|
||||
Deagle = 5,
|
||||
FiveSeven = 6,
|
||||
Tec9 = 7,
|
||||
CZ75 = 8,
|
||||
DualBerettas = 9,
|
||||
|
||||
// SMGs
|
||||
MP9 = 10,
|
||||
MAC10 = 11,
|
||||
MP7 = 12,
|
||||
UMP45 = 13,
|
||||
P90 = 14,
|
||||
PPBizon = 15,
|
||||
MP5SD = 16,
|
||||
|
||||
// Rifles
|
||||
AK47 = 17,
|
||||
M4A4 = 18,
|
||||
M4A1S = 19,
|
||||
Galil = 20,
|
||||
Famas = 21,
|
||||
AUG = 22,
|
||||
SG553 = 23,
|
||||
|
||||
// Sniper Rifles
|
||||
AWP = 24,
|
||||
SSG08 = 25,
|
||||
SCAR20 = 26,
|
||||
G3SG1 = 27,
|
||||
|
||||
// Heavy
|
||||
Nova = 28,
|
||||
XM1014 = 29,
|
||||
Mag7 = 30,
|
||||
SawedOff = 31,
|
||||
M249 = 32,
|
||||
Negev = 33,
|
||||
|
||||
// Equipment
|
||||
Zeus = 34,
|
||||
Knife = 35,
|
||||
HEGrenade = 36,
|
||||
Flashbang = 37,
|
||||
Smoke = 38,
|
||||
Molotov = 39,
|
||||
Decoy = 40,
|
||||
Incendiary = 41,
|
||||
C4 = 42
|
||||
}
|
||||
161
src/lib/types/api.ts
Normal file
161
src/lib/types/api.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* API response types and error handling
|
||||
*/
|
||||
|
||||
import type { Match, MatchListItem } from './Match';
|
||||
import type { Player, PlayerMeta } from './Player';
|
||||
|
||||
/**
|
||||
* Standard API error response
|
||||
*/
|
||||
export interface APIError {
|
||||
error: string;
|
||||
message: string;
|
||||
status_code: number;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic API response wrapper
|
||||
*/
|
||||
export interface APIResponse<T> {
|
||||
data: T;
|
||||
success: boolean;
|
||||
error?: APIError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match parse response
|
||||
*/
|
||||
export interface MatchParseResponse {
|
||||
match_id: string; // uint64 as string to preserve precision
|
||||
status: 'parsing' | 'queued' | 'completed' | 'error';
|
||||
message: string;
|
||||
estimated_time?: number; // seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Match parse status
|
||||
*/
|
||||
export interface MatchParseStatus {
|
||||
match_id: string; // uint64 as string to preserve precision
|
||||
status: 'pending' | 'parsing' | 'completed' | 'error';
|
||||
progress?: number; // 0-100
|
||||
error_message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches list response with pagination
|
||||
*/
|
||||
export interface MatchesListResponse {
|
||||
matches: MatchListItem[];
|
||||
next_page_time?: number; // Unix timestamp
|
||||
has_more: boolean;
|
||||
total_count?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match list query parameters
|
||||
*/
|
||||
export interface MatchesQueryParams {
|
||||
limit?: number; // 1-100
|
||||
map?: string;
|
||||
player_id?: string; // Steam ID uint64 as string
|
||||
before_time?: number; // Unix timestamp for pagination
|
||||
}
|
||||
|
||||
/**
|
||||
* Player track/untrack response
|
||||
*/
|
||||
export interface TrackPlayerResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Player profile response
|
||||
*/
|
||||
export type PlayerProfileResponse = Player;
|
||||
|
||||
/**
|
||||
* Player metadata response
|
||||
*/
|
||||
export type PlayerMetaResponse = PlayerMeta;
|
||||
|
||||
/**
|
||||
* Match details response
|
||||
*/
|
||||
export type MatchDetailsResponse = Match;
|
||||
|
||||
/**
|
||||
* Error types for better error handling
|
||||
*/
|
||||
export enum APIErrorType {
|
||||
NetworkError = 'NETWORK_ERROR',
|
||||
ServerError = 'SERVER_ERROR',
|
||||
NotFound = 'NOT_FOUND',
|
||||
BadRequest = 'BAD_REQUEST',
|
||||
Unauthorized = 'UNAUTHORIZED',
|
||||
Timeout = 'TIMEOUT',
|
||||
ValidationError = 'VALIDATION_ERROR',
|
||||
UnknownError = 'UNKNOWN_ERROR'
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed API error class
|
||||
*/
|
||||
export class APIException extends Error {
|
||||
constructor(
|
||||
public type: APIErrorType,
|
||||
public message: string,
|
||||
public statusCode?: number,
|
||||
public details?: unknown
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'APIException';
|
||||
}
|
||||
|
||||
static fromResponse(statusCode: number, data?: unknown): APIException {
|
||||
let type: APIErrorType;
|
||||
let message: string;
|
||||
|
||||
switch (statusCode) {
|
||||
case 400:
|
||||
type = APIErrorType.BadRequest;
|
||||
message = 'Invalid request parameters';
|
||||
break;
|
||||
case 401:
|
||||
type = APIErrorType.Unauthorized;
|
||||
message = 'Unauthorized access';
|
||||
break;
|
||||
case 404:
|
||||
type = APIErrorType.NotFound;
|
||||
message = 'Resource not found';
|
||||
break;
|
||||
case 500:
|
||||
case 502:
|
||||
case 503:
|
||||
type = APIErrorType.ServerError;
|
||||
message = 'Server error occurred';
|
||||
break;
|
||||
default:
|
||||
type = APIErrorType.UnknownError;
|
||||
message = 'An unknown error occurred';
|
||||
}
|
||||
|
||||
// Extract message from response data if available
|
||||
if (data && typeof data === 'object' && 'message' in data) {
|
||||
message = String(data.message);
|
||||
}
|
||||
|
||||
return new APIException(type, message, statusCode, data);
|
||||
}
|
||||
|
||||
static networkError(message = 'Network connection failed'): APIException {
|
||||
return new APIException(APIErrorType.NetworkError, message);
|
||||
}
|
||||
|
||||
static timeout(message = 'Request timed out'): APIException {
|
||||
return new APIException(APIErrorType.Timeout, message);
|
||||
}
|
||||
}
|
||||
9
src/lib/types/api/ChatAPIResponse.ts
Normal file
9
src/lib/types/api/ChatAPIResponse.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { Message } from '../Message';
|
||||
|
||||
/**
|
||||
* Raw API response format for match chat endpoint
|
||||
* API returns: { "player_id": [{ message, all_chat, tick }, ...], ... }
|
||||
*/
|
||||
export interface ChatAPIResponse {
|
||||
[playerId: string]: Message[];
|
||||
}
|
||||
9
src/lib/types/api/RoundsAPIResponse.ts
Normal file
9
src/lib/types/api/RoundsAPIResponse.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Raw API response format for match rounds endpoint
|
||||
* API returns: { "0": { "player_id": [bank, equipment, spent] }, "1": {...}, ... }
|
||||
*/
|
||||
export interface RoundsAPIResponse {
|
||||
[roundNumber: string]: {
|
||||
[playerId: string]: [bank: number, equipment: number, spent: number];
|
||||
};
|
||||
}
|
||||
12
src/lib/types/api/WeaponsAPIResponse.ts
Normal file
12
src/lib/types/api/WeaponsAPIResponse.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Raw API response format for match weapons endpoint
|
||||
* API returns: { equipment_map: { "1": "P2000", ... }, stats: [...] }
|
||||
*/
|
||||
export interface WeaponsAPIResponse {
|
||||
equipment_map: Record<string, string>; // eq_type ID -> weapon name
|
||||
stats: Array<{
|
||||
[attackerId: string]: {
|
||||
[victimId: string]: Array<[eqType: number, hitGroup: number, damage: number]>;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
47
src/lib/types/index.ts
Normal file
47
src/lib/types/index.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Central export for all CS2.WTF type definitions
|
||||
*/
|
||||
|
||||
// Raw API response types (internal format from backend)
|
||||
export type { RoundsAPIResponse } from './api/RoundsAPIResponse';
|
||||
export type { WeaponsAPIResponse } from './api/WeaponsAPIResponse';
|
||||
export type { ChatAPIResponse } from './api/ChatAPIResponse';
|
||||
|
||||
// Match types
|
||||
export type { Match, MatchListItem, MatchPlayer, MatchWithPlayers } from './Match';
|
||||
|
||||
// Player types
|
||||
export type { Player, PlayerMatch, PlayerMeta, PlayerProfile } from './Player';
|
||||
|
||||
// Round statistics types
|
||||
export type {
|
||||
RoundStats,
|
||||
TeamRoundStats,
|
||||
MatchRoundsData,
|
||||
RoundDetail,
|
||||
MatchRoundsResponse
|
||||
} from './RoundStats';
|
||||
|
||||
// Weapon types
|
||||
export type { Weapon, WeaponStats, PlayerWeaponStats, MatchWeaponsResponse } from './Weapon';
|
||||
|
||||
export { HitGroup, WeaponType } from './Weapon';
|
||||
|
||||
// Message/Chat types
|
||||
export type { Message, MatchChatResponse, EnrichedMessage, ChatFilter, ChatStats } from './Message';
|
||||
|
||||
// API response types
|
||||
export type {
|
||||
APIError,
|
||||
APIResponse,
|
||||
MatchParseResponse,
|
||||
MatchParseStatus,
|
||||
MatchesListResponse,
|
||||
MatchesQueryParams,
|
||||
TrackPlayerResponse,
|
||||
PlayerProfileResponse,
|
||||
PlayerMetaResponse,
|
||||
MatchDetailsResponse
|
||||
} from './api';
|
||||
|
||||
export { APIErrorType, APIException } from './api';
|
||||
154
src/lib/utils/export.ts
Normal file
154
src/lib/utils/export.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Export utilities for match data
|
||||
* Provides CSV and JSON export functionality for match listings
|
||||
*/
|
||||
|
||||
import type { MatchListItem } from '$lib/types';
|
||||
import { formatDuration } from './formatters';
|
||||
|
||||
/**
|
||||
* Format date to readable string (YYYY-MM-DD HH:MM)
|
||||
* @param dateString - ISO date string
|
||||
* @returns Formatted date string
|
||||
*/
|
||||
function formatDateForExport(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert matches array to CSV format
|
||||
* @param matches - Array of match items to export
|
||||
* @returns CSV string
|
||||
*/
|
||||
function matchesToCSV(matches: MatchListItem[]): string {
|
||||
// CSV Headers
|
||||
const headers = [
|
||||
'Match ID',
|
||||
'Date',
|
||||
'Map',
|
||||
'Score Team A',
|
||||
'Score Team B',
|
||||
'Duration',
|
||||
'Demo Parsed',
|
||||
'Player Count'
|
||||
];
|
||||
|
||||
// CSV rows
|
||||
const rows = matches.map((match) => {
|
||||
return [
|
||||
match.match_id,
|
||||
formatDateForExport(match.date),
|
||||
match.map,
|
||||
match.score_team_a.toString(),
|
||||
match.score_team_b.toString(),
|
||||
formatDuration(match.duration),
|
||||
match.demo_parsed ? 'Yes' : 'No',
|
||||
match.player_count?.toString() || '-'
|
||||
];
|
||||
});
|
||||
|
||||
// Combine headers and rows
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...rows.map((row) =>
|
||||
row
|
||||
.map((cell) => {
|
||||
// Escape cells containing commas or quotes
|
||||
if (cell.includes(',') || cell.includes('"')) {
|
||||
return `"${cell.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return cell;
|
||||
})
|
||||
.join(',')
|
||||
)
|
||||
].join('\n');
|
||||
|
||||
return csvContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert matches array to formatted JSON
|
||||
* @param matches - Array of match items to export
|
||||
* @returns Formatted JSON string
|
||||
*/
|
||||
function matchesToJSON(matches: MatchListItem[]): string {
|
||||
// Create clean export format
|
||||
const exportData = {
|
||||
export_date: new Date().toISOString(),
|
||||
total_matches: matches.length,
|
||||
matches: matches.map((match) => ({
|
||||
match_id: match.match_id,
|
||||
date: formatDateForExport(match.date),
|
||||
map: match.map,
|
||||
score: `${match.score_team_a} - ${match.score_team_b}`,
|
||||
score_team_a: match.score_team_a,
|
||||
score_team_b: match.score_team_b,
|
||||
duration: formatDuration(match.duration),
|
||||
duration_seconds: match.duration,
|
||||
demo_parsed: match.demo_parsed,
|
||||
player_count: match.player_count
|
||||
}))
|
||||
};
|
||||
|
||||
return JSON.stringify(exportData, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger browser download for a file
|
||||
* @param content - File content
|
||||
* @param filename - Name of file to download
|
||||
* @param mimeType - MIME type of file
|
||||
*/
|
||||
function triggerDownload(content: string, filename: string, mimeType: string): void {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export matches to CSV file
|
||||
* Generates and downloads a CSV file with match data
|
||||
* @param matches - Array of match items to export
|
||||
* @throws Error if matches array is empty
|
||||
*/
|
||||
export function exportMatchesToCSV(matches: MatchListItem[]): void {
|
||||
if (!matches || matches.length === 0) {
|
||||
throw new Error('No matches to export');
|
||||
}
|
||||
|
||||
const csvContent = matchesToCSV(matches);
|
||||
const timestamp = new Date().toISOString().split('T')[0];
|
||||
const filename = `cs2wtf-matches-${timestamp}.csv`;
|
||||
|
||||
triggerDownload(csvContent, filename, 'text/csv;charset=utf-8;');
|
||||
}
|
||||
|
||||
/**
|
||||
* Export matches to JSON file
|
||||
* Generates and downloads a JSON file with match data
|
||||
* @param matches - Array of match items to export
|
||||
* @throws Error if matches array is empty
|
||||
*/
|
||||
export function exportMatchesToJSON(matches: MatchListItem[]): void {
|
||||
if (!matches || matches.length === 0) {
|
||||
throw new Error('No matches to export');
|
||||
}
|
||||
|
||||
const jsonContent = matchesToJSON(matches);
|
||||
const timestamp = new Date().toISOString().split('T')[0];
|
||||
const filename = `cs2wtf-matches-${timestamp}.json`;
|
||||
|
||||
triggerDownload(jsonContent, filename, 'application/json;charset=utf-8;');
|
||||
}
|
||||
196
src/lib/utils/formatters.ts
Normal file
196
src/lib/utils/formatters.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Formatting utilities for CS2 data
|
||||
*/
|
||||
|
||||
/**
|
||||
* Premier rating tier information
|
||||
*/
|
||||
export interface PremierRatingTier {
|
||||
/** Formatted rating with comma separator (e.g., "15,000") */
|
||||
formatted: string;
|
||||
/** Hex color for this tier */
|
||||
color: string;
|
||||
/** Tier name */
|
||||
tier: string;
|
||||
/** Tailwind CSS classes for styling */
|
||||
cssClasses: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Premier rating and return tier information
|
||||
* CS2 Premier rating range: 0-30000
|
||||
* Color tiers: <5000 (gray), 5000-9999 (blue), 10000-14999 (purple),
|
||||
* 15000-19999 (pink), 20000-24999 (red), 25000+ (gold)
|
||||
*
|
||||
* @param rating - Premier rating (0-30000)
|
||||
* @returns Tier information with formatted rating and colors
|
||||
*/
|
||||
export function formatPremierRating(rating: number | undefined | null): PremierRatingTier {
|
||||
// Default for unranked/unknown
|
||||
if (rating === undefined || rating === null || rating === 0) {
|
||||
return {
|
||||
formatted: 'Unranked',
|
||||
color: '#9CA3AF',
|
||||
tier: 'Unranked',
|
||||
cssClasses: 'bg-base-300/50 border-base-content/20 text-base-content/60'
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure rating is within valid range
|
||||
const validRating = Math.max(0, Math.min(30000, rating));
|
||||
const formatted = validRating.toLocaleString('en-US');
|
||||
|
||||
// Determine tier based on rating
|
||||
if (validRating >= 25000) {
|
||||
return {
|
||||
formatted,
|
||||
color: '#EAB308',
|
||||
tier: 'Legendary',
|
||||
cssClasses:
|
||||
'bg-gradient-to-br from-yellow-500/20 to-amber-600/20 border-yellow-500/40 text-yellow-400 font-bold shadow-lg shadow-yellow-500/20'
|
||||
};
|
||||
} else if (validRating >= 20000) {
|
||||
return {
|
||||
formatted,
|
||||
color: '#EF4444',
|
||||
tier: 'Elite',
|
||||
cssClasses:
|
||||
'bg-gradient-to-br from-red-500/20 to-rose-600/20 border-red-500/40 text-red-400 font-semibold shadow-md shadow-red-500/10'
|
||||
};
|
||||
} else if (validRating >= 15000) {
|
||||
return {
|
||||
formatted,
|
||||
color: '#EC4899',
|
||||
tier: 'Expert',
|
||||
cssClasses:
|
||||
'bg-gradient-to-br from-pink-500/20 to-fuchsia-500/20 border-pink-500/40 text-pink-400 font-semibold shadow-md shadow-pink-500/10'
|
||||
};
|
||||
} else if (validRating >= 10000) {
|
||||
return {
|
||||
formatted,
|
||||
color: '#A855F7',
|
||||
tier: 'Advanced',
|
||||
cssClasses:
|
||||
'bg-gradient-to-br from-purple-500/20 to-violet-600/20 border-purple-500/40 text-purple-400 font-medium'
|
||||
};
|
||||
} else if (validRating >= 5000) {
|
||||
return {
|
||||
formatted,
|
||||
color: '#3B82F6',
|
||||
tier: 'Intermediate',
|
||||
cssClasses:
|
||||
'bg-gradient-to-br from-blue-500/20 to-indigo-500/20 border-blue-500/40 text-blue-400'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
formatted,
|
||||
color: '#9CA3AF',
|
||||
tier: 'Beginner',
|
||||
cssClasses: 'bg-gray-500/10 border-gray-500/30 text-gray-400'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Tailwind CSS classes for Premier rating badge
|
||||
* @param rating - Premier rating (0-30000)
|
||||
* @returns Tailwind CSS class string
|
||||
*/
|
||||
export function getPremierRatingClass(rating: number | undefined | null): string {
|
||||
return formatPremierRating(rating).cssClasses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate rating change display
|
||||
* @param oldRating - Previous rating
|
||||
* @param newRating - New rating
|
||||
* @returns Object with change amount and display string
|
||||
*/
|
||||
export function getPremierRatingChange(
|
||||
oldRating: number | undefined | null,
|
||||
newRating: number | undefined | null
|
||||
): {
|
||||
change: number;
|
||||
display: string;
|
||||
isPositive: boolean;
|
||||
cssClasses: string;
|
||||
} | null {
|
||||
if (
|
||||
oldRating === undefined ||
|
||||
oldRating === null ||
|
||||
newRating === undefined ||
|
||||
newRating === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const change = newRating - oldRating;
|
||||
|
||||
if (change === 0) {
|
||||
return {
|
||||
change: 0,
|
||||
display: '±0',
|
||||
isPositive: false,
|
||||
cssClasses: 'text-base-content/60'
|
||||
};
|
||||
}
|
||||
|
||||
const isPositive = change > 0;
|
||||
const display = isPositive ? `+${change}` : change.toString();
|
||||
|
||||
return {
|
||||
change,
|
||||
display,
|
||||
isPositive,
|
||||
cssClasses: isPositive ? 'text-success font-semibold' : 'text-error font-semibold'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format K/D ratio
|
||||
* @param kills - Number of kills
|
||||
* @param deaths - Number of deaths
|
||||
* @returns Formatted K/D ratio
|
||||
*/
|
||||
export function formatKD(kills: number, deaths: number): string {
|
||||
if (deaths === 0) {
|
||||
return kills.toFixed(2);
|
||||
}
|
||||
return (kills / deaths).toFixed(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format percentage
|
||||
* @param value - Percentage value (0-100)
|
||||
* @param decimals - Number of decimal places (default: 1)
|
||||
* @returns Formatted percentage string
|
||||
*/
|
||||
export function formatPercent(value: number | undefined | null, decimals = 1): string {
|
||||
if (value === undefined || value === null) {
|
||||
return '0.0%';
|
||||
}
|
||||
return `${value.toFixed(decimals)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in seconds to MM:SS
|
||||
* @param seconds - Duration in seconds
|
||||
* @returns Formatted duration string
|
||||
*/
|
||||
export function formatDuration(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format large numbers with comma separators
|
||||
* @param value - Number to format
|
||||
* @returns Formatted number string
|
||||
*/
|
||||
export function formatNumber(value: number | undefined | null): string {
|
||||
if (value === undefined || value === null) {
|
||||
return '0';
|
||||
}
|
||||
return value.toLocaleString('en-US');
|
||||
}
|
||||
75
src/lib/utils/mapAssets.ts
Normal file
75
src/lib/utils/mapAssets.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Utility functions for accessing CS2 map assets (icons, backgrounds, screenshots)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get the background image URL for a map
|
||||
* @param mapName - The map name (e.g., "de_dust2")
|
||||
* @returns URL to the map screenshot/background
|
||||
*/
|
||||
export function getMapBackground(mapName: string | null | undefined): string {
|
||||
// If no map name provided, use default
|
||||
if (!mapName || mapName.trim() === '') {
|
||||
return getDefaultMapBackground();
|
||||
}
|
||||
// For "unknown" maps, use default background directly
|
||||
if (mapName.toLowerCase() === 'unknown') {
|
||||
return getDefaultMapBackground();
|
||||
}
|
||||
// Try WebP first (better compression), fallback to PNG
|
||||
return `/images/map_screenshots/${mapName}.webp`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the icon SVG URL for a map
|
||||
* @param mapName - The map name (e.g., "de_dust2")
|
||||
* @returns URL to the map icon SVG
|
||||
*/
|
||||
export function getMapIcon(mapName: string | null | undefined): string {
|
||||
if (!mapName || mapName.trim() === '') {
|
||||
return `/images/map_icons/map_icon_lobby_mapveto.svg`; // Generic map icon
|
||||
}
|
||||
return `/images/map_icons/map_icon_${mapName}.svg`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fallback default map background if specific map is not found
|
||||
*/
|
||||
export function getDefaultMapBackground(): string {
|
||||
return '/images/map_screenshots/default.webp';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format map name for display (remove de_ prefix, capitalize)
|
||||
* @param mapName - The map name (e.g., "de_dust2")
|
||||
* @returns Formatted name (e.g., "Dust 2")
|
||||
*/
|
||||
export function formatMapName(mapName: string | null | undefined): string {
|
||||
if (!mapName || mapName.trim() === '') {
|
||||
return 'Unknown Map';
|
||||
}
|
||||
return mapName
|
||||
.replace(/^(de|cs|ar|dz|gd|coop)_/, '')
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get team logo URL
|
||||
* @param team - "t" or "ct"
|
||||
* @param variant - "logo" (color) or "logo_1c" (monochrome)
|
||||
* @returns URL to the team logo SVG
|
||||
*/
|
||||
export function getTeamLogo(team: 't' | 'ct', variant: 'logo' | 'logo_1c' = 'logo'): string {
|
||||
return `/images/icons/${team}_${variant}.svg`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get team character background
|
||||
* @param team - "t" or "ct"
|
||||
* @returns URL to the team character background SVG
|
||||
*/
|
||||
export function getTeamBackground(team: 't' | 'ct'): string {
|
||||
return `/images/icons/${team}_char_bg.svg`;
|
||||
}
|
||||
102
src/lib/utils/navigation.ts
Normal file
102
src/lib/utils/navigation.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Navigation utility for preserving scroll state and match position
|
||||
* when navigating between matches and the matches listing page.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'matches-navigation-state';
|
||||
|
||||
interface NavigationState {
|
||||
matchId: string;
|
||||
scrollY: number;
|
||||
timestamp: number;
|
||||
loadedCount: number; // Number of matches loaded (for pagination)
|
||||
}
|
||||
|
||||
/**
|
||||
* Store navigation state when leaving the matches page
|
||||
*/
|
||||
export function storeMatchesState(matchId: string, loadedCount: number): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const state: NavigationState = {
|
||||
matchId,
|
||||
scrollY: window.scrollY,
|
||||
timestamp: Date.now(),
|
||||
loadedCount
|
||||
};
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (e) {
|
||||
console.warn('Failed to store navigation state:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve stored navigation state
|
||||
*/
|
||||
export function getMatchesState(): NavigationState | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
try {
|
||||
const stored = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) return null;
|
||||
|
||||
const state: NavigationState = JSON.parse(stored);
|
||||
|
||||
// Clear state if older than 5 minutes (likely stale)
|
||||
if (Date.now() - state.timestamp > 5 * 60 * 1000) {
|
||||
clearMatchesState();
|
||||
return null;
|
||||
}
|
||||
|
||||
return state;
|
||||
} catch (e) {
|
||||
console.warn('Failed to retrieve navigation state:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear stored navigation state
|
||||
*/
|
||||
export function clearMatchesState(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
} catch (e) {
|
||||
console.warn('Failed to clear navigation state:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to a specific match card element by ID
|
||||
*/
|
||||
export function scrollToMatch(matchId: string, fallbackScrollY?: number): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
// Use requestAnimationFrame to ensure DOM is ready
|
||||
requestAnimationFrame(() => {
|
||||
// Try to find the match card element
|
||||
const matchElement = document.querySelector(`[data-match-id="${matchId}"]`);
|
||||
|
||||
if (matchElement) {
|
||||
// Found the element, scroll to it with some offset for the header
|
||||
const offset = 100; // Header height + some padding
|
||||
const elementPosition = matchElement.getBoundingClientRect().top + window.scrollY;
|
||||
const offsetPosition = elementPosition - offset;
|
||||
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
} else if (fallbackScrollY !== undefined) {
|
||||
// Element not found (might be new matches), use stored scroll position
|
||||
window.scrollTo({
|
||||
top: fallbackScrollY,
|
||||
behavior: 'instant'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
126
src/lib/utils/rankingSystem.ts
Normal file
126
src/lib/utils/rankingSystem.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { Match } from '$lib/types';
|
||||
|
||||
/**
|
||||
* CS2 Ranking System Utilities
|
||||
*
|
||||
* Based on research: Counter-Strike 2 implements a bifurcated ranking architecture:
|
||||
* - Premier Mode: CS Rating (numerical ELO, 0-30,000+) - launched August 31, 2023
|
||||
* - Competitive Mode: Skill Groups (0-18, Silver I to Global Elite) - retained from CS:GO
|
||||
*
|
||||
* Key dates:
|
||||
* - August 31, 2023: Premier Mode and CS Rating launched
|
||||
* - September 27, 2023: CS2 officially replaced CS:GO
|
||||
*/
|
||||
|
||||
/**
|
||||
* CS2 official launch date (September 27, 2023)
|
||||
* Matches before this date are considered CS:GO legacy matches
|
||||
*/
|
||||
export const CS2_LAUNCH_DATE = new Date('2023-09-27T00:00:00Z');
|
||||
|
||||
/**
|
||||
* Premier Mode launch date (August 31, 2023)
|
||||
* CS Rating system became available on this date
|
||||
*/
|
||||
export const CS_RATING_LAUNCH_DATE = new Date('2023-08-31T00:00:00Z');
|
||||
|
||||
/**
|
||||
* Ranking system type
|
||||
*/
|
||||
export type RankingSystem = 'cs_rating' | 'skill_group' | 'unknown';
|
||||
|
||||
/**
|
||||
* Determines which ranking system a match uses
|
||||
*
|
||||
* Logic:
|
||||
* 1. If match date < September 27, 2023 → CS:GO legacy (Skill Groups only)
|
||||
* 2. If match date >= September 27, 2023 AND game_mode = 'premier' → CS Rating
|
||||
* 3. If match date >= September 27, 2023 AND game_mode = 'competitive'/'wingman' → Skill Groups
|
||||
* 4. Fallback: Use heuristic (0-18 = Skill Group, >1000 = CS Rating)
|
||||
*
|
||||
* @param match - Match object with date and optional game_mode
|
||||
* @param rating - The rating value to check (for fallback heuristic)
|
||||
* @returns The ranking system type
|
||||
*/
|
||||
export function getRankingSystem(
|
||||
match: Pick<Match, 'date' | 'game_mode'>,
|
||||
rating?: number | null
|
||||
): RankingSystem {
|
||||
// Parse match date (could be Unix timestamp or ISO string)
|
||||
const matchDate =
|
||||
typeof match.date === 'number' ? new Date(match.date * 1000) : new Date(match.date);
|
||||
|
||||
// Legacy CS:GO match (before CS2 launch)
|
||||
if (matchDate < CS2_LAUNCH_DATE) {
|
||||
return 'skill_group';
|
||||
}
|
||||
|
||||
// CS2 match - check game mode
|
||||
if (match.game_mode) {
|
||||
// Premier Mode always uses CS Rating
|
||||
if (match.game_mode === 'premier') {
|
||||
return 'cs_rating';
|
||||
}
|
||||
|
||||
// Competitive and Wingman always use Skill Groups
|
||||
if (match.game_mode === 'competitive' || match.game_mode === 'wingman') {
|
||||
return 'skill_group';
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback heuristic: Check rating value
|
||||
// Skill Groups are 0-18, CS Rating is typically 1000-30000+
|
||||
if (rating !== undefined && rating !== null) {
|
||||
if (rating >= 0 && rating <= 18) {
|
||||
return 'skill_group';
|
||||
}
|
||||
if (rating >= 1000) {
|
||||
return 'cs_rating';
|
||||
}
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a match uses the CS Rating system (Premier Mode)
|
||||
*/
|
||||
export function usesCsRating(
|
||||
match: Pick<Match, 'date' | 'game_mode'>,
|
||||
rating?: number | null
|
||||
): boolean {
|
||||
return getRankingSystem(match, rating) === 'cs_rating';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a match uses the Skill Group system (Competitive/Wingman/Legacy CS:GO)
|
||||
*/
|
||||
export function usesSkillGroup(
|
||||
match: Pick<Match, 'date' | 'game_mode'>,
|
||||
rating?: number | null
|
||||
): boolean {
|
||||
return getRankingSystem(match, rating) === 'skill_group';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a match is a legacy CS:GO match (before CS2 launch)
|
||||
*/
|
||||
export function isLegacyMatch(match: Pick<Match, 'date'>): boolean {
|
||||
const matchDate =
|
||||
typeof match.date === 'number' ? new Date(match.date * 1000) : new Date(match.date);
|
||||
return matchDate < CS2_LAUNCH_DATE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable description of the ranking system used
|
||||
*/
|
||||
export function getRankingSystemDescription(system: RankingSystem): string {
|
||||
switch (system) {
|
||||
case 'cs_rating':
|
||||
return 'CS Rating (Premier Mode)';
|
||||
case 'skill_group':
|
||||
return 'Skill Group';
|
||||
case 'unknown':
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
87
src/lib/utils/recentPlayers.ts
Normal file
87
src/lib/utils/recentPlayers.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Utility for managing recently visited players in localStorage
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'cs2wtf_recent_players';
|
||||
const MAX_RECENT_PLAYERS = 10;
|
||||
|
||||
export interface RecentPlayer {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
visitedAt: number; // Unix timestamp
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all recently visited players from localStorage
|
||||
*/
|
||||
export function getRecentPlayers(): RecentPlayer[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) return [];
|
||||
|
||||
const players: RecentPlayer[] = JSON.parse(stored);
|
||||
// Sort by most recent first
|
||||
return players.sort((a, b) => b.visitedAt - a.visitedAt);
|
||||
} catch (error) {
|
||||
console.error('Failed to load recent players:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update a player in the recently visited list
|
||||
*/
|
||||
export function addRecentPlayer(player: Omit<RecentPlayer, 'visitedAt'>): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
const recent = getRecentPlayers();
|
||||
|
||||
// Remove existing entry if present
|
||||
const filtered = recent.filter((p) => p.id !== player.id);
|
||||
|
||||
// Add new entry with current timestamp
|
||||
const newPlayer: RecentPlayer = {
|
||||
...player,
|
||||
visitedAt: Date.now()
|
||||
};
|
||||
|
||||
// Keep only the most recent MAX_RECENT_PLAYERS
|
||||
const updated = [newPlayer, ...filtered].slice(0, MAX_RECENT_PLAYERS);
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
||||
} catch (error) {
|
||||
console.error('Failed to save recent player:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all recently visited players
|
||||
*/
|
||||
export function clearRecentPlayers(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch (error) {
|
||||
console.error('Failed to clear recent players:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a specific player from the recently visited list
|
||||
*/
|
||||
export function removeRecentPlayer(playerId: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
const recent = getRecentPlayers();
|
||||
const filtered = recent.filter((p) => p.id !== playerId);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered));
|
||||
} catch (error) {
|
||||
console.error('Failed to remove recent player:', error);
|
||||
}
|
||||
}
|
||||
44
src/mocks/browser.ts
Normal file
44
src/mocks/browser.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { setupWorker } from 'msw/browser';
|
||||
import { handlers } from './handlers';
|
||||
|
||||
/**
|
||||
* MSW Browser Worker
|
||||
* Used for mocking API requests in the browser (development mode)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create MSW service worker
|
||||
*/
|
||||
export const worker = setupWorker(...handlers);
|
||||
|
||||
/**
|
||||
* Start MSW worker with console logging
|
||||
*/
|
||||
export const startMocking = async () => {
|
||||
const isDev = import.meta.env?.DEV ?? false;
|
||||
const isMockingEnabled = import.meta.env?.VITE_ENABLE_MSW_MOCKING === 'true';
|
||||
|
||||
if (isDev && isMockingEnabled) {
|
||||
await worker.start({
|
||||
onUnhandledRequest: 'bypass',
|
||||
serviceWorker: {
|
||||
url: '/mockServiceWorker.js'
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[MSW] API mocking enabled for development');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Stop MSW worker
|
||||
*/
|
||||
export const stopMocking = () => {
|
||||
worker.stop();
|
||||
console.log('[MSW] API mocking stopped');
|
||||
};
|
||||
|
||||
/**
|
||||
* Default export
|
||||
*/
|
||||
export default worker;
|
||||
203
src/mocks/fixtures.ts
Normal file
203
src/mocks/fixtures.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import type { Player, Match, MatchPlayer, MatchListItem, PlayerMeta } from '$lib/types';
|
||||
|
||||
/**
|
||||
* Mock data fixtures for testing and development
|
||||
*/
|
||||
|
||||
/** Mock players */
|
||||
export const mockPlayers: Player[] = [
|
||||
{
|
||||
id: '765611980123456', // Smaller mock Steam ID (safe integer)
|
||||
name: 'TestPlayer1',
|
||||
avatar:
|
||||
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg',
|
||||
vanity_url: 'testplayer1',
|
||||
steam_updated: '2024-11-04T10:30:00Z',
|
||||
profile_created: '2015-03-12T00:00:00Z',
|
||||
wins: 1250,
|
||||
losses: 980,
|
||||
ties: 45,
|
||||
vac_count: 0,
|
||||
game_ban_count: 0
|
||||
},
|
||||
{
|
||||
id: '765611980876543', // Smaller mock Steam ID (safe integer)
|
||||
name: 'TestPlayer2',
|
||||
avatar: 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/ab/abc123.jpg',
|
||||
steam_updated: '2024-11-04T11:15:00Z',
|
||||
wins: 850,
|
||||
losses: 720,
|
||||
ties: 30,
|
||||
vac_count: 0,
|
||||
game_ban_count: 0
|
||||
}
|
||||
];
|
||||
|
||||
/** Mock player metadata */
|
||||
export const mockPlayerMeta: PlayerMeta = {
|
||||
id: '765611980123456',
|
||||
name: 'TestPlayer1',
|
||||
avatar:
|
||||
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg',
|
||||
recent_matches: 25,
|
||||
last_match_date: '2024-11-01T18:45:00Z',
|
||||
avg_kills: 21.3,
|
||||
avg_deaths: 17.8,
|
||||
avg_kast: 75.2,
|
||||
win_rate: 56.5,
|
||||
vac_count: 0,
|
||||
game_ban_count: 0,
|
||||
tracked: false
|
||||
};
|
||||
|
||||
/** Mock match players */
|
||||
export const mockMatchPlayers: MatchPlayer[] = [
|
||||
{
|
||||
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,
|
||||
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',
|
||||
vac: false,
|
||||
game_ban: false
|
||||
},
|
||||
{
|
||||
id: '765611980876543',
|
||||
name: 'Player2',
|
||||
avatar: 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/ab/abc123.jpg',
|
||||
team_id: 2,
|
||||
kills: 19,
|
||||
deaths: 20,
|
||||
assists: 8,
|
||||
headshot: 9,
|
||||
mvp: 2,
|
||||
score: 48,
|
||||
kast: 72,
|
||||
rank_old: 17200,
|
||||
rank_new: 17180,
|
||||
dmg_enemy: 2180,
|
||||
dmg_team: 85,
|
||||
avg_ping: 32.1,
|
||||
color: 'blue',
|
||||
vac: false,
|
||||
game_ban: false
|
||||
},
|
||||
{
|
||||
id: '765611980111111',
|
||||
name: 'Player3',
|
||||
avatar: 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/cd/cde456.jpg',
|
||||
team_id: 3,
|
||||
kills: 22,
|
||||
deaths: 19,
|
||||
assists: 5,
|
||||
headshot: 14,
|
||||
mvp: 4,
|
||||
score: 60,
|
||||
kast: 80,
|
||||
rank_old: 19800,
|
||||
rank_new: 19920,
|
||||
dmg_enemy: 2680,
|
||||
dmg_team: 45,
|
||||
avg_ping: 18.3,
|
||||
color: 'green',
|
||||
vac: false,
|
||||
game_ban: false
|
||||
}
|
||||
];
|
||||
|
||||
/** Mock matches */
|
||||
export const mockMatches: Match[] = [
|
||||
{
|
||||
match_id: '358948771684207', // Smaller mock match ID (safe integer)
|
||||
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,
|
||||
// Note: tick_rate is not provided by the API
|
||||
players: mockMatchPlayers
|
||||
},
|
||||
{
|
||||
match_id: '358948771684208',
|
||||
share_code: 'CSGO-YYYYY-YYYYY-YYYYY-YYYYY-YYYYY',
|
||||
map: 'de_mirage',
|
||||
date: '2024-11-02T20:15:00Z',
|
||||
score_team_a: 16,
|
||||
score_team_b: 14,
|
||||
duration: 2845,
|
||||
match_result: 1,
|
||||
max_rounds: 24,
|
||||
demo_parsed: true,
|
||||
vac_present: false,
|
||||
gameban_present: false
|
||||
// Note: tick_rate is not provided by the API
|
||||
},
|
||||
{
|
||||
match_id: '358948771684209',
|
||||
share_code: 'CSGO-ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ',
|
||||
map: 'de_dust2',
|
||||
date: '2024-11-03T15:30:00Z',
|
||||
score_team_a: 9,
|
||||
score_team_b: 13,
|
||||
duration: 1980,
|
||||
match_result: 2,
|
||||
max_rounds: 24,
|
||||
demo_parsed: true,
|
||||
vac_present: false,
|
||||
gameban_present: false
|
||||
// Note: tick_rate is not provided by the API
|
||||
}
|
||||
];
|
||||
|
||||
/** Mock match list items */
|
||||
export const mockMatchListItems: MatchListItem[] = mockMatches.map((match) => ({
|
||||
match_id: match.match_id,
|
||||
map: match.map,
|
||||
date: match.date,
|
||||
score_team_a: match.score_team_a,
|
||||
score_team_b: match.score_team_b,
|
||||
duration: match.duration,
|
||||
demo_parsed: match.demo_parsed
|
||||
// Note: player_count is not provided by the API, so it's omitted from mocks
|
||||
}));
|
||||
|
||||
/** Helper: Generate random Steam ID (safe integer) */
|
||||
export const generateSteamId = (): number => {
|
||||
return 765611980000000 + Math.floor(Math.random() * 999999);
|
||||
};
|
||||
|
||||
/** Helper: Get mock player by ID */
|
||||
export const getMockPlayer = (id: string): Player | undefined => {
|
||||
return mockPlayers.find((p) => p.id === id);
|
||||
};
|
||||
|
||||
/** Helper: Get mock match by ID */
|
||||
export const getMockMatch = (id: string): Match | undefined => {
|
||||
return mockMatches.find((m) => m.match_id === id);
|
||||
};
|
||||
17
src/mocks/handlers/index.ts
Normal file
17
src/mocks/handlers/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* MSW Request Handlers
|
||||
* Mocks all CS2.WTF API endpoints for testing and development
|
||||
*/
|
||||
|
||||
import { playersHandlers } from './players';
|
||||
import { matchesHandlers } from './matches';
|
||||
|
||||
/**
|
||||
* Combined handlers for all API endpoints
|
||||
*/
|
||||
export const handlers = [...playersHandlers, ...matchesHandlers];
|
||||
|
||||
/**
|
||||
* Default export
|
||||
*/
|
||||
export default handlers;
|
||||
185
src/mocks/handlers/matches.ts
Normal file
185
src/mocks/handlers/matches.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { http, HttpResponse, delay } from 'msw';
|
||||
import { mockMatches, mockMatchListItems, getMockMatch } from '../fixtures';
|
||||
import type {
|
||||
MatchParseResponse,
|
||||
MatchRoundsResponse,
|
||||
MatchWeaponsResponse,
|
||||
MatchChatResponse
|
||||
} from '$lib/types';
|
||||
|
||||
/**
|
||||
* MSW handlers for Match API endpoints
|
||||
*/
|
||||
|
||||
const API_BASE_URL = 'http://localhost:8000';
|
||||
|
||||
export const matchesHandlers = [
|
||||
// GET /match/parse/:sharecode
|
||||
http.get(`${API_BASE_URL}/match/parse/:sharecode`, async () => {
|
||||
// Simulate parsing delay
|
||||
await delay(500);
|
||||
|
||||
const response: MatchParseResponse = {
|
||||
match_id: '358948771684207',
|
||||
status: 'parsing',
|
||||
message: 'Demo download and parsing initiated',
|
||||
estimated_time: 120
|
||||
};
|
||||
|
||||
return HttpResponse.json(response);
|
||||
}),
|
||||
|
||||
// GET /match/:id
|
||||
http.get(`${API_BASE_URL}/match/:id`, ({ params }) => {
|
||||
const { id } = params;
|
||||
const matchId = String(id);
|
||||
|
||||
const match = getMockMatch(matchId) || mockMatches[0];
|
||||
|
||||
return HttpResponse.json(match);
|
||||
}),
|
||||
|
||||
// GET /match/:id/weapons
|
||||
http.get(`${API_BASE_URL}/match/:id/weapons`, ({ params }) => {
|
||||
const { id } = params;
|
||||
const matchId = Number(id);
|
||||
|
||||
const response: MatchWeaponsResponse = {
|
||||
match_id: matchId,
|
||||
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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return HttpResponse.json(response);
|
||||
}),
|
||||
|
||||
// GET /match/:id/rounds
|
||||
http.get(`${API_BASE_URL}/match/:id/rounds`, ({ params }) => {
|
||||
const { id } = params;
|
||||
const matchId = Number(id);
|
||||
|
||||
const winReasons = ['elimination', 'bomb_defused', 'bomb_exploded'];
|
||||
const response: MatchRoundsResponse = {
|
||||
match_id: matchId,
|
||||
rounds: Array.from({ length: 23 }, (_, i) => ({
|
||||
round: i + 1,
|
||||
winner: i % 2 === 0 ? 2 : 3,
|
||||
win_reason: winReasons[i % 3] || 'elimination',
|
||||
players: [
|
||||
{
|
||||
round: i + 1,
|
||||
player_id: 765611980123456,
|
||||
bank: 800 + i * 1000,
|
||||
equipment: 650 + i * 500,
|
||||
spent: 650 + i * 500,
|
||||
kills_in_round: i % 3,
|
||||
damage_in_round: 100 + i * 20
|
||||
}
|
||||
]
|
||||
}))
|
||||
};
|
||||
|
||||
return HttpResponse.json(response);
|
||||
}),
|
||||
|
||||
// GET /match/:id/chat
|
||||
http.get(`${API_BASE_URL}/match/:id/chat`, ({ params }) => {
|
||||
const { id } = params;
|
||||
const matchId = Number(id);
|
||||
|
||||
const response: MatchChatResponse = {
|
||||
match_id: matchId,
|
||||
messages: [
|
||||
{
|
||||
player_id: 765611980123456,
|
||||
player_name: 'Player1',
|
||||
message: 'nice shot!',
|
||||
tick: 15840,
|
||||
round: 8,
|
||||
all_chat: true,
|
||||
timestamp: '2024-11-01T19:12:34Z'
|
||||
},
|
||||
{
|
||||
player_id: 765611980876543,
|
||||
player_name: 'Player2',
|
||||
message: 'thanks',
|
||||
tick: 15920,
|
||||
round: 8,
|
||||
all_chat: true,
|
||||
timestamp: '2024-11-01T19:12:38Z'
|
||||
},
|
||||
{
|
||||
player_id: 765611980111111,
|
||||
player_name: 'Player3',
|
||||
message: 'rush b no stop',
|
||||
tick: 18400,
|
||||
round: 9,
|
||||
all_chat: false,
|
||||
timestamp: '2024-11-01T19:14:12Z'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return HttpResponse.json(response);
|
||||
}),
|
||||
|
||||
// GET /matches
|
||||
http.get(`${API_BASE_URL}/matches`, ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const limit = Number(url.searchParams.get('limit')) || 50;
|
||||
const map = url.searchParams.get('map');
|
||||
const playerId = url.searchParams.get('player_id');
|
||||
|
||||
let matches = [...mockMatchListItems];
|
||||
|
||||
// Apply filters
|
||||
if (map) {
|
||||
matches = matches.filter((m) => m.map === map);
|
||||
}
|
||||
|
||||
if (playerId) {
|
||||
// In a real scenario, filter by player participation
|
||||
matches = matches.slice(0, Math.ceil(matches.length / 2));
|
||||
}
|
||||
|
||||
// NOTE: The real API returns a plain array, not a MatchesListResponse object
|
||||
// The transformation to MatchesListResponse happens in the API client
|
||||
const matchArray = matches.slice(0, limit);
|
||||
|
||||
return HttpResponse.json(matchArray);
|
||||
}),
|
||||
|
||||
// GET /matches/next/:time
|
||||
http.get(`${API_BASE_URL}/matches/next/:time`, ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const limit = Number(url.searchParams.get('limit')) || 50;
|
||||
|
||||
// Return older matches for pagination
|
||||
// NOTE: The real API returns a plain array, not a MatchesListResponse object
|
||||
const matchArray = mockMatchListItems.slice(limit, limit * 2);
|
||||
|
||||
return HttpResponse.json(matchArray);
|
||||
})
|
||||
];
|
||||
99
src/mocks/handlers/players.ts
Normal file
99
src/mocks/handlers/players.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { mockPlayers, mockPlayerMeta, getMockPlayer } from '../fixtures';
|
||||
import type { TrackPlayerResponse } from '$lib/types';
|
||||
|
||||
/**
|
||||
* MSW handlers for Player API endpoints
|
||||
*/
|
||||
|
||||
const API_BASE_URL = 'http://localhost:8000';
|
||||
|
||||
export const playersHandlers = [
|
||||
// GET /player/:id
|
||||
http.get(`${API_BASE_URL}/player/:id`, ({ params }) => {
|
||||
const { id } = params;
|
||||
const playerId = String(id);
|
||||
|
||||
const player = getMockPlayer(playerId);
|
||||
if (!player) {
|
||||
return HttpResponse.json(mockPlayers[0]);
|
||||
}
|
||||
|
||||
return HttpResponse.json(player);
|
||||
}),
|
||||
|
||||
// GET /player/:id/next/:time
|
||||
http.get(`${API_BASE_URL}/player/:id/next/:time`, ({ params }) => {
|
||||
const { id } = params;
|
||||
const playerId = String(id);
|
||||
|
||||
const player = getMockPlayer(playerId) ?? mockPlayers[0];
|
||||
|
||||
// Return player with paginated matches (simulate older matches)
|
||||
return HttpResponse.json({
|
||||
...player,
|
||||
matches: player?.matches?.slice(5, 10) || []
|
||||
});
|
||||
}),
|
||||
|
||||
// GET /player/:id/meta
|
||||
http.get(`${API_BASE_URL}/player/:id/meta`, () => {
|
||||
return HttpResponse.json(mockPlayerMeta);
|
||||
}),
|
||||
|
||||
// GET /player/:id/meta/:limit
|
||||
http.get(`${API_BASE_URL}/player/:id/meta/:limit`, ({ params }) => {
|
||||
const { limit } = params;
|
||||
const limitNum = Number(limit);
|
||||
|
||||
return HttpResponse.json({
|
||||
...mockPlayerMeta,
|
||||
recent_matches: limitNum
|
||||
});
|
||||
}),
|
||||
|
||||
// POST /player/:id/track
|
||||
http.post(`${API_BASE_URL}/player/:id/track`, async () => {
|
||||
const response: TrackPlayerResponse = {
|
||||
success: true,
|
||||
message: 'Player added to tracking queue'
|
||||
};
|
||||
|
||||
return HttpResponse.json(response);
|
||||
}),
|
||||
|
||||
// DELETE /player/:id/track
|
||||
http.delete(`${API_BASE_URL}/player/:id/track`, () => {
|
||||
const response: TrackPlayerResponse = {
|
||||
success: true,
|
||||
message: 'Player removed from tracking'
|
||||
};
|
||||
|
||||
return HttpResponse.json(response);
|
||||
}),
|
||||
|
||||
// GET /players/search (custom endpoint for search)
|
||||
http.get(`${API_BASE_URL}/players/search`, ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const query = url.searchParams.get('q');
|
||||
const limit = Number(url.searchParams.get('limit')) || 10;
|
||||
|
||||
// Filter players by name
|
||||
const filtered = mockPlayers
|
||||
.filter((p) => p.name.toLowerCase().includes(query?.toLowerCase() || ''))
|
||||
.slice(0, limit)
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
avatar: p.avatar,
|
||||
recent_matches: 25,
|
||||
last_match_date: '2024-11-01T18:45:00Z',
|
||||
avg_kills: 20.5,
|
||||
avg_deaths: 18.2,
|
||||
avg_kast: 74.0,
|
||||
win_rate: 55.0
|
||||
}));
|
||||
|
||||
return HttpResponse.json(filtered);
|
||||
})
|
||||
];
|
||||
39
src/mocks/server.ts
Normal file
39
src/mocks/server.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { setupServer } from 'msw/node';
|
||||
import { beforeAll, afterEach, afterAll } from 'vitest';
|
||||
import { handlers } from './handlers';
|
||||
|
||||
/**
|
||||
* MSW Server
|
||||
* Used for mocking API requests in Node.js (tests)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create MSW server for testing
|
||||
*/
|
||||
export const server = setupServer(...handlers);
|
||||
|
||||
/**
|
||||
* Setup server for tests
|
||||
* Call this in test setup files (e.g., vitest.setup.ts)
|
||||
*/
|
||||
export const setupMockServer = () => {
|
||||
// Start server before all tests
|
||||
beforeAll(() => {
|
||||
server.listen({ onUnhandledRequest: 'bypass' });
|
||||
});
|
||||
|
||||
// Reset handlers after each test
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
// Close server after all tests
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Default export
|
||||
*/
|
||||
export default server;
|
||||
97
src/routes/+error.svelte
Normal file
97
src/routes/+error.svelte
Normal file
@@ -0,0 +1,97 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import { Home, ArrowLeft } from 'lucide-svelte';
|
||||
|
||||
// Get error information
|
||||
const error = $page.error;
|
||||
const status = $page.status;
|
||||
|
||||
// Determine error message
|
||||
const getErrorMessage = (status: number): string => {
|
||||
switch (status) {
|
||||
case 404:
|
||||
return "We couldn't find the page you're looking for.";
|
||||
case 500:
|
||||
return 'Something went wrong on our end. Please try again later.';
|
||||
case 503:
|
||||
return 'Service temporarily unavailable. Please check back soon.';
|
||||
default:
|
||||
return 'An unexpected error occurred.';
|
||||
}
|
||||
};
|
||||
|
||||
const getErrorTitle = (status: number): string => {
|
||||
switch (status) {
|
||||
case 404:
|
||||
return 'Page Not Found';
|
||||
case 500:
|
||||
return 'Internal Server Error';
|
||||
case 503:
|
||||
return 'Service Unavailable';
|
||||
default:
|
||||
return 'Error';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{status} - {getErrorTitle(status)} | CS2.WTF</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container mx-auto flex min-h-[60vh] items-center justify-center px-4 py-16">
|
||||
<Card padding="lg" class="w-full max-w-2xl">
|
||||
<div class="text-center">
|
||||
<!-- Error Code -->
|
||||
<div class="mb-4 text-8xl font-bold text-primary">
|
||||
{status}
|
||||
</div>
|
||||
|
||||
<!-- Error Title -->
|
||||
<h1 class="mb-4 text-3xl font-bold text-base-content">
|
||||
{getErrorTitle(status)}
|
||||
</h1>
|
||||
|
||||
<!-- Error Message -->
|
||||
<p class="mb-8 text-lg text-base-content/70">
|
||||
{getErrorMessage(status)}
|
||||
</p>
|
||||
|
||||
<!-- Debug Info (only in development) -->
|
||||
{#if import.meta.env?.DEV && error}
|
||||
<div class="mb-8 rounded-lg bg-base-300 p-4 text-left">
|
||||
<p class="mb-2 font-mono text-sm text-error">
|
||||
<strong>Debug Info:</strong>
|
||||
</p>
|
||||
<pre class="overflow-x-auto text-xs text-base-content/80">{JSON.stringify(
|
||||
error,
|
||||
null,
|
||||
2
|
||||
)}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-col justify-center gap-4 sm:flex-row">
|
||||
<Button variant="secondary" href="javascript:history.back()">
|
||||
<ArrowLeft class="mr-2 h-5 w-5" />
|
||||
Go Back
|
||||
</Button>
|
||||
|
||||
<Button variant="primary" href="/">
|
||||
<Home class="mr-2 h-5 w-5" />
|
||||
Go Home
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Help Text -->
|
||||
<p class="mt-8 text-sm text-base-content/50">
|
||||
If this problem persists, please
|
||||
<a href="https://somegit.dev/CSGOWTF/csgowtf/issues" class="link-hover link text-primary">
|
||||
report it on GitHub
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
19
src/routes/+layout.svelte
Normal file
19
src/routes/+layout.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import Header from '$lib/components/layout/Header.svelte';
|
||||
import Footer from '$lib/components/layout/Footer.svelte';
|
||||
import ToastContainer from '$lib/components/ui/ToastContainer.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen flex-col bg-base-100">
|
||||
<Header />
|
||||
<main class="flex-1">
|
||||
{@render children()}
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
<!-- Toast notifications -->
|
||||
<ToastContainer />
|
||||
</div>
|
||||
38
src/routes/+layout.ts
Normal file
38
src/routes/+layout.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { LayoutLoad } from './$types';
|
||||
|
||||
/**
|
||||
* Root layout load function
|
||||
* Runs on both server and client
|
||||
*/
|
||||
export const load: LayoutLoad = async () => {
|
||||
// Load application-wide data here
|
||||
// For now, just return empty data structure
|
||||
|
||||
return {
|
||||
// App version from environment
|
||||
appVersion: import.meta.env?.VITE_APP_VERSION || '2.0.0',
|
||||
|
||||
// Feature flags
|
||||
features: {
|
||||
liveMatches: import.meta.env?.VITE_ENABLE_LIVE_MATCHES === 'true',
|
||||
analytics: import.meta.env?.VITE_ENABLE_ANALYTICS === 'true',
|
||||
debugMode: import.meta.env?.VITE_DEBUG_MODE === 'true'
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Prerender this layout (static generation)
|
||||
* Set to false if you need dynamic data
|
||||
*/
|
||||
export const prerender = false;
|
||||
|
||||
/**
|
||||
* Enable client-side routing
|
||||
*/
|
||||
export const ssr = true;
|
||||
|
||||
/**
|
||||
* Trailing slash handling
|
||||
*/
|
||||
export const trailingSlash = 'never';
|
||||
433
src/routes/+page.svelte
Normal file
433
src/routes/+page.svelte
Normal file
@@ -0,0 +1,433 @@
|
||||
<script lang="ts">
|
||||
import { Search, TrendingUp, Users, Zap, ChevronLeft, ChevronRight } from 'lucide-svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import MatchCard from '$lib/components/match/MatchCard.svelte';
|
||||
import RecentPlayers from '$lib/components/player/RecentPlayers.svelte';
|
||||
import PieChart from '$lib/components/charts/PieChart.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
// Get data from page loader
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// Use matches directly - already transformed by API client
|
||||
const featuredMatches = data.featuredMatches;
|
||||
const mapStats = data.mapStats;
|
||||
|
||||
// Count matches being processed (demos not yet parsed)
|
||||
const processingCount = $derived(featuredMatches.filter((m) => !m.demo_parsed).length);
|
||||
|
||||
// Prepare map chart data
|
||||
const mapChartData = $derived({
|
||||
labels: mapStats.map((s) => s.map),
|
||||
datasets: [
|
||||
{
|
||||
data: mapStats.map((s) => s.count),
|
||||
backgroundColor: [
|
||||
'rgba(59, 130, 246, 0.8)', // blue
|
||||
'rgba(16, 185, 129, 0.8)', // green
|
||||
'rgba(245, 158, 11, 0.8)', // amber
|
||||
'rgba(239, 68, 68, 0.8)', // red
|
||||
'rgba(139, 92, 246, 0.8)', // purple
|
||||
'rgba(236, 72, 153, 0.8)', // pink
|
||||
'rgba(20, 184, 166, 0.8)' // teal
|
||||
],
|
||||
borderColor: [
|
||||
'rgba(255, 255, 255, 0.8)',
|
||||
'rgba(255, 255, 255, 0.8)',
|
||||
'rgba(255, 255, 255, 0.8)',
|
||||
'rgba(255, 255, 255, 0.8)',
|
||||
'rgba(255, 255, 255, 0.8)',
|
||||
'rgba(255, 255, 255, 0.8)',
|
||||
'rgba(255, 255, 255, 0.8)'
|
||||
],
|
||||
borderWidth: 2
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const stats = [
|
||||
{ icon: Users, label: 'Players Tracked', value: '1.2M+' },
|
||||
{ icon: TrendingUp, label: 'Matches Analyzed', value: '500K+' },
|
||||
{ icon: Zap, label: 'Demos Parsed', value: '2M+' }
|
||||
];
|
||||
|
||||
// Carousel state
|
||||
let currentSlide = $state(0);
|
||||
let isPaused = $state(false);
|
||||
let autoRotateInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let manualNavigationTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let windowWidth = $state(1024); // Default to desktop
|
||||
|
||||
// Track window width for responsive slides
|
||||
$effect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
windowWidth = window.innerWidth;
|
||||
|
||||
const handleResize = () => {
|
||||
windowWidth = window.innerWidth;
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}
|
||||
// Return empty cleanup function for server-side rendering path
|
||||
return () => {};
|
||||
});
|
||||
|
||||
// Determine matches per slide based on screen width
|
||||
const matchesPerSlide = $derived(windowWidth < 768 ? 1 : windowWidth < 1024 ? 2 : 3);
|
||||
|
||||
const totalSlides = $derived(Math.ceil(featuredMatches.length / matchesPerSlide));
|
||||
|
||||
// Get visible matches for current slide
|
||||
const visibleMatches = $derived.by(() => {
|
||||
const start = currentSlide * matchesPerSlide;
|
||||
return featuredMatches.slice(start, start + matchesPerSlide);
|
||||
});
|
||||
|
||||
function nextSlide() {
|
||||
currentSlide = (currentSlide + 1) % totalSlides;
|
||||
}
|
||||
|
||||
function prevSlide() {
|
||||
currentSlide = (currentSlide - 1 + totalSlides) % totalSlides;
|
||||
}
|
||||
|
||||
function goToSlide(index: number) {
|
||||
currentSlide = index;
|
||||
pauseAutoRotateTemporarily();
|
||||
}
|
||||
|
||||
function pauseAutoRotateTemporarily() {
|
||||
isPaused = true;
|
||||
if (manualNavigationTimeout) clearTimeout(manualNavigationTimeout);
|
||||
manualNavigationTimeout = setTimeout(() => {
|
||||
isPaused = false;
|
||||
}, 10000); // Resume after 10 seconds
|
||||
}
|
||||
|
||||
function handleManualNavigation(direction: 'prev' | 'next') {
|
||||
if (direction === 'prev') {
|
||||
prevSlide();
|
||||
} else {
|
||||
nextSlide();
|
||||
}
|
||||
pauseAutoRotateTemporarily();
|
||||
}
|
||||
|
||||
// Auto-rotation effect
|
||||
$effect(() => {
|
||||
if (autoRotateInterval) clearInterval(autoRotateInterval);
|
||||
|
||||
autoRotateInterval = setInterval(() => {
|
||||
if (!isPaused) {
|
||||
nextSlide();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
if (autoRotateInterval) clearInterval(autoRotateInterval);
|
||||
if (manualNavigationTimeout) clearTimeout(manualNavigationTimeout);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.meta.title}</title>
|
||||
<meta name="description" content={data.meta.description} />
|
||||
</svelte:head>
|
||||
|
||||
<!-- Hero Section -->
|
||||
<section class="border-b border-base-300 bg-gradient-to-b from-base-100 to-base-200 py-24">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="mx-auto max-w-4xl text-center">
|
||||
<div class="mb-6">
|
||||
<Badge variant="info" size="md">🎮 Now supporting CS2</Badge>
|
||||
</div>
|
||||
|
||||
<h1 class="mb-6 text-6xl font-bold leading-tight md:text-7xl">
|
||||
<span class="text-primary">CS2</span><span class="text-secondary">.WTF</span>
|
||||
</h1>
|
||||
|
||||
<p class="mb-8 text-xl text-base-content/70 md:text-2xl">
|
||||
Track your performance, analyze matches, and improve your game with
|
||||
<span class="font-semibold text-primary">detailed statistics</span> and insights.
|
||||
</p>
|
||||
|
||||
<div class="mb-12 flex flex-col justify-center gap-4 sm:flex-row">
|
||||
<Button variant="primary" size="lg" href="/matches">
|
||||
<Search class="mr-2 h-5 w-5" />
|
||||
Browse Matches
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" href="/player/76561198012345678">
|
||||
<Users class="mr-2 h-5 w-5" />
|
||||
View Demo Profile
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid gap-6 md:grid-cols-3">
|
||||
{#each stats as stat}
|
||||
{@const StatIcon = stat.icon}
|
||||
<div class="rounded-lg bg-base-100 p-6 shadow-lg">
|
||||
<StatIcon class="mx-auto mb-3 h-8 w-8 text-primary" />
|
||||
<div class="text-3xl font-bold text-base-content">{stat.value}</div>
|
||||
<div class="text-sm text-base-content/60">{stat.label}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Recently Visited Players -->
|
||||
<section class="py-8">
|
||||
<div class="container mx-auto px-4">
|
||||
<RecentPlayers />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Featured Matches -->
|
||||
<section class="py-16">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="mb-8 flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-3xl font-bold text-base-content">Featured Matches</h2>
|
||||
{#if processingCount > 0}
|
||||
<Badge variant="warning" size="sm">
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span
|
||||
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-warning opacity-75"
|
||||
></span>
|
||||
<span class="relative inline-flex h-2 w-2 rounded-full bg-warning"></span>
|
||||
</span>
|
||||
<span class="ml-1.5">{processingCount} Processing</span>
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="mt-2 text-base-content/60">Latest competitive matches from our community</p>
|
||||
</div>
|
||||
<Button variant="ghost" href="/matches">View All</Button>
|
||||
</div>
|
||||
|
||||
{#if featuredMatches.length > 0}
|
||||
<!-- Carousel Container -->
|
||||
<div
|
||||
class="relative"
|
||||
onmouseenter={() => (isPaused = true)}
|
||||
onmouseleave={() => (isPaused = false)}
|
||||
role="region"
|
||||
aria-label="Featured matches carousel"
|
||||
>
|
||||
<!-- Matches Grid with Fade Transition -->
|
||||
<div class="transition-opacity duration-500" class:opacity-100={true}>
|
||||
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each visibleMatches as match (match.match_id)}
|
||||
<MatchCard {match} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Arrows - Only show if there are multiple slides -->
|
||||
{#if totalSlides > 1}
|
||||
<!-- Previous Button -->
|
||||
<button
|
||||
onclick={() => handleManualNavigation('prev')}
|
||||
class="group absolute left-0 top-1/2 z-10 -translate-x-4 -translate-y-1/2 rounded-md border border-base-content/10 bg-base-100/95 p-2 shadow-[0_8px_30px_rgb(0,0,0,0.12)] backdrop-blur-md transition-all duration-200 hover:-translate-x-5 hover:border-primary/30 hover:bg-base-100 hover:shadow-[0_12px_40px_rgb(0,0,0,0.15)] focus:outline-none focus:ring-2 focus:ring-primary/50 md:-translate-x-6 md:hover:-translate-x-7"
|
||||
aria-label="Previous slide"
|
||||
>
|
||||
<ChevronLeft
|
||||
class="h-6 w-6 text-base-content/70 transition-colors duration-200 group-hover:text-primary"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Next Button -->
|
||||
<button
|
||||
onclick={() => handleManualNavigation('next')}
|
||||
class="group absolute right-0 top-1/2 z-10 -translate-y-1/2 translate-x-4 rounded-md border border-base-content/10 bg-base-100/95 p-2 shadow-[0_8px_30px_rgb(0,0,0,0.12)] backdrop-blur-md transition-all duration-200 hover:translate-x-5 hover:border-primary/30 hover:bg-base-100 hover:shadow-[0_12px_40px_rgb(0,0,0,0.15)] focus:outline-none focus:ring-2 focus:ring-primary/50 md:translate-x-6 md:hover:translate-x-7"
|
||||
aria-label="Next slide"
|
||||
>
|
||||
<ChevronRight
|
||||
class="h-6 w-6 text-base-content/70 transition-colors duration-200 group-hover:text-primary"
|
||||
/>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Dot Indicators - Only show if there are multiple slides -->
|
||||
{#if totalSlides > 1}
|
||||
<div class="mt-8 flex justify-center gap-2">
|
||||
{#each Array(totalSlides) as _, i}
|
||||
<button
|
||||
onclick={() => goToSlide(i)}
|
||||
class="h-2 w-2 rounded-full transition-all duration-300 hover:scale-125 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
class:bg-primary={i === currentSlide}
|
||||
class:w-8={i === currentSlide}
|
||||
class:bg-base-300={i !== currentSlide}
|
||||
aria-label={`Go to slide ${i + 1}`}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- No Matches Found -->
|
||||
<div class="rounded-lg border border-base-300 bg-base-100 p-12 text-center">
|
||||
<p class="text-lg text-base-content/60">No featured matches available at the moment.</p>
|
||||
<p class="mt-2 text-sm text-base-content/40">Check back soon for the latest matches!</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Statistics Dashboard -->
|
||||
{#if mapStats.length > 0}
|
||||
<section class="border-t border-base-300 bg-base-100 py-16">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="mb-8 text-center">
|
||||
<h2 class="text-3xl font-bold text-base-content">Community Statistics</h2>
|
||||
<p class="mt-2 text-base-content/60">
|
||||
Insights from {data.totalMatchesAnalyzed.toLocaleString()} recent matches
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-8 lg:grid-cols-2">
|
||||
<!-- Most Played Maps -->
|
||||
<Card padding="lg">
|
||||
<h3 class="mb-6 text-xl font-semibold text-base-content">Most Played Maps</h3>
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="w-full max-w-md">
|
||||
<PieChart data={mapChartData} options={{ maintainAspectRatio: true }} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 space-y-2">
|
||||
{#each mapStats as stat, i}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="h-3 w-3 rounded-sm"
|
||||
style="background-color: {mapChartData.datasets[0]?.backgroundColor?.[i] ||
|
||||
'rgba(59, 130, 246, 0.8)'}"
|
||||
></div>
|
||||
<span class="text-sm font-medium text-base-content">{stat.map}</span>
|
||||
</div>
|
||||
<span class="text-sm text-base-content/60"
|
||||
>{stat.count} matches ({((stat.count / data.totalMatchesAnalyzed) * 100).toFixed(
|
||||
1
|
||||
)}%)</span
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Quick Stats Summary -->
|
||||
<Card padding="lg">
|
||||
<h3 class="mb-6 text-xl font-semibold text-base-content">Recent Activity</h3>
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-lg bg-base-200 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-base-content/60">Total Matches</p>
|
||||
<p class="text-3xl font-bold text-primary">
|
||||
{data.totalMatchesAnalyzed.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<TrendingUp class="h-12 w-12 text-primary/40" />
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-base-content/50">From the last 24 hours</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-base-200 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-base-content/60">Most Popular Map</p>
|
||||
<p class="text-3xl font-bold text-secondary">
|
||||
{mapStats[0]?.map || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="success" size="lg"
|
||||
>{mapStats[0]
|
||||
? `${((mapStats[0].count / data.totalMatchesAnalyzed) * 100).toFixed(0)}%`
|
||||
: '0%'}</Badge
|
||||
>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-base-content/50">
|
||||
Played in {mapStats[0]?.count || 0} matches
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<Button variant="ghost" href="/matches">View All Match Statistics →</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Features Section -->
|
||||
<section class="border-t border-base-300 bg-base-200 py-16">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="mb-12 text-center">
|
||||
<h2 class="text-3xl font-bold text-base-content">Why CS2.WTF?</h2>
|
||||
<p class="mt-2 text-base-content/60">Everything you need to analyze your CS2 performance</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Card padding="lg">
|
||||
<div class="mb-4 inline-flex rounded-lg bg-primary/10 p-3">
|
||||
<TrendingUp class="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<h3 class="mb-2 text-xl font-semibold">Detailed Statistics</h3>
|
||||
<p class="text-base-content/60">
|
||||
Track K/D, ADR, HS%, KAST, and more. Analyze your performance round-by-round with
|
||||
comprehensive stats.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg">
|
||||
<div class="mb-4 inline-flex rounded-lg bg-secondary/10 p-3">
|
||||
<Zap class="h-6 w-6 text-secondary" />
|
||||
</div>
|
||||
<h3 class="mb-2 text-xl font-semibold">Economy Tracking</h3>
|
||||
<p class="text-base-content/60">
|
||||
Understand money management with round-by-round economy analysis and spending patterns.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg">
|
||||
<div class="mb-4 inline-flex rounded-lg bg-info/10 p-3">
|
||||
<Users class="h-6 w-6 text-info" />
|
||||
</div>
|
||||
<h3 class="mb-2 text-xl font-semibold">Player Profiles</h3>
|
||||
<p class="text-base-content/60">
|
||||
View comprehensive player profiles with match history, favorite maps, and performance
|
||||
trends.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA Section -->
|
||||
<section class="py-16">
|
||||
<div class="container mx-auto px-4">
|
||||
<Card variant="elevated" padding="lg">
|
||||
<div class="text-center">
|
||||
<h2 class="mb-4 text-3xl font-bold text-base-content">Ready to improve your game?</h2>
|
||||
<p class="mb-8 text-lg text-base-content/70">
|
||||
Start tracking your CS2 matches and get insights that help you rank up.
|
||||
</p>
|
||||
<Button variant="primary" size="lg" href="/matches">Get Started - It's Free</Button>
|
||||
<p class="mt-4 text-sm text-base-content/50">Free and open source. No signup required.</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
59
src/routes/+page.ts
Normal file
59
src/routes/+page.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { PageLoad } from './$types';
|
||||
import { api } from '$lib/api';
|
||||
|
||||
/**
|
||||
* Homepage data loader
|
||||
* Loads featured matches for the homepage
|
||||
*/
|
||||
export const load: PageLoad = async ({ parent }) => {
|
||||
// Wait for parent layout data
|
||||
await parent();
|
||||
|
||||
try {
|
||||
// Load matches for homepage - get more for statistics
|
||||
const matchesData = await api.matches.getMatches({ limit: 50 });
|
||||
const allMatches = matchesData.matches;
|
||||
|
||||
// Calculate map statistics
|
||||
const mapCounts = new Map<string, number>();
|
||||
allMatches.forEach((match) => {
|
||||
const count = mapCounts.get(match.map) || 0;
|
||||
mapCounts.set(match.map, count + 1);
|
||||
});
|
||||
|
||||
// Convert to sorted array for pie chart
|
||||
const mapStats = Array.from(mapCounts.entries())
|
||||
.map(([map, count]) => ({ map, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 7); // Top 7 maps
|
||||
|
||||
return {
|
||||
featuredMatches: allMatches.slice(0, 9), // Get 9 matches for carousel (3 slides)
|
||||
mapStats, // For most played maps pie chart
|
||||
totalMatchesAnalyzed: allMatches.length,
|
||||
meta: {
|
||||
title: 'CS2.WTF - Statistics for CS2 Matchmaking',
|
||||
description:
|
||||
'Track your CS2 performance, analyze matches, and improve your game with detailed statistics and insights.'
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
// Log error but don't fail the page load
|
||||
console.error(
|
||||
'Failed to load featured matches:',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
|
||||
// Return empty data - page will show without featured matches
|
||||
return {
|
||||
featuredMatches: [],
|
||||
mapStats: [],
|
||||
totalMatchesAnalyzed: 0,
|
||||
meta: {
|
||||
title: 'CS2.WTF - Statistics for CS2 Matchmaking',
|
||||
description:
|
||||
'Track your CS2 performance, analyze matches, and improve your game with detailed statistics and insights.'
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
87
src/routes/about/+page.svelte
Normal file
87
src/routes/about/+page.svelte
Normal file
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import { Github, Heart, Code } from 'lucide-svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>About - CS2.WTF</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container mx-auto max-w-4xl px-4 py-12">
|
||||
<h1 class="mb-8 text-4xl font-bold">About CS2.WTF</h1>
|
||||
|
||||
<Card padding="lg" class="mb-8">
|
||||
<h2 class="mb-4 text-2xl font-semibold">Our Mission</h2>
|
||||
<p class="mb-4 text-base-content/80">
|
||||
CS2.WTF is a free and open-source platform for analyzing Counter-Strike 2 matchmaking matches.
|
||||
We provide detailed statistics, performance insights, and tools to help players improve their
|
||||
game.
|
||||
</p>
|
||||
<p class="text-base-content/80">
|
||||
Originally created for CS:GO, we've completely rewritten the platform to support CS2 with
|
||||
modern technologies and enhanced features.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<div class="mb-8 grid gap-6 md:grid-cols-3">
|
||||
<Card padding="lg">
|
||||
<Code class="mb-3 h-8 w-8 text-primary" />
|
||||
<h3 class="mb-2 text-xl font-semibold">Open Source</h3>
|
||||
<p class="text-sm text-base-content/70">
|
||||
Built by the community, for the community. All code is available on GitHub.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg">
|
||||
<Heart class="mb-3 h-8 w-8 text-error" />
|
||||
<h3 class="mb-2 text-xl font-semibold">Free Forever</h3>
|
||||
<p class="text-sm text-base-content/70">
|
||||
No paywalls, no premium features. Everyone gets full access to all statistics.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg">
|
||||
<Github class="mb-3 h-8 w-8 text-info" />
|
||||
<h3 class="mb-2 text-xl font-semibold">Community Driven</h3>
|
||||
<p class="text-sm text-base-content/70">
|
||||
Contributions welcome! Help us make CS2.WTF better for everyone.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card padding="lg" class="mb-8">
|
||||
<h2 class="mb-4 text-2xl font-semibold">Technology Stack</h2>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<h3 class="mb-2 font-semibold text-primary">Frontend</h3>
|
||||
<ul class="space-y-1 text-sm text-base-content/80">
|
||||
<li>• SvelteKit 2.0 + Svelte 5</li>
|
||||
<li>• TypeScript (Strict Mode)</li>
|
||||
<li>• Tailwind CSS + DaisyUI</li>
|
||||
<li>• Vitest + Playwright</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="mb-2 font-semibold text-secondary">Backend</h3>
|
||||
<ul class="space-y-1 text-sm text-base-content/80">
|
||||
<li>• Go + Gin Framework</li>
|
||||
<li>• PostgreSQL Database</li>
|
||||
<li>• Redis Cache</li>
|
||||
<li>• Demo Parser</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div class="flex justify-center gap-4">
|
||||
<Button variant="primary" href="https://somegit.dev/CSGOWTF/csgowtf">
|
||||
<Github class="mr-2 h-5 w-5" />
|
||||
View on GitHub
|
||||
</Button>
|
||||
<Button variant="secondary" href="https://liberapay.com/CSGOWTF/">
|
||||
<Heart class="mr-2 h-5 w-5" />
|
||||
Support Us
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user