Compare commits
51 Commits
| 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 | |||
| be89c68f89 | |||
| 9ab7ee91ea | |||
| 408ee9df1e | |||
| 36fbe8a685 | |||
| 110586942d |
@@ -1,3 +0,0 @@
|
|||||||
> 1%
|
|
||||||
last 2 versions
|
|
||||||
not dead
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
root = true
|
|
||||||
|
|
||||||
[*]
|
|
||||||
end_of_line = lf
|
|
||||||
insert_final_newline = true
|
|
||||||
|
|
||||||
[*.{js,json,yml}]
|
|
||||||
charset = utf-8
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
80
.env.example
Normal file
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
|
||||||
17
.eslintrc.js
17
.eslintrc.js
@@ -1,17 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
root: true,
|
|
||||||
env: {
|
|
||||||
node: true
|
|
||||||
},
|
|
||||||
'extends': [
|
|
||||||
'plugin:vue/vue3-essential',
|
|
||||||
'eslint:recommended'
|
|
||||||
],
|
|
||||||
parserOptions: {
|
|
||||||
parser: '@babel/eslint-parser'
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
|
||||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
304
.gitignore
vendored
304
.gitignore
vendored
@@ -1,286 +1,52 @@
|
|||||||
# Created by https://www.toptal.com/developers/gitignore/api/webstorm+all,yarn,windows,linux,node,vuejs
|
.DS_Store
|
||||||
# Edit at https://www.toptal.com/developers/gitignore?templates=webstorm+all,yarn,windows,linux,node,vuejs
|
node_modules
|
||||||
|
/build
|
||||||
|
/.svelte-kit
|
||||||
|
/package
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
### Linux ###
|
|
||||||
*~
|
|
||||||
|
|
||||||
# temporary files which can be created if a process still has a handle open of a deleted file
|
|
||||||
.fuse_hidden*
|
|
||||||
|
|
||||||
# KDE directory preferences
|
|
||||||
.directory
|
|
||||||
|
|
||||||
# Linux trash folder which might appear on any partition or disk
|
|
||||||
.Trash-*
|
|
||||||
|
|
||||||
# .nfs files are created when an open file is removed but is still being accessed
|
|
||||||
.nfs*
|
|
||||||
|
|
||||||
### Node ###
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
.pnpm-debug.log*
|
|
||||||
|
|
||||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
# Editor directories and files
|
||||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
# Runtime data
|
# Build artifacts
|
||||||
pids
|
dist
|
||||||
*.pid
|
dist-ssr
|
||||||
*.seed
|
*.local
|
||||||
*.pid.lock
|
|
||||||
|
|
||||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
# Test coverage
|
||||||
lib-cov
|
|
||||||
|
|
||||||
# Coverage directory used by tools like istanbul
|
|
||||||
coverage
|
coverage
|
||||||
*.lcov
|
*.lcov
|
||||||
|
|
||||||
# nyc test coverage
|
|
||||||
.nyc_output
|
.nyc_output
|
||||||
|
|
||||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
# Playwright
|
||||||
.grunt
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
# Bower dependency directory (https://bower.io/)
|
/playwright/.cache/
|
||||||
bower_components
|
|
||||||
|
|
||||||
# node-waf configuration
|
|
||||||
.lock-wscript
|
|
||||||
|
|
||||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
|
||||||
build/Release
|
|
||||||
|
|
||||||
# Dependency directories
|
|
||||||
node_modules/
|
|
||||||
jspm_packages/
|
|
||||||
|
|
||||||
# Snowpack dependency directory (https://snowpack.dev/)
|
|
||||||
web_modules/
|
|
||||||
|
|
||||||
# TypeScript cache
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
# Optional npm cache directory
|
|
||||||
.npm
|
|
||||||
|
|
||||||
# Optional eslint cache
|
|
||||||
.eslintcache
|
|
||||||
|
|
||||||
# Microbundle cache
|
|
||||||
.rpt2_cache/
|
|
||||||
.rts2_cache_cjs/
|
|
||||||
.rts2_cache_es/
|
|
||||||
.rts2_cache_umd/
|
|
||||||
|
|
||||||
# Optional REPL history
|
|
||||||
.node_repl_history
|
|
||||||
|
|
||||||
# Output of 'npm pack'
|
|
||||||
*.tgz
|
|
||||||
|
|
||||||
# Yarn Integrity file
|
|
||||||
.yarn-integrity
|
|
||||||
|
|
||||||
# dotenv environment variables file
|
|
||||||
.env
|
|
||||||
.env.test
|
|
||||||
.env.production
|
|
||||||
|
|
||||||
# parcel-bundler cache (https://parceljs.org/)
|
|
||||||
.cache
|
|
||||||
.parcel-cache
|
|
||||||
|
|
||||||
# Next.js build output
|
|
||||||
.next
|
|
||||||
out
|
|
||||||
|
|
||||||
# Nuxt.js build / generate output
|
|
||||||
.nuxt
|
|
||||||
dist
|
|
||||||
|
|
||||||
# Gatsby files
|
|
||||||
.cache/
|
|
||||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
|
||||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
|
||||||
# public
|
|
||||||
|
|
||||||
# vuepress build output
|
|
||||||
.vuepress/dist
|
|
||||||
|
|
||||||
# Serverless directories
|
|
||||||
.serverless/
|
|
||||||
|
|
||||||
# FuseBox cache
|
|
||||||
.fusebox/
|
|
||||||
|
|
||||||
# DynamoDB Local files
|
|
||||||
.dynamodb/
|
|
||||||
|
|
||||||
# TernJS port file
|
|
||||||
.tern-port
|
|
||||||
|
|
||||||
# Stores VSCode versions used for testing VSCode extensions
|
|
||||||
.vscode-test
|
|
||||||
|
|
||||||
# yarn v2
|
|
||||||
.yarn/cache
|
|
||||||
.yarn/unplugged
|
|
||||||
.yarn/build-state.yml
|
|
||||||
.yarn/install-state.gz
|
|
||||||
.pnp.*
|
|
||||||
|
|
||||||
### Node Patch ###
|
|
||||||
# Serverless Webpack directories
|
|
||||||
.webpack/
|
|
||||||
|
|
||||||
### Vuejs ###
|
|
||||||
# Recommended template: Node.gitignore
|
|
||||||
|
|
||||||
dist/
|
|
||||||
npm-debug.log
|
|
||||||
yarn-error.log
|
|
||||||
|
|
||||||
### WebStorm+all ###
|
|
||||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
|
||||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
|
||||||
|
|
||||||
# User-specific stuff
|
|
||||||
.idea/**/workspace.xml
|
|
||||||
.idea/**/tasks.xml
|
|
||||||
.idea/**/usage.statistics.xml
|
|
||||||
.idea/**/dictionaries
|
|
||||||
.idea/**/shelf
|
|
||||||
|
|
||||||
# AWS User-specific
|
|
||||||
.idea/**/aws.xml
|
|
||||||
|
|
||||||
# Generated files
|
|
||||||
.idea/**/contentModel.xml
|
|
||||||
|
|
||||||
# Sensitive or high-churn files
|
|
||||||
.idea/**/dataSources/
|
|
||||||
.idea/**/dataSources.ids
|
|
||||||
.idea/**/dataSources.local.xml
|
|
||||||
.idea/**/sqlDataSources.xml
|
|
||||||
.idea/**/dynamic.xml
|
|
||||||
.idea/**/uiDesigner.xml
|
|
||||||
.idea/**/dbnavigator.xml
|
|
||||||
|
|
||||||
# Gradle
|
|
||||||
.idea/**/gradle.xml
|
|
||||||
.idea/**/libraries
|
|
||||||
|
|
||||||
# Gradle and Maven with auto-import
|
|
||||||
# When using Gradle or Maven with auto-import, you should exclude module files,
|
|
||||||
# since they will be recreated, and may cause churn. Uncomment if using
|
|
||||||
# auto-import.
|
|
||||||
# .idea/artifacts
|
|
||||||
# .idea/compiler.xml
|
|
||||||
# .idea/jarRepositories.xml
|
|
||||||
# .idea/modules.xml
|
|
||||||
# .idea/*.iml
|
|
||||||
# .idea/modules
|
|
||||||
# *.iml
|
|
||||||
# *.ipr
|
|
||||||
|
|
||||||
# CMake
|
|
||||||
cmake-build-*/
|
|
||||||
|
|
||||||
# Mongo Explorer plugin
|
|
||||||
.idea/**/mongoSettings.xml
|
|
||||||
|
|
||||||
# File-based project format
|
|
||||||
*.iws
|
|
||||||
|
|
||||||
# IntelliJ
|
|
||||||
out/
|
|
||||||
|
|
||||||
# mpeltonen/sbt-idea plugin
|
|
||||||
.idea_modules/
|
|
||||||
|
|
||||||
# JIRA plugin
|
|
||||||
atlassian-ide-plugin.xml
|
|
||||||
|
|
||||||
# Cursive Clojure plugin
|
|
||||||
.idea/replstate.xml
|
|
||||||
|
|
||||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
|
||||||
com_crashlytics_export_strings.xml
|
|
||||||
crashlytics.properties
|
|
||||||
crashlytics-build.properties
|
|
||||||
fabric.properties
|
|
||||||
|
|
||||||
# Editor-based Rest Client
|
|
||||||
.idea/httpRequests
|
|
||||||
|
|
||||||
# Android studio 3.1+ serialized cache file
|
|
||||||
.idea/caches/build_file_checksums.ser
|
|
||||||
|
|
||||||
### WebStorm+all Patch ###
|
|
||||||
# Ignores the whole .idea folder and all .iml files
|
|
||||||
# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360
|
|
||||||
|
|
||||||
.idea/
|
|
||||||
|
|
||||||
# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023
|
|
||||||
|
|
||||||
*.iml
|
|
||||||
modules.xml
|
|
||||||
.idea/misc.xml
|
|
||||||
*.ipr
|
|
||||||
|
|
||||||
# Sonarlint plugin
|
|
||||||
.idea/sonarlint
|
|
||||||
|
|
||||||
### Windows ###
|
|
||||||
# Windows thumbnail cache files
|
|
||||||
Thumbs.db
|
|
||||||
Thumbs.db:encryptable
|
|
||||||
ehthumbs.db
|
|
||||||
ehthumbs_vista.db
|
|
||||||
|
|
||||||
# Dump file
|
|
||||||
*.stackdump
|
|
||||||
|
|
||||||
# Folder config file
|
|
||||||
[Dd]esktop.ini
|
|
||||||
|
|
||||||
# Recycle Bin used on file shares
|
|
||||||
$RECYCLE.BIN/
|
|
||||||
|
|
||||||
# Windows Installer files
|
|
||||||
*.cab
|
|
||||||
*.msi
|
|
||||||
*.msix
|
|
||||||
*.msm
|
|
||||||
*.msp
|
|
||||||
|
|
||||||
# Windows shortcuts
|
|
||||||
*.lnk
|
|
||||||
|
|
||||||
### yarn ###
|
|
||||||
# https://yarnpkg.com/advanced/qa#which-files-should-be-gitignored
|
|
||||||
|
|
||||||
.yarn/*
|
|
||||||
!.yarn/releases
|
|
||||||
!.yarn/plugins
|
|
||||||
!.yarn/sdks
|
|
||||||
!.yarn/versions
|
|
||||||
|
|
||||||
# if you are NOT using Zero-installs, then:
|
|
||||||
# comment the following lines
|
|
||||||
#!.yarn/cache
|
|
||||||
|
|
||||||
# and uncomment the following lines
|
|
||||||
.pnp.*
|
|
||||||
|
|
||||||
# End of https://www.toptal.com/developers/gitignore/api/webstorm+all,yarn,windows,linux,node,vuejs
|
|
||||||
|
|
||||||
|
# Vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
.tmp
|
||||||
|
tmp
|
||||||
|
*.tmp
|
||||||
|
|||||||
4
.husky/pre-commit
Executable file
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
|
||||||
100
.woodpecker.yml
Normal file
100
.woodpecker.yml
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
pipeline:
|
||||||
|
install:
|
||||||
|
image: node:20
|
||||||
|
commands:
|
||||||
|
- 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:20
|
||||||
|
commands:
|
||||||
|
- 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: '/build/'
|
||||||
|
clean_dir: true
|
||||||
|
secrets: [ftp_username, ftp_password]
|
||||||
|
when:
|
||||||
|
branch: master
|
||||||
|
event: [push, tag]
|
||||||
|
status: success
|
||||||
|
|
||||||
|
deploy-dev:
|
||||||
|
image: cschlosser/drone-ftps
|
||||||
|
settings:
|
||||||
|
hostname:
|
||||||
|
from_secret: ftp_host
|
||||||
|
src_dir: '/build/'
|
||||||
|
clean_dir: true
|
||||||
|
secrets:
|
||||||
|
- source: ftp_username_dev
|
||||||
|
target: ftp_username
|
||||||
|
- source: ftp_password_dev
|
||||||
|
target: ftp_password
|
||||||
|
when:
|
||||||
|
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
|
||||||
363
.yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
vendored
363
.yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
vendored
File diff suppressed because one or more lines are too long
807
.yarn/releases/yarn-3.3.0.cjs
vendored
807
.yarn/releases/yarn-3.3.0.cjs
vendored
File diff suppressed because one or more lines are too long
@@ -1,7 +0,0 @@
|
|||||||
nodeLinker: node-modules
|
|
||||||
|
|
||||||
plugins:
|
|
||||||
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
|
|
||||||
spec: "@yarnpkg/plugin-interactive-tools"
|
|
||||||
|
|
||||||
yarnPath: .yarn/releases/yarn-3.3.0.cjs
|
|
||||||
63
Jenkinsfile
vendored
63
Jenkinsfile
vendored
@@ -1,63 +0,0 @@
|
|||||||
pipeline {
|
|
||||||
agent any
|
|
||||||
options {
|
|
||||||
buildDiscarder(logRotator(artifactDaysToKeepStr: '', artifactNumToKeepStr: '5', daysToKeepStr: '', numToKeepStr: ''))
|
|
||||||
}
|
|
||||||
|
|
||||||
environment {
|
|
||||||
FTP_HOST = credentials('csgowtf-deploy-host')
|
|
||||||
LFTP_PASSWORD = credentials('csgowtf-deploy-password')
|
|
||||||
API_HOST = credentials('csgowtf-api-host')
|
|
||||||
TRACK_HOST = credentials('csgowtf-track-host')
|
|
||||||
TRACK_ID = credentials('csgowtf-track-id')
|
|
||||||
TRACK_DOMAINS = credentials('csgowtf-track-domains')
|
|
||||||
TRACK = credentials('csgowtf-track')
|
|
||||||
}
|
|
||||||
|
|
||||||
stages {
|
|
||||||
stage('Prepare') {
|
|
||||||
steps {
|
|
||||||
writeFile file: '.env.production', text: 'VUE_APP_API_URL=$API_HOST\nVUE_APP_TRACK_URL=$TRACK_HOST\nVUE_APP_TRACK_ID=$TRACK_ID\nVUE_APP_TRACK_DOMAINS=$TRACK_DOMAINS\nVUE_APP_TRACKING=$TRACK'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stage('Install Dependencies') {
|
|
||||||
steps {
|
|
||||||
sh 'yarn install'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stage('Build') {
|
|
||||||
steps {
|
|
||||||
sh 'yarn build'
|
|
||||||
archiveArtifacts artifacts: '**/dist/**', excludes: '**/node_modules/**'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stage('Deploy') {
|
|
||||||
when {
|
|
||||||
branch 'master'
|
|
||||||
expression {
|
|
||||||
currentBuild.result == null || currentBuild.result == 'SUCCESS'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
environment {
|
|
||||||
FTP_USERNAME = credentials('csgowtf-deploy-user')
|
|
||||||
}
|
|
||||||
steps {
|
|
||||||
sh 'lftp -u $FTP_USERNAME --env-password -e \'mirror --reverse --verbose --delete --recursion=always dist/ /\' $FTP_HOST'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stage('Deploy Dev') {
|
|
||||||
when {
|
|
||||||
branch 'dev'
|
|
||||||
expression {
|
|
||||||
currentBuild.result == null || currentBuild.result == 'SUCCESS'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
environment {
|
|
||||||
FTP_USERNAME = credentials('csgowtf-deploy-user-dev')
|
|
||||||
}
|
|
||||||
steps {
|
|
||||||
sh 'lftp -u $FTP_USERNAME --env-password -e \'mirror --reverse --verbose --delete --recursion=always dist/ /\' $FTP_HOST'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
325
README.md
325
README.md
@@ -1,27 +1,314 @@
|
|||||||
# CSGOW.TF
|
# CS2.WTF
|
||||||
|
|
||||||
[](https://vuejs.org/)
|
[](https://kit.svelte.dev/)
|
||||||
[](https://go.dev/)
|
[](https://www.typescriptlang.org/)
|
||||||
|
[](https://tailwindcss.com/)
|
||||||
[](https://git.harting.dev/CSGOWTF/csgowtf/src/branch/master/LICENSE)
|
[](https://git.harting.dev/CSGOWTF/csgowtf/src/branch/master/LICENSE)
|
||||||
[](https://liberapay.com/CSGOWTF/)
|
[](https://liberapay.com/CSGOWTF/)
|
||||||
[](https://liberapay.com/CSGOWTF/)
|
[](https://ci.somegit.dev/CSGOWTF/csgowtf)
|
||||||
[](https://csgow.tf/)
|
|
||||||
<!--[](https://www.typescriptlang.org/)-->
|
|
||||||
|
|
||||||
### Statistics for CS:GO matchmaking matches.
|
**Statistics for CS2 matchmaking matches** - A complete rewrite of CSGOW.TF with modern web technologies.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Backend
|
## 🚀 Quick Start
|
||||||
This is the frontend to the [csgowtfd](https://git.harting.dev/CSGOWTF/csgowtfd) backend.
|
|
||||||
|
|
||||||
## Tips on how to contribute
|
### Prerequisites
|
||||||
- If you are implementing or fixing an issue, please comment on the issue so work is not duplicated.
|
|
||||||
- If you want to implement a new feature, create an issue first describing the issue, so we know about it.
|
- **Node.js** ≥ 18.0.0 (v20.11.0 recommended - see `.nvmrc`)
|
||||||
- Don't commit unnecessary changes to the codebase or debugging code.
|
- **npm** or **yarn**
|
||||||
- Write meaningful commits or squash them.
|
|
||||||
- Please try to follow the code style of the rest of the codebase.
|
### Installation
|
||||||
- Only make pull requests to the dev branch.
|
|
||||||
- Only implement one feature per pull request to keep it easy to understand.
|
```bash
|
||||||
- Expect comments or questions on your pull request from the project maintainers. We try to keep the code as consistent and maintainable as possible.
|
# Clone the repository
|
||||||
- Each pull request should come from a new branch in your fork, it should have a meaningful name.
|
git clone https://somegit.dev/CSGOWTF/csgowtf.git
|
||||||
|
cd csgowtf
|
||||||
|
|
||||||
|
# Switch to the cs2-port branch
|
||||||
|
git checkout cs2-port
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Copy environment variables
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The app will be available at `http://localhost:5173`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Tech Stack
|
||||||
|
|
||||||
|
### Core Framework
|
||||||
|
|
||||||
|
- **SvelteKit 2.0** - Full-stack framework with SSR/SSG
|
||||||
|
- **Svelte 5** - Reactive UI framework
|
||||||
|
- **TypeScript 5.3** - Type safety (strict mode)
|
||||||
|
- **Vite 5** - Build tool and dev server
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
|
||||||
|
- **Tailwind CSS 3.4** - Utility-first CSS framework
|
||||||
|
- **DaisyUI 4.0** - Component library with CS2 custom themes
|
||||||
|
- **PostCSS** - CSS processing
|
||||||
|
|
||||||
|
### Data & State
|
||||||
|
|
||||||
|
- **Axios** - HTTP client for API requests
|
||||||
|
- **Zod** - Runtime type validation and parsing
|
||||||
|
- **Svelte Stores** - State management
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- **Vitest** - Unit and component testing
|
||||||
|
- **Playwright** - End-to-end testing
|
||||||
|
- **Testing Library** - Component testing utilities
|
||||||
|
- **MSW** - API mocking
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
|
||||||
|
- **ESLint** - Linting (TypeScript + Svelte)
|
||||||
|
- **Prettier** - Code formatting
|
||||||
|
- **Stylelint** - CSS linting
|
||||||
|
- **Husky** - Git hooks
|
||||||
|
- **lint-staged** - Pre-commit linting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Development
|
||||||
|
|
||||||
|
### Available Scripts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
npm run dev # Start dev server
|
||||||
|
npm run dev -- --host # Expose to network
|
||||||
|
|
||||||
|
# Type Checking
|
||||||
|
npm run check # Run type check
|
||||||
|
npm run check:watch # Type check in watch mode
|
||||||
|
|
||||||
|
# Linting & Formatting
|
||||||
|
npm run lint # Run ESLint + Prettier check
|
||||||
|
npm run lint:fix # Auto-fix linting issues
|
||||||
|
npm run format # Format code with Prettier
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
npm run test # Run unit tests
|
||||||
|
npm run test:watch # Run tests in watch mode
|
||||||
|
npm run test:coverage # Generate coverage report
|
||||||
|
npm run test:e2e # Run E2E tests (headless)
|
||||||
|
npm run test:e2e:ui # Run E2E tests with UI
|
||||||
|
npm run test:e2e:debug # Debug E2E tests
|
||||||
|
|
||||||
|
# Building
|
||||||
|
npm run build # Build for production
|
||||||
|
npm run preview # Preview production build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
csgowtf/
|
||||||
|
├── src/
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ ├── api/ # API client & endpoints
|
||||||
|
│ │ ├── components/ # Reusable Svelte components
|
||||||
|
│ │ │ ├── layout/ # Header, Footer, Nav
|
||||||
|
│ │ │ ├── ui/ # Base UI components
|
||||||
|
│ │ │ ├── charts/ # Data visualization
|
||||||
|
│ │ │ ├── match/ # Match-specific components
|
||||||
|
│ │ │ └── player/ # Player-specific components
|
||||||
|
│ │ ├── stores/ # Svelte stores (state)
|
||||||
|
│ │ ├── types/ # TypeScript types
|
||||||
|
│ │ ├── utils/ # Helper functions
|
||||||
|
│ │ └── i18n/ # Internationalization
|
||||||
|
│ ├── routes/ # SvelteKit routes (pages)
|
||||||
|
│ ├── mocks/ # MSW mock handlers
|
||||||
|
│ ├── tests/ # Test setup
|
||||||
|
│ ├── app.html # HTML shell
|
||||||
|
│ └── app.css # Global styles
|
||||||
|
├── tests/
|
||||||
|
│ ├── unit/ # Unit tests
|
||||||
|
│ ├── integration/ # Integration tests
|
||||||
|
│ └── e2e/ # E2E tests
|
||||||
|
├── docs/ # Documentation
|
||||||
|
│ ├── API.md # Backend API reference
|
||||||
|
│ └── TODO.md # Project roadmap
|
||||||
|
├── public/ # Static assets
|
||||||
|
└── static/ # Additional static files
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Features
|
||||||
|
|
||||||
|
### Current (Phase 1 - ✅ Complete)
|
||||||
|
|
||||||
|
- ✅ SvelteKit project scaffolded with TypeScript strict mode
|
||||||
|
- ✅ Tailwind CSS + DaisyUI with CS2-themed color palette
|
||||||
|
- ✅ Complete development tooling (ESLint, Prettier, Husky)
|
||||||
|
- ✅ Testing infrastructure (Vitest + Playwright)
|
||||||
|
- ✅ CI/CD pipeline (Woodpecker)
|
||||||
|
- ✅ Backend API documented
|
||||||
|
|
||||||
|
### Planned (See `docs/TODO.md` for details)
|
||||||
|
|
||||||
|
- 🏠 Homepage with featured matches
|
||||||
|
- 📊 Match listing with advanced filters
|
||||||
|
- 👤 Player profiles with stats & charts
|
||||||
|
- 🎮 Match detail pages (overview, economy, flashes, damage, chat)
|
||||||
|
- 🌍 Multi-language support (i18n)
|
||||||
|
- 🌙 Dark/Light theme toggle (default: dark)
|
||||||
|
- 📱 Mobile-responsive design
|
||||||
|
- ♿ WCAG 2.1 AA accessibility
|
||||||
|
- 🎯 CS2-specific features (MR12, Premier rating, volumetric smokes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Backend
|
||||||
|
|
||||||
|
This frontend connects to the [csgowtfd](https://somegit.dev/CSGOWTF/csgowtfd) backend.
|
||||||
|
|
||||||
|
- **Language**: Go
|
||||||
|
- **Framework**: Gin
|
||||||
|
- **Database**: PostgreSQL
|
||||||
|
- **Cache**: Redis
|
||||||
|
- **API Docs**: See `docs/API.md`
|
||||||
|
|
||||||
|
Default API endpoint: `http://localhost:8000`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
### Unit & Component Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
npm run test
|
||||||
|
|
||||||
|
# Watch mode for TDD
|
||||||
|
npm run test:watch
|
||||||
|
|
||||||
|
# Generate coverage report
|
||||||
|
npm run test:coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
### End-to-End Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run E2E tests (headless)
|
||||||
|
npm run test:e2e
|
||||||
|
|
||||||
|
# Run with Playwright UI
|
||||||
|
npm run test:e2e:ui
|
||||||
|
|
||||||
|
# Debug mode
|
||||||
|
npm run test:e2e:debug
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚢 Deployment
|
||||||
|
|
||||||
|
### Build for Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
The built app will be in the `build/` directory, ready to be deployed to any Node.js hosting platform.
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
See `.env.example` for all available configuration options:
|
||||||
|
|
||||||
|
- `VITE_API_BASE_URL` - Backend API URL
|
||||||
|
- `VITE_API_TIMEOUT` - API request timeout
|
||||||
|
- `VITE_ENABLE_LIVE_MATCHES` - Feature flag for live matches
|
||||||
|
- `VITE_ENABLE_ANALYTICS` - Feature flag for analytics
|
||||||
|
|
||||||
|
### CI/CD
|
||||||
|
|
||||||
|
Woodpecker CI automatically builds and deploys:
|
||||||
|
|
||||||
|
- **`master`** branch → Production
|
||||||
|
- **`dev`** branch → Development/Staging
|
||||||
|
- **`cs2-port`** branch → CS2 Preview (during rewrite)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
We welcome contributions! Please follow these guidelines:
|
||||||
|
|
||||||
|
### Before You Start
|
||||||
|
|
||||||
|
- Check existing issues or create one describing your feature/fix
|
||||||
|
- Comment on the issue to avoid duplicate work
|
||||||
|
- Fork the repository and create a feature branch
|
||||||
|
|
||||||
|
### Code Standards
|
||||||
|
|
||||||
|
- Follow TypeScript strict mode (no `any` types)
|
||||||
|
- Write tests for new features
|
||||||
|
- Follow existing code style (enforced by ESLint/Prettier)
|
||||||
|
- Keep components under 300 lines
|
||||||
|
- Write meaningful commit messages (Conventional Commits)
|
||||||
|
|
||||||
|
### Pull Request Process
|
||||||
|
|
||||||
|
1. Create a feature branch: `feature/your-feature-name`
|
||||||
|
2. Make your changes and commit with clear messages
|
||||||
|
3. Run linting and tests: `npm run lint && npm run test`
|
||||||
|
4. Push to your fork and create a PR to the `cs2-port` branch
|
||||||
|
5. Ensure CI passes and address review feedback
|
||||||
|
|
||||||
|
### Git Workflow
|
||||||
|
|
||||||
|
- Branch naming: `feature/`, `fix/`, `refactor/`, `docs/`
|
||||||
|
- Commit messages: `feat:`, `fix:`, `docs:`, `test:`, `refactor:`
|
||||||
|
- Only one feature/fix per PR
|
||||||
|
- Squash commits before merging
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Documentation
|
||||||
|
|
||||||
|
- **API Reference**: [`docs/API.md`](docs/API.md) - Complete backend API documentation
|
||||||
|
- **Project Roadmap**: [`docs/TODO.md`](docs/TODO.md) - Detailed implementation plan
|
||||||
|
- **SvelteKit Docs**: [kit.svelte.dev](https://kit.svelte.dev/)
|
||||||
|
- **Tailwind CSS**: [tailwindcss.com](https://tailwindcss.com/)
|
||||||
|
- **DaisyUI**: [daisyui.com](https://daisyui.com/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
[GPL-3.0](LICENSE) © CSGOW.TF Team
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💖 Support
|
||||||
|
|
||||||
|
If you find this project helpful, consider supporting us:
|
||||||
|
|
||||||
|
[](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
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
presets: [
|
|
||||||
'@vue/cli-plugin-babel/preset'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
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
121
package.json
121
package.json
@@ -1,46 +1,79 @@
|
|||||||
{
|
{
|
||||||
"name": "csgowtf",
|
"name": "cs2wtf",
|
||||||
"version": "1.0.9",
|
"version": "2.0.0",
|
||||||
"private": true,
|
"description": "Statistics for CS2 matchmaking matches",
|
||||||
"scripts": {
|
"private": true,
|
||||||
"serve": "vue-cli-service serve",
|
"type": "module",
|
||||||
"build": "vue-cli-service build --mode production",
|
"scripts": {
|
||||||
"lint": "vue-cli-service lint"
|
"dev": "vite dev",
|
||||||
},
|
"build": "vite build",
|
||||||
"dependencies": {
|
"preview": "vite preview",
|
||||||
"@fontsource/open-sans": "^4.5.13",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"@fontsource/orbitron": "^4.5.10",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"@popperjs/core": "^2.11.6",
|
"lint": "prettier --check . && eslint .",
|
||||||
"axios": "^1.2.1",
|
"lint:fix": "prettier --write . && eslint --fix .",
|
||||||
"bootstrap": "^5.2.3",
|
"format": "prettier --write .",
|
||||||
"core-js": "^3.26.1",
|
"test": "vitest run",
|
||||||
"dotenv-webpack": "^8.0.1",
|
"test:watch": "vitest",
|
||||||
"echarts": "^5.4.0",
|
"test:coverage": "vitest run --coverage",
|
||||||
"fork-awesome": "^1.2.0",
|
"test:e2e": "playwright test",
|
||||||
"http-status-codes": "^2.2.0",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
"iso-639-1": "^2.1.15",
|
"test:e2e:debug": "playwright test --debug",
|
||||||
"jquery": "^3.6.1",
|
"prepare": "husky"
|
||||||
"luxon": "^3.1.1",
|
},
|
||||||
"string-sanitizer": "^2.0.2",
|
"dependencies": {
|
||||||
"vue": "^3.2.45",
|
"@sveltejs/kit": "^2.0.0",
|
||||||
"vue-matomo": "^4.2.0",
|
"axios": "^1.6.0",
|
||||||
"vue-router": "^4.1.6",
|
"chart.js": "^4.5.1",
|
||||||
"vue3-cookies": "^1.0.6",
|
"svelte": "^5.0.0",
|
||||||
"vuex": "^4.1.0"
|
"zod": "^3.22.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.20.5",
|
"@playwright/test": "^1.40.0",
|
||||||
"@babel/eslint-parser": "^7.19.1",
|
"@sveltejs/adapter-auto": "^3.0.0",
|
||||||
"@vue/cli-plugin-babel": "~5.0.8",
|
"@sveltejs/adapter-node": "^5.0.0",
|
||||||
"@vue/cli-plugin-eslint": "~5.0.8",
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
"@vue/cli-plugin-router": "~5.0.8",
|
"@testing-library/jest-dom": "^6.0.0",
|
||||||
"@vue/cli-plugin-vuex": "~5.0.8",
|
"@testing-library/svelte": "^5.0.0",
|
||||||
"@vue/cli-service": "~5.0.8",
|
"@types/node": "^20.10.0",
|
||||||
"@vue/compiler-sfc": "^3.2.45",
|
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||||
"eslint": "^8.29.0",
|
"@typescript-eslint/parser": "^7.0.0",
|
||||||
"eslint-plugin-vue": "^9.8.0",
|
"@vitest/coverage-v8": "^1.0.0",
|
||||||
"sass": "^1.56.1",
|
"autoprefixer": "^10.4.0",
|
||||||
"sass-loader": "^13.2.0"
|
"daisyui": "^4.0.0",
|
||||||
},
|
"eslint": "^8.57.0",
|
||||||
"packageManager": "yarn@3.3.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
69
src/App.vue
69
src/App.vue
@@ -1,69 +0,0 @@
|
|||||||
<template>
|
|
||||||
<img alt="" class="bg-img" src="">
|
|
||||||
<header>
|
|
||||||
<CompNav/>
|
|
||||||
</header>
|
|
||||||
<main>
|
|
||||||
<div :style="{height: offset + 'px'}"/>
|
|
||||||
<InfoModal/>
|
|
||||||
<router-view name="main"/>
|
|
||||||
</main>
|
|
||||||
<footer class="mt-auto">
|
|
||||||
<CompFooter/>
|
|
||||||
</footer>
|
|
||||||
<CookieConsentBtn id="cookie-btn"/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import {onMounted, ref} from "vue";
|
|
||||||
import InfoModal from "@/components/InfoModal";
|
|
||||||
import CompFooter from "@/components/CompFooter";
|
|
||||||
import CompNav from "@/components/CompNav";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
components: {CompNav, CompFooter, InfoModal},
|
|
||||||
setup() {
|
|
||||||
const offset = ref(0)
|
|
||||||
|
|
||||||
const setOffset = () => {
|
|
||||||
return document.getElementsByTagName('nav')[0].clientHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
const setBgHeight = () => {
|
|
||||||
document.querySelector('.bg-img').style.height = document.documentElement.clientHeight + 'px'
|
|
||||||
}
|
|
||||||
|
|
||||||
window.onresize = () => {
|
|
||||||
offset.value = setOffset()
|
|
||||||
setBgHeight()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
offset.value = setOffset()
|
|
||||||
setBgHeight()
|
|
||||||
})
|
|
||||||
|
|
||||||
return {offset}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
@font-face {
|
|
||||||
font-family: "Obitron";
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-img {
|
|
||||||
z-index: -1;
|
|
||||||
position: fixed;
|
|
||||||
width: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
#cookie-btn {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 30px;
|
|
||||||
right: 20px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
131
src/app.css
Normal file
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>
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="details-site">
|
|
||||||
<div class="multi-kills">
|
|
||||||
<h3 class="text-center mt-2">Multi-Kills</h3>
|
|
||||||
<MultiKillsChart/>
|
|
||||||
</div>
|
|
||||||
<!-- <hr>-->
|
|
||||||
<!-- <div class="spray">-->
|
|
||||||
<!-- <h3 class="text-center">Spray</h3>-->
|
|
||||||
<!-- <SprayGraph :spray="data.spray"/>-->
|
|
||||||
<!-- </div>-->
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import MultiKillsChart from "@/components/MultiKillsChart";
|
|
||||||
import {useStore} from "vuex";
|
|
||||||
import {onMounted, reactive} from "vue";
|
|
||||||
import {GetWeaponDmg} from "@/utils";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: "CompDetails",
|
|
||||||
components: {MultiKillsChart},
|
|
||||||
setup() {
|
|
||||||
const store = useStore()
|
|
||||||
|
|
||||||
const data = reactive({
|
|
||||||
spray: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
const getWeaponDamage = async () => {
|
|
||||||
const resData = await GetWeaponDmg(store, store.state.matchDetails.match_id)
|
|
||||||
if (resData !== null) {
|
|
||||||
data.spray = resData.spray
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
getWeaponDamage()
|
|
||||||
})
|
|
||||||
|
|
||||||
return {data}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.details-site {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
hr {
|
|
||||||
width: 100%;
|
|
||||||
border: 1px solid white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="footer bg-secondary text-center pt-4 pb-2">
|
|
||||||
<div class="text">
|
|
||||||
<p class="fs-6">Made with <i class="fa fa-heart text-warning" aria-hidden="true"></i>, <span
|
|
||||||
style="color: #41b883">Vue.js</span> and<a aria-label="Gitea" class="text-warning ms-2"
|
|
||||||
href="https://git.harting.dev/CSGOWTF"
|
|
||||||
target="_blank">
|
|
||||||
<i aria-hidden="true" class="fa fa-gitea"></i>
|
|
||||||
</a></p>
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-center align-items-center gap-4">
|
|
||||||
<p><a class="text-decoration-none text-warning"
|
|
||||||
href="https://git.harting.dev/CSGOWTF/csgowtf/issues"
|
|
||||||
target="_blank">Issue Tracker</a></p>
|
|
||||||
<p class="text-muted">Version {{ version }}</p>
|
|
||||||
<p>
|
|
||||||
<a class="text-decoration-none text-warning" href="/privacy-policy">Privacy Policy</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
name: "CompFooter",
|
|
||||||
setup() {
|
|
||||||
const version = process.env.VUE_APP_VERSION
|
|
||||||
return {version}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.footer {
|
|
||||||
.fa-gitea:hover {
|
|
||||||
color: #609926 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fa-heart:hover {
|
|
||||||
color: red !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
font-size: .85rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,360 +0,0 @@
|
|||||||
<template>
|
|
||||||
<nav class="navbar navbar-expand-md navbar-dark fixed-top">
|
|
||||||
<div class="container">
|
|
||||||
<router-link class="navbar-brand" to="/" @click="closeNav('mainNav')">
|
|
||||||
<img alt="logo-nav"
|
|
||||||
class="logo-nav"
|
|
||||||
src="/images/logo.svg">
|
|
||||||
</router-link>
|
|
||||||
<button aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation" class="navbar-toggler"
|
|
||||||
data-bs-target="#mainNav" data-bs-toggle="collapse" type="button">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div id="mainNav" class="collapse navbar-collapse navbar-nav justify-content-between">
|
|
||||||
<ul class="list-unstyled">
|
|
||||||
<li class="nav-item">
|
|
||||||
<router-link class="nav-link" to="/matches" @click="closeNav('mainNav')">
|
|
||||||
Matches
|
|
||||||
</router-link>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<form id="searchform" class="d-flex" @keydown.enter.prevent="parseSearch" @submit.prevent="parseSearch">
|
|
||||||
<label for="search">
|
|
||||||
<i class="fa fa-search"></i>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<input id="search" v-model="data.searchInput" aria-label="Search"
|
|
||||||
autocomplete="off"
|
|
||||||
class="form-control bg-transparent border-0"
|
|
||||||
placeholder="SteamID64, Profile Link or Custom URL"
|
|
||||||
title="SteamID64, Profile Link or Custom URL"
|
|
||||||
type="search">
|
|
||||||
<button
|
|
||||||
id="search-button"
|
|
||||||
class="btn border-2 btn-outline-info"
|
|
||||||
type="button"
|
|
||||||
@click="parseSearch"
|
|
||||||
>
|
|
||||||
Search!
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import {reactive} from "vue";
|
|
||||||
import {useStore} from 'vuex'
|
|
||||||
import {closeNav, GetUser, GoToPlayer} from '@/utils'
|
|
||||||
import {StatusCodes as STATUS} from "http-status-codes";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'CompNav',
|
|
||||||
setup() {
|
|
||||||
const store = useStore()
|
|
||||||
const data = reactive({
|
|
||||||
searchInput: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const parseSearch = async () => {
|
|
||||||
const input = data.searchInput
|
|
||||||
const customUrlPattern = 'https://steamcommunity.com/id/'
|
|
||||||
const profileUrlPattern = 'https://steamcommunity.com/profiles/'
|
|
||||||
const id64Pattern = /^\d{17}$/
|
|
||||||
const vanityPattern = /^[A-Za-z0-9-_]{3,32}$/
|
|
||||||
|
|
||||||
store.commit({
|
|
||||||
type: 'changeVanityUrl',
|
|
||||||
id: ''
|
|
||||||
})
|
|
||||||
store.commit({
|
|
||||||
type: 'changeId64',
|
|
||||||
id: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
if (data.searchInput !== '') {
|
|
||||||
if (id64Pattern.test(input)) {
|
|
||||||
store.commit({
|
|
||||||
type: 'changeId64',
|
|
||||||
id: input
|
|
||||||
})
|
|
||||||
} else if (input.match(customUrlPattern)) {
|
|
||||||
store.commit({
|
|
||||||
type: 'changeVanityUrl',
|
|
||||||
id: input.split('/')[4].split('?')[0]
|
|
||||||
})
|
|
||||||
} else if (input.match(profileUrlPattern)) {
|
|
||||||
const tmp = input.split('/')[4].split('?')[0]
|
|
||||||
if (id64Pattern.test(tmp)) {
|
|
||||||
store.commit({
|
|
||||||
type: 'changeId64',
|
|
||||||
id: tmp
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
store.commit({
|
|
||||||
type: 'changeVanityUrl',
|
|
||||||
id: input
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (store.state.vanityUrl && !vanityPattern.test(store.state.vanityUrl)) {
|
|
||||||
store.commit({
|
|
||||||
type: 'changeInfoState',
|
|
||||||
data: {
|
|
||||||
statuscode: STATUS.NOT_ACCEPTABLE,
|
|
||||||
message: 'Only alphanumeric symbols, "_", and "-", between 3-32 characters',
|
|
||||||
type: 'warning'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
store.commit({
|
|
||||||
type: 'changeVanityUrl',
|
|
||||||
id: ''
|
|
||||||
})
|
|
||||||
data.searchInput = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
if (store.state.id64 !== '' || store.state.vanityUrl !== '') {
|
|
||||||
const resData = await GetUser(store, store.state.vanityUrl || store.state.id64)
|
|
||||||
|
|
||||||
if (resData !== null) {
|
|
||||||
data.searchInput = ''
|
|
||||||
document.activeElement.blur()
|
|
||||||
|
|
||||||
store.commit({
|
|
||||||
type: 'changePlayerDetails',
|
|
||||||
data: resData
|
|
||||||
})
|
|
||||||
|
|
||||||
if (store.state.vanityUrl) {
|
|
||||||
closeNav('mainNav')
|
|
||||||
GoToPlayer(store.state.vanityUrl)
|
|
||||||
} else if (store.state.id64) {
|
|
||||||
closeNav('mainNav')
|
|
||||||
GoToPlayer(store.state.id64)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
if (!e.target.attributes.id)
|
|
||||||
closeNav('mainNav')
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
data, parseSearch, closeNav
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.navbar-dark .navbar-brand:hover,
|
|
||||||
.navbar-dark .navbar-brand:focus {
|
|
||||||
color: var(--bs-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
nav {
|
|
||||||
max-width: 100vw;
|
|
||||||
width: 100vw;
|
|
||||||
height: 70px;
|
|
||||||
background: rgba(16, 18, 26, .9);
|
|
||||||
box-shadow: 0 1px 10px 0 #111;
|
|
||||||
z-index: 2;
|
|
||||||
vertical-align: center !important;
|
|
||||||
|
|
||||||
.navbar-brand {
|
|
||||||
img {
|
|
||||||
width: 75px;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: none;
|
|
||||||
box-shadow: 0 4px 2px -2px var(--bs-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--bs-warning);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ul li {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: lighter;
|
|
||||||
margin: 22px 0 0 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: 100ms ease-in-out;
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
text-decoration: none;
|
|
||||||
color: white !important;
|
|
||||||
|
|
||||||
.router-link-exact-active {
|
|
||||||
box-shadow: 0 4px 2px -2px var(--bs-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: none;
|
|
||||||
box-shadow: 0 4px 2px -2px var(--bs-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #bdbdbd !important;
|
|
||||||
transition: 250ms ease-in-out;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: 0 4px 2px -2px var(--bs-warning);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
form {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
fill: currentColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
padding-top: 6px;
|
|
||||||
font-size: 1.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="search"] {
|
|
||||||
min-width: 300px;
|
|
||||||
max-width: 300px;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
box-shadow: 0 4px 2px -2px rgba(95, 120, 146, 0.59);
|
|
||||||
transition: .2s ease-in-out;
|
|
||||||
transform: scale(.975);
|
|
||||||
}
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
color: #aaa;
|
|
||||||
font-size: .9rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert {
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
top: 55px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 410px) {
|
|
||||||
form {
|
|
||||||
margin-left: auto !important;
|
|
||||||
margin-right: auto !important;
|
|
||||||
|
|
||||||
input[type="search"] {
|
|
||||||
margin-left: 0 !important;
|
|
||||||
max-width: 60vw !important;
|
|
||||||
min-width: 60vw !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 455px) and (min-width: 410px) {
|
|
||||||
form {
|
|
||||||
margin-left: auto !important;
|
|
||||||
margin-right: auto !important;
|
|
||||||
|
|
||||||
input[type="search"] {
|
|
||||||
margin-left: 0 !important;
|
|
||||||
max-width: 65vw !important;
|
|
||||||
min-width: 65vw !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 610px) and (min-width: 456px) {
|
|
||||||
form {
|
|
||||||
margin-left: auto !important;
|
|
||||||
margin-right: auto !important;
|
|
||||||
|
|
||||||
input[type="search"] {
|
|
||||||
margin-left: 0 !important;
|
|
||||||
max-width: 68vw !important;
|
|
||||||
min-width: 68vw !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 768px) {
|
|
||||||
nav {
|
|
||||||
button {
|
|
||||||
outline: 1px solid var(--bs-primary);
|
|
||||||
margin-left: auto;
|
|
||||||
float: right;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
box-shadow: none;
|
|
||||||
outline: 1px solid var(--bs-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-collapse {
|
|
||||||
background: var(--bs-secondary);
|
|
||||||
border-radius: 5px;
|
|
||||||
border: 1px solid var(--bs-primary)
|
|
||||||
}
|
|
||||||
|
|
||||||
#mainNav {
|
|
||||||
ul {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
li {
|
|
||||||
line-height: 1;
|
|
||||||
padding: 0 0 20px 0;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, .1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
form {
|
|
||||||
max-width: 87vw;
|
|
||||||
margin-left: -40px;
|
|
||||||
|
|
||||||
label {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="search"] {
|
|
||||||
margin-bottom: 15px;
|
|
||||||
margin-left: 37px;
|
|
||||||
max-width: 400px;
|
|
||||||
min-width: 400px;
|
|
||||||
font-size: 1rem;
|
|
||||||
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
background: var(--bs-body-bg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
margin-left: 10px;
|
|
||||||
display: block;
|
|
||||||
margin-top: -2px;
|
|
||||||
height: 40px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="damage-site">
|
|
||||||
<div class="total-damage">
|
|
||||||
<h3 class="text-center mt-2">Total Damage</h3>
|
|
||||||
<TotalDamage/>
|
|
||||||
</div>
|
|
||||||
<div class="hitgroup">
|
|
||||||
<!-- <h3 class="text-center">Damage by Hitgroup</h3>-->
|
|
||||||
<HitgroupPuppet :equipment_map="data.equipment_map" :stats="data.stats" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import HitgroupPuppet from '@/components/HitgroupPuppet'
|
|
||||||
import TotalDamage from "@/components/TotalDamage"
|
|
||||||
import {onMounted, reactive} from "vue";
|
|
||||||
import {useStore} from "vuex";
|
|
||||||
import {GetWeaponDmg} from "@/utils";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: "DamageSite.vue",
|
|
||||||
components: {HitgroupPuppet, TotalDamage},
|
|
||||||
setup() {
|
|
||||||
const store = useStore()
|
|
||||||
|
|
||||||
const data = reactive({
|
|
||||||
equipment_map: {},
|
|
||||||
stats: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
const getWeaponDamage = async () => {
|
|
||||||
const resData = await GetWeaponDmg(store, store.state.matchDetails.match_id)
|
|
||||||
if (resData !== null) {
|
|
||||||
data.equipment_map = resData.equipment_map
|
|
||||||
data.stats = resData.stats
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
getWeaponDamage()
|
|
||||||
})
|
|
||||||
|
|
||||||
return {data}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.damage-site {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="economy">
|
|
||||||
<h3 class="text-center mt-2">Economy</h3>
|
|
||||||
<div class="flexbreak"></div>
|
|
||||||
<div id="economy-graph"></div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
|
|
||||||
import {GetPlayerValue} from "@/utils";
|
|
||||||
import {useStore} from "vuex";
|
|
||||||
import {onBeforeMount, onMounted, onUnmounted, reactive, ref, watch} from "vue";
|
|
||||||
|
|
||||||
import * as echarts from 'echarts/core';
|
|
||||||
import {
|
|
||||||
GridComponent,
|
|
||||||
MarkAreaComponent,
|
|
||||||
TitleComponent,
|
|
||||||
TooltipComponent,
|
|
||||||
VisualMapComponent
|
|
||||||
} from 'echarts/components';
|
|
||||||
import {LineChart} from 'echarts/charts';
|
|
||||||
import {UniversalTransition} from 'echarts/features';
|
|
||||||
import {CanvasRenderer} from 'echarts/renderers';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: "EqValueGraph",
|
|
||||||
setup() {
|
|
||||||
const store = useStore()
|
|
||||||
|
|
||||||
let myChart1, max_rounds
|
|
||||||
let valueList = []
|
|
||||||
let dataList = []
|
|
||||||
const width = ref(window.innerWidth >= 800 && window.innerWidth <= 1200 ? window.innerWidth : window.innerWidth < 800 ? 800 : 1200)
|
|
||||||
const height = ref(width.value * 1 / 3)
|
|
||||||
|
|
||||||
const data = reactive({
|
|
||||||
rounds: {},
|
|
||||||
team: [],
|
|
||||||
eq_team_1: [],
|
|
||||||
eq_team_2: [],
|
|
||||||
eq_team_player_1: [],
|
|
||||||
eq_team_player_2: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
const getTeamPlayer = (stats, team) => {
|
|
||||||
let arr = []
|
|
||||||
for (let i = (team - 1) * 5; i < team * 5; i++) {
|
|
||||||
arr.push(stats[i].player.steamid64)
|
|
||||||
}
|
|
||||||
|
|
||||||
return arr
|
|
||||||
}
|
|
||||||
|
|
||||||
const parseObject = async () => {
|
|
||||||
data.rounds = await GetPlayerValue(store, store.state.matchDetails.match_id)
|
|
||||||
if (data.rounds === null)
|
|
||||||
data.rounds = {}
|
|
||||||
|
|
||||||
for (const round in data.rounds) {
|
|
||||||
for (const player in data.rounds[round]) {
|
|
||||||
for (let p in data.team[0]) {
|
|
||||||
if (data.team[0][p] === player) {
|
|
||||||
data.eq_team_player_1.push({
|
|
||||||
round: round,
|
|
||||||
player: player,
|
|
||||||
eq: (data.rounds[round][player][0] + data.rounds[round][player][2])
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let p in data.team[1]) {
|
|
||||||
if (data.team[1][p] === player) {
|
|
||||||
data.eq_team_player_2.push({
|
|
||||||
round: round,
|
|
||||||
player: player,
|
|
||||||
eq: (data.rounds[round][player][0] + data.rounds[round][player][2])
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sumArr = (arr) => {
|
|
||||||
return arr.reduce((acc, current) => ({
|
|
||||||
...acc,
|
|
||||||
[current.round]: (acc[current.round] || 0) + current.eq
|
|
||||||
}), {})
|
|
||||||
}
|
|
||||||
|
|
||||||
const BuildGraphData = (team_1, team_2, max_rounds) => {
|
|
||||||
let newArr = []
|
|
||||||
const half_point = max_rounds / 2 - 1
|
|
||||||
for (let round in team_1) {
|
|
||||||
if (round <= half_point) {
|
|
||||||
newArr.push(team_1[round] - team_2[round])
|
|
||||||
} else
|
|
||||||
newArr.push(team_2[round] - team_1[round])
|
|
||||||
}
|
|
||||||
return newArr
|
|
||||||
}
|
|
||||||
|
|
||||||
const optionGen = (dataList, valueList) => {
|
|
||||||
return {
|
|
||||||
// Make gradient line here
|
|
||||||
visualMap: [
|
|
||||||
{
|
|
||||||
show: false,
|
|
||||||
type: 'continuous',
|
|
||||||
seriesIndex: 0,
|
|
||||||
color: ['#3a6e99', '#c3a235'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
tooltip: {
|
|
||||||
trigger: 'axis',
|
|
||||||
formatter: 'Round <b>{b0}</b><br />{a0} <b>{c0}</b>',
|
|
||||||
},
|
|
||||||
xAxis: [
|
|
||||||
{
|
|
||||||
type: 'category',
|
|
||||||
data: dataList,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
yAxis: [
|
|
||||||
{},
|
|
||||||
],
|
|
||||||
grid: [
|
|
||||||
{
|
|
||||||
bottom: '10%'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
top: '0%'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
right: '0%'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
left: '0%'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: 'Net-Worth',
|
|
||||||
type: 'line',
|
|
||||||
lineStyle: {
|
|
||||||
width: 4
|
|
||||||
},
|
|
||||||
showSymbol: false,
|
|
||||||
data: valueList,
|
|
||||||
markArea: {
|
|
||||||
data: [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
name: 'Half-Point',
|
|
||||||
xAxis: max_rounds / 2 - 1,
|
|
||||||
label: {
|
|
||||||
color: 'white'
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
xAxis: max_rounds / 2
|
|
||||||
}
|
|
||||||
]
|
|
||||||
],
|
|
||||||
itemStyle: {
|
|
||||||
color: 'rgba(200,200,200, 0.3)'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const disposeCharts = () => {
|
|
||||||
if (myChart1 != null && myChart1 !== '' && myChart1 !== undefined) {
|
|
||||||
myChart1.dispose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildCharts = () => {
|
|
||||||
disposeCharts()
|
|
||||||
|
|
||||||
myChart1 = echarts.init(document.getElementById('economy-graph'), {}, {
|
|
||||||
width: width.value,
|
|
||||||
height: height.value
|
|
||||||
})
|
|
||||||
myChart1.setOption(optionGen(dataList, valueList))
|
|
||||||
}
|
|
||||||
|
|
||||||
onBeforeMount(() => {
|
|
||||||
max_rounds = store.state.matchDetails.max_rounds ? store.state.matchDetails.max_rounds : 30
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (store.state.matchDetails.stats) {
|
|
||||||
echarts.use([
|
|
||||||
TitleComponent,
|
|
||||||
TooltipComponent,
|
|
||||||
GridComponent,
|
|
||||||
VisualMapComponent,
|
|
||||||
LineChart,
|
|
||||||
CanvasRenderer,
|
|
||||||
UniversalTransition,
|
|
||||||
MarkAreaComponent
|
|
||||||
]);
|
|
||||||
|
|
||||||
data.team.push(getTeamPlayer(store.state.matchDetails.stats, 1))
|
|
||||||
data.team.push(getTeamPlayer(store.state.matchDetails.stats, 2))
|
|
||||||
|
|
||||||
parseObject()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
disposeCharts()
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => data.rounds, () => {
|
|
||||||
data.eq_team_1 = sumArr(data.eq_team_player_1)
|
|
||||||
data.eq_team_2 = sumArr(data.eq_team_player_2)
|
|
||||||
|
|
||||||
valueList = BuildGraphData(data.eq_team_1, data.eq_team_2, max_rounds)
|
|
||||||
|
|
||||||
dataList = Array.from(Array(valueList.length + 1).keys())
|
|
||||||
dataList.shift()
|
|
||||||
|
|
||||||
buildCharts()
|
|
||||||
})
|
|
||||||
|
|
||||||
window.onresize = () => {
|
|
||||||
if (window.innerWidth > 1200) {
|
|
||||||
width.value = 1200
|
|
||||||
}
|
|
||||||
if (window.innerWidth <= 1200 && window.innerWidth >= 800) {
|
|
||||||
width.value = window.innerWidth - 20
|
|
||||||
}
|
|
||||||
if (window.innerWidth < 800) {
|
|
||||||
width.value = 800
|
|
||||||
}
|
|
||||||
|
|
||||||
height.value = width.value * 1 / 3
|
|
||||||
buildCharts()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.economy {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
margin: 0 auto 3rem;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin-bottom: -1rem;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
|
||||||
h3 {
|
|
||||||
margin-left: 2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 800px) and (min-width: 1199px) {
|
|
||||||
#economy-graph {
|
|
||||||
overflow: scroll;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="player-flash">
|
|
||||||
<h3 class="text-center mt-2">Flash</h3>
|
|
||||||
<div class="flex-break"></div>
|
|
||||||
<div class="toggle-btn">
|
|
||||||
<div @click="toggleShow">
|
|
||||||
<table class="table table-borderless text-muted">
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<span class="text-uppercase float-end" :class="toggle === 'duration' ? 'text-warning' : ''">Duration</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-center">
|
|
||||||
<i id="toggle-off" class="fa fa-toggle-off show"></i>
|
|
||||||
<i id="toggle-on" class="fa fa-toggle-on"></i>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="text-uppercase float-start" :class="toggle === 'total' ? 'text-warning' : ''">Count</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex-break"></div>
|
|
||||||
<div id="flash-chart-1"></div>
|
|
||||||
<div id="flash-chart-2"></div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import * as echarts from 'echarts/core';
|
|
||||||
import {GridComponent, LegendComponent, TooltipComponent} from 'echarts/components';
|
|
||||||
import {BarChart} from 'echarts/charts';
|
|
||||||
import {CanvasRenderer} from 'echarts/renderers';
|
|
||||||
import {onMounted, onUnmounted, ref, watch} from "vue";
|
|
||||||
import {checkStatEmpty, getPlayerArr} from "@/utils";
|
|
||||||
import {useStore} from "vuex";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: "FlashChart",
|
|
||||||
setup() {
|
|
||||||
const store = useStore()
|
|
||||||
|
|
||||||
const toggle = ref('duration')
|
|
||||||
let myChart1, myChart2
|
|
||||||
const color = ['#bb792c', '#9bd270', '#eac42a']
|
|
||||||
const width = ref(window.innerWidth <= 600 ? window.innerWidth : 600)
|
|
||||||
const height = ref(width.value * 2 / 3)
|
|
||||||
|
|
||||||
const toggleShow = () => {
|
|
||||||
const offBtn = document.getElementById('toggle-off')
|
|
||||||
const onBtn = document.getElementById('toggle-on')
|
|
||||||
|
|
||||||
if (offBtn.classList.contains('show')) {
|
|
||||||
offBtn.classList.remove('show')
|
|
||||||
onBtn.classList.add('show')
|
|
||||||
toggle.value = 'total'
|
|
||||||
} else if (onBtn.classList.contains('show')) {
|
|
||||||
onBtn.classList.remove('show')
|
|
||||||
offBtn.classList.add('show')
|
|
||||||
toggle.value = 'duration'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const valueArr = (stats, team, toggle, prop) => {
|
|
||||||
if (['team', 'enemy', 'self'].indexOf(prop) > -1) {
|
|
||||||
let arr = []
|
|
||||||
for (let i = (team - 1) * 5; i < team * 5; i++) {
|
|
||||||
arr.push(checkStatEmpty(Function('return(function(stats, i){ return stats[i].flash.' + toggle.value + '.' + prop + '})')()(stats, i)).toFixed(2))
|
|
||||||
}
|
|
||||||
arr.reverse()
|
|
||||||
|
|
||||||
return arr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const setOptions = (id, color) => {
|
|
||||||
return {
|
|
||||||
tooltip: {
|
|
||||||
trigger: 'axis',
|
|
||||||
axisPointer: {
|
|
||||||
type: 'shadow',
|
|
||||||
shadowStyle: {
|
|
||||||
shadowBlur: 2,
|
|
||||||
shadowColor: 'rgba(255, 255, 255, .3)'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
left: '3%',
|
|
||||||
right: '4%',
|
|
||||||
bottom: '3%',
|
|
||||||
containLabel: true
|
|
||||||
},
|
|
||||||
xAxis: {
|
|
||||||
type: 'value',
|
|
||||||
boundaryGap: [0, 0.01]
|
|
||||||
},
|
|
||||||
yAxis: {
|
|
||||||
type: 'category',
|
|
||||||
data: getPlayerArr(store.state.matchDetails.stats, id, true)
|
|
||||||
},
|
|
||||||
color: color,
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: 'Enemy',
|
|
||||||
type: 'bar',
|
|
||||||
data: valueArr(store.state.matchDetails.stats, id, toggle, 'enemy'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Team',
|
|
||||||
type: 'bar',
|
|
||||||
data: valueArr(store.state.matchDetails.stats, id, toggle, 'team'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Self',
|
|
||||||
type: 'bar',
|
|
||||||
data: valueArr(store.state.matchDetails.stats, id, toggle, 'self'),
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const disposeCharts = () => {
|
|
||||||
if (myChart1 != null && myChart1 !== '' && myChart1 !== undefined) {
|
|
||||||
myChart1.dispose()
|
|
||||||
}
|
|
||||||
if (myChart2 != null && myChart2 !== '' && myChart2 !== undefined) {
|
|
||||||
myChart2.dispose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildCharts = () => {
|
|
||||||
disposeCharts()
|
|
||||||
|
|
||||||
myChart1 = echarts.init(document.getElementById('flash-chart-1'), {}, {
|
|
||||||
width: width.value,
|
|
||||||
height: height.value
|
|
||||||
});
|
|
||||||
myChart1.setOption(setOptions(1, color));
|
|
||||||
|
|
||||||
myChart2 = echarts.init(document.getElementById('flash-chart-2'), {}, {
|
|
||||||
width: width.value,
|
|
||||||
height: height.value
|
|
||||||
});
|
|
||||||
myChart2.setOption(setOptions(2, color));
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (store.state.matchDetails.stats) {
|
|
||||||
echarts.use([
|
|
||||||
TooltipComponent,
|
|
||||||
GridComponent,
|
|
||||||
LegendComponent,
|
|
||||||
BarChart,
|
|
||||||
CanvasRenderer
|
|
||||||
]);
|
|
||||||
|
|
||||||
buildCharts()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
disposeCharts()
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => toggle.value, () => {
|
|
||||||
buildCharts()
|
|
||||||
})
|
|
||||||
|
|
||||||
window.onresize = () => {
|
|
||||||
if (window.innerWidth <= 600) {
|
|
||||||
width.value = window.innerWidth - 20
|
|
||||||
height.value = width.value * 2 / 3
|
|
||||||
|
|
||||||
buildCharts()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {toggleShow, toggle}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.player-flash {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
|
|
||||||
.flex-break {
|
|
||||||
flex-basis: 100%;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 1rem auto -1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-btn {
|
|
||||||
margin: 0 auto;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
table {
|
|
||||||
margin-top: 1rem;
|
|
||||||
|
|
||||||
td {
|
|
||||||
font-size: .8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
td:first-child,
|
|
||||||
td:last-child {
|
|
||||||
max-width: 80px;
|
|
||||||
width: 80px;
|
|
||||||
}
|
|
||||||
td:nth-child(2) {
|
|
||||||
max-width: 30px;
|
|
||||||
width: 30px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.fa {
|
|
||||||
display: none;
|
|
||||||
|
|
||||||
&.show {
|
|
||||||
display: initial;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#flash-chart-1,
|
|
||||||
#flash-chart-2 {
|
|
||||||
flex-basis: 50%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
|
||||||
.player-flash {
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,564 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="hitgroup pt-2">
|
|
||||||
<div class="d-flex flex-lg-nowrap flex-wrap justify-content-center gap-4">
|
|
||||||
<div class="d-flex flex-column justify-content-center align-items-center w-auto">
|
|
||||||
<div class="select-group mb-4">
|
|
||||||
<select v-if="store.state.playersArr" v-model="data.selectPlayer" class="form-select">
|
|
||||||
<option value="All">All</option>
|
|
||||||
<option value="Team 1">Team 1</option>
|
|
||||||
<option value="Team 2">Team 2</option>
|
|
||||||
<option disabled>───────────────────────────</option>
|
|
||||||
<option v-for="(value, index) in props.stats" :key="index"
|
|
||||||
:value="Object.keys(value).toString() === store.state.playersArr[index].player.steamid64 ? store.state.playersArr[index].player : ''">
|
|
||||||
{{
|
|
||||||
Object.keys(value).toString() === store.state.playersArr[index].player.steamid64 ? store.state.playersArr[index].player.name : ''
|
|
||||||
}}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select v-if="data.selectPlayer !== ''" :key="data.selectPlayer" v-model="data.selectWeapon"
|
|
||||||
class="form-select">
|
|
||||||
<option class="select-hr" value="All">All</option>
|
|
||||||
<option disabled>───────────────────────────</option>
|
|
||||||
<option v-for="(value, index) in processPlayerWeapon()" :key="index" :value="value">
|
|
||||||
<!-- This is here, because weapons are not always named correctly -->
|
|
||||||
<!-- {{ Object.values(value).toString().charAt(0).toUpperCase() + Object.values(value).toString().slice(1) }}-->
|
|
||||||
{{ Object.values(value).toString() }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="hitgroup-puppet"/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="data.weaponDmg"
|
|
||||||
id="bar-graph"
|
|
||||||
class="w-auto"
|
|
||||||
:style="{
|
|
||||||
minWidth: dmgWidth + 'px'
|
|
||||||
}">
|
|
||||||
<table class="table table-borderless">
|
|
||||||
<tr v-for="(value, index) in data.weaponDmg" :key="index">
|
|
||||||
<td v-if="index < 10 && (data.selectWeapon === 'All' || Object.keys(data.selectWeapon).toString() === Object.keys(value).toString())"
|
|
||||||
style="width: 100px">
|
|
||||||
<img :alt="Object.values(value).toString()"
|
|
||||||
:src="DisplayWeapon(parseInt(Object.keys(value)[0]))"/>
|
|
||||||
</td>
|
|
||||||
<td v-if="index < 10 && (data.selectWeapon === 'All' || Object.keys(data.selectWeapon).toString() === Object.keys(value).toString())">
|
|
||||||
<span :style="{
|
|
||||||
width: (processWeaponDmg(Object.keys(value).toString()) / processWeaponDmg(Object.keys(data.weaponDmg[0]).toString()) * 100).toFixed(0) + '%',
|
|
||||||
backgroundColor: 'orangered',
|
|
||||||
display: 'block',
|
|
||||||
}"
|
|
||||||
class="rounded"
|
|
||||||
>
|
|
||||||
<span>{{ processWeaponDmg(Object.keys(value).toString()) }}</span>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import * as echarts from 'echarts/core';
|
|
||||||
import {GeoComponent, TooltipComponent, VisualMapComponent} from 'echarts/components';
|
|
||||||
import {MapChart} from 'echarts/charts';
|
|
||||||
import {CanvasRenderer} from 'echarts/renderers';
|
|
||||||
import {onMounted, onUnmounted, reactive, ref, watch} from "vue";
|
|
||||||
import {useStore} from "vuex";
|
|
||||||
import {DisplayWeapon} from '@/utils'
|
|
||||||
|
|
||||||
import $ from 'jquery'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: "HitgroupPuppet.vue",
|
|
||||||
props: {
|
|
||||||
equipment_map: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
stats: {
|
|
||||||
type: Array,
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setup(props) {
|
|
||||||
const store = useStore()
|
|
||||||
|
|
||||||
const data = reactive({
|
|
||||||
selectPlayer: 'All',
|
|
||||||
selectWeapon: 'All',
|
|
||||||
eq_map: [],
|
|
||||||
weaponDmg: []
|
|
||||||
})
|
|
||||||
|
|
||||||
let myChart1
|
|
||||||
|
|
||||||
const getWindowWidth = () => {
|
|
||||||
const windowWidth = window.innerWidth
|
|
||||||
if (windowWidth <= 750)
|
|
||||||
return windowWidth
|
|
||||||
else
|
|
||||||
return 650
|
|
||||||
}
|
|
||||||
|
|
||||||
const setDmgWidth = () => {
|
|
||||||
const windowWidth = getWindowWidth()
|
|
||||||
if (windowWidth >= 500)
|
|
||||||
return 500
|
|
||||||
else
|
|
||||||
return windowWidth - 10
|
|
||||||
}
|
|
||||||
|
|
||||||
const dmgWidth = ref(setDmgWidth())
|
|
||||||
|
|
||||||
const setHeight = () => {
|
|
||||||
const windowWidth = getWindowWidth()
|
|
||||||
if (windowWidth >= 751)
|
|
||||||
return windowWidth * 3 / 7.5
|
|
||||||
else if (windowWidth >= 501 && windowWidth <= 750)
|
|
||||||
return windowWidth * 3 / 6.5
|
|
||||||
else
|
|
||||||
return windowWidth * 3 / 5.5
|
|
||||||
}
|
|
||||||
|
|
||||||
const width = ref(getWindowWidth())
|
|
||||||
const height = ref(setHeight())
|
|
||||||
|
|
||||||
const processWeaponDmg = (id) => {
|
|
||||||
let value = ''
|
|
||||||
data.weaponDmg.forEach(w => {
|
|
||||||
if (Object.keys(w).toString() === id) {
|
|
||||||
value = Object.values(w).toString()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
const processPlayerWeapon = () => {
|
|
||||||
let arr = []
|
|
||||||
if (data.selectPlayer === 'All') {
|
|
||||||
props.stats.forEach(player => {
|
|
||||||
Object.values(player).forEach(enemies => {
|
|
||||||
Object.values(enemies).forEach(weapons => {
|
|
||||||
Object.values(weapons).forEach(weapon => {
|
|
||||||
arr.push(weapon[0])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
} else if (data.selectPlayer === 'Team 1') {
|
|
||||||
props.stats.forEach(player => {
|
|
||||||
store.state.playersArr.forEach(p => {
|
|
||||||
if (p.player.steamid64 === Object.keys(player).toString() && p.team_id === 1)
|
|
||||||
Object.values(player).forEach(enemies => {
|
|
||||||
Object.values(enemies).forEach(weapons => {
|
|
||||||
Object.values(weapons).forEach(weapon => {
|
|
||||||
arr.push(weapon[0])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
} else if (data.selectPlayer === 'Team 2') {
|
|
||||||
props.stats.forEach(player => {
|
|
||||||
store.state.playersArr.forEach(p => {
|
|
||||||
if (p.player.steamid64 === Object.keys(player).toString() && p.team_id === 2)
|
|
||||||
Object.values(player).forEach(enemies => {
|
|
||||||
Object.values(enemies).forEach(weapons => {
|
|
||||||
Object.values(weapons).forEach(weapon => {
|
|
||||||
arr.push(weapon[0])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
props.stats.forEach(player => {
|
|
||||||
if (Object.keys(player).toString() === data.selectPlayer.steamid64) {
|
|
||||||
Object.values(player).forEach(enemies => {
|
|
||||||
Object.values(enemies).forEach(weapons => {
|
|
||||||
Object.values(weapons).forEach(weapon => {
|
|
||||||
arr.push(weapon[0])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const unique = arr.filter((a, b) => arr.indexOf(a) === b && a < 400)
|
|
||||||
|
|
||||||
let arr2 = []
|
|
||||||
|
|
||||||
unique.forEach(w => {
|
|
||||||
for (let weapon in props.equipment_map) {
|
|
||||||
if (parseInt(w) === parseInt(weapon)) {
|
|
||||||
let obj = {}
|
|
||||||
obj[w] = props.equipment_map[weapon]
|
|
||||||
arr2.push(obj)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return arr2
|
|
||||||
}
|
|
||||||
|
|
||||||
const processDmg = (by = 'hitgroup') => {
|
|
||||||
let arr = []
|
|
||||||
if (data.selectPlayer && data.selectWeapon) {
|
|
||||||
switch (data.selectPlayer) {
|
|
||||||
case "All":
|
|
||||||
props.stats.forEach(player => {
|
|
||||||
Object.values(player).forEach(enemies => {
|
|
||||||
Object.values(enemies).forEach(weapons => {
|
|
||||||
Object.values(weapons).forEach(weapon => {
|
|
||||||
// 0: weapon
|
|
||||||
// 1: hitgroup
|
|
||||||
// 2: dmg
|
|
||||||
if (weapon) {
|
|
||||||
if (by === 'hitgroup') {
|
|
||||||
if (Object.values(weapon)[0] === parseInt(Object.keys(data.selectWeapon).toString())) {
|
|
||||||
let obj = {}
|
|
||||||
obj[weapon[1]] = weapon[2]
|
|
||||||
arr.push(obj)
|
|
||||||
} else if (data.selectWeapon === 'All') {
|
|
||||||
let obj = {}
|
|
||||||
obj[weapon[1]] = weapon[2]
|
|
||||||
arr.push(obj)
|
|
||||||
}
|
|
||||||
} else if (by === 'weapon') {
|
|
||||||
if (Object.values(weapon)[0] === parseInt(Object.keys(data.selectWeapon).toString())) {
|
|
||||||
let obj = {}
|
|
||||||
obj[weapon[0]] = weapon[2]
|
|
||||||
arr.push(obj)
|
|
||||||
} else if (data.selectWeapon === 'All') {
|
|
||||||
let obj = {}
|
|
||||||
obj[weapon[0]] = weapon[2]
|
|
||||||
arr.push(obj)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "Team 1":
|
|
||||||
props.stats.forEach(player => {
|
|
||||||
store.state.playersArr.forEach(p => {
|
|
||||||
if (p.player.steamid64 === Object.keys(player).toString() && p.team_id === 1)
|
|
||||||
Object.values(player).forEach(enemies => {
|
|
||||||
Object.values(enemies).forEach(weapons => {
|
|
||||||
Object.values(weapons).forEach(weapon => {
|
|
||||||
// 0: weapon
|
|
||||||
// 1: hitgroup
|
|
||||||
// 2: dmg
|
|
||||||
if (weapon) {
|
|
||||||
if (by === 'hitgroup') {
|
|
||||||
if (Object.values(weapon)[0] === parseInt(Object.keys(data.selectWeapon).toString())) {
|
|
||||||
let obj = {}
|
|
||||||
obj[weapon[1]] = weapon[2]
|
|
||||||
arr.push(obj)
|
|
||||||
} else if (data.selectWeapon === 'All') {
|
|
||||||
let obj = {}
|
|
||||||
obj[weapon[1]] = weapon[2]
|
|
||||||
arr.push(obj)
|
|
||||||
}
|
|
||||||
} else if (by === 'weapon') {
|
|
||||||
if (Object.values(weapon)[0] === parseInt(Object.keys(data.selectWeapon).toString())) {
|
|
||||||
let obj = {}
|
|
||||||
obj[weapon[0]] = weapon[2]
|
|
||||||
arr.push(obj)
|
|
||||||
} else if (data.selectWeapon === 'All') {
|
|
||||||
let obj = {}
|
|
||||||
obj[weapon[0]] = weapon[2]
|
|
||||||
arr.push(obj)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "Team 2":
|
|
||||||
props.stats.forEach(player => {
|
|
||||||
store.state.playersArr.forEach(p => {
|
|
||||||
if (p.player.steamid64 === Object.keys(player).toString() && p.team_id === 2)
|
|
||||||
Object.values(player).forEach(enemies => {
|
|
||||||
Object.values(enemies).forEach(weapons => {
|
|
||||||
Object.values(weapons).forEach(weapon => {
|
|
||||||
// 0: weapon
|
|
||||||
// 1: hitgroup
|
|
||||||
// 2: dmg
|
|
||||||
if (weapon) {
|
|
||||||
if (by === 'hitgroup') {
|
|
||||||
if (Object.values(weapon)[0] === parseInt(Object.keys(data.selectWeapon).toString())) {
|
|
||||||
let obj = {}
|
|
||||||
obj[weapon[1]] = weapon[2]
|
|
||||||
arr.push(obj)
|
|
||||||
} else if (data.selectWeapon === 'All') {
|
|
||||||
let obj = {}
|
|
||||||
obj[weapon[1]] = weapon[2]
|
|
||||||
arr.push(obj)
|
|
||||||
}
|
|
||||||
} else if (by === 'weapon') {
|
|
||||||
if (Object.values(weapon)[0] === parseInt(Object.keys(data.selectWeapon).toString())) {
|
|
||||||
let obj = {}
|
|
||||||
obj[weapon[0]] = weapon[2]
|
|
||||||
arr.push(obj)
|
|
||||||
} else if (data.selectWeapon === 'All') {
|
|
||||||
let obj = {}
|
|
||||||
obj[weapon[0]] = weapon[2]
|
|
||||||
arr.push(obj)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
props.stats.forEach(player => {
|
|
||||||
if (Object.keys(player).toString() === data.selectPlayer.steamid64) {
|
|
||||||
Object.values(player).forEach(enemies => {
|
|
||||||
Object.values(enemies).forEach(weapons => {
|
|
||||||
Object.values(weapons).forEach(weapon => {
|
|
||||||
// 0: weapon
|
|
||||||
// 1: hitgroup
|
|
||||||
// 2: dmg
|
|
||||||
if (weapon) {
|
|
||||||
if (by === 'hitgroup') {
|
|
||||||
if (Object.values(weapon)[0] === parseInt(Object.keys(data.selectWeapon).toString())) {
|
|
||||||
let obj = {}
|
|
||||||
obj[weapon[1]] = weapon[2]
|
|
||||||
arr.push(obj)
|
|
||||||
} else if (data.selectWeapon === 'All') {
|
|
||||||
let obj = {}
|
|
||||||
obj[weapon[1]] = weapon[2]
|
|
||||||
arr.push(obj)
|
|
||||||
}
|
|
||||||
} else if (by === 'weapon') {
|
|
||||||
if (Object.values(weapon)[0] === parseInt(Object.keys(data.selectWeapon).toString())) {
|
|
||||||
let obj = {}
|
|
||||||
obj[weapon[0]] = weapon[2]
|
|
||||||
arr.push(obj)
|
|
||||||
} else if (data.selectWeapon === 'All') {
|
|
||||||
let obj = {}
|
|
||||||
obj[weapon[0]] = weapon[2]
|
|
||||||
arr.push(obj)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
arr = []
|
|
||||||
}
|
|
||||||
|
|
||||||
if (by === 'hitgroup') {
|
|
||||||
buildCharts(sumDmgArr(arr))
|
|
||||||
} else if (by === 'weapon') {
|
|
||||||
data.weaponDmg = sumDmgArr(arr, 'weapon')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sumDmgArr = (arr, by = 'hitgroup') => {
|
|
||||||
let holder = {};
|
|
||||||
|
|
||||||
arr.forEach(function (d) {
|
|
||||||
// eslint-disable-next-line no-prototype-builtins
|
|
||||||
if (holder.hasOwnProperty(parseInt(Object.keys(d).toString()))) {
|
|
||||||
holder[parseInt(Object.keys(d).toString())] = holder[parseInt(Object.keys(d).toString())] + parseInt(Object.values(d).toString());
|
|
||||||
} else {
|
|
||||||
holder[parseInt(Object.keys(d).toString())] = parseInt(Object.values(d).toString());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let arr2 = [];
|
|
||||||
|
|
||||||
if (by === 'hitgroup') {
|
|
||||||
for (let i = 1; i < 8; i++) {
|
|
||||||
if (holder[i] !== undefined) {
|
|
||||||
arr2.push(holder[i])
|
|
||||||
} else {
|
|
||||||
arr2.push(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (by === 'weapon') {
|
|
||||||
for (let i = 1; i < 312; i++) {
|
|
||||||
if (holder[i] !== undefined) {
|
|
||||||
let obj = {}
|
|
||||||
obj[i] = holder[i]
|
|
||||||
arr2.push(obj)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
arr2.sort((a, b) => {
|
|
||||||
return Object.values(b).toString() - Object.values(a).toString()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return arr2
|
|
||||||
}
|
|
||||||
|
|
||||||
const getMax = (arr) => {
|
|
||||||
let max = 0
|
|
||||||
for (let i = 0; i < 7; i++) {
|
|
||||||
if (arr[i] > max)
|
|
||||||
max = arr[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
return max
|
|
||||||
}
|
|
||||||
|
|
||||||
const optionGen = (arr = []) => {
|
|
||||||
return {
|
|
||||||
tooltip: {},
|
|
||||||
visualMap: {
|
|
||||||
left: 'center',
|
|
||||||
bottom: '5%',
|
|
||||||
textStyle: {
|
|
||||||
color: 'white',
|
|
||||||
},
|
|
||||||
min: 0,
|
|
||||||
max: getMax(arr) || 100,
|
|
||||||
orient: 'horizontal',
|
|
||||||
realtime: true,
|
|
||||||
calculable: true,
|
|
||||||
inRange: {
|
|
||||||
color: ['#00ff00', '#db6e00', '#cf0000']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: 'Hitgroup',
|
|
||||||
type: 'map',
|
|
||||||
map: 'hitgroup-puppet',
|
|
||||||
top: '0%',
|
|
||||||
emphasis: {
|
|
||||||
label: {
|
|
||||||
show: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
selectedMode: false,
|
|
||||||
data: [
|
|
||||||
{name: 'Head', value: arr[0] || 0},
|
|
||||||
{name: 'Chest', value: arr[1] || 0},
|
|
||||||
{name: 'Stomach', value: arr[2] || 0},
|
|
||||||
{name: 'Left Arm', value: arr[3] || 0},
|
|
||||||
{name: 'Right Arm', value: arr[4] || 0},
|
|
||||||
{name: 'Left Foot', value: arr[5] || 0},
|
|
||||||
{name: 'Right Foot', value: arr[6] || 0}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const disposeCharts = () => {
|
|
||||||
if (myChart1 != null && myChart1 !== '' && myChart1 !== undefined) {
|
|
||||||
myChart1.dispose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildCharts = (arr) => {
|
|
||||||
disposeCharts()
|
|
||||||
|
|
||||||
myChart1 = echarts.init(document.getElementById('hitgroup-puppet'), {}, {width: 300, height: 500})
|
|
||||||
|
|
||||||
const url = '/images/icons/hitgroup-puppet.svg'
|
|
||||||
$.get(url, function (svg) {
|
|
||||||
echarts.registerMap('hitgroup-puppet', {svg: svg})
|
|
||||||
myChart1.setOption(optionGen(arr));
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (store.state.matchDetails.stats) {
|
|
||||||
echarts.use([
|
|
||||||
TooltipComponent,
|
|
||||||
VisualMapComponent,
|
|
||||||
GeoComponent,
|
|
||||||
MapChart,
|
|
||||||
CanvasRenderer
|
|
||||||
]);
|
|
||||||
|
|
||||||
buildCharts()
|
|
||||||
|
|
||||||
watch(() => props.stats, () => {
|
|
||||||
processDmg()
|
|
||||||
processDmg('weapon')
|
|
||||||
processPlayerWeapon()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
disposeCharts()
|
|
||||||
})
|
|
||||||
|
|
||||||
window.onresize = () => {
|
|
||||||
if (window.innerWidth <= 750) {
|
|
||||||
width.value = getWindowWidth() - 20
|
|
||||||
height.value = setHeight()
|
|
||||||
dmgWidth.value = setDmgWidth()
|
|
||||||
}
|
|
||||||
|
|
||||||
buildCharts()
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => data.selectPlayer, () => {
|
|
||||||
data.selectWeapon = 'All'
|
|
||||||
processPlayerWeapon()
|
|
||||||
processDmg()
|
|
||||||
processDmg('weapon')
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => data.selectWeapon, () => {
|
|
||||||
processDmg()
|
|
||||||
processDmg('weapon')
|
|
||||||
})
|
|
||||||
|
|
||||||
return {props, data, store, dmgWidth, processPlayerWeapon, processWeaponDmg, DisplayWeapon}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.select-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 1rem;
|
|
||||||
|
|
||||||
.form-select {
|
|
||||||
background: var(--bs-secondary);
|
|
||||||
color: var(--bs-primary);
|
|
||||||
width: 250px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.select-group {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="infos.data" id="modal">
|
|
||||||
<div v-for="(info, id) in infos.data" :key="id" class="custom-modal">
|
|
||||||
<div :class="info.type === 'error'
|
|
||||||
? 'bg-danger text-white'
|
|
||||||
: info.type === 'warning'
|
|
||||||
? 'bg-warning text-secondary'
|
|
||||||
: info.type === 'success'
|
|
||||||
? 'bg-success text-white'
|
|
||||||
: 'bg-secondary text-white'"
|
|
||||||
class="card">
|
|
||||||
<div class="card-body d-flex justify-content-between">
|
|
||||||
<span class="info-text">{{ info.message }}</span>
|
|
||||||
<button aria-label="Close" class="btn-close" type="button" @click="closeModal(id)"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import {useStore} from "vuex";
|
|
||||||
import {onMounted, reactive} from "vue";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: "InfoModal",
|
|
||||||
setup() {
|
|
||||||
const store = useStore()
|
|
||||||
const infos = reactive({
|
|
||||||
data: []
|
|
||||||
})
|
|
||||||
|
|
||||||
const closeModal = (id) => {
|
|
||||||
store.commit('removeInfoState', id)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
store.subscribe(((mutation, state) => {
|
|
||||||
if (mutation.type === 'changeInfoState') {
|
|
||||||
infos.data = state.info
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
closeModal(store.state.info.length - 1)
|
|
||||||
}, 5000)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
return {infos, closeModal}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
#modal {
|
|
||||||
--height: 56px;
|
|
||||||
|
|
||||||
.card {
|
|
||||||
z-index: 10;
|
|
||||||
position: absolute;
|
|
||||||
right: 1rem;
|
|
||||||
opacity: .8;
|
|
||||||
width: min(100vw - 2rem, 50ch);
|
|
||||||
height: var(--height);
|
|
||||||
|
|
||||||
.btn-close {
|
|
||||||
background-color: white;
|
|
||||||
opacity: .5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-text {
|
|
||||||
font-size: .8rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@for $i from 1 through 10 {
|
|
||||||
.custom-modal:nth-of-type(#{$i}) {
|
|
||||||
.card {
|
|
||||||
@if $i == 1 {
|
|
||||||
margin: 1rem 0;
|
|
||||||
} @else {
|
|
||||||
margin-top: calc(#{$i}rem + (#{$i} - 1) * var(--height));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="container w-50">
|
|
||||||
<TranslateChatButton
|
|
||||||
v-if="data.chat.length > 0"
|
|
||||||
:translated="data.translatedText.length > 0"
|
|
||||||
class="translate-btn"
|
|
||||||
@translated="handleTranslatedText"
|
|
||||||
/>
|
|
||||||
<div v-if="data.chat.length > 0" class="chat-history mt-2">
|
|
||||||
<table id="chat" :style="`max-width: ${data.clientWidth}px; width: ${data.clientWidth}px`" class="table table-borderless">
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="(m, id) in data.chat" :key="id">
|
|
||||||
<td class="td-time">
|
|
||||||
{{ ConvertTickToTime(m.tick, m.tick_rate) }}
|
|
||||||
</td>
|
|
||||||
<td class="td-avatar">
|
|
||||||
<img :class="'team-color-' + m.color"
|
|
||||||
:src="constructAvatarUrl(m.avatar)"
|
|
||||||
alt="Player avatar"
|
|
||||||
class="avatar">
|
|
||||||
</td>
|
|
||||||
<td :class="m.startSide === 1 ? 'text-info' : 'text-warning'"
|
|
||||||
class="td-name d-flex"
|
|
||||||
@click="GoToPlayer(m.steamid64)">
|
|
||||||
<span>
|
|
||||||
<i v-if="m.tracked" class="fa fa-dot-circle-o text-success tracked" title="Tracked user"/>
|
|
||||||
<span :class="(m.vac && FormatVacDate(m.vac_date, store.state.matchDetails.date) !== '')
|
|
||||||
|| (!m.vac && m.game_ban && FormatVacDate(m.game_ban_date, store.state.matchDetails.date) !== '')
|
|
||||||
? 'ban-shadow'
|
|
||||||
: ''"
|
|
||||||
:title="!m.vac && m.game_ban
|
|
||||||
? 'Game-banned: ' + FormatVacDate(m.game_ban_date, store.state.matchDetails.date)
|
|
||||||
: m.vac && !m.game_ban
|
|
||||||
? 'Vac-banned: ' + FormatVacDate(m.vac_date, store.state.matchDetails.date)
|
|
||||||
: ''">
|
|
||||||
{{ m.player }}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="td-icon">
|
|
||||||
<i class="fa fa-caret-right"/>
|
|
||||||
<span v-if="!m.all_chat" class="ms-1">
|
|
||||||
(team)
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="td-message">
|
|
||||||
{{ data.translatedText.length === 0 ? m.message : data.originalChat[id].message }}
|
|
||||||
<span v-if="m.translated_from"
|
|
||||||
:class="m.translated_from ? 'text-success' : ''"
|
|
||||||
:title="`Translated from ${ISO6391.getName(m.translated_from)}`"
|
|
||||||
class="ms-2 helpicon">
|
|
||||||
<br/>
|
|
||||||
{{ m.message }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<h3>No chat available</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import {useStore} from "vuex";
|
|
||||||
import {onMounted, reactive} from "vue";
|
|
||||||
import {constructAvatarUrl, ConvertTickToTime, FormatVacDate, GetChatHistory, GoToPlayer, truncate} from "@/utils";
|
|
||||||
import TranslateChatButton from "@/components/TranslateChatButton";
|
|
||||||
import ISO6391 from 'iso-639-1'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: "MatchChatHistory",
|
|
||||||
components: {TranslateChatButton},
|
|
||||||
setup() {
|
|
||||||
const store = useStore()
|
|
||||||
|
|
||||||
const data = reactive({
|
|
||||||
chat: [],
|
|
||||||
translatedText: [],
|
|
||||||
originalChat: [],
|
|
||||||
clientWidth: 0
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleTranslatedText = async (e) => {
|
|
||||||
const [res, toggle] = await e
|
|
||||||
|
|
||||||
if (res !== null) {
|
|
||||||
if (toggle === 'translated') {
|
|
||||||
data.translatedText = await setPlayer(sortChatHistory(res, true))
|
|
||||||
data.chat = data.translatedText
|
|
||||||
} else if (toggle === 'original') {
|
|
||||||
data.chat = data.originalChat
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getChatHistory = async () => {
|
|
||||||
const resData = await GetChatHistory(store, store.state.matchDetails.match_id)
|
|
||||||
if (resData !== null) {
|
|
||||||
data.chat = await setPlayer(sortChatHistory(resData))
|
|
||||||
data.originalChat = data.chat
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortChatHistory = (res = {}, translated = false) => {
|
|
||||||
let arr = []
|
|
||||||
if (res !== {}) {
|
|
||||||
Object.keys(res).forEach(i => {
|
|
||||||
res[i].forEach(o => {
|
|
||||||
let obj = Object.assign({
|
|
||||||
player: i,
|
|
||||||
tick: o.tick,
|
|
||||||
all_chat: o.all_chat,
|
|
||||||
message: o.message,
|
|
||||||
translated_from: translated ? o.translated_from : null,
|
|
||||||
translated_to: translated ? o.translated_to : null
|
|
||||||
})
|
|
||||||
arr.push(obj)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
arr.sort((a, b) => a.tick - b.tick)
|
|
||||||
return arr
|
|
||||||
}
|
|
||||||
|
|
||||||
const setPlayer = async (chat) => {
|
|
||||||
let arr = []
|
|
||||||
for (const o of chat) {
|
|
||||||
for (const p of store.state.matchDetails.stats) {
|
|
||||||
if (o.player === p.player.steamid64) {
|
|
||||||
const obj = Object.assign({
|
|
||||||
player: truncate(p.player.name, 20),
|
|
||||||
steamid64: p.player.steamid64,
|
|
||||||
avatar: p.player.avatar,
|
|
||||||
color: p.color,
|
|
||||||
startSide: p.team_id,
|
|
||||||
tracked: p.player.tracked,
|
|
||||||
vac: p.player.vac,
|
|
||||||
vac_date: p.player.vac_date,
|
|
||||||
game_ban: p.player.game_ban,
|
|
||||||
game_ban_date: p.player.game_ban_date,
|
|
||||||
tick: o.tick,
|
|
||||||
tick_rate: store.state.matchDetails.tick_rate && store.state.matchDetails.tick_rate !== -1 ? store.state.matchDetails.tick_rate : 64,
|
|
||||||
all_chat: o.all_chat,
|
|
||||||
message: o.message,
|
|
||||||
translated_from: o.translated_from,
|
|
||||||
translated_to: o.translated_to
|
|
||||||
})
|
|
||||||
arr.push(obj)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return arr
|
|
||||||
}
|
|
||||||
|
|
||||||
const sizeTable = () => {
|
|
||||||
if (document.documentElement.clientWidth <= 768) {
|
|
||||||
data.clientWidth = document.documentElement.clientWidth - 32
|
|
||||||
} else {
|
|
||||||
data.clientWidth = 700
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.onresize = () => {
|
|
||||||
sizeTable()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
getChatHistory()
|
|
||||||
sizeTable()
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
data,
|
|
||||||
store,
|
|
||||||
ISO6391,
|
|
||||||
constructAvatarUrl,
|
|
||||||
GoToPlayer,
|
|
||||||
ConvertTickToTime,
|
|
||||||
FormatVacDate,
|
|
||||||
handleTranslatedText
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.translate-btn {
|
|
||||||
margin-top: .5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
padding: .5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.td-time {
|
|
||||||
width: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.td-avatar {
|
|
||||||
width: 30px;
|
|
||||||
|
|
||||||
.avatar {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.td-name {
|
|
||||||
width: 200px;
|
|
||||||
max-width: 200px;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
|
|
||||||
.tracked {
|
|
||||||
font-size: .8rem;
|
|
||||||
margin-right: .2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ban-shadow {
|
|
||||||
color: red;
|
|
||||||
text-shadow: 0 0 1rem orangered;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.td-icon {
|
|
||||||
width: 20px;
|
|
||||||
|
|
||||||
.fa-caret-right {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.td-message {
|
|
||||||
width: 400px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 768px) {
|
|
||||||
.container {
|
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin-left: 1rem;
|
|
||||||
}
|
|
||||||
.td-name {
|
|
||||||
width: 120px !important;
|
|
||||||
max-width: 120px !important;
|
|
||||||
}
|
|
||||||
.td-message {
|
|
||||||
width: auto !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 576px) {
|
|
||||||
.container {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
.td-avatar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,532 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="props.matches.length === 0" id="matches-placeholder">
|
|
||||||
<span v-for="i in 20" :key="i" :class="i % 2 === 1 ? 'placeholder-wave' : 'placeholder-wave-alt'"
|
|
||||||
class="placeholder col-12"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else id="matches">
|
|
||||||
<table class="table table-borderless">
|
|
||||||
<thead class="border-bottom">
|
|
||||||
<tr>
|
|
||||||
<th class="text-center map" scope="col">Map</th>
|
|
||||||
<th class="text-center rank" scope="col">Rank</th>
|
|
||||||
<th class="text-center length" scope="col" title="Match Length">
|
|
||||||
<img alt="Match length" class="match-len helpicon" src="/images/icons/timer_both.svg">
|
|
||||||
</th>
|
|
||||||
<th class="text-center score" scope="col">Score</th>
|
|
||||||
<th v-if="!props.explore" class="text-center kills" scope="col">K</th>
|
|
||||||
<th v-if="!props.explore" class="text-center assists" scope="col">A</th>
|
|
||||||
<th v-if="!props.explore" class="text-center deaths" scope="col">D</th>
|
|
||||||
<th v-if="!props.explore" class="text-center kdiff helptext" scope="col" title="Kill-to-death difference">+/-</th>
|
|
||||||
<th v-if="!props.explore" class="text-center hltv helptext" scope="col" title="HLTV 1.0 Rating">Rating</th>
|
|
||||||
<th class="text-center duration" scope="col">Duration</th>
|
|
||||||
<th class="date" scope="col">Date</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="match in props.matches"
|
|
||||||
:key="match.match_id"
|
|
||||||
:class="props.colorFront ? (GetWinLoss(match.match_result, match.stats.team_id) + (match.vac || match.game_ban ? ' ban' : '')) : (match.vac || match.game_ban ? ' matches_ban' : '')"
|
|
||||||
:title="match.vac ? 'VAC-banned player in this game' : match.game_ban ? 'Game-banned player in this game' : ''"
|
|
||||||
class="match default"
|
|
||||||
@click="GoToMatch(match.match_id)"
|
|
||||||
>
|
|
||||||
<td class="td-map text-center">
|
|
||||||
<i v-if="match.parsed" class="fa fa-bar-chart parsed helpicon"
|
|
||||||
title="Demo has been parsed for additional data"></i>
|
|
||||||
<i v-if="!match.parsed && MatchNotParsedTime(match.date)" class="fa fa-hourglass-half not-yet-parsed helpicon"
|
|
||||||
title="Match has not been parsed yet"></i>
|
|
||||||
<img v-if="match.map !== ''"
|
|
||||||
:alt="match.map"
|
|
||||||
:src="'/images/map_icons/map_icon_' + match.map + '.svg'"
|
|
||||||
:title="FixMapName(match.map)"
|
|
||||||
class="map-icon">
|
|
||||||
<i v-else class="fa fa-question-circle-o map-not-found" title="Match not parsed"></i>
|
|
||||||
</td>
|
|
||||||
<td class="td-rank text-center">
|
|
||||||
<img v-if="props.explore"
|
|
||||||
:alt="DisplayRank(Math.floor(match.avg_rank || 0))[1]"
|
|
||||||
:src="DisplayRank(Math.floor(match.avg_rank || 0))[0]"
|
|
||||||
:title="DisplayRank(Math.floor(match.avg_rank || 0))[1]" class="rank-icon">
|
|
||||||
<img v-else
|
|
||||||
:alt="DisplayRank(match.stats.rank?.new)[1]"
|
|
||||||
:class="match.stats.rank?.new > match.stats.rank?.old ? 'uprank' : match.stats.rank?.new < match.stats.rank?.old ? 'downrank' : ''"
|
|
||||||
:src="DisplayRank(match.stats.rank?.new)[0]"
|
|
||||||
:title="DisplayRank(match.stats.rank?.new)[1]" class="rank-icon">
|
|
||||||
</td>
|
|
||||||
<td class="td-length text-center">
|
|
||||||
<img v-if="match.max_rounds === 30 || !match.max_rounds"
|
|
||||||
alt="Match long"
|
|
||||||
class="match-len"
|
|
||||||
src="/images/icons/timer_long.svg"
|
|
||||||
title="Long Match">
|
|
||||||
<img v-if="match.max_rounds === 16"
|
|
||||||
alt="Match short"
|
|
||||||
class="match-len"
|
|
||||||
src="/images/icons/timer_short.svg"
|
|
||||||
title="Short Match">
|
|
||||||
</td>
|
|
||||||
<td class="td-score text-center fw-bold">
|
|
||||||
<span
|
|
||||||
:class="match.match_result === 1 ? 'text-success' : match.match_result === 0 ? 'text-warning' : 'text-danger'">{{
|
|
||||||
match.score[0]
|
|
||||||
}}</span> - <span
|
|
||||||
:class="match.match_result === 2 ? 'text-success' : match.match_result === 0 ? 'text-warning' : 'text-danger'">{{
|
|
||||||
match.score[1]
|
|
||||||
}}</span>
|
|
||||||
</td>
|
|
||||||
<td v-if="match.stats" class="td-kills text-center">
|
|
||||||
{{ match.stats.kills ? match.stats.kills : "0" }}
|
|
||||||
</td>
|
|
||||||
<td v-if="match.stats" class="td-assists text-center">
|
|
||||||
{{ match.stats.assists ? match.stats.assists : "0" }}
|
|
||||||
</td>
|
|
||||||
<td v-if="match.stats" class="td-deaths text-center">
|
|
||||||
{{ match.stats.deaths ? match.stats.deaths : "0" }}
|
|
||||||
</td>
|
|
||||||
<td v-if="match.stats"
|
|
||||||
:class="(match.stats.kills ? match.stats.kills : 0) - (match.stats.deaths ? match.stats.deaths : 0) >= 0 ? 'text-success' : 'text-danger'"
|
|
||||||
class="td-plus text-center">
|
|
||||||
{{
|
|
||||||
(match.stats.kills ? match.stats.kills : 0) - (match.stats.deaths ? match.stats.deaths : 0)
|
|
||||||
}}
|
|
||||||
</td>
|
|
||||||
<td v-if="match.stats"
|
|
||||||
:class="GetHLTV_1(
|
|
||||||
match.stats.kills,
|
|
||||||
match.score[0] + match.score[1],
|
|
||||||
match.stats.deaths,
|
|
||||||
match.stats.multi_kills?.duo,
|
|
||||||
match.stats.multi_kills?.triple,
|
|
||||||
match.stats.multi_kills?.quad,
|
|
||||||
match.stats.multi_kills?.pent) >= 1 ? 'text-success' : 'text-warning'"
|
|
||||||
class="td-hltv text-center fw-bold">
|
|
||||||
{{
|
|
||||||
GetHLTV_1(
|
|
||||||
match.stats.kills,
|
|
||||||
match.score[0] + match.score[1],
|
|
||||||
match.stats.deaths,
|
|
||||||
match.stats.multi_kills?.duo,
|
|
||||||
match.stats.multi_kills?.triple,
|
|
||||||
match.stats.multi_kills?.quad,
|
|
||||||
match.stats.multi_kills?.pent)
|
|
||||||
}}
|
|
||||||
</td>
|
|
||||||
<td :title="FormatFullDuration(match.duration)" class="td-duration text-center">
|
|
||||||
{{ FormatDuration(match.duration) }}
|
|
||||||
|
|
||||||
</td>
|
|
||||||
<td :title="FormatFullDate(match.date)" class="td-date">
|
|
||||||
{{ FormatDate(match.date) }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import {
|
|
||||||
DisplayRank,
|
|
||||||
FixMapName,
|
|
||||||
FormatDate,
|
|
||||||
FormatDuration,
|
|
||||||
FormatFullDate,
|
|
||||||
FormatFullDuration,
|
|
||||||
GetHLTV_1,
|
|
||||||
GetWinLoss,
|
|
||||||
GoToMatch,
|
|
||||||
MatchNotParsedTime
|
|
||||||
} from "@/utils";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: "MatchesTable",
|
|
||||||
props: {
|
|
||||||
colorFront: {
|
|
||||||
type: Boolean,
|
|
||||||
required: false,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
matches: {
|
|
||||||
type: Array,
|
|
||||||
required: false
|
|
||||||
},
|
|
||||||
explore: {
|
|
||||||
type: Boolean,
|
|
||||||
required: false,
|
|
||||||
default: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setup(props) {
|
|
||||||
return {
|
|
||||||
props,
|
|
||||||
FormatDate,
|
|
||||||
FormatFullDate,
|
|
||||||
FormatDuration,
|
|
||||||
FormatFullDuration,
|
|
||||||
GetHLTV_1,
|
|
||||||
GetWinLoss,
|
|
||||||
GoToMatch,
|
|
||||||
MatchNotParsedTime,
|
|
||||||
DisplayRank,
|
|
||||||
FixMapName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
|
|
||||||
#matches-placeholder {
|
|
||||||
.placeholder {
|
|
||||||
height: 78px;
|
|
||||||
margin: 1px 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
margin-bottom: 0;
|
|
||||||
|
|
||||||
tr {
|
|
||||||
th {
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
line-height: 60px;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
th:last-child, td:last-child {
|
|
||||||
text-align: right;
|
|
||||||
width: 150px;
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map {
|
|
||||||
padding-left: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.match-len {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.td-map {
|
|
||||||
position: relative;
|
|
||||||
padding-left: 3rem;
|
|
||||||
text-align: left !important;
|
|
||||||
width: 50px;
|
|
||||||
|
|
||||||
.parsed {
|
|
||||||
position: absolute;
|
|
||||||
left: 7px;
|
|
||||||
bottom: 23px;
|
|
||||||
color: var(--bs-warning);
|
|
||||||
font-size: 1.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.not-yet-parsed {
|
|
||||||
position: absolute;
|
|
||||||
left: 10px;
|
|
||||||
bottom: 25px;
|
|
||||||
color: darkgrey;
|
|
||||||
font-size: 1.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-not-found {
|
|
||||||
position: absolute;
|
|
||||||
top: 4px;
|
|
||||||
left: 48px;
|
|
||||||
font-size: 4.35rem;
|
|
||||||
color: rgba(255, 193, 7, .86);
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 60px;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.td-rank {
|
|
||||||
img {
|
|
||||||
width: 70px;
|
|
||||||
height: auto;
|
|
||||||
|
|
||||||
.rank-icon {
|
|
||||||
height: 35px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.td-score {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.td-date, .date {
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.match {
|
|
||||||
$first: rgb(0, 0, 0);
|
|
||||||
$last: rgb(0, 0, 0);
|
|
||||||
$win: false;
|
|
||||||
$loss: false;
|
|
||||||
$draw: false;
|
|
||||||
$ban: false;
|
|
||||||
|
|
||||||
&.default {
|
|
||||||
background: linear-gradient(to right,
|
|
||||||
rgba($first, 0.2) 0%,
|
|
||||||
rgba($first, 0.1) 15%,
|
|
||||||
rgba(0, 0, 0, 0.4) 30%,
|
|
||||||
rgba(0, 0, 0, 0.4) 70%,
|
|
||||||
rgba($last, 0.6) 80%,
|
|
||||||
rgba($last, 0.6) 100%
|
|
||||||
);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: linear-gradient(to right,
|
|
||||||
rgba($first, 0.3) 0%,
|
|
||||||
rgba($first, 0.2) 15%,
|
|
||||||
rgba(0, 0, 0, 0.5) 30%,
|
|
||||||
rgba(0, 0, 0, 0.5) 70%,
|
|
||||||
rgba($last, 0.7) 80%,
|
|
||||||
rgba($last, 0.7) 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.win {
|
|
||||||
$first: rgb(0, 255, 0);
|
|
||||||
background: linear-gradient(to right,
|
|
||||||
rgba($first, 0.2) 0%,
|
|
||||||
rgba($first, 0.1) 15%,
|
|
||||||
rgba(0, 0, 0, 0.4) 30%,
|
|
||||||
rgba(0, 0, 0, 0.4) 70%,
|
|
||||||
rgba($last, 0.6) 80%,
|
|
||||||
rgba($last, 0.6) 100%
|
|
||||||
);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: linear-gradient(to right,
|
|
||||||
rgba($first, 0.3) 0%,
|
|
||||||
rgba($first, 0.2) 15%,
|
|
||||||
rgba(0, 0, 0, 0.5) 30%,
|
|
||||||
rgba(0, 0, 0, 0.5) 70%,
|
|
||||||
rgba($last, 0.7) 80%,
|
|
||||||
rgba($last, 0.7) 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.draw {
|
|
||||||
$first: rgb(255, 255, 0);
|
|
||||||
background: linear-gradient(to right,
|
|
||||||
rgba($first, 0.2) 0%,
|
|
||||||
rgba($first, 0.1) 15%,
|
|
||||||
rgba(0, 0, 0, 0.4) 30%,
|
|
||||||
rgba(0, 0, 0, 0.4) 70%,
|
|
||||||
rgba($last, 0.6) 80%,
|
|
||||||
rgba($last, 0.6) 100%
|
|
||||||
);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: linear-gradient(to right,
|
|
||||||
rgba($first, 0.3) 0%,
|
|
||||||
rgba($first, 0.2) 15%,
|
|
||||||
rgba(0, 0, 0, 0.5) 30%,
|
|
||||||
rgba(0, 0, 0, 0.5) 70%,
|
|
||||||
rgba($last, 0.7) 80%,
|
|
||||||
rgba($last, 0.7) 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.loss {
|
|
||||||
$first: rgb(255, 0, 0);
|
|
||||||
background: linear-gradient(to right,
|
|
||||||
rgba($first, 0.2) 0%,
|
|
||||||
rgba($first, 0.1) 15%,
|
|
||||||
rgba(0, 0, 0, 0.4) 30%,
|
|
||||||
rgba(0, 0, 0, 0.4) 70%,
|
|
||||||
rgba($last, 0.6) 80%,
|
|
||||||
rgba($last, 0.6) 100%
|
|
||||||
);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: linear-gradient(to right,
|
|
||||||
rgba($first, 0.3) 0%,
|
|
||||||
rgba($first, 0.2) 15%,
|
|
||||||
rgba(0, 0, 0, 0.5) 30%,
|
|
||||||
rgba(0, 0, 0, 0.5) 70%,
|
|
||||||
rgba($last, 0.7) 80%,
|
|
||||||
rgba($last, 0.7) 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.ban {
|
|
||||||
$last: rgb(93, 3, 3);
|
|
||||||
background: linear-gradient(to right,
|
|
||||||
rgba($first, 0.2) 0%,
|
|
||||||
rgba($first, 0.1) 15%,
|
|
||||||
rgba(0, 0, 0, 0.4) 30%,
|
|
||||||
rgba(0, 0, 0, 0.4) 70%,
|
|
||||||
rgba($last, 0.6) 80%,
|
|
||||||
rgba($last, 0.6) 100%
|
|
||||||
);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: linear-gradient(to right,
|
|
||||||
rgba($first, 0.3) 0%,
|
|
||||||
rgba($first, 0.2) 15%,
|
|
||||||
rgba(0, 0, 0, 0.5) 30%,
|
|
||||||
rgba(0, 0, 0, 0.5) 70%,
|
|
||||||
rgba($last, 0.7) 80%,
|
|
||||||
rgba($last, 0.7) 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.matches_ban {
|
|
||||||
$first: rgb(0, 0, 0);
|
|
||||||
$last: rgb(93, 3, 3);
|
|
||||||
background: linear-gradient(to right,
|
|
||||||
rgba($first, 0.2) 0%,
|
|
||||||
rgba($first, 0.1) 15%,
|
|
||||||
rgba(0, 0, 0, 0.4) 30%,
|
|
||||||
rgba(0, 0, 0, 0.4) 70%,
|
|
||||||
rgba($last, 0.6) 80%,
|
|
||||||
rgba($last, 0.6) 100%
|
|
||||||
);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: linear-gradient(to right,
|
|
||||||
rgba($first, 0.3) 0%,
|
|
||||||
rgba($first, 0.2) 15%,
|
|
||||||
rgba(0, 0, 0, 0.5) 30%,
|
|
||||||
rgba(0, 0, 0, 0.5) 70%,
|
|
||||||
rgba($last, 0.7) 80%,
|
|
||||||
rgba($last, 0.7) 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
border-bottom: 1px solid rgba(73, 73, 73, 0.73);
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 400px) {
|
|
||||||
table tr {
|
|
||||||
.map-icon {
|
|
||||||
margin-left: 0 !important;
|
|
||||||
}
|
|
||||||
.map {
|
|
||||||
padding: 0.5rem !important;
|
|
||||||
}
|
|
||||||
.td-map {
|
|
||||||
padding: 0 1rem !important;
|
|
||||||
|
|
||||||
.parsed {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.not-yet-parsed {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 768px) {
|
|
||||||
.map-icon {
|
|
||||||
margin-left: -1.32em !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.td-map {
|
|
||||||
position: relative;
|
|
||||||
width: 35px !important;
|
|
||||||
|
|
||||||
.parsed {
|
|
||||||
position: absolute;
|
|
||||||
left: .3rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.not-yet-parsed {
|
|
||||||
position: absolute;
|
|
||||||
left: .3rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 35px !important;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.td-rank img {
|
|
||||||
width: 50px !important;
|
|
||||||
height: auto;
|
|
||||||
max-width: 50px !important;
|
|
||||||
margin-left: -0.5rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.td-score {
|
|
||||||
font-size: .7rem !important;
|
|
||||||
//width: 110px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.td-date {
|
|
||||||
font-size: .8rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kills, .deaths, .assists, .kdiff, .duration, .hltv, .length,
|
|
||||||
.td-kills, .td-deaths, .td-assists, .td-plus, .td-duration, .td-hltv, .td-length {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 992px) {
|
|
||||||
.avatar {
|
|
||||||
width: 100px !important;
|
|
||||||
height: 100px !important;
|
|
||||||
}
|
|
||||||
.trackme-btn {
|
|
||||||
top: 25px;
|
|
||||||
}
|
|
||||||
.map, .td-map {
|
|
||||||
padding-left: 4rem !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 1200px) {
|
|
||||||
.td-plus, .kdiff {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.td-rank img {
|
|
||||||
width: 60px !important;
|
|
||||||
height: auto;
|
|
||||||
max-width: 60px;
|
|
||||||
}
|
|
||||||
.td-map img {
|
|
||||||
width: 50px !important;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
.td-score {
|
|
||||||
font-size: 1.1rem !important;
|
|
||||||
width: 130px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="charts">
|
|
||||||
<div id="multi-kills-chart-1"></div>
|
|
||||||
<div id="multi-kills-chart-2"></div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import * as echarts from 'echarts/core';
|
|
||||||
import {GridComponent, TooltipComponent, VisualMapComponent} from 'echarts/components';
|
|
||||||
import {HeatmapChart} from 'echarts/charts';
|
|
||||||
import {CanvasRenderer} from 'echarts/renderers';
|
|
||||||
import {onMounted, onUnmounted, ref} from "vue";
|
|
||||||
import {checkStatEmpty, getPlayerArr} from "../utils";
|
|
||||||
import {useStore} from "vuex";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: "MultiKillsChart",
|
|
||||||
setup() {
|
|
||||||
const store = useStore()
|
|
||||||
|
|
||||||
const multiKills = ['2k', '3k', '4k', '5k']
|
|
||||||
let myChart1, myChart2
|
|
||||||
const width = ref(window.innerWidth <= 500 ? window.innerWidth : 500)
|
|
||||||
const height = ref(width.value)
|
|
||||||
|
|
||||||
const multiKillArr = (stats, team) => {
|
|
||||||
let arr = []
|
|
||||||
for (let i = (team - 1) * 5; i < team * 5; i++) {
|
|
||||||
for (let j = 0; j < multiKills.length; j++) {
|
|
||||||
if (j === 0)
|
|
||||||
arr.push([i % 5, j, checkStatEmpty(stats[i].multi_kills.duo) === 0 ? null : stats[i].multi_kills.duo])
|
|
||||||
if (j === 1)
|
|
||||||
arr.push([i % 5, j, checkStatEmpty(stats[i].multi_kills.triple) === 0 ? null : stats[i].multi_kills.triple])
|
|
||||||
if (j === 2)
|
|
||||||
arr.push([i % 5, j, checkStatEmpty(stats[i].multi_kills.quad) === 0 ? null : stats[i].multi_kills.quad])
|
|
||||||
if (j === 3)
|
|
||||||
arr.push([i % 5, j, checkStatEmpty(stats[i].multi_kills.pent) === 0 ? null : stats[i].multi_kills.pent])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return arr
|
|
||||||
}
|
|
||||||
|
|
||||||
const getMax = (stats, team) => {
|
|
||||||
let max = 0
|
|
||||||
for (let i = (team - 1) * 5; i < team * 5; i++) {
|
|
||||||
if (stats[i].multi_kills.duo > max)
|
|
||||||
max = stats[i].multi_kills.duo
|
|
||||||
if (stats[i].multi_kills.triple > max)
|
|
||||||
max = stats[i].multi_kills.triple
|
|
||||||
if (stats[i].multi_kills.quad > max)
|
|
||||||
max = stats[i].multi_kills.quad
|
|
||||||
if (stats[i].multi_kills.pent > max)
|
|
||||||
max = stats[i].multi_kills.pent
|
|
||||||
}
|
|
||||||
return max
|
|
||||||
}
|
|
||||||
|
|
||||||
const optionGen = (team) => {
|
|
||||||
return {
|
|
||||||
tooltip: {},
|
|
||||||
grid: {
|
|
||||||
height: '65%',
|
|
||||||
top: '0%',
|
|
||||||
bottom: '10%'
|
|
||||||
},
|
|
||||||
xAxis: {
|
|
||||||
type: 'category',
|
|
||||||
data: getPlayerArr(store.state.matchDetails.stats, team, true).reverse(),
|
|
||||||
splitArea: {
|
|
||||||
show: true
|
|
||||||
},
|
|
||||||
axisLabel: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: 'white',
|
|
||||||
rotate: 50
|
|
||||||
}
|
|
||||||
},
|
|
||||||
yAxis: {
|
|
||||||
type: 'category',
|
|
||||||
data: multiKills,
|
|
||||||
splitArea: {
|
|
||||||
show: true
|
|
||||||
},
|
|
||||||
axisLabel: {
|
|
||||||
color: 'white'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
visualMap: {
|
|
||||||
min: 0,
|
|
||||||
max: getMax(store.state.matchDetails.stats, team),
|
|
||||||
calculable: true,
|
|
||||||
orient: 'horizontal',
|
|
||||||
left: 'center',
|
|
||||||
bottom: '5%',
|
|
||||||
textStyle: {
|
|
||||||
color: 'white'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
type: 'heatmap',
|
|
||||||
data: multiKillArr(store.state.matchDetails.stats, team),
|
|
||||||
label: {
|
|
||||||
fontSize: 14,
|
|
||||||
show: true
|
|
||||||
},
|
|
||||||
emphasis: {
|
|
||||||
itemStyle: {
|
|
||||||
shadowBlur: 10,
|
|
||||||
shadowColor: 'rgba(0, 0, 0, 0.5)'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const disposeCharts = () => {
|
|
||||||
if (myChart1 != null && myChart1 !== '' && myChart1 !== undefined) {
|
|
||||||
myChart1.dispose()
|
|
||||||
}
|
|
||||||
if (myChart2 != null && myChart2 !== '' && myChart2 !== undefined) {
|
|
||||||
myChart2.dispose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildCharts = () => {
|
|
||||||
disposeCharts()
|
|
||||||
|
|
||||||
myChart1 = echarts.init(document.getElementById('multi-kills-chart-1'), {}, {
|
|
||||||
width: width.value,
|
|
||||||
height: height.value
|
|
||||||
});
|
|
||||||
myChart1.setOption(optionGen(1));
|
|
||||||
|
|
||||||
myChart2 = echarts.init(document.getElementById('multi-kills-chart-2'), {}, {
|
|
||||||
width: width.value,
|
|
||||||
height: height.value
|
|
||||||
});
|
|
||||||
myChart2.setOption(optionGen(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (store.state.matchDetails.stats) {
|
|
||||||
echarts.use([
|
|
||||||
TooltipComponent,
|
|
||||||
GridComponent,
|
|
||||||
VisualMapComponent,
|
|
||||||
HeatmapChart,
|
|
||||||
CanvasRenderer
|
|
||||||
]);
|
|
||||||
|
|
||||||
buildCharts()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
disposeCharts()
|
|
||||||
})
|
|
||||||
|
|
||||||
window.onresize = () => {
|
|
||||||
if (window.innerWidth <= 500) {
|
|
||||||
width.value = window.innerWidth - 20
|
|
||||||
height.value = width.value
|
|
||||||
|
|
||||||
buildCharts()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.charts {
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
#multi-kills-chart-1,
|
|
||||||
#multi-kills-chart-2 {
|
|
||||||
flex-basis: 50%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,373 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="side-info">
|
|
||||||
|
|
||||||
<div v-if="props.player_meta.most_mates" class="side-info-box most-played-with">
|
|
||||||
<div class="heading">
|
|
||||||
<h5>Most played with</h5>
|
|
||||||
</div>
|
|
||||||
<hr>
|
|
||||||
<ul v-for="mate in props.player_meta.most_mates" :key="mate.player.steamid64" class="list-unstyled">
|
|
||||||
<li @click="GoToPlayer(mate.player.vanity_url || mate.player.steamid64)">
|
|
||||||
<span class="start">
|
|
||||||
<img :class="mate.player.tracked ? 'tracked' : ''" :src="constructAvatarUrl(mate.player.avatar)"
|
|
||||||
:title="mate.player.tracked ? 'Tracked' : ''" alt="Player avatar">
|
|
||||||
<span class="text">{{ mate.player.name }}</span>
|
|
||||||
</span>
|
|
||||||
<span class="end">
|
|
||||||
{{ mate.total }}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="mostMatesLoading" class="side-info-box most-played-with">
|
|
||||||
<div class="heading">
|
|
||||||
<h5>Most played with</h5>
|
|
||||||
</div>
|
|
||||||
<hr>
|
|
||||||
<ul class="list-unstyled placeholder-glow">
|
|
||||||
<li class="placeholder col-11"></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="props.player_meta.best_mates" class="side-info-box best-mate">
|
|
||||||
<div class="heading">
|
|
||||||
<h5>Best Mate <span class="text-muted">(by winrate)</span></h5>
|
|
||||||
</div>
|
|
||||||
<hr>
|
|
||||||
<ul v-for="mate in props.player_meta.best_mates" :key="mate.player.steamid64" class="list-unstyled">
|
|
||||||
<li @click="GoToPlayer(mate.player.vanity_url || mate.player.steamid64)">
|
|
||||||
<span class="start">
|
|
||||||
<img :class="mate.player.tracked ? 'tracked' : ''" :src="constructAvatarUrl(mate.player.avatar)"
|
|
||||||
:title="mate.player.tracked ? 'Tracked' : ''" alt="Player avatar">
|
|
||||||
<span class="text">{{ mate.player.name }}</span>
|
|
||||||
</span>
|
|
||||||
<span class="end">
|
|
||||||
{{ mate.win_rate ? (mate.win_rate * 100).toFixed(0) : 0 }} %
|
|
||||||
<span v-if="mate.total" class="total text-muted">({{ mate.total }})</span>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="bestMatesLoading" class="side-info-box best-mate">
|
|
||||||
<div class="heading">
|
|
||||||
<h5>Best Mate <span class="text-muted">(by winrate)</span></h5>
|
|
||||||
</div>
|
|
||||||
<hr>
|
|
||||||
<ul class="list-unstyled placeholder-glow">
|
|
||||||
<li class="placeholder col-11"></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="props.player_meta.eq_map && props.player_meta.weapon_dmg" class="side-info-box preferred-weapons">
|
|
||||||
<div class="heading">
|
|
||||||
<h5>Weapons <span class="text-muted">(by dmg)</span></h5>
|
|
||||||
</div>
|
|
||||||
<hr>
|
|
||||||
<ul v-for="(id, key) in data.best_weapons" :key="id[0]" class="list-unstyled">
|
|
||||||
<li>
|
|
||||||
<span class="start">
|
|
||||||
<span class="text">{{ id[0] }}</span>
|
|
||||||
</span>
|
|
||||||
<span :title="id[0] + ' - ' + id[1] + ' dmg'" class="end">
|
|
||||||
<span :class="'dmg-chart-' + key">
|
|
||||||
{{ id[1] }}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
{{ setDmgGraphWidth() }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="weaponsLoading" class="side-info-box preferred-weapons">
|
|
||||||
<div class="heading">
|
|
||||||
<h5>Weapons <span class="text-muted">(by dmg)</span></h5>
|
|
||||||
</div>
|
|
||||||
<hr>
|
|
||||||
<ul class="list-unstyled placeholder-glow">
|
|
||||||
<li class="placeholder col-11"></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="props.player_meta.win_maps" class="side-info-box best-map">
|
|
||||||
<div class="heading">
|
|
||||||
<h5>Best Map <span class="text-muted">(by winrate)</span></h5>
|
|
||||||
</div>
|
|
||||||
<hr>
|
|
||||||
<ul v-for="map in data.best_maps" :key="map[0]" class="list-unstyled">
|
|
||||||
<li>
|
|
||||||
<span class="start">
|
|
||||||
<img :src="'/images/map_icons/map_icon_' + map[0] + '.svg'" alt="Player avatar">
|
|
||||||
<span class="text">{{ FixMapName(map[0]) }}</span>
|
|
||||||
</span>
|
|
||||||
<span class="end">
|
|
||||||
{{ (map[1] * 100).toFixed(0) }} %
|
|
||||||
<span v-if="props.player_meta.total_maps[map[0]]"
|
|
||||||
class="total text-muted">({{ props.player_meta.total_maps[map[0]] }})</span>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="mapsLoading" class="side-info-box best-map">
|
|
||||||
<div class="heading">
|
|
||||||
<h5>Best Map <span class="text-muted">(by winrate)</span></h5>
|
|
||||||
</div>
|
|
||||||
<hr>
|
|
||||||
<ul class="list-unstyled placeholder-glow">
|
|
||||||
<li class="placeholder col-11"></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
|
|
||||||
import {constructAvatarUrl, FixMapName, GoToPlayer, sortObjectValue} from "@/utils";
|
|
||||||
import {reactive, ref, watch} from "vue";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: "PlayerSideInfo",
|
|
||||||
props: {
|
|
||||||
player_meta: {
|
|
||||||
type: Object,
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setup(props) {
|
|
||||||
const displayCounter = 3
|
|
||||||
|
|
||||||
const mostMatesLoading = ref(true)
|
|
||||||
const bestMatesLoading = ref(true)
|
|
||||||
const weaponsLoading = ref(true)
|
|
||||||
const mapsLoading = ref(true)
|
|
||||||
|
|
||||||
const data = reactive({
|
|
||||||
best_maps: [],
|
|
||||||
best_weapons_tmp: [],
|
|
||||||
best_weapons: []
|
|
||||||
})
|
|
||||||
|
|
||||||
const mapWeaponDamage = () => {
|
|
||||||
if (props.player_meta.eq_map && props.player_meta.weapon_dmg) {
|
|
||||||
Object.keys(props.player_meta.eq_map).forEach((key) => {
|
|
||||||
for (const id in props.player_meta.weapon_dmg) {
|
|
||||||
Object.keys(props.player_meta.weapon_dmg[id]).forEach((k) => {
|
|
||||||
if (k === 'eq') {
|
|
||||||
if (props.player_meta.weapon_dmg[id][k] === key * 1) {
|
|
||||||
data.best_weapons_tmp.push([props.player_meta.eq_map[key], props.player_meta.weapon_dmg[id]['dmg']])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
data.best_weapons_tmp.sort((a, b) => {
|
|
||||||
return b[1] - a[1]
|
|
||||||
})
|
|
||||||
|
|
||||||
data.best_weapons = data.best_weapons_tmp
|
|
||||||
data.best_weapons_tmp = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const setDmgGraphWidth = () => {
|
|
||||||
setTimeout(() => {
|
|
||||||
let weaponsContainer
|
|
||||||
const dmg100 = ref(0)
|
|
||||||
const dmg = ref(0)
|
|
||||||
|
|
||||||
for (let i = 0; i <= 4; i++) {
|
|
||||||
weaponsContainer = document.querySelector('.dmg-chart-' + i)
|
|
||||||
if (weaponsContainer !== null) {
|
|
||||||
if (i === 0) {
|
|
||||||
dmg100.value = weaponsContainer.innerHTML * 1
|
|
||||||
weaponsContainer.style.width = '100%'
|
|
||||||
}
|
|
||||||
|
|
||||||
dmg.value = weaponsContainer.innerHTML * 1
|
|
||||||
weaponsContainer.style.width = dmg.value * 100 / dmg100.value + '%'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => props.player_meta, () => {
|
|
||||||
mapWeaponDamage()
|
|
||||||
|
|
||||||
data.best_maps = sortObjectValue(props.player_meta.win_maps, 'desc')
|
|
||||||
|
|
||||||
if (data.best_maps.length > displayCounter)
|
|
||||||
data.best_maps.splice(displayCounter, data.best_maps.length - displayCounter)
|
|
||||||
|
|
||||||
if (!props.player_meta.most_mates) {
|
|
||||||
mostMatesLoading.value = false
|
|
||||||
}
|
|
||||||
if (!props.player_meta.best_mates) {
|
|
||||||
bestMatesLoading.value = false
|
|
||||||
}
|
|
||||||
if (!props.player_meta.win_maps) {
|
|
||||||
mapsLoading.value = false
|
|
||||||
}
|
|
||||||
if (!props.player_meta.eq_map || !props.player_meta.weapon_dmg) {
|
|
||||||
weaponsLoading.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
props,
|
|
||||||
data,
|
|
||||||
weaponsLoading,
|
|
||||||
mapsLoading,
|
|
||||||
mostMatesLoading,
|
|
||||||
bestMatesLoading,
|
|
||||||
setDmgGraphWidth,
|
|
||||||
GoToPlayer,
|
|
||||||
constructAvatarUrl,
|
|
||||||
FixMapName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.side-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
margin-top: 30px;
|
|
||||||
|
|
||||||
.placeholder {
|
|
||||||
height: 25px;
|
|
||||||
padding: 0 10px !important;
|
|
||||||
margin: 14px auto !important;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-info-box {
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
background: rgba(20, 20, 20, .8);
|
|
||||||
border: 1px solid rgba(white, .3);
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
ol, ul, dl {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.best-mate,
|
|
||||||
.preferred-weapons,
|
|
||||||
.most-played-with,
|
|
||||||
.best-map {
|
|
||||||
.heading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
height: 30px;
|
|
||||||
|
|
||||||
h5 {
|
|
||||||
font-size: 1rem;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hr {
|
|
||||||
margin: 0 0 5px 0;
|
|
||||||
border-color: rgba(white, .3);
|
|
||||||
}
|
|
||||||
|
|
||||||
ul li {
|
|
||||||
line-height: 25px;
|
|
||||||
font-size: .9rem;
|
|
||||||
padding: 0 10px;
|
|
||||||
margin: 10px 0;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
|
|
||||||
.start {
|
|
||||||
width: 50%;
|
|
||||||
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
|
|
||||||
.tracked {
|
|
||||||
font-size: .8rem;
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 25px;
|
|
||||||
height: 25px;
|
|
||||||
border-radius: 50%;
|
|
||||||
margin-right: 5px;
|
|
||||||
margin-left: 5px;
|
|
||||||
|
|
||||||
&.tracked {
|
|
||||||
border: 2px solid var(--bs-success);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.end {
|
|
||||||
display: flex;
|
|
||||||
width: 45%;
|
|
||||||
justify-content: flex-end;
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.best-map, .best-mate {
|
|
||||||
ul li {
|
|
||||||
.start {
|
|
||||||
width: 75%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.end {
|
|
||||||
.total {
|
|
||||||
padding-left: 5px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.preferred-weapons,
|
|
||||||
.best-map {
|
|
||||||
ul li {
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.preferred-weapons {
|
|
||||||
.end {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
@for $i from 0 through 3 {
|
|
||||||
.dmg-chart-#{$i} {
|
|
||||||
position: absolute;
|
|
||||||
background: rgba(150, 50, 50, 1);
|
|
||||||
border-radius: 15px;
|
|
||||||
color: transparent;
|
|
||||||
user-select: none;
|
|
||||||
cursor: help;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(220, 50, 50, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="scoreboard">
|
|
||||||
<table>
|
|
||||||
<caption>
|
|
||||||
<div v-if="store.state.matchDetails.max_rounds === 16" id="short-match">
|
|
||||||
<div class="team-1">
|
|
||||||
<div class="score-text">
|
|
||||||
<span v-if="store.state.matchDetails.score[0] < 10"
|
|
||||||
:style="store.state.matchDetails.score[0] < 10 ? 'margin-left: -10px;' : ''"
|
|
||||||
class="hidden">0</span><span
|
|
||||||
:class="store.state.matchDetails.score[0] === 9 ? 'text-success' : store.state.matchDetails.score[0] === 8 ? 'text-warning' : 'text-danger'">{{
|
|
||||||
store.state.matchDetails.score[0]
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
<img alt="CT logo" src="/images/icons/ct_logo.svg">
|
|
||||||
<img alt="T logo" src="/images/icons/t_logo.svg">
|
|
||||||
</div>
|
|
||||||
<div class="team-2">
|
|
||||||
<div class="score-text">
|
|
||||||
<span v-if="store.state.matchDetails.score[1] < 10"
|
|
||||||
:style="store.state.matchDetails.score[1] < 10 ? 'margin-left: -10px;' : ''"
|
|
||||||
class="hidden">0</span><span
|
|
||||||
:class="store.state.matchDetails.score[1] === 9 ? 'text-success' : store.state.matchDetails.score[1] === 8 ? 'text-warning' : 'text-danger'">{{
|
|
||||||
store.state.matchDetails.score[1]
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
<img alt="T logo" src="/images/icons/t_logo.svg">
|
|
||||||
<img alt="CT logo" src="/images/icons/ct_logo.svg">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="store.state.matchDetails.max_rounds === 30 || !store.state.matchDetails.max_rounds" id="long-match">
|
|
||||||
<div class="team-1">
|
|
||||||
<div class="score-text">
|
|
||||||
<span v-if="store.state.matchDetails.score[0] < 10"
|
|
||||||
:style="store.state.matchDetails.score[0] < 10 ? 'margin-left: -10px;' : ''"
|
|
||||||
class="hidden">0</span><span
|
|
||||||
:class="store.state.matchDetails.match_result === 1 ? 'text-success' : store.state.matchDetails.match_result === 0 ? 'text-warning' : 'text-danger'">{{
|
|
||||||
store.state.matchDetails.score[0]
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
<img alt="CT logo" src="/images/icons/ct_logo.svg">
|
|
||||||
<img alt="T logo" src="/images/icons/t_logo.svg">
|
|
||||||
</div>
|
|
||||||
<div class="team-2">
|
|
||||||
<div class="score-text">
|
|
||||||
<span v-if="store.state.matchDetails.score[1] < 10"
|
|
||||||
:style="store.state.matchDetails.score[1] < 10 ? 'margin-left: -10px;' : ''"
|
|
||||||
class="hidden">0</span><span
|
|
||||||
:class="store.state.matchDetails.match_result === 2 ? 'text-success' : store.state.matchDetails.match_result === 0 ? 'text-warning' : 'text-danger'">{{
|
|
||||||
store.state.matchDetails.score[1]
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
<img alt="T logo" src="/images/icons/t_logo.svg">
|
|
||||||
<img alt="CT logo" src="/images/icons/ct_logo.svg">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</caption>
|
|
||||||
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="player__vac"></th>
|
|
||||||
<th class="player__avatar"></th>
|
|
||||||
<th class="player__name"></th>
|
|
||||||
<th class="player__rank"></th>
|
|
||||||
<th class="player__kills">K</th>
|
|
||||||
<th class="player__assist">A</th>
|
|
||||||
<th class="player__deaths">D</th>
|
|
||||||
<th class="player__diff helptext" title="Kill death difference">+/-</th>
|
|
||||||
<th class="player__kd">K/D</th>
|
|
||||||
<th v-if="store.state.matchDetails.parsed" class="player__adr helptext" title="Average damage per round">
|
|
||||||
ADR
|
|
||||||
</th>
|
|
||||||
<th class="player__hs helptext" title="Percentage of kills with a headshot">HS%</th>
|
|
||||||
<th class="player__rating helptext" title="Estimated HLTV Rating 1.0">Rating</th>
|
|
||||||
<th class="player__mvp helptext" title="Most valuable player">MVP</th>
|
|
||||||
<th class="player__score">Score</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="player in teamStats(1)"
|
|
||||||
:key="player.player.steamid64"
|
|
||||||
class="team-1">
|
|
||||||
<ScoreTeamPlayer :assists="player.assists"
|
|
||||||
:avatar="player.player.avatar"
|
|
||||||
:color="player.color"
|
|
||||||
:deaths="player.deaths"
|
|
||||||
:dmg="player.dmg?.enemy"
|
|
||||||
:game_ban="player.player.game_ban"
|
|
||||||
:game_ban_date="player.player.game_ban_date"
|
|
||||||
:hs="player.headshot"
|
|
||||||
:kdiff="player.kills - player.deaths"
|
|
||||||
:kills="player.kills"
|
|
||||||
:mk_duo="player.multi_kills?.duo"
|
|
||||||
:mk_pent="player.multi_kills?.pent"
|
|
||||||
:mk_quad="player.multi_kills?.quad"
|
|
||||||
:mk_triple="player.multi_kills?.triple"
|
|
||||||
:mvp="player.mvp"
|
|
||||||
:name="player.player.name"
|
|
||||||
:parsed="store.state.matchDetails.parsed"
|
|
||||||
:player_score="player.score"
|
|
||||||
:rank_new="player.rank?.new"
|
|
||||||
:rank_old="player.rank?.old"
|
|
||||||
:rounds_played="store.state.matchDetails.score.reduce((a, b) => a + b)"
|
|
||||||
:steamid64="player.player.steamid64"
|
|
||||||
:tracked="player.player.tracked"
|
|
||||||
:vac="player.player.vac"
|
|
||||||
:vac_date="player.player.vac_date"
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<tr class="hr_outer">
|
|
||||||
<td colspan="14"></td>
|
|
||||||
</tr>
|
|
||||||
<tr class="hr">
|
|
||||||
<td colspan="14"></td>
|
|
||||||
</tr>
|
|
||||||
<tr class="hr_outer">
|
|
||||||
<td colspan="14"></td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<tr v-for="player in teamStats(2)"
|
|
||||||
:key="player.player.steamid64"
|
|
||||||
class="team-2">
|
|
||||||
<ScoreTeamPlayer :assists="player.assists"
|
|
||||||
:avatar="player.player.avatar"
|
|
||||||
:color="player.color"
|
|
||||||
:deaths="player.deaths"
|
|
||||||
:dmg="player.dmg?.enemy"
|
|
||||||
:game_ban="player.player.game_ban"
|
|
||||||
:game_ban_date="player.player.game_ban_date"
|
|
||||||
:hs="player.headshot"
|
|
||||||
:kdiff="player.kills - player.deaths"
|
|
||||||
:kills="player.kills"
|
|
||||||
:mk_duo="player.multi_kills?.duo"
|
|
||||||
:mk_pent="player.multi_kills?.pent"
|
|
||||||
:mk_quad="player.multi_kills?.quad"
|
|
||||||
:mk_triple="player.multi_kills?.triple"
|
|
||||||
:mvp="player.mvp"
|
|
||||||
:name="player.player.name"
|
|
||||||
:parsed="store.state.matchDetails.parsed"
|
|
||||||
:player_score="player.score"
|
|
||||||
:rank_new="player.rank?.new"
|
|
||||||
:rank_old="player.rank?.old"
|
|
||||||
:rounds_played="store.state.matchDetails.score.reduce((a, b) => a + b)"
|
|
||||||
:steamid64="player.player.steamid64"
|
|
||||||
:tracked="player.player.tracked"
|
|
||||||
:vac="player.player.vac"
|
|
||||||
:vac_date="player.player.vac_date"
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import ScoreTeamPlayer from '@/components/ScoreTeamPlayer.vue'
|
|
||||||
import {useStore} from "vuex";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'ScoreTeam',
|
|
||||||
components: {ScoreTeamPlayer},
|
|
||||||
setup() {
|
|
||||||
const store = useStore()
|
|
||||||
|
|
||||||
const teamStats = (team) => {
|
|
||||||
let arr = []
|
|
||||||
|
|
||||||
if (team === 1) {
|
|
||||||
arr = []
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
arr.push(store.state.matchDetails.stats[i])
|
|
||||||
}
|
|
||||||
} else if (team === 2) {
|
|
||||||
arr = []
|
|
||||||
for (let i = 5; i < store.state.matchDetails.stats.length; i++) {
|
|
||||||
arr.push(store.state.matchDetails.stats[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return arr
|
|
||||||
}
|
|
||||||
|
|
||||||
return {store, teamStats}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.scoreboard {
|
|
||||||
margin: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
hr {
|
|
||||||
width: 900px;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
width: 900px;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
caption {
|
|
||||||
position: relative;
|
|
||||||
color: white;
|
|
||||||
caption-side: top;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
z-index: 0;
|
|
||||||
|
|
||||||
.hidden {
|
|
||||||
color: transparent;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-text {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.team-1,
|
|
||||||
.team-2 {
|
|
||||||
position: absolute;
|
|
||||||
font-size: 3rem;
|
|
||||||
opacity: .8;
|
|
||||||
|
|
||||||
margin-left: -100px;
|
|
||||||
|
|
||||||
img {
|
|
||||||
position: absolute;
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
margin-top: 22px;
|
|
||||||
margin-left: 10px;
|
|
||||||
|
|
||||||
&:first-child {
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
margin-left: 30px;
|
|
||||||
z-index: 0 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.team-1 {
|
|
||||||
top: 85px;
|
|
||||||
|
|
||||||
.score-text {
|
|
||||||
margin-left: 5px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.team-2 {
|
|
||||||
top: 180px;
|
|
||||||
|
|
||||||
.score-text {
|
|
||||||
top: 150px;
|
|
||||||
margin-left: 5px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
tr.team-1, tr.team-2 {
|
|
||||||
height: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
padding: 5px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hr {
|
|
||||||
td {
|
|
||||||
height: 1px;
|
|
||||||
padding: 0;
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hr_outer {
|
|
||||||
height: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player__vac {
|
|
||||||
width: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
|
||||||
.scoreboard {
|
|
||||||
margin-left: 65px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 991px) {
|
|
||||||
.scoreboard {
|
|
||||||
margin-left: 2px;
|
|
||||||
|
|
||||||
caption {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,268 +0,0 @@
|
|||||||
<template>
|
|
||||||
<td class="player__vac">
|
|
||||||
<div v-if="!props.vac && !props.game_ban" class="vac-placeholder"></div>
|
|
||||||
<img v-if="props.vac && FormatVacDate(props.vac_date, store.state.matchDetails.date) !== ''"
|
|
||||||
:title="'Vac-banned: ' + FormatVacDate(props.vac_date, store.state.matchDetails.date)"
|
|
||||||
alt="VAC-Ban"
|
|
||||||
src="/images/icons/vac_banned.svg">
|
|
||||||
<img v-if="!props.vac && props.game_ban && FormatVacDate(props.game_ban_date, store.state.matchDetails.date) !== ''"
|
|
||||||
:title="'Game-banned: ' + FormatVacDate(props.game_ban_date, store.state.matchDetails.date)"
|
|
||||||
alt="Game-Ban"
|
|
||||||
src="/images/icons/game_banned.svg">
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<img :class="'team-color-' + props.color" :src="constructAvatarUrl(props.avatar)" alt="Player avatar"
|
|
||||||
class="player__avatar">
|
|
||||||
</td>
|
|
||||||
<td class="player__name" @click="GoToPlayer(props.steamid64)">
|
|
||||||
<i v-if="props.tracked" class="fa fa-dot-circle-o text-success tracked" title="Tracked user"></i>
|
|
||||||
{{ props.name }}
|
|
||||||
<i class="fa fa-external-link"></i>
|
|
||||||
</td>
|
|
||||||
<td v-if="props.parsed" class="player__rank">
|
|
||||||
<img :alt="DisplayRank(props.rank_old)[1]"
|
|
||||||
:class="props.rank_new > props.rank_old ? 'uprank' : props.rank_new < props.rank_old ? 'downrank' : ''"
|
|
||||||
:src="DisplayRank(props.rank_old)[0]"
|
|
||||||
:title="props.rank_new > props.rank_old ? 'Uprank to ' + DisplayRank(props.rank_new)[1] : props.rank_new < props.rank_old ? 'Downrank to ' + DisplayRank(props.rank_new)[1] : DisplayRank(props.rank_old)[1]">
|
|
||||||
</td>
|
|
||||||
<td v-if="!props.parsed" class="rank-placeholder"></td>
|
|
||||||
<td class="player__kills">
|
|
||||||
{{ props.kills }}
|
|
||||||
</td>
|
|
||||||
<td class="player__assist">
|
|
||||||
{{ props.assists }}
|
|
||||||
</td>
|
|
||||||
<td class="player__deaths">
|
|
||||||
{{ props.deaths }}
|
|
||||||
</td>
|
|
||||||
<td :class="props.kdiff >= 0 ? 'text-success' : 'text-danger'" class="player__kdiff">
|
|
||||||
{{ props.kdiff }}
|
|
||||||
</td>
|
|
||||||
<td class="player__kd">
|
|
||||||
{{
|
|
||||||
(props.kills > 0 && props.deaths > 0) ? (props.kills / props.deaths).toFixed(2) : (props.kills > 0 && props.deaths === 0) ? props.kills : 0.00
|
|
||||||
}}
|
|
||||||
</td>
|
|
||||||
<td v-if="props.parsed" class="player__adr">
|
|
||||||
{{ (props.dmg / props.rounds_played).toFixed(2) }}
|
|
||||||
</td>
|
|
||||||
<td class="player__hs">
|
|
||||||
{{ (props.hs > 0 && props.kills > 0) ? (props.hs * 100 / props.kills).toFixed(0) + "%" : "0%" }}
|
|
||||||
</td>
|
|
||||||
<td class="player__rating">
|
|
||||||
{{
|
|
||||||
GetHLTV_1(props.kills, props.rounds_played, props.deaths, props.mk_duo, props.mk_triple, props.mk_quad, props.mk_pent)
|
|
||||||
}}
|
|
||||||
</td>
|
|
||||||
<td class="player__mvp">
|
|
||||||
{{ props.mvp }}
|
|
||||||
</td>
|
|
||||||
<td class="player__score">
|
|
||||||
{{ props.player_score }}
|
|
||||||
</td>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import {constructAvatarUrl, DisplayRank, FormatVacDate, GetHLTV_1, GoToPlayer} from "@/utils";
|
|
||||||
import {useStore} from "vuex";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'ScoreTeamPlayer',
|
|
||||||
props: {
|
|
||||||
steamid64: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
default: ''
|
|
||||||
},
|
|
||||||
avatar: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
default: 'Avatar'
|
|
||||||
},
|
|
||||||
name: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
default: 'Name'
|
|
||||||
},
|
|
||||||
rank_old: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
default: 0
|
|
||||||
},
|
|
||||||
rank_new: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
default: 0
|
|
||||||
},
|
|
||||||
kills: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
default: 0
|
|
||||||
},
|
|
||||||
assists: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
default: 0
|
|
||||||
},
|
|
||||||
deaths: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
default: 0
|
|
||||||
},
|
|
||||||
kdiff: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
default: 0
|
|
||||||
},
|
|
||||||
hs: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
default: 0
|
|
||||||
},
|
|
||||||
rounds_played: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
default: 0
|
|
||||||
},
|
|
||||||
mk_duo: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
default: 0
|
|
||||||
},
|
|
||||||
mk_triple: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
default: 0
|
|
||||||
},
|
|
||||||
mk_quad: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
default: 0
|
|
||||||
},
|
|
||||||
mk_pent: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
default: 0
|
|
||||||
},
|
|
||||||
dmg: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
default: 0
|
|
||||||
},
|
|
||||||
mvp: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
default: 0
|
|
||||||
},
|
|
||||||
player_score: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
default: 0
|
|
||||||
},
|
|
||||||
color: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
default: ''
|
|
||||||
},
|
|
||||||
tracked: {
|
|
||||||
type: Boolean,
|
|
||||||
required: true,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
parsed: {
|
|
||||||
type: Boolean,
|
|
||||||
required: true,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
vac: {
|
|
||||||
type: Boolean,
|
|
||||||
required: true,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
vac_date: {
|
|
||||||
type: Number,
|
|
||||||
required: false,
|
|
||||||
default: 0
|
|
||||||
},
|
|
||||||
game_ban: {
|
|
||||||
type: Boolean,
|
|
||||||
required: true,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
game_ban_date: {
|
|
||||||
type: Number,
|
|
||||||
required: false,
|
|
||||||
default: 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setup(props) {
|
|
||||||
const store = useStore()
|
|
||||||
return {props, GetHLTV_1, GoToPlayer, DisplayRank, constructAvatarUrl, FormatVacDate, store}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.player__vac,
|
|
||||||
.vac-placeholder {
|
|
||||||
width: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player__vac {
|
|
||||||
img {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.player__avatar {
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player__name {
|
|
||||||
text-align: left;
|
|
||||||
width: 150px;
|
|
||||||
max-width: 150px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
.tracked {
|
|
||||||
font-size: .8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fa-external-link {
|
|
||||||
font-size: .8rem;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.player__rank,
|
|
||||||
.rank-placeholder {
|
|
||||||
width: 100px;
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 60px;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.player__kills, .player__assist, .player__deaths, .player__kdiff, .player__mvp {
|
|
||||||
width: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player__kd, .player__hs, .player__rating, .player__score {
|
|
||||||
width: 75px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player__adr {
|
|
||||||
width: 85px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player__rating {
|
|
||||||
border-radius: 25% 25%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<template>
|
|
||||||
<h3>This Graph will be available soon</h3>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import {watch} from "vue";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: "SprayGraph",
|
|
||||||
props: {
|
|
||||||
spray: {
|
|
||||||
type: Object,
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setup(props) {
|
|
||||||
watch(() => props.spray, () => {
|
|
||||||
// console.log(props.spray)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
|
|
||||||
</style>
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="player-dmg">
|
|
||||||
<div id="dmg-chart-1"></div>
|
|
||||||
<div id="dmg-chart-2"></div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import * as echarts from 'echarts/core';
|
|
||||||
import {GridComponent, LegendComponent, TooltipComponent} from 'echarts/components';
|
|
||||||
import {BarChart} from 'echarts/charts';
|
|
||||||
import {CanvasRenderer} from 'echarts/renderers';
|
|
||||||
import {onMounted, onUnmounted, ref} from "vue";
|
|
||||||
import {checkStatEmpty, getPlayerArr} from "../utils";
|
|
||||||
import {useStore} from "vuex";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: "FlashChart",
|
|
||||||
setup() {
|
|
||||||
const store = useStore()
|
|
||||||
|
|
||||||
let myChart1, myChart2
|
|
||||||
|
|
||||||
const getWindowWidth = () => {
|
|
||||||
const windowWidth = window.innerWidth
|
|
||||||
if (windowWidth <= 750)
|
|
||||||
return windowWidth
|
|
||||||
else
|
|
||||||
return 650
|
|
||||||
}
|
|
||||||
|
|
||||||
const setHeight = () => {
|
|
||||||
const windowWidth = getWindowWidth()
|
|
||||||
if (windowWidth >= 751)
|
|
||||||
return windowWidth * 3 / 7.5
|
|
||||||
else if (windowWidth >= 501 && windowWidth <= 750)
|
|
||||||
return windowWidth * 3 / 6.5
|
|
||||||
else
|
|
||||||
return windowWidth * 3 / 5.5
|
|
||||||
}
|
|
||||||
|
|
||||||
const width = ref(getWindowWidth())
|
|
||||||
const height = ref(setHeight())
|
|
||||||
|
|
||||||
const dataArr = (stats, team, prop) => {
|
|
||||||
if (['team', 'enemy', 'self'].indexOf(prop) > -1) {
|
|
||||||
let arr = []
|
|
||||||
for (let i = (team - 1) * 5; i < team * 5; i++) {
|
|
||||||
arr.push({
|
|
||||||
value: checkStatEmpty(Function('return(function(stats, i){ return stats[i].dmg.' + prop + '})')()(stats, i)) * (prop === 'enemy' ? 1 : -1),
|
|
||||||
itemStyle: {
|
|
||||||
color: prop === 'enemy' ? getComputedStyle(document.documentElement).getPropertyValue(`--csgo-${stats[i].color}`) : 'firebrick'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
arr.reverse()
|
|
||||||
return arr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const optionGen = (team) => {
|
|
||||||
return {
|
|
||||||
tooltip: {
|
|
||||||
trigger: 'axis',
|
|
||||||
axisPointer: {
|
|
||||||
type: 'shadow'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
legend: {
|
|
||||||
show: false
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
left: '3%',
|
|
||||||
right: '4%',
|
|
||||||
bottom: '3%',
|
|
||||||
containLabel: true
|
|
||||||
},
|
|
||||||
xAxis: [
|
|
||||||
{
|
|
||||||
type: 'value',
|
|
||||||
min: -300
|
|
||||||
}
|
|
||||||
],
|
|
||||||
yAxis: [
|
|
||||||
{
|
|
||||||
type: 'category',
|
|
||||||
axisTick: {
|
|
||||||
show: false
|
|
||||||
},
|
|
||||||
data: getPlayerArr(store.state.matchDetails.stats, team)
|
|
||||||
}
|
|
||||||
],
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: 'Team',
|
|
||||||
type: 'bar',
|
|
||||||
stack: 'Total',
|
|
||||||
label: {
|
|
||||||
show: true,
|
|
||||||
},
|
|
||||||
emphasis: {
|
|
||||||
focus: 'series'
|
|
||||||
},
|
|
||||||
data: dataArr(store.state.matchDetails.stats, team, 'team')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Enemy',
|
|
||||||
type: 'bar',
|
|
||||||
stack: 'Total',
|
|
||||||
label: {
|
|
||||||
show: true,
|
|
||||||
position: 'inside'
|
|
||||||
},
|
|
||||||
emphasis: {
|
|
||||||
focus: 'series'
|
|
||||||
},
|
|
||||||
data: dataArr(store.state.matchDetails.stats, team, 'enemy')
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const disposeCharts = () => {
|
|
||||||
if (myChart1 != null && myChart1 !== '' && myChart1 !== undefined) {
|
|
||||||
myChart1.dispose()
|
|
||||||
}
|
|
||||||
if (myChart2 != null && myChart2 !== '' && myChart2 !== undefined) {
|
|
||||||
myChart2.dispose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildCharts = () => {
|
|
||||||
disposeCharts()
|
|
||||||
|
|
||||||
myChart1 = echarts.init(document.getElementById('dmg-chart-1'), {}, {width: width.value, height: height.value});
|
|
||||||
myChart1.setOption(optionGen(1));
|
|
||||||
|
|
||||||
myChart2 = echarts.init(document.getElementById('dmg-chart-2'), {}, {width: width.value, height: height.value});
|
|
||||||
myChart2.setOption(optionGen(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (store.state.matchDetails.stats) {
|
|
||||||
echarts.use([
|
|
||||||
TooltipComponent,
|
|
||||||
GridComponent,
|
|
||||||
LegendComponent,
|
|
||||||
BarChart,
|
|
||||||
CanvasRenderer
|
|
||||||
]);
|
|
||||||
|
|
||||||
buildCharts()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
disposeCharts()
|
|
||||||
})
|
|
||||||
|
|
||||||
window.onresize = () => {
|
|
||||||
if (window.innerWidth <= 750) {
|
|
||||||
width.value = getWindowWidth() - 20
|
|
||||||
height.value = setHeight()
|
|
||||||
}
|
|
||||||
|
|
||||||
buildCharts()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.player-dmg {
|
|
||||||
display: flex;
|
|
||||||
margin-bottom: 4rem;
|
|
||||||
|
|
||||||
#dmg-chart-1,
|
|
||||||
#dmg-chart-2 {
|
|
||||||
flex-basis: 50%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
|
||||||
.player-dmg {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="toggle-btn text-muted">
|
|
||||||
<div @click.prevent="$emit('translated', handleBtnClick())"
|
|
||||||
class="d-flex">
|
|
||||||
<span class="text-center mx-2">
|
|
||||||
<i id="toggle-off" class="fa fa-toggle-off show"/>
|
|
||||||
<i id="toggle-on" class="fa fa-toggle-on"/>
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<span :class="toggle === 'translated' ? 'text-warning' : ''"
|
|
||||||
class="float-start">
|
|
||||||
<span class="text-uppercase">Translate to {{data.browserLang}}</span>
|
|
||||||
<span class="loading-icon ms-2" title="Translating..">
|
|
||||||
<i class="fa fa-spinner fa-pulse fa-fw"/>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import {onMounted, reactive, ref} from "vue";
|
|
||||||
import ISO6391 from 'iso-639-1'
|
|
||||||
import {GetChatHistoryTranslated} from "@/utils";
|
|
||||||
import {useStore} from "vuex";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'TranslateChatButton',
|
|
||||||
props: {
|
|
||||||
translated: {
|
|
||||||
type: Boolean,
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setup() {
|
|
||||||
const store = useStore()
|
|
||||||
|
|
||||||
const data = reactive({
|
|
||||||
browserIsoCode: '',
|
|
||||||
browserLangCode: '',
|
|
||||||
browserLang: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const toggle = ref('original')
|
|
||||||
|
|
||||||
const setLanguageVariables = () => {
|
|
||||||
const navLangs = navigator.languages
|
|
||||||
|
|
||||||
data.browserIsoCode = navLangs.find((l) => l.length === 5)
|
|
||||||
data.browserLangCode = navLangs[0]
|
|
||||||
|
|
||||||
if (ISO6391.validate(data.browserLangCode)) {
|
|
||||||
data.browserLang = ISO6391.getNativeName(data.browserLangCode)
|
|
||||||
} else {
|
|
||||||
data.browserIsoCode = 'en-US'
|
|
||||||
data.browserLangCode = 'en'
|
|
||||||
data.browserLang = 'English'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBtnClick = async () => {
|
|
||||||
let response
|
|
||||||
|
|
||||||
const refreshButton = document.querySelector('.loading-icon .fa-spinner')
|
|
||||||
refreshButton.classList.add('show')
|
|
||||||
|
|
||||||
toggleShow()
|
|
||||||
|
|
||||||
response = await GetChatHistoryTranslated(store, store.state.matchDetails.match_id)
|
|
||||||
|
|
||||||
if (refreshButton.classList.contains('show'))
|
|
||||||
refreshButton.classList.remove('show')
|
|
||||||
|
|
||||||
return [response, toggle.value]
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleShow = () => {
|
|
||||||
const offBtn = document.getElementById('toggle-off')
|
|
||||||
const onBtn = document.getElementById('toggle-on')
|
|
||||||
|
|
||||||
if (offBtn.classList.contains('show')) {
|
|
||||||
offBtn.classList.remove('show')
|
|
||||||
onBtn.classList.add('show')
|
|
||||||
toggle.value = 'translated'
|
|
||||||
} else if (onBtn.classList.contains('show')) {
|
|
||||||
onBtn.classList.remove('show')
|
|
||||||
offBtn.classList.add('show')
|
|
||||||
toggle.value = 'original'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
setLanguageVariables()
|
|
||||||
})
|
|
||||||
return {data, toggle, handleBtnClick}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.toggle-btn {
|
|
||||||
margin: 0 auto;
|
|
||||||
cursor: pointer;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
.fa {
|
|
||||||
display: none;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
vertical-align: middle;
|
|
||||||
|
|
||||||
&.show {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div :style="props.ud.flames || props.ud.flash || props.ud.he ? 'display: flex' : 'display: none'"
|
|
||||||
class="player-utility">
|
|
||||||
<div class="heading">
|
|
||||||
<img :src="props.avatar" alt="Player avatar" class="avatar">
|
|
||||||
<h4>{{ props.name }}</h4>
|
|
||||||
</div>
|
|
||||||
<div :id="'utility-chart-' + props.id"></div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import * as echarts from 'echarts/core';
|
|
||||||
import {LegendComponent, TooltipComponent} from 'echarts/components';
|
|
||||||
import {PieChart} from 'echarts/charts';
|
|
||||||
import {LabelLayout} from 'echarts/features';
|
|
||||||
import {CanvasRenderer} from 'echarts/renderers';
|
|
||||||
import { TitleComponent } from 'echarts/components';
|
|
||||||
import {onMounted} from "vue";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: "FlashChart",
|
|
||||||
props: {
|
|
||||||
id: {
|
|
||||||
type: Number,
|
|
||||||
default: 0,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
avatar: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
name: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
ud: {
|
|
||||||
type: Object,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setup(props) {
|
|
||||||
onMounted(() => {
|
|
||||||
echarts.use([
|
|
||||||
TooltipComponent,
|
|
||||||
LegendComponent,
|
|
||||||
PieChart,
|
|
||||||
CanvasRenderer,
|
|
||||||
TitleComponent,
|
|
||||||
LabelLayout
|
|
||||||
]);
|
|
||||||
|
|
||||||
let myChart = echarts.init(document.getElementById(`utility-chart-${props.id}`), {}, {width: 500, height: 300});
|
|
||||||
let option
|
|
||||||
|
|
||||||
option = {
|
|
||||||
tooltip: {
|
|
||||||
trigger: 'item',
|
|
||||||
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
|
||||||
},
|
|
||||||
legend: {
|
|
||||||
show: false
|
|
||||||
},
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: 'Utility Damage',
|
|
||||||
type: 'pie',
|
|
||||||
radius: [0, '65%'],
|
|
||||||
avoidLabelOverlap: true,
|
|
||||||
itemStyle: {
|
|
||||||
borderRadius: 10,
|
|
||||||
borderColor: '#000',
|
|
||||||
borderWidth: 3
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
position: 'inside',
|
|
||||||
fontsize: 36,
|
|
||||||
fontWeight: 'bold'
|
|
||||||
},
|
|
||||||
labelLine: {
|
|
||||||
show: false
|
|
||||||
},
|
|
||||||
data: [
|
|
||||||
(props.ud.flames ? {
|
|
||||||
value: props.ud.flames ? props.ud.flames : null,
|
|
||||||
name: 'Flames',
|
|
||||||
itemStyle: {
|
|
||||||
color: '#FF4343FF'
|
|
||||||
}
|
|
||||||
} : {}),
|
|
||||||
(props.ud.he ? {
|
|
||||||
value: props.ud.he ? props.ud.he : null,
|
|
||||||
name: 'HE',
|
|
||||||
itemStyle: {
|
|
||||||
color: '#62c265'
|
|
||||||
}
|
|
||||||
} : {})
|
|
||||||
,
|
|
||||||
(props.ud.flash ? {
|
|
||||||
value: props.ud.flash ? props.ud.flash : null,
|
|
||||||
name: 'Flash',
|
|
||||||
itemStyle: {
|
|
||||||
color: '#18cff3'
|
|
||||||
}
|
|
||||||
} : {}),
|
|
||||||
(props.ud.smoke ? {
|
|
||||||
value: props.ud.smoke ? props.ud.smoke : null,
|
|
||||||
name: 'Smoke',
|
|
||||||
itemStyle: {
|
|
||||||
color: '#6e6b78'
|
|
||||||
}
|
|
||||||
} : {}),
|
|
||||||
(props.ud.decoy ? {
|
|
||||||
value: props.ud.decoy ? props.ud.decoy : null,
|
|
||||||
name: 'Decoy',
|
|
||||||
itemStyle: {
|
|
||||||
color: '#e28428'
|
|
||||||
}
|
|
||||||
} : {})
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
myChart.setOption(option);
|
|
||||||
})
|
|
||||||
|
|
||||||
return {props}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.player-utility {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.heading {
|
|
||||||
display: flex;
|
|
||||||
margin-top: 10px;
|
|
||||||
margin-bottom: -30px;
|
|
||||||
|
|
||||||
.avatar {
|
|
||||||
width: 50px;
|
|
||||||
height: 50px;
|
|
||||||
border-radius: 50%;
|
|
||||||
margin-right: 20px;
|
|
||||||
color: #ff4343;
|
|
||||||
}
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
margin-top: 7px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
padding-top: 40px;
|
|
||||||
margin-bottom: -20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@for $i from 0 through 9 {
|
|
||||||
#utility-chart-#{$i} {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="utility-chart-total" v-if="props.stats">
|
|
||||||
<div class="heading">
|
|
||||||
<h4>Total Utility Damage</h4>
|
|
||||||
</div>
|
|
||||||
<div id="utility-chart-total"></div>
|
|
||||||
<hr>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import * as echarts from 'echarts/core';
|
|
||||||
import {GridComponent, LegendComponent, TooltipComponent} from 'echarts/components';
|
|
||||||
import {BarChart} from 'echarts/charts';
|
|
||||||
import {CanvasRenderer} from 'echarts/renderers';
|
|
||||||
import {onMounted} from "vue";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: "FlashChart",
|
|
||||||
props: {
|
|
||||||
stats: {
|
|
||||||
type: Object,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setup(props) {
|
|
||||||
const checkStatEmpty = (stat) => {
|
|
||||||
if (stat)
|
|
||||||
return stat
|
|
||||||
else
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const seriesArr = (stats) => {
|
|
||||||
let arr = []
|
|
||||||
for (let i = 0; i < stats.length; i++) {
|
|
||||||
const sum = checkStatEmpty(stats[i].dmg.ud.flames) + checkStatEmpty(stats[i].dmg.ud.flash) + checkStatEmpty(stats[i].dmg.ud.he) + checkStatEmpty(stats[i].dmg.ud.smoke)
|
|
||||||
|
|
||||||
if (sum !== 0) {
|
|
||||||
arr.push({
|
|
||||||
name: stats[i].player.name,
|
|
||||||
type: 'bar',
|
|
||||||
stack: 'total',
|
|
||||||
label: {
|
|
||||||
show: true
|
|
||||||
},
|
|
||||||
emphasis: {
|
|
||||||
focus: 'series'
|
|
||||||
},
|
|
||||||
data: [sum]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
arr.sort((a, b) => parseFloat(b.data[0]) - parseFloat(a.data[0]))
|
|
||||||
|
|
||||||
return arr
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
echarts.use([
|
|
||||||
TooltipComponent,
|
|
||||||
GridComponent,
|
|
||||||
LegendComponent,
|
|
||||||
BarChart,
|
|
||||||
CanvasRenderer
|
|
||||||
]);
|
|
||||||
|
|
||||||
let myChart = echarts.init(document.getElementById('utility-chart-total'), {}, {width: 800, height: 200});
|
|
||||||
let option
|
|
||||||
|
|
||||||
option = {
|
|
||||||
tooltip: {
|
|
||||||
trigger: 'axis',
|
|
||||||
axisPointer: {
|
|
||||||
// Use axis to trigger tooltip
|
|
||||||
type: 'shadow' // 'shadow' as default; can also be 'line'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// color: ['#143147', '#39546c', '#617a94', '#89a2bd', '#b3cce8', '#eac65c', '#bd9d2c', '#917501', '#685000', '#412c00'],
|
|
||||||
// color: ['#003470', '#005a9b', '#0982c7', '#4bace5', '#90d3fe', '#febf4a', '#d7931c', '#ac6a01', '#804400', '#572000'],
|
|
||||||
// color: ['#888F98', '#10121A', '#1B2732', '#5F7892', '#C3A235'],
|
|
||||||
legend: {
|
|
||||||
textStyle: {
|
|
||||||
color: 'white'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
left: '3%',
|
|
||||||
right: '4%',
|
|
||||||
bottom: '3%',
|
|
||||||
containLabel: true
|
|
||||||
},
|
|
||||||
xAxis: {
|
|
||||||
type: 'value'
|
|
||||||
},
|
|
||||||
yAxis: {
|
|
||||||
type: 'category',
|
|
||||||
data: ['Total']
|
|
||||||
},
|
|
||||||
aria: {
|
|
||||||
enabled: true,
|
|
||||||
show: true,
|
|
||||||
decal: {
|
|
||||||
show: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
series: seriesArr(props.stats)
|
|
||||||
};
|
|
||||||
|
|
||||||
myChart.setOption(option);
|
|
||||||
})
|
|
||||||
|
|
||||||
return {props}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.utility-chart-total {
|
|
||||||
.heading {
|
|
||||||
display: flex;
|
|
||||||
margin-top: 10px;
|
|
||||||
margin-bottom: -30px;
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
margin: 7px auto 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
padding-top: 40px;
|
|
||||||
margin-bottom: -20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#utility-chart-total {
|
|
||||||
margin: 40px 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export const SHARECODE_REGEX = /^CSGO(?:-?[ABCDEFGHJKLMNOPQRSTUVWXYZabcdefhijkmnopqrstuvwxyz23456789]{5}){5}$/
|
|
||||||
export const AUTHCODE_REGEX = /^[ABCDEFGHJKLMNOPQRSTUVWXYZ23456789]{4}-[ABCDEFGHJKLMNOPQRSTUVWXYZ23456789]{5}-[ABCDEFGHJKLMNOPQRSTUVWXYZ23456789]{4}$/
|
|
||||||
|
|
||||||
export const NAV_HEIGHT = 70
|
|
||||||
export const FOOTER_HEIGHT = 200
|
|
||||||
187
src/lib/api/client.ts
Normal file
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
|
||||||
|
);
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user