forked from CSGOWTF/csgowtf
Compare commits
59 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 | |||
| 515de7f747 | |||
| 2a541196a4 | |||
| 78da0877c7 | |||
| 0739d3bf7b | |||
| 5279267c8e | |||
| b3dc4c3d73 | |||
| f65fc0a0ea | |||
| befc14d894 |
@@ -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'
|
||||
},
|
||||
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
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=webstorm+all,yarn,windows,linux,node,vuejs
|
||||
.DS_Store
|
||||
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
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
# Build artifacts
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
# Test coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
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
|
||||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/playwright/.cache/
|
||||
|
||||
# Vercel
|
||||
.vercel
|
||||
|
||||
# Temporary files
|
||||
.tmp
|
||||
tmp
|
||||
*.tmp
|
||||
|
||||
4
.husky/pre-commit
Executable file
4
.husky/pre-commit
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
||||
30
.prettierignore
Normal file
30
.prettierignore
Normal file
@@ -0,0 +1,30 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Ignore files for PNPM, NPM and YARN
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Build artifacts
|
||||
dist/
|
||||
.vercel/
|
||||
.netlify/
|
||||
.output/
|
||||
|
||||
# Generated files
|
||||
src-tauri/target/
|
||||
**/.svelte-kit/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
17
.prettierrc.json
Normal file
17
.prettierrc.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"semi": true,
|
||||
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
6
.stylelintignore
Normal file
6
.stylelintignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
build/
|
||||
.svelte-kit/
|
||||
dist/
|
||||
**/*.js
|
||||
**/*.ts
|
||||
13
.stylelintrc.cjs
Normal file
13
.stylelintrc.cjs
Normal file
@@ -0,0 +1,13 @@
|
||||
module.exports = {
|
||||
extends: ['stylelint-config-standard'],
|
||||
rules: {
|
||||
'at-rule-no-unknown': [
|
||||
true,
|
||||
{
|
||||
ignoreAtRules: ['tailwind', 'apply', 'variants', 'responsive', 'screen', 'layer']
|
||||
}
|
||||
],
|
||||
'selector-class-pattern': null,
|
||||
'custom-property-pattern': null
|
||||
}
|
||||
};
|
||||
1
.tool-versions
Normal file
1
.tool-versions
Normal file
@@ -0,0 +1 @@
|
||||
nodejs 20.11.0
|
||||
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
631
.yarn/releases/yarn-3.0.2.cjs
vendored
631
.yarn/releases/yarn-3.0.2.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.0.2.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://go.dev/)
|
||||
[](https://kit.svelte.dev/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://tailwindcss.com/)
|
||||
[](https://git.harting.dev/CSGOWTF/csgowtf/src/branch/master/LICENSE)
|
||||
[](https://liberapay.com/CSGOWTF/)
|
||||
[](https://liberapay.com/CSGOWTF/)
|
||||
[](https://csgow.tf/)
|
||||
<!--[](https://www.typescriptlang.org/)-->
|
||||
[](https://ci.somegit.dev/CSGOWTF/csgowtf)
|
||||
|
||||
### Statistics for CS:GO matchmaking matches.
|
||||
**Statistics for CS2 matchmaking matches** - A complete rewrite of CSGOW.TF with modern web technologies.
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
This is the frontend to the [csgowtfd](https://git.harting.dev/CSGOWTF/csgowtfd) backend.
|
||||
## 🚀 Quick Start
|
||||
|
||||
## Tips on how to contribute
|
||||
- If you are implementing or fixing an issue, please comment on the issue so work is not duplicated.
|
||||
- If you want to implement a new feature, create an issue first describing the issue, so we know about it.
|
||||
- Don't commit unnecessary changes to the codebase or debugging code.
|
||||
- Write meaningful commits or squash them.
|
||||
- Please try to follow the code style of the rest of the codebase.
|
||||
- Only make pull requests to the dev branch.
|
||||
- Only implement one feature per pull request to keep it easy to understand.
|
||||
- Expect comments or questions on your pull request from the project maintainers. We try to keep the code as consistent and maintainable as possible.
|
||||
- Each pull request should come from a new branch in your fork, it should have a meaningful name.
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js** ≥ 18.0.0 (v20.11.0 recommended - see `.nvmrc`)
|
||||
- **npm** or **yarn**
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://somegit.dev/CSGOWTF/csgowtf.git
|
||||
cd csgowtf
|
||||
|
||||
# Switch to the cs2-port branch
|
||||
git checkout cs2-port
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Copy environment variables
|
||||
cp .env.example .env
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The app will be available at `http://localhost:5173`
|
||||
|
||||
---
|
||||
|
||||
## 📦 Tech Stack
|
||||
|
||||
### Core Framework
|
||||
|
||||
- **SvelteKit 2.0** - Full-stack framework with SSR/SSG
|
||||
- **Svelte 5** - Reactive UI framework
|
||||
- **TypeScript 5.3** - Type safety (strict mode)
|
||||
- **Vite 5** - Build tool and dev server
|
||||
|
||||
### Styling
|
||||
|
||||
- **Tailwind CSS 3.4** - Utility-first CSS framework
|
||||
- **DaisyUI 4.0** - Component library with CS2 custom themes
|
||||
- **PostCSS** - CSS processing
|
||||
|
||||
### Data & State
|
||||
|
||||
- **Axios** - HTTP client for API requests
|
||||
- **Zod** - Runtime type validation and parsing
|
||||
- **Svelte Stores** - State management
|
||||
|
||||
### Testing
|
||||
|
||||
- **Vitest** - Unit and component testing
|
||||
- **Playwright** - End-to-end testing
|
||||
- **Testing Library** - Component testing utilities
|
||||
- **MSW** - API mocking
|
||||
|
||||
### Code Quality
|
||||
|
||||
- **ESLint** - Linting (TypeScript + Svelte)
|
||||
- **Prettier** - Code formatting
|
||||
- **Stylelint** - CSS linting
|
||||
- **Husky** - Git hooks
|
||||
- **lint-staged** - Pre-commit linting
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
### Available Scripts
|
||||
|
||||
```bash
|
||||
# Development
|
||||
npm run dev # Start dev server
|
||||
npm run dev -- --host # Expose to network
|
||||
|
||||
# Type Checking
|
||||
npm run check # Run type check
|
||||
npm run check:watch # Type check in watch mode
|
||||
|
||||
# Linting & Formatting
|
||||
npm run lint # Run ESLint + Prettier check
|
||||
npm run lint:fix # Auto-fix linting issues
|
||||
npm run format # Format code with Prettier
|
||||
|
||||
# Testing
|
||||
npm run test # Run unit tests
|
||||
npm run test:watch # Run tests in watch mode
|
||||
npm run test:coverage # Generate coverage report
|
||||
npm run test:e2e # Run E2E tests (headless)
|
||||
npm run test:e2e:ui # Run E2E tests with UI
|
||||
npm run test:e2e:debug # Debug E2E tests
|
||||
|
||||
# Building
|
||||
npm run build # Build for production
|
||||
npm run preview # Preview production build
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
csgowtf/
|
||||
├── src/
|
||||
│ ├── lib/
|
||||
│ │ ├── api/ # API client & endpoints
|
||||
│ │ ├── components/ # Reusable Svelte components
|
||||
│ │ │ ├── layout/ # Header, Footer, Nav
|
||||
│ │ │ ├── ui/ # Base UI components
|
||||
│ │ │ ├── charts/ # Data visualization
|
||||
│ │ │ ├── match/ # Match-specific components
|
||||
│ │ │ └── player/ # Player-specific components
|
||||
│ │ ├── stores/ # Svelte stores (state)
|
||||
│ │ ├── types/ # TypeScript types
|
||||
│ │ ├── utils/ # Helper functions
|
||||
│ │ └── i18n/ # Internationalization
|
||||
│ ├── routes/ # SvelteKit routes (pages)
|
||||
│ ├── mocks/ # MSW mock handlers
|
||||
│ ├── tests/ # Test setup
|
||||
│ ├── app.html # HTML shell
|
||||
│ └── app.css # Global styles
|
||||
├── tests/
|
||||
│ ├── unit/ # Unit tests
|
||||
│ ├── integration/ # Integration tests
|
||||
│ └── e2e/ # E2E tests
|
||||
├── docs/ # Documentation
|
||||
│ ├── API.md # Backend API reference
|
||||
│ └── TODO.md # Project roadmap
|
||||
├── public/ # Static assets
|
||||
└── static/ # Additional static files
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Features
|
||||
|
||||
### Current (Phase 1 - ✅ Complete)
|
||||
|
||||
- ✅ SvelteKit project scaffolded with TypeScript strict mode
|
||||
- ✅ Tailwind CSS + DaisyUI with CS2-themed color palette
|
||||
- ✅ Complete development tooling (ESLint, Prettier, Husky)
|
||||
- ✅ Testing infrastructure (Vitest + Playwright)
|
||||
- ✅ CI/CD pipeline (Woodpecker)
|
||||
- ✅ Backend API documented
|
||||
|
||||
### Planned (See `docs/TODO.md` for details)
|
||||
|
||||
- 🏠 Homepage with featured matches
|
||||
- 📊 Match listing with advanced filters
|
||||
- 👤 Player profiles with stats & charts
|
||||
- 🎮 Match detail pages (overview, economy, flashes, damage, chat)
|
||||
- 🌍 Multi-language support (i18n)
|
||||
- 🌙 Dark/Light theme toggle (default: dark)
|
||||
- 📱 Mobile-responsive design
|
||||
- ♿ WCAG 2.1 AA accessibility
|
||||
- 🎯 CS2-specific features (MR12, Premier rating, volumetric smokes)
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Backend
|
||||
|
||||
This frontend connects to the [csgowtfd](https://somegit.dev/CSGOWTF/csgowtfd) backend.
|
||||
|
||||
- **Language**: Go
|
||||
- **Framework**: Gin
|
||||
- **Database**: PostgreSQL
|
||||
- **Cache**: Redis
|
||||
- **API Docs**: See `docs/API.md`
|
||||
|
||||
Default API endpoint: `http://localhost:8000`
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Unit & Component Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npm run test
|
||||
|
||||
# Watch mode for TDD
|
||||
npm run test:watch
|
||||
|
||||
# Generate coverage report
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
### End-to-End Tests
|
||||
|
||||
```bash
|
||||
# Run E2E tests (headless)
|
||||
npm run test:e2e
|
||||
|
||||
# Run with Playwright UI
|
||||
npm run test:e2e:ui
|
||||
|
||||
# Debug mode
|
||||
npm run test:e2e:debug
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚢 Deployment
|
||||
|
||||
### Build for Production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
The built app will be in the `build/` directory, ready to be deployed to any Node.js hosting platform.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
See `.env.example` for all available configuration options:
|
||||
|
||||
- `VITE_API_BASE_URL` - Backend API URL
|
||||
- `VITE_API_TIMEOUT` - API request timeout
|
||||
- `VITE_ENABLE_LIVE_MATCHES` - Feature flag for live matches
|
||||
- `VITE_ENABLE_ANALYTICS` - Feature flag for analytics
|
||||
|
||||
### CI/CD
|
||||
|
||||
Woodpecker CI automatically builds and deploys:
|
||||
|
||||
- **`master`** branch → Production
|
||||
- **`dev`** branch → Development/Staging
|
||||
- **`cs2-port`** branch → CS2 Preview (during rewrite)
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We welcome contributions! Please follow these guidelines:
|
||||
|
||||
### Before You Start
|
||||
|
||||
- Check existing issues or create one describing your feature/fix
|
||||
- Comment on the issue to avoid duplicate work
|
||||
- Fork the repository and create a feature branch
|
||||
|
||||
### Code Standards
|
||||
|
||||
- Follow TypeScript strict mode (no `any` types)
|
||||
- Write tests for new features
|
||||
- Follow existing code style (enforced by ESLint/Prettier)
|
||||
- Keep components under 300 lines
|
||||
- Write meaningful commit messages (Conventional Commits)
|
||||
|
||||
### Pull Request Process
|
||||
|
||||
1. Create a feature branch: `feature/your-feature-name`
|
||||
2. Make your changes and commit with clear messages
|
||||
3. Run linting and tests: `npm run lint && npm run test`
|
||||
4. Push to your fork and create a PR to the `cs2-port` branch
|
||||
5. Ensure CI passes and address review feedback
|
||||
|
||||
### Git Workflow
|
||||
|
||||
- Branch naming: `feature/`, `fix/`, `refactor/`, `docs/`
|
||||
- Commit messages: `feat:`, `fix:`, `docs:`, `test:`, `refactor:`
|
||||
- Only one feature/fix per PR
|
||||
- Squash commits before merging
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- **API Reference**: [`docs/API.md`](docs/API.md) - Complete backend API documentation
|
||||
- **Project Roadmap**: [`docs/TODO.md`](docs/TODO.md) - Detailed implementation plan
|
||||
- **SvelteKit Docs**: [kit.svelte.dev](https://kit.svelte.dev/)
|
||||
- **Tailwind CSS**: [tailwindcss.com](https://tailwindcss.com/)
|
||||
- **DaisyUI**: [daisyui.com](https://daisyui.com/)
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
[GPL-3.0](LICENSE) © CSGOW.TF Team
|
||||
|
||||
---
|
||||
|
||||
## 💖 Support
|
||||
|
||||
If you find this project helpful, consider supporting us:
|
||||
|
||||
[](https://liberapay.com/CSGOWTF/)
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Links
|
||||
|
||||
- **Website**: [csgow.tf](https://csgow.tf) (legacy CS:GO version)
|
||||
- **Backend**: [csgowtfd](https://somegit.dev/CSGOWTF/csgowtfd)
|
||||
- **Issues**: [Report a bug](https://somegit.dev/CSGOWTF/csgowtf/issues)
|
||||
|
||||
---
|
||||
|
||||
**Status**: 🚧 **Phase 1 Complete** - Active rewrite for CS2 support
|
||||
|
||||
@@ -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
118
package.json
118
package.json
@@ -1,43 +1,79 @@
|
||||
{
|
||||
"name": "csgowtf",
|
||||
"version": "1.0.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build --mode production",
|
||||
"lint": "vue-cli-service lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.11.6",
|
||||
"axios": "^1.1.3",
|
||||
"bootstrap": "^5.2.2",
|
||||
"core-js": "^3.26.0",
|
||||
"dotenv-webpack": "^8.0.1",
|
||||
"echarts": "^5.4.0",
|
||||
"fork-awesome": "^1.2.0",
|
||||
"http-status-codes": "^2.2.0",
|
||||
"iso-639-1": "^2.1.15",
|
||||
"jquery": "^3.6.1",
|
||||
"luxon": "^3.1.0",
|
||||
"string-sanitizer": "^2.0.2",
|
||||
"vue": "^3.2.41",
|
||||
"vue-matomo": "^4.2.0",
|
||||
"vue-router": "^4.1.6",
|
||||
"vue3-cookies": "^1.0.6",
|
||||
"vuex": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/cli-plugin-babel": "~4.5.19",
|
||||
"@vue/cli-plugin-eslint": "~4.5.19",
|
||||
"@vue/cli-plugin-router": "~4.5.19",
|
||||
"@vue/cli-plugin-vuex": "~4.5.19",
|
||||
"@vue/cli-service": "~4.5.19",
|
||||
"@vue/compiler-sfc": "^3.2.41",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"eslint": "^6.8.0",
|
||||
"eslint-plugin-vue": "^7.20.0",
|
||||
"sass": "^1.56.0",
|
||||
"sass-loader": "^10.2.1"
|
||||
},
|
||||
"packageManager": "yarn@3.0.2"
|
||||
"name": "cs2wtf",
|
||||
"version": "2.0.0",
|
||||
"description": "Statistics for CS2 matchmaking matches",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"lint:fix": "prettier --write . && eslint --fix .",
|
||||
"format": "prettier --write .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:debug": "playwright test --debug",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"axios": "^1.6.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"svelte": "^5.0.0",
|
||||
"zod": "^3.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.40.0",
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/adapter-node": "^5.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@testing-library/jest-dom": "^6.0.0",
|
||||
"@testing-library/svelte": "^5.0.0",
|
||||
"@types/node": "^20.10.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
"@typescript-eslint/parser": "^7.0.0",
|
||||
"@vitest/coverage-v8": "^1.0.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"daisyui": "^4.0.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.35.0",
|
||||
"globals": "^15.0.0",
|
||||
"husky": "^9.0.0",
|
||||
"jsdom": "^24.0.0",
|
||||
"lint-staged": "^15.0.0",
|
||||
"lucide-svelte": "^0.400.0",
|
||||
"msw": "^2.0.0",
|
||||
"postcss": "^8.4.0",
|
||||
"prettier": "^3.2.0",
|
||||
"prettier-plugin-svelte": "^3.1.0",
|
||||
"prettier-plugin-tailwindcss": "^0.5.0",
|
||||
"stylelint": "^16.0.0",
|
||||
"stylelint-config-standard": "^36.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"tslib": "^2.6.0",
|
||||
"typescript": "^5.3.0",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"vite": "^5.0.0",
|
||||
"vitest": "^1.0.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts,svelte}": [
|
||||
"prettier --write",
|
||||
"eslint --fix"
|
||||
],
|
||||
"*.{json,css,md}": [
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
36
playwright.config.ts
Normal file
36
playwright.config.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { PlaywrightTestConfig } from '@playwright/test';
|
||||
|
||||
const config: PlaywrightTestConfig = {
|
||||
webServer: {
|
||||
command: 'npm run build && npm run preview',
|
||||
port: 4173,
|
||||
reuseExistingServer: !process.env.CI
|
||||
},
|
||||
testDir: 'tests/e2e',
|
||||
testMatch: /(.+\.)?(test|spec)\.[jt]s/,
|
||||
use: {
|
||||
baseURL: 'http://localhost:4173',
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'retain-on-failure'
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { browserName: 'chromium' }
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { browserName: 'firefox' }
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { browserName: 'webkit' }
|
||||
}
|
||||
],
|
||||
reporter: process.env.CI ? 'github' : 'html',
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined
|
||||
};
|
||||
|
||||
export default config;
|
||||
6
postcss.config.cjs
Normal file
6
postcss.config.cjs
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 213 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.4 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 226 KiB |
@@ -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
71
src/App.vue
71
src/App.vue
@@ -1,71 +0,0 @@
|
||||
<template>
|
||||
<img alt="" class="bg-img" src="">
|
||||
<header>
|
||||
<Nav/>
|
||||
</header>
|
||||
<main>
|
||||
<div :style="{height: offset + 'px'}"/>
|
||||
<InfoModal/>
|
||||
<router-view name="main"/>
|
||||
</main>
|
||||
<footer class="mt-auto">
|
||||
<Footer/>
|
||||
</footer>
|
||||
<CookieConsentBtn id="cookie-btn"/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Nav from "@/components/Nav";
|
||||
import Footer from "@/components/Footer";
|
||||
import CookieConsentBtn from "@/components/CookieConsentBtn";
|
||||
import {onMounted, ref} from "vue";
|
||||
import InfoModal from "@/components/InfoModal";
|
||||
|
||||
export default {
|
||||
components: {InfoModal, Footer, Nav, CookieConsentBtn},
|
||||
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";
|
||||
src: local("Obitron"), url("../public/fonts/Orbitron-VariableFont_wght.ttf") format("truetype");
|
||||
}
|
||||
|
||||
.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,74 +0,0 @@
|
||||
<template>
|
||||
<div v-if="!consent" class="card text-end bg-secondary text-white border border-1">
|
||||
<div class="card-body">
|
||||
<form class="mb-1">
|
||||
<div class="form-check">
|
||||
<input id="essential-cookies" checked class="form-check-input" disabled type="checkbox" value="">
|
||||
<label class="form-check-label" for="essential-cookies">
|
||||
Essential
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input id="tracking" v-model="tracking" class="form-check-input" type="checkbox">
|
||||
<label class="form-check-label" for="tracking">
|
||||
Matomo
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<a href="/privacy-policy" class="text-muted">Privacy Policy</a>
|
||||
|
||||
<div class="d-flex justify-content-between mt-2">
|
||||
<button class="btn btn-outline-primary" type="button" @click="handleConsentForget">Decline</button>
|
||||
<button class="btn btn-info" type="button" @click="handleConsent">Accept</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {onMounted, ref} from "vue";
|
||||
import {useCookies} from 'vue3-cookies'
|
||||
|
||||
export default {
|
||||
name: "CookieConsentBtn",
|
||||
setup() {
|
||||
const tracking = ref(true)
|
||||
const {cookies} = useCookies()
|
||||
const consent = ref(false)
|
||||
|
||||
const handleConsent = () => {
|
||||
window._paq.push(['rememberCookieConsentGiven'])
|
||||
cookies.set('consent', 'given', Infinity)
|
||||
|
||||
if (tracking.value){
|
||||
window._paq.push(['rememberConsentGiven'])
|
||||
}
|
||||
consent.value = true
|
||||
}
|
||||
const handleConsentForget = () => {
|
||||
consent.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window._paq.push(['requireCookieConsent']);
|
||||
window._paq.push(['trackPageView']);
|
||||
|
||||
if (cookies.get('consent') === 'given')
|
||||
consent.value = true
|
||||
})
|
||||
|
||||
return {handleConsent, handleConsentForget, tracking, consent}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
z-index: 10;
|
||||
}
|
||||
form {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
</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,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: "Details",
|
||||
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,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,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: "Footer",
|
||||
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,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,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: 'Nav',
|
||||
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,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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user