Compare commits
49 Commits
master
...
49033560fa
| Author | SHA1 | Date | |
|---|---|---|---|
| 49033560fa | |||
| 3192180c60 | |||
| 05a6c10458 | |||
| 12115198b7 | |||
| 2e053a6388 | |||
| 973d188a26 | |||
| afd1d7a822 | |||
| 68aada0c33 | |||
| f8cf85661e | |||
| 668c32ed8a | |||
| 78c87aaedd | |||
| 4cc2b70dbc | |||
| a3955da7f2 | |||
| eb68c5d00b | |||
| 05e6182bcf | |||
| 8f21b56223 | |||
| 248c4a8523 | |||
| 469f0df756 | |||
| 7e101ba274 | |||
| b59eebcddb | |||
| d4d7015df6 | |||
| b1284bad71 | |||
| 05ef985851 | |||
| ae7d880bc1 | |||
| 2215cab77f | |||
| 7d642b0be3 | |||
| 8f3b652740 | |||
| a861b1c1b6 | |||
| 62bfdc8090 | |||
| 7d8e3a6de0 | |||
| 8093d4d308 | |||
| f583ff54a9 | |||
| 43c50084c6 | |||
| ea61061530 | |||
| 8b73a68a6b | |||
| 274f5b3b53 | |||
| e81be2cf68 | |||
| 523136ffbc | |||
| 24b990ac62 | |||
| 09ce400cd7 | |||
| d811efc394 | |||
| 66aea51c39 | |||
| 153c0e9f13 | |||
| 288438a953 | |||
| 0404188d4d | |||
| 366bfbeb54 | |||
| be89c68f89 | |||
| 9ab7ee91ea | |||
| 408ee9df1e |
@@ -1,3 +0,0 @@
|
||||
> 1%
|
||||
last 2 versions
|
||||
not dead
|
||||
@@ -1,10 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{js,json,yml}]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
80
.env.example
Normal file
80
.env.example
Normal file
@@ -0,0 +1,80 @@
|
||||
# CS2.WTF Environment Configuration
|
||||
# Copy this file to .env for local development
|
||||
# DO NOT commit .env to version control
|
||||
|
||||
# ============================================
|
||||
# API Configuration
|
||||
# ============================================
|
||||
|
||||
# Backend API Base URL
|
||||
# Development: Vite proxy forwards /api to this URL (default: http://localhost:8000)
|
||||
# Production: Set to your actual backend URL (e.g., https://api.csgow.tf)
|
||||
# Note: In development, the frontend uses /api and Vite proxies to this URL
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
|
||||
# API request timeout in milliseconds
|
||||
# Default: 10000 (10 seconds)
|
||||
VITE_API_TIMEOUT=10000
|
||||
|
||||
# ============================================
|
||||
# Feature Flags
|
||||
# ============================================
|
||||
|
||||
# Enable live match updates (polling/WebSocket)
|
||||
# Default: false
|
||||
VITE_ENABLE_LIVE_MATCHES=false
|
||||
|
||||
# Enable analytics tracking
|
||||
# Default: true (respects user consent)
|
||||
VITE_ENABLE_ANALYTICS=true
|
||||
|
||||
# Enable debug mode (verbose logging, dev tools)
|
||||
# Default: false
|
||||
VITE_DEBUG_MODE=false
|
||||
|
||||
# ============================================
|
||||
# Analytics & Tracking (Optional)
|
||||
# ============================================
|
||||
|
||||
# Plausible Analytics
|
||||
# Only required if analytics is enabled
|
||||
# VITE_PLAUSIBLE_DOMAIN=cs2.wtf
|
||||
# VITE_PLAUSIBLE_API_HOST=https://plausible.io
|
||||
|
||||
# Umami Analytics (alternative)
|
||||
# VITE_UMAMI_WEBSITE_ID=your-website-id
|
||||
# VITE_UMAMI_SRC=https://analytics.example.com/script.js
|
||||
|
||||
# ============================================
|
||||
# Experimental Features
|
||||
# ============================================
|
||||
|
||||
# Enable WebGL-based heatmaps (high performance)
|
||||
# Default: false (use Canvas fallback)
|
||||
# VITE_ENABLE_WEBGL_HEATMAPS=false
|
||||
|
||||
# Enable MSW API mocking in development
|
||||
# Useful for frontend development without backend
|
||||
# Default: false
|
||||
# VITE_ENABLE_MSW_MOCKING=false
|
||||
|
||||
# ============================================
|
||||
# Build Configuration
|
||||
# ============================================
|
||||
|
||||
# App version (auto-populated from package.json)
|
||||
# VITE_APP_VERSION=2.0.0
|
||||
|
||||
# Build timestamp (auto-populated during build)
|
||||
# VITE_BUILD_TIMESTAMP=2024-11-04T12:00:00Z
|
||||
|
||||
# ============================================
|
||||
# SSR/Deployment (Advanced)
|
||||
# ============================================
|
||||
|
||||
# Public base URL for the application
|
||||
# Used for canonical URLs, sitemaps, etc.
|
||||
# PUBLIC_BASE_URL=https://cs2.wtf
|
||||
|
||||
# Origin whitelist for CORS (if handling API in same domain)
|
||||
# PUBLIC_CORS_ORIGINS=https://cs2.wtf,https://www.cs2.wtf
|
||||
17
.eslintrc.js
17
.eslintrc.js
@@ -1,17 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
node: true
|
||||
},
|
||||
'extends': [
|
||||
'plugin:vue/vue3-essential',
|
||||
'eslint:recommended'
|
||||
],
|
||||
parserOptions: {
|
||||
parser: '@babel/eslint-parser'
|
||||
},
|
||||
rules: {
|
||||
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||
}
|
||||
}
|
||||
304
.gitignore
vendored
304
.gitignore
vendored
@@ -1,286 +1,52 @@
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/webstorm+all,yarn,windows,linux,node,vuejs
|
||||
# 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
873
.yarn/releases/yarn-3.4.1.cjs
vendored
873
.yarn/releases/yarn-3.4.1.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.4.1.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
107
package.json
107
package.json
@@ -1,46 +1,79 @@
|
||||
{
|
||||
"name": "csgowtf",
|
||||
"version": "1.0.9",
|
||||
"name": "cs2wtf",
|
||||
"version": "2.0.0",
|
||||
"description": "Statistics for CS2 matchmaking matches",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build --mode production",
|
||||
"lint": "vue-cli-service lint"
|
||||
"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": {
|
||||
"@fontsource/open-sans": "^4.5.14",
|
||||
"@fontsource/orbitron": "^4.5.11",
|
||||
"@popperjs/core": "^2.11.6",
|
||||
"axios": "^1.3.4",
|
||||
"bootstrap": "^5.2.3",
|
||||
"core-js": "^3.29.0",
|
||||
"dotenv-webpack": "^8.0.1",
|
||||
"echarts": "^5.4.1",
|
||||
"fork-awesome": "^1.2.0",
|
||||
"http-status-codes": "^2.2.0",
|
||||
"iso-639-1": "^2.1.15",
|
||||
"jquery": "^3.6.3",
|
||||
"luxon": "^3.2.1",
|
||||
"string-sanitizer": "^2.0.2",
|
||||
"vue": "^3.2.47",
|
||||
"vue-matomo": "^4.2.0",
|
||||
"vue-router": "^4.1.6",
|
||||
"vue3-cookies": "^1.0.6",
|
||||
"vuex": "^4.1.0"
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"axios": "^1.6.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"svelte": "^5.0.0",
|
||||
"zod": "^3.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.21.0",
|
||||
"@babel/eslint-parser": "^7.19.1",
|
||||
"@vue/cli-plugin-babel": "~5.0.8",
|
||||
"@vue/cli-plugin-eslint": "~5.0.8",
|
||||
"@vue/cli-plugin-router": "~5.0.8",
|
||||
"@vue/cli-plugin-vuex": "~5.0.8",
|
||||
"@vue/cli-service": "~5.0.8",
|
||||
"@vue/compiler-sfc": "^3.2.47",
|
||||
"eslint": "^8.35.0",
|
||||
"eslint-plugin-vue": "^9.9.0",
|
||||
"sass": "^1.58.3",
|
||||
"sass-loader": "^13.2.0"
|
||||
"@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"
|
||||
},
|
||||
"packageManager": "yarn@3.4.1"
|
||||
"lint-staged": {
|
||||
"*.{js,ts,svelte}": [
|
||||
"prettier --write",
|
||||
"eslint --fix"
|
||||
],
|
||||
"*.{json,css,md}": [
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
36
playwright.config.ts
Normal file
36
playwright.config.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { PlaywrightTestConfig } from '@playwright/test';
|
||||
|
||||
const config: PlaywrightTestConfig = {
|
||||
webServer: {
|
||||
command: 'npm run build && npm run preview',
|
||||
port: 4173,
|
||||
reuseExistingServer: !process.env.CI
|
||||
},
|
||||
testDir: 'tests/e2e',
|
||||
testMatch: /(.+\.)?(test|spec)\.[jt]s/,
|
||||
use: {
|
||||
baseURL: 'http://localhost:4173',
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'retain-on-failure'
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { browserName: 'chromium' }
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { browserName: 'firefox' }
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { browserName: 'webkit' }
|
||||
}
|
||||
],
|
||||
reporter: process.env.CI ? 'github' : 'html',
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined
|
||||
};
|
||||
|
||||
export default config;
|
||||
6
postcss.config.cjs
Normal file
6
postcss.config.cjs
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
@@ -1,66 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta content="IE=edge" http-equiv="X-UA-Compatible">
|
||||
<meta content="width=device-width,initial-scale=1.0" name="viewport">
|
||||
|
||||
<meta content="Track your CSGO matches and see your match details."
|
||||
name="description">
|
||||
<meta content="index, follow, archive"
|
||||
name="robots">
|
||||
<meta content="Track your CSGO matches and see your match details."
|
||||
property="st:section">
|
||||
<meta content="csgoWTF - Open source CSGO data platform"
|
||||
name="twitter:title">
|
||||
<meta content="Track your CSGO matches and see your match details."
|
||||
name="twitter:description">
|
||||
<meta content="summary_large_image"
|
||||
name="twitter:card">
|
||||
<meta content="https://csgow.tf/"
|
||||
property="og:url">
|
||||
<meta content="csgoWTF - Open source CSGO data platform"
|
||||
property="og:title">
|
||||
<meta content="Track your CSGO matches and see your match details."
|
||||
property="og:description">
|
||||
<meta content="website"
|
||||
property="og:type">
|
||||
<meta content="en_US"
|
||||
property="og:locale">
|
||||
<meta content="csgoWTF - Open source CSGO data platform"
|
||||
property="og:site_name">
|
||||
<meta content="https://csgow.tf/images/logo.png"
|
||||
name="twitter:image">
|
||||
<meta content="https://csgow.tf/images/logo.png"
|
||||
property="og:image">
|
||||
<meta content="1024"
|
||||
property="og:image:width">
|
||||
<meta content="526"
|
||||
property="og:image:height">
|
||||
<meta content="https://csgow.tf/images/logo.png"
|
||||
property="og:image:secure_url">
|
||||
|
||||
<link href="<%= BASE_URL %>images/apple-touch-icon.png" rel="apple-touch-icon" sizes="180x180">
|
||||
<link href="<%= BASE_URL %>images/favicon-32x32.png" rel="icon" sizes="32x32" type="image/png">
|
||||
<link href="<%= BASE_URL %>images/favicon-16x16.png" rel="icon" sizes="16x16" type="image/png">
|
||||
|
||||
<link href="<%= BASE_URL %>site.webmanifest" rel="manifest">
|
||||
|
||||
<link rel="preconnect" href="https://steamcdn-a.akamaihd.net" crossorigin>
|
||||
<link rel="dns-prefetch" href="https://steamcdn-a.akamaihd.net">
|
||||
<link rel="preconnect" href="https://api.csgow.tf" crossorigin>
|
||||
<link rel="dns-prefetch" href="https://api.csgow.tf">
|
||||
<link rel="preconnect" href="https://piwik.harting.hosting" crossorigin>
|
||||
<link rel="dns-prefetch" href="https://piwik.harting.hosting">
|
||||
|
||||
<title><%= htmlWebpackPlugin.options.title %></title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled.
|
||||
Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
<div id="app" class="d-flex flex-column min-vh-100"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
{"name":"","short_name":"","icons":[{"src":"/images/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/images/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||
35
research.md
Normal file
35
research.md
Normal file
File diff suppressed because one or more lines are too long
69
src/App.vue
69
src/App.vue
@@ -1,69 +0,0 @@
|
||||
<template>
|
||||
<img alt="" class="bg-img" src="">
|
||||
<header>
|
||||
<CompNav/>
|
||||
</header>
|
||||
<main>
|
||||
<div :style="{height: offset + 'px'}"/>
|
||||
<InfoModal/>
|
||||
<router-view name="main"/>
|
||||
</main>
|
||||
<footer class="mt-auto">
|
||||
<CompFooter/>
|
||||
</footer>
|
||||
<CookieConsentBtn id="cookie-btn"/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {onMounted, ref} from "vue";
|
||||
import InfoModal from "@/components/InfoModal";
|
||||
import CompFooter from "@/components/CompFooter";
|
||||
import CompNav from "@/components/CompNav";
|
||||
|
||||
export default {
|
||||
components: {CompNav, CompFooter, InfoModal},
|
||||
setup() {
|
||||
const offset = ref(0)
|
||||
|
||||
const setOffset = () => {
|
||||
return document.getElementsByTagName('nav')[0].clientHeight
|
||||
}
|
||||
|
||||
const setBgHeight = () => {
|
||||
document.querySelector('.bg-img').style.height = document.documentElement.clientHeight + 'px'
|
||||
}
|
||||
|
||||
window.onresize = () => {
|
||||
offset.value = setOffset()
|
||||
setBgHeight()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
offset.value = setOffset()
|
||||
setBgHeight()
|
||||
})
|
||||
|
||||
return {offset}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@font-face {
|
||||
font-family: "Obitron";
|
||||
}
|
||||
|
||||
.bg-img {
|
||||
z-index: -1;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#cookie-btn {
|
||||
position: fixed;
|
||||
bottom: 30px;
|
||||
right: 20px;
|
||||
}
|
||||
</style>
|
||||
131
src/app.css
Normal file
131
src/app.css
Normal file
@@ -0,0 +1,131 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* CS2 Custom Font */
|
||||
@font-face {
|
||||
font-family: 'CS Regular';
|
||||
src:
|
||||
url('/fonts/cs_regular.woff2') format('woff2'),
|
||||
url('/fonts/cs_regular.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Default to dark theme */
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-base-100 text-base-content;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
sans-serif;
|
||||
font-feature-settings:
|
||||
'rlig' 1,
|
||||
'calt' 1;
|
||||
}
|
||||
|
||||
/* CS2 Font for headlines only */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family:
|
||||
'CS Regular',
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Custom scrollbar */
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: theme('colors.base-300') transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: theme('colors.base-300');
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: theme('colors.base-content');
|
||||
}
|
||||
|
||||
/* Loading skeleton */
|
||||
.skeleton {
|
||||
@apply animate-pulse rounded bg-base-300;
|
||||
}
|
||||
|
||||
/* Team colors */
|
||||
.team-t {
|
||||
@apply text-terrorist;
|
||||
}
|
||||
|
||||
.team-ct {
|
||||
@apply text-ct;
|
||||
}
|
||||
|
||||
.bg-team-t {
|
||||
@apply bg-terrorist;
|
||||
}
|
||||
|
||||
.bg-team-ct {
|
||||
@apply bg-ct;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Animations */
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
15
src/app.html
Normal file
15
src/app.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
|
||||
<link rel="manifest" href="%sveltekit.assets%/site.webmanifest" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<meta name="description" content="Statistics for CS2 matchmaking matches" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body>
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,63 +0,0 @@
|
||||
<template>
|
||||
<div class="details-site">
|
||||
<div class="multi-kills">
|
||||
<h3 class="text-center mt-2">Multi-Kills</h3>
|
||||
<MultiKillsChart/>
|
||||
</div>
|
||||
<!-- <hr>-->
|
||||
<!-- <div class="spray">-->
|
||||
<!-- <h3 class="text-center">Spray</h3>-->
|
||||
<!-- <SprayGraph :spray="data.spray"/>-->
|
||||
<!-- </div>-->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MultiKillsChart from "@/components/MultiKillsChart";
|
||||
import {useStore} from "vuex";
|
||||
import {onMounted, reactive} from "vue";
|
||||
import {GetWeaponDmg} from "@/utils";
|
||||
|
||||
export default {
|
||||
name: "CompDetails",
|
||||
components: {MultiKillsChart},
|
||||
setup() {
|
||||
const store = useStore()
|
||||
|
||||
const data = reactive({
|
||||
spray: [],
|
||||
})
|
||||
|
||||
const getWeaponDamage = async () => {
|
||||
const resData = await GetWeaponDmg(store, store.state.matchDetails.match_id)
|
||||
if (resData !== null) {
|
||||
data.spray = resData.spray
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getWeaponDamage()
|
||||
})
|
||||
|
||||
return {data}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.details-site {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
h3 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
hr {
|
||||
width: 100%;
|
||||
border: 1px solid white;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,48 +0,0 @@
|
||||
<template>
|
||||
<div class="footer bg-secondary text-center pt-4 pb-2">
|
||||
<div class="text">
|
||||
<p class="fs-6">Made with <i class="fa fa-heart text-warning" aria-hidden="true"></i>, <span
|
||||
style="color: #41b883">Vue.js</span> and<a aria-label="Gitea" class="text-warning ms-2"
|
||||
href="https://somegit.dev/CSGOWTF"
|
||||
target="_blank">
|
||||
<i aria-hidden="true" class="fa fa-gitea"></i>
|
||||
</a></p>
|
||||
|
||||
<div class="d-flex justify-content-center align-items-center gap-4">
|
||||
<p><a class="text-decoration-none text-warning"
|
||||
href="https://somegit.dev/CSGOWTF/csgowtf/issues"
|
||||
target="_blank">Issue Tracker</a></p>
|
||||
<p class="text-muted">Version {{ version }}</p>
|
||||
<p>
|
||||
<a class="text-decoration-none text-warning" href="/privacy-policy">Privacy Policy</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "CompFooter",
|
||||
setup() {
|
||||
const version = process.env.VUE_APP_VERSION
|
||||
return {version}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.footer {
|
||||
.fa-gitea:hover {
|
||||
color: #609926 !important;
|
||||
}
|
||||
|
||||
.fa-heart:hover {
|
||||
color: red !important;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: .85rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,360 +0,0 @@
|
||||
<template>
|
||||
<nav class="navbar navbar-expand-md navbar-dark fixed-top">
|
||||
<div class="container">
|
||||
<router-link class="navbar-brand" to="/" @click="closeNav('mainNav')">
|
||||
<img alt="logo-nav"
|
||||
class="logo-nav"
|
||||
src="/images/logo.svg">
|
||||
</router-link>
|
||||
<button aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation" class="navbar-toggler"
|
||||
data-bs-target="#mainNav" data-bs-toggle="collapse" type="button">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div id="mainNav" class="collapse navbar-collapse navbar-nav justify-content-between">
|
||||
<ul class="list-unstyled">
|
||||
<li class="nav-item">
|
||||
<router-link class="nav-link" to="/matches" @click="closeNav('mainNav')">
|
||||
Matches
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
<form id="searchform" class="d-flex" @keydown.enter.prevent="parseSearch" @submit.prevent="parseSearch">
|
||||
<label for="search">
|
||||
<i class="fa fa-search"></i>
|
||||
</label>
|
||||
|
||||
<input id="search" v-model="data.searchInput" aria-label="Search"
|
||||
autocomplete="off"
|
||||
class="form-control bg-transparent border-0"
|
||||
placeholder="SteamID64, Profile Link or Custom URL"
|
||||
title="SteamID64, Profile Link or Custom URL"
|
||||
type="search">
|
||||
<button
|
||||
id="search-button"
|
||||
class="btn border-2 btn-outline-info"
|
||||
type="button"
|
||||
@click="parseSearch"
|
||||
>
|
||||
Search!
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {reactive} from "vue";
|
||||
import {useStore} from 'vuex'
|
||||
import {closeNav, GetUser, GoToPlayer} from '@/utils'
|
||||
import {StatusCodes as STATUS} from "http-status-codes";
|
||||
|
||||
export default {
|
||||
name: 'CompNav',
|
||||
setup() {
|
||||
const store = useStore()
|
||||
const data = reactive({
|
||||
searchInput: '',
|
||||
})
|
||||
|
||||
const parseSearch = async () => {
|
||||
const input = data.searchInput
|
||||
const customUrlPattern = 'https://steamcommunity.com/id/'
|
||||
const profileUrlPattern = 'https://steamcommunity.com/profiles/'
|
||||
const id64Pattern = /^\d{17}$/
|
||||
const vanityPattern = /^[A-Za-z0-9-_]{3,32}$/
|
||||
|
||||
store.commit({
|
||||
type: 'changeVanityUrl',
|
||||
id: ''
|
||||
})
|
||||
store.commit({
|
||||
type: 'changeId64',
|
||||
id: ''
|
||||
})
|
||||
|
||||
if (data.searchInput !== '') {
|
||||
if (id64Pattern.test(input)) {
|
||||
store.commit({
|
||||
type: 'changeId64',
|
||||
id: input
|
||||
})
|
||||
} else if (input.match(customUrlPattern)) {
|
||||
store.commit({
|
||||
type: 'changeVanityUrl',
|
||||
id: input.split('/')[4].split('?')[0]
|
||||
})
|
||||
} else if (input.match(profileUrlPattern)) {
|
||||
const tmp = input.split('/')[4].split('?')[0]
|
||||
if (id64Pattern.test(tmp)) {
|
||||
store.commit({
|
||||
type: 'changeId64',
|
||||
id: tmp
|
||||
})
|
||||
}
|
||||
} else {
|
||||
store.commit({
|
||||
type: 'changeVanityUrl',
|
||||
id: input
|
||||
})
|
||||
}
|
||||
|
||||
if (store.state.vanityUrl && !vanityPattern.test(store.state.vanityUrl)) {
|
||||
store.commit({
|
||||
type: 'changeInfoState',
|
||||
data: {
|
||||
statuscode: STATUS.NOT_ACCEPTABLE,
|
||||
message: 'Only alphanumeric symbols, "_", and "-", between 3-32 characters',
|
||||
type: 'warning'
|
||||
}
|
||||
})
|
||||
store.commit({
|
||||
type: 'changeVanityUrl',
|
||||
id: ''
|
||||
})
|
||||
data.searchInput = ''
|
||||
}
|
||||
|
||||
if (store.state.id64 !== '' || store.state.vanityUrl !== '') {
|
||||
const resData = await GetUser(store, store.state.vanityUrl || store.state.id64)
|
||||
|
||||
if (resData !== null) {
|
||||
data.searchInput = ''
|
||||
document.activeElement.blur()
|
||||
|
||||
store.commit({
|
||||
type: 'changePlayerDetails',
|
||||
data: resData
|
||||
})
|
||||
|
||||
if (store.state.vanityUrl) {
|
||||
closeNav('mainNav')
|
||||
GoToPlayer(store.state.vanityUrl)
|
||||
} else if (store.state.id64) {
|
||||
closeNav('mainNav')
|
||||
GoToPlayer(store.state.id64)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.attributes.id)
|
||||
closeNav('mainNav')
|
||||
})
|
||||
|
||||
return {
|
||||
data, parseSearch, closeNav
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.navbar-dark .navbar-brand:hover,
|
||||
.navbar-dark .navbar-brand:focus {
|
||||
color: var(--bs-warning);
|
||||
}
|
||||
|
||||
nav {
|
||||
max-width: 100vw;
|
||||
width: 100vw;
|
||||
height: 70px;
|
||||
background: rgba(16, 18, 26, .9);
|
||||
box-shadow: 0 1px 10px 0 #111;
|
||||
z-index: 2;
|
||||
vertical-align: center !important;
|
||||
|
||||
.navbar-brand {
|
||||
img {
|
||||
width: 75px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 4px 2px -2px var(--bs-warning);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--bs-warning);
|
||||
}
|
||||
}
|
||||
|
||||
ul li {
|
||||
font-size: 1.5rem;
|
||||
font-weight: lighter;
|
||||
margin: 22px 0 0 10px;
|
||||
cursor: pointer;
|
||||
transition: 100ms ease-in-out;
|
||||
|
||||
.nav-link {
|
||||
text-decoration: none;
|
||||
color: white !important;
|
||||
|
||||
.router-link-exact-active {
|
||||
box-shadow: 0 4px 2px -2px var(--bs-warning);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 4px 2px -2px var(--bs-warning);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #bdbdbd !important;
|
||||
transition: 250ms ease-in-out;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 2px -2px var(--bs-warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
form {
|
||||
position: relative;
|
||||
|
||||
svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
label {
|
||||
padding-top: 6px;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
input[type="search"] {
|
||||
min-width: 300px;
|
||||
max-width: 300px;
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 4px 2px -2px rgba(95, 120, 146, 0.59);
|
||||
transition: .2s ease-in-out;
|
||||
transform: scale(.975);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #aaa;
|
||||
font-size: .9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.alert {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 55px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 410px) {
|
||||
form {
|
||||
margin-left: auto !important;
|
||||
margin-right: auto !important;
|
||||
|
||||
input[type="search"] {
|
||||
margin-left: 0 !important;
|
||||
max-width: 60vw !important;
|
||||
min-width: 60vw !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 455px) and (min-width: 410px) {
|
||||
form {
|
||||
margin-left: auto !important;
|
||||
margin-right: auto !important;
|
||||
|
||||
input[type="search"] {
|
||||
margin-left: 0 !important;
|
||||
max-width: 65vw !important;
|
||||
min-width: 65vw !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 610px) and (min-width: 456px) {
|
||||
form {
|
||||
margin-left: auto !important;
|
||||
margin-right: auto !important;
|
||||
|
||||
input[type="search"] {
|
||||
margin-left: 0 !important;
|
||||
max-width: 68vw !important;
|
||||
min-width: 68vw !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
nav {
|
||||
button {
|
||||
outline: 1px solid var(--bs-primary);
|
||||
margin-left: auto;
|
||||
float: right;
|
||||
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
outline: 1px solid var(--bs-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-collapse {
|
||||
background: var(--bs-secondary);
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--bs-primary)
|
||||
}
|
||||
|
||||
#mainNav {
|
||||
ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
|
||||
li {
|
||||
line-height: 1;
|
||||
padding: 0 0 20px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, .1);
|
||||
}
|
||||
}
|
||||
|
||||
form {
|
||||
max-width: 87vw;
|
||||
margin-left: -40px;
|
||||
|
||||
label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
input[type="search"] {
|
||||
margin-bottom: 15px;
|
||||
margin-left: 37px;
|
||||
max-width: 400px;
|
||||
min-width: 400px;
|
||||
font-size: 1rem;
|
||||
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&::placeholder {
|
||||
background: var(--bs-body-bg);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
margin-left: 10px;
|
||||
display: block;
|
||||
margin-top: -2px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,56 +0,0 @@
|
||||
<template>
|
||||
<div class="damage-site">
|
||||
<div class="total-damage">
|
||||
<h3 class="text-center mt-2">Total Damage</h3>
|
||||
<TotalDamage/>
|
||||
</div>
|
||||
<div class="hitgroup">
|
||||
<!-- <h3 class="text-center">Damage by Hitgroup</h3>-->
|
||||
<HitgroupPuppet :equipment_map="data.equipment_map" :stats="data.stats" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import HitgroupPuppet from '@/components/HitgroupPuppet'
|
||||
import TotalDamage from "@/components/TotalDamage"
|
||||
import {onMounted, reactive} from "vue";
|
||||
import {useStore} from "vuex";
|
||||
import {GetWeaponDmg} from "@/utils";
|
||||
|
||||
export default {
|
||||
name: "DamageSite.vue",
|
||||
components: {HitgroupPuppet, TotalDamage},
|
||||
setup() {
|
||||
const store = useStore()
|
||||
|
||||
const data = reactive({
|
||||
equipment_map: {},
|
||||
stats: [],
|
||||
})
|
||||
|
||||
const getWeaponDamage = async () => {
|
||||
const resData = await GetWeaponDmg(store, store.state.matchDetails.match_id)
|
||||
if (resData !== null) {
|
||||
data.equipment_map = resData.equipment_map
|
||||
data.stats = resData.stats
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getWeaponDamage()
|
||||
})
|
||||
|
||||
return {data}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.damage-site {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,276 +0,0 @@
|
||||
<template>
|
||||
<div class="economy">
|
||||
<h3 class="text-center mt-2">Economy</h3>
|
||||
<div class="flexbreak"></div>
|
||||
<div id="economy-graph"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import {GetPlayerValue} from "@/utils";
|
||||
import {useStore} from "vuex";
|
||||
import {onBeforeMount, onMounted, onUnmounted, reactive, ref, watch} from "vue";
|
||||
|
||||
import * as echarts from 'echarts/core';
|
||||
import {
|
||||
GridComponent,
|
||||
MarkAreaComponent,
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
VisualMapComponent
|
||||
} from 'echarts/components';
|
||||
import {LineChart} from 'echarts/charts';
|
||||
import {UniversalTransition} from 'echarts/features';
|
||||
import {CanvasRenderer} from 'echarts/renderers';
|
||||
|
||||
export default {
|
||||
name: "EqValueGraph",
|
||||
setup() {
|
||||
const store = useStore()
|
||||
|
||||
let myChart1, max_rounds
|
||||
let valueList = []
|
||||
let dataList = []
|
||||
const width = ref(window.innerWidth >= 800 && window.innerWidth <= 1200 ? window.innerWidth : window.innerWidth < 800 ? 800 : 1200)
|
||||
const height = ref(width.value * 1 / 3)
|
||||
|
||||
const data = reactive({
|
||||
rounds: {},
|
||||
team: [],
|
||||
eq_team_1: [],
|
||||
eq_team_2: [],
|
||||
eq_team_player_1: [],
|
||||
eq_team_player_2: [],
|
||||
})
|
||||
|
||||
const getTeamPlayer = (stats, team) => {
|
||||
let arr = []
|
||||
for (let i = (team - 1) * 5; i < team * 5; i++) {
|
||||
arr.push(stats[i].player.steamid64)
|
||||
}
|
||||
|
||||
return arr
|
||||
}
|
||||
|
||||
const parseObject = async () => {
|
||||
data.rounds = await GetPlayerValue(store, store.state.matchDetails.match_id)
|
||||
if (data.rounds === null)
|
||||
data.rounds = {}
|
||||
|
||||
for (const round in data.rounds) {
|
||||
for (const player in data.rounds[round]) {
|
||||
for (let p in data.team[0]) {
|
||||
if (data.team[0][p] === player) {
|
||||
data.eq_team_player_1.push({
|
||||
round: round,
|
||||
player: player,
|
||||
eq: (data.rounds[round][player][0] + data.rounds[round][player][2])
|
||||
})
|
||||
}
|
||||
}
|
||||
for (let p in data.team[1]) {
|
||||
if (data.team[1][p] === player) {
|
||||
data.eq_team_player_2.push({
|
||||
round: round,
|
||||
player: player,
|
||||
eq: (data.rounds[round][player][0] + data.rounds[round][player][2])
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sumArr = (arr) => {
|
||||
return arr.reduce((acc, current) => ({
|
||||
...acc,
|
||||
[current.round]: (acc[current.round] || 0) + current.eq
|
||||
}), {})
|
||||
}
|
||||
|
||||
const BuildGraphData = (team_1, team_2, max_rounds) => {
|
||||
let newArr = []
|
||||
const half_point = max_rounds / 2 - 1
|
||||
for (let round in team_1) {
|
||||
if (round <= half_point) {
|
||||
newArr.push(team_1[round] - team_2[round])
|
||||
} else
|
||||
newArr.push(team_2[round] - team_1[round])
|
||||
}
|
||||
return newArr
|
||||
}
|
||||
|
||||
const optionGen = (dataList, valueList) => {
|
||||
return {
|
||||
// Make gradient line here
|
||||
visualMap: [
|
||||
{
|
||||
show: false,
|
||||
type: 'continuous',
|
||||
seriesIndex: 0,
|
||||
color: ['#3a6e99', '#c3a235'],
|
||||
},
|
||||
],
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: 'Round <b>{b0}</b><br />{a0} <b>{c0}</b>',
|
||||
},
|
||||
xAxis: [
|
||||
{
|
||||
type: 'category',
|
||||
data: dataList,
|
||||
}
|
||||
],
|
||||
yAxis: [
|
||||
{},
|
||||
],
|
||||
grid: [
|
||||
{
|
||||
bottom: '10%'
|
||||
},
|
||||
{
|
||||
top: '0%'
|
||||
},
|
||||
{
|
||||
right: '0%'
|
||||
},
|
||||
{
|
||||
left: '0%'
|
||||
}
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: 'Net-Worth',
|
||||
type: 'line',
|
||||
lineStyle: {
|
||||
width: 4
|
||||
},
|
||||
showSymbol: false,
|
||||
data: valueList,
|
||||
markArea: {
|
||||
data: [
|
||||
[
|
||||
{
|
||||
name: 'Half-Point',
|
||||
xAxis: max_rounds / 2 - 1,
|
||||
label: {
|
||||
color: 'white'
|
||||
},
|
||||
},
|
||||
{
|
||||
xAxis: max_rounds / 2
|
||||
}
|
||||
]
|
||||
],
|
||||
itemStyle: {
|
||||
color: 'rgba(200,200,200, 0.3)'
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const disposeCharts = () => {
|
||||
if (myChart1 != null && myChart1 !== '' && myChart1 !== undefined) {
|
||||
myChart1.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
const buildCharts = () => {
|
||||
disposeCharts()
|
||||
|
||||
myChart1 = echarts.init(document.getElementById('economy-graph'), {}, {
|
||||
width: width.value,
|
||||
height: height.value
|
||||
})
|
||||
myChart1.setOption(optionGen(dataList, valueList))
|
||||
}
|
||||
|
||||
onBeforeMount(() => {
|
||||
max_rounds = store.state.matchDetails.max_rounds ? store.state.matchDetails.max_rounds : 30
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (store.state.matchDetails.stats) {
|
||||
echarts.use([
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
GridComponent,
|
||||
VisualMapComponent,
|
||||
LineChart,
|
||||
CanvasRenderer,
|
||||
UniversalTransition,
|
||||
MarkAreaComponent
|
||||
]);
|
||||
|
||||
data.team.push(getTeamPlayer(store.state.matchDetails.stats, 1))
|
||||
data.team.push(getTeamPlayer(store.state.matchDetails.stats, 2))
|
||||
|
||||
parseObject()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disposeCharts()
|
||||
})
|
||||
|
||||
watch(() => data.rounds, () => {
|
||||
data.eq_team_1 = sumArr(data.eq_team_player_1)
|
||||
data.eq_team_2 = sumArr(data.eq_team_player_2)
|
||||
|
||||
valueList = BuildGraphData(data.eq_team_1, data.eq_team_2, max_rounds)
|
||||
|
||||
dataList = Array.from(Array(valueList.length + 1).keys())
|
||||
dataList.shift()
|
||||
|
||||
buildCharts()
|
||||
})
|
||||
|
||||
window.onresize = () => {
|
||||
if (window.innerWidth > 1200) {
|
||||
width.value = 1200
|
||||
}
|
||||
if (window.innerWidth <= 1200 && window.innerWidth >= 800) {
|
||||
width.value = window.innerWidth - 20
|
||||
}
|
||||
if (window.innerWidth < 800) {
|
||||
width.value = 800
|
||||
}
|
||||
|
||||
height.value = width.value * 1 / 3
|
||||
buildCharts()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.economy {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
margin: 0 auto 3rem;
|
||||
|
||||
h3 {
|
||||
margin-bottom: -1rem;
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
h3 {
|
||||
margin-left: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) and (min-width: 1199px) {
|
||||
#economy-graph {
|
||||
overflow: scroll;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,244 +0,0 @@
|
||||
<template>
|
||||
<div class="player-flash">
|
||||
<h3 class="text-center mt-2">Flash</h3>
|
||||
<div class="flex-break"></div>
|
||||
<div class="toggle-btn">
|
||||
<div @click="toggleShow">
|
||||
<table class="table table-borderless text-muted">
|
||||
<tr>
|
||||
<td>
|
||||
<span class="text-uppercase float-end" :class="toggle === 'duration' ? 'text-warning' : ''">Duration</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<i id="toggle-off" class="fa fa-toggle-off show"></i>
|
||||
<i id="toggle-on" class="fa fa-toggle-on"></i>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-uppercase float-start" :class="toggle === 'total' ? 'text-warning' : ''">Count</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-break"></div>
|
||||
<div id="flash-chart-1"></div>
|
||||
<div id="flash-chart-2"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as echarts from 'echarts/core';
|
||||
import {GridComponent, LegendComponent, TooltipComponent} from 'echarts/components';
|
||||
import {BarChart} from 'echarts/charts';
|
||||
import {CanvasRenderer} from 'echarts/renderers';
|
||||
import {onMounted, onUnmounted, ref, watch} from "vue";
|
||||
import {checkStatEmpty, getPlayerArr} from "@/utils";
|
||||
import {useStore} from "vuex";
|
||||
|
||||
export default {
|
||||
name: "FlashChart",
|
||||
setup() {
|
||||
const store = useStore()
|
||||
|
||||
const toggle = ref('duration')
|
||||
let myChart1, myChart2
|
||||
const color = ['#bb792c', '#9bd270', '#eac42a']
|
||||
const width = ref(window.innerWidth <= 600 ? window.innerWidth : 600)
|
||||
const height = ref(width.value * 2 / 3)
|
||||
|
||||
const toggleShow = () => {
|
||||
const offBtn = document.getElementById('toggle-off')
|
||||
const onBtn = document.getElementById('toggle-on')
|
||||
|
||||
if (offBtn.classList.contains('show')) {
|
||||
offBtn.classList.remove('show')
|
||||
onBtn.classList.add('show')
|
||||
toggle.value = 'total'
|
||||
} else if (onBtn.classList.contains('show')) {
|
||||
onBtn.classList.remove('show')
|
||||
offBtn.classList.add('show')
|
||||
toggle.value = 'duration'
|
||||
}
|
||||
}
|
||||
|
||||
const valueArr = (stats, team, toggle, prop) => {
|
||||
if (['team', 'enemy', 'self'].indexOf(prop) > -1) {
|
||||
let arr = []
|
||||
for (let i = (team - 1) * 5; i < team * 5; i++) {
|
||||
arr.push(checkStatEmpty(Function('return(function(stats, i){ return stats[i].flash.' + toggle.value + '.' + prop + '})')()(stats, i)).toFixed(2))
|
||||
}
|
||||
arr.reverse()
|
||||
|
||||
return arr
|
||||
}
|
||||
}
|
||||
|
||||
const setOptions = (id, color) => {
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow',
|
||||
shadowStyle: {
|
||||
shadowBlur: 2,
|
||||
shadowColor: 'rgba(255, 255, 255, .3)'
|
||||
}
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
boundaryGap: [0, 0.01]
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: getPlayerArr(store.state.matchDetails.stats, id, true)
|
||||
},
|
||||
color: color,
|
||||
series: [
|
||||
{
|
||||
name: 'Enemy',
|
||||
type: 'bar',
|
||||
data: valueArr(store.state.matchDetails.stats, id, toggle, 'enemy'),
|
||||
},
|
||||
{
|
||||
name: 'Team',
|
||||
type: 'bar',
|
||||
data: valueArr(store.state.matchDetails.stats, id, toggle, 'team'),
|
||||
},
|
||||
{
|
||||
name: 'Self',
|
||||
type: 'bar',
|
||||
data: valueArr(store.state.matchDetails.stats, id, toggle, 'self'),
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const disposeCharts = () => {
|
||||
if (myChart1 != null && myChart1 !== '' && myChart1 !== undefined) {
|
||||
myChart1.dispose()
|
||||
}
|
||||
if (myChart2 != null && myChart2 !== '' && myChart2 !== undefined) {
|
||||
myChart2.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
const buildCharts = () => {
|
||||
disposeCharts()
|
||||
|
||||
myChart1 = echarts.init(document.getElementById('flash-chart-1'), {}, {
|
||||
width: width.value,
|
||||
height: height.value
|
||||
});
|
||||
myChart1.setOption(setOptions(1, color));
|
||||
|
||||
myChart2 = echarts.init(document.getElementById('flash-chart-2'), {}, {
|
||||
width: width.value,
|
||||
height: height.value
|
||||
});
|
||||
myChart2.setOption(setOptions(2, color));
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (store.state.matchDetails.stats) {
|
||||
echarts.use([
|
||||
TooltipComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
BarChart,
|
||||
CanvasRenderer
|
||||
]);
|
||||
|
||||
buildCharts()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disposeCharts()
|
||||
})
|
||||
|
||||
watch(() => toggle.value, () => {
|
||||
buildCharts()
|
||||
})
|
||||
|
||||
window.onresize = () => {
|
||||
if (window.innerWidth <= 600) {
|
||||
width.value = window.innerWidth - 20
|
||||
height.value = width.value * 2 / 3
|
||||
|
||||
buildCharts()
|
||||
}
|
||||
}
|
||||
|
||||
return {toggleShow, toggle}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.player-flash {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.flex-break {
|
||||
flex-basis: 100%;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 1rem auto -1rem;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
margin: 0 auto;
|
||||
cursor: pointer;
|
||||
|
||||
table {
|
||||
margin-top: 1rem;
|
||||
|
||||
td {
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
td:first-child,
|
||||
td:last-child {
|
||||
max-width: 80px;
|
||||
width: 80px;
|
||||
}
|
||||
td:nth-child(2) {
|
||||
max-width: 30px;
|
||||
width: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.fa {
|
||||
display: none;
|
||||
|
||||
&.show {
|
||||
display: initial;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#flash-chart-1,
|
||||
#flash-chart-2 {
|
||||
flex-basis: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.player-flash {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,564 +0,0 @@
|
||||
<template>
|
||||
<div class="hitgroup pt-2">
|
||||
<div class="d-flex flex-lg-nowrap flex-wrap justify-content-center gap-4">
|
||||
<div class="d-flex flex-column justify-content-center align-items-center w-auto">
|
||||
<div class="select-group mb-4">
|
||||
<select v-if="store.state.playersArr" v-model="data.selectPlayer" class="form-select">
|
||||
<option value="All">All</option>
|
||||
<option value="Team 1">Team 1</option>
|
||||
<option value="Team 2">Team 2</option>
|
||||
<option disabled>───────────────────────────</option>
|
||||
<option v-for="(value, index) in props.stats" :key="index"
|
||||
:value="Object.keys(value).toString() === store.state.playersArr[index].player.steamid64 ? store.state.playersArr[index].player : ''">
|
||||
{{
|
||||
Object.keys(value).toString() === store.state.playersArr[index].player.steamid64 ? store.state.playersArr[index].player.name : ''
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<select v-if="data.selectPlayer !== ''" :key="data.selectPlayer" v-model="data.selectWeapon"
|
||||
class="form-select">
|
||||
<option class="select-hr" value="All">All</option>
|
||||
<option disabled>───────────────────────────</option>
|
||||
<option v-for="(value, index) in processPlayerWeapon()" :key="index" :value="value">
|
||||
<!-- This is here, because weapons are not always named correctly -->
|
||||
<!-- {{ Object.values(value).toString().charAt(0).toUpperCase() + Object.values(value).toString().slice(1) }}-->
|
||||
{{ Object.values(value).toString() }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="hitgroup-puppet"/>
|
||||
</div>
|
||||
|
||||
<div v-if="data.weaponDmg"
|
||||
id="bar-graph"
|
||||
class="w-auto"
|
||||
:style="{
|
||||
minWidth: dmgWidth + 'px'
|
||||
}">
|
||||
<table class="table table-borderless">
|
||||
<tr v-for="(value, index) in data.weaponDmg" :key="index">
|
||||
<td v-if="index < 10 && (data.selectWeapon === 'All' || Object.keys(data.selectWeapon).toString() === Object.keys(value).toString())"
|
||||
style="width: 100px">
|
||||
<img :alt="Object.values(value).toString()"
|
||||
:src="DisplayWeapon(parseInt(Object.keys(value)[0]))"/>
|
||||
</td>
|
||||
<td v-if="index < 10 && (data.selectWeapon === 'All' || Object.keys(data.selectWeapon).toString() === Object.keys(value).toString())">
|
||||
<span :style="{
|
||||
width: (processWeaponDmg(Object.keys(value).toString()) / processWeaponDmg(Object.keys(data.weaponDmg[0]).toString()) * 100).toFixed(0) + '%',
|
||||
backgroundColor: 'orangered',
|
||||
display: 'block',
|
||||
}"
|
||||
class="rounded"
|
||||
>
|
||||
<span>{{ processWeaponDmg(Object.keys(value).toString()) }}</span>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as echarts from 'echarts/core';
|
||||
import {GeoComponent, TooltipComponent, VisualMapComponent} from 'echarts/components';
|
||||
import {MapChart} from 'echarts/charts';
|
||||
import {CanvasRenderer} from 'echarts/renderers';
|
||||
import {onMounted, onUnmounted, reactive, ref, watch} from "vue";
|
||||
import {useStore} from "vuex";
|
||||
import {DisplayWeapon} from '@/utils'
|
||||
|
||||
import $ from 'jquery'
|
||||
|
||||
export default {
|
||||
name: "HitgroupPuppet.vue",
|
||||
props: {
|
||||
equipment_map: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
stats: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const store = useStore()
|
||||
|
||||
const data = reactive({
|
||||
selectPlayer: 'All',
|
||||
selectWeapon: 'All',
|
||||
eq_map: [],
|
||||
weaponDmg: []
|
||||
})
|
||||
|
||||
let myChart1
|
||||
|
||||
const getWindowWidth = () => {
|
||||
const windowWidth = window.innerWidth
|
||||
if (windowWidth <= 750)
|
||||
return windowWidth
|
||||
else
|
||||
return 650
|
||||
}
|
||||
|
||||
const setDmgWidth = () => {
|
||||
const windowWidth = getWindowWidth()
|
||||
if (windowWidth >= 500)
|
||||
return 500
|
||||
else
|
||||
return windowWidth - 10
|
||||
}
|
||||
|
||||
const dmgWidth = ref(setDmgWidth())
|
||||
|
||||
const setHeight = () => {
|
||||
const windowWidth = getWindowWidth()
|
||||
if (windowWidth >= 751)
|
||||
return windowWidth * 3 / 7.5
|
||||
else if (windowWidth >= 501 && windowWidth <= 750)
|
||||
return windowWidth * 3 / 6.5
|
||||
else
|
||||
return windowWidth * 3 / 5.5
|
||||
}
|
||||
|
||||
const width = ref(getWindowWidth())
|
||||
const height = ref(setHeight())
|
||||
|
||||
const processWeaponDmg = (id) => {
|
||||
let value = ''
|
||||
data.weaponDmg.forEach(w => {
|
||||
if (Object.keys(w).toString() === id) {
|
||||
value = Object.values(w).toString()
|
||||
}
|
||||
})
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
const processPlayerWeapon = () => {
|
||||
let arr = []
|
||||
if (data.selectPlayer === 'All') {
|
||||
props.stats.forEach(player => {
|
||||
Object.values(player).forEach(enemies => {
|
||||
Object.values(enemies).forEach(weapons => {
|
||||
Object.values(weapons).forEach(weapon => {
|
||||
arr.push(weapon[0])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
} else if (data.selectPlayer === 'Team 1') {
|
||||
props.stats.forEach(player => {
|
||||
store.state.playersArr.forEach(p => {
|
||||
if (p.player.steamid64 === Object.keys(player).toString() && p.team_id === 1)
|
||||
Object.values(player).forEach(enemies => {
|
||||
Object.values(enemies).forEach(weapons => {
|
||||
Object.values(weapons).forEach(weapon => {
|
||||
arr.push(weapon[0])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
} else if (data.selectPlayer === 'Team 2') {
|
||||
props.stats.forEach(player => {
|
||||
store.state.playersArr.forEach(p => {
|
||||
if (p.player.steamid64 === Object.keys(player).toString() && p.team_id === 2)
|
||||
Object.values(player).forEach(enemies => {
|
||||
Object.values(enemies).forEach(weapons => {
|
||||
Object.values(weapons).forEach(weapon => {
|
||||
arr.push(weapon[0])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
} else {
|
||||
props.stats.forEach(player => {
|
||||
if (Object.keys(player).toString() === data.selectPlayer.steamid64) {
|
||||
Object.values(player).forEach(enemies => {
|
||||
Object.values(enemies).forEach(weapons => {
|
||||
Object.values(weapons).forEach(weapon => {
|
||||
arr.push(weapon[0])
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const unique = arr.filter((a, b) => arr.indexOf(a) === b && a < 400)
|
||||
|
||||
let arr2 = []
|
||||
|
||||
unique.forEach(w => {
|
||||
for (let weapon in props.equipment_map) {
|
||||
if (parseInt(w) === parseInt(weapon)) {
|
||||
let obj = {}
|
||||
obj[w] = props.equipment_map[weapon]
|
||||
arr2.push(obj)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return arr2
|
||||
}
|
||||
|
||||
const processDmg = (by = 'hitgroup') => {
|
||||
let arr = []
|
||||
if (data.selectPlayer && data.selectWeapon) {
|
||||
switch (data.selectPlayer) {
|
||||
case "All":
|
||||
props.stats.forEach(player => {
|
||||
Object.values(player).forEach(enemies => {
|
||||
Object.values(enemies).forEach(weapons => {
|
||||
Object.values(weapons).forEach(weapon => {
|
||||
// 0: weapon
|
||||
// 1: hitgroup
|
||||
// 2: dmg
|
||||
if (weapon) {
|
||||
if (by === 'hitgroup') {
|
||||
if (Object.values(weapon)[0] === parseInt(Object.keys(data.selectWeapon).toString())) {
|
||||
let obj = {}
|
||||
obj[weapon[1]] = weapon[2]
|
||||
arr.push(obj)
|
||||
} else if (data.selectWeapon === 'All') {
|
||||
let obj = {}
|
||||
obj[weapon[1]] = weapon[2]
|
||||
arr.push(obj)
|
||||
}
|
||||
} else if (by === 'weapon') {
|
||||
if (Object.values(weapon)[0] === parseInt(Object.keys(data.selectWeapon).toString())) {
|
||||
let obj = {}
|
||||
obj[weapon[0]] = weapon[2]
|
||||
arr.push(obj)
|
||||
} else if (data.selectWeapon === 'All') {
|
||||
let obj = {}
|
||||
obj[weapon[0]] = weapon[2]
|
||||
arr.push(obj)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
break;
|
||||
|
||||
case "Team 1":
|
||||
props.stats.forEach(player => {
|
||||
store.state.playersArr.forEach(p => {
|
||||
if (p.player.steamid64 === Object.keys(player).toString() && p.team_id === 1)
|
||||
Object.values(player).forEach(enemies => {
|
||||
Object.values(enemies).forEach(weapons => {
|
||||
Object.values(weapons).forEach(weapon => {
|
||||
// 0: weapon
|
||||
// 1: hitgroup
|
||||
// 2: dmg
|
||||
if (weapon) {
|
||||
if (by === 'hitgroup') {
|
||||
if (Object.values(weapon)[0] === parseInt(Object.keys(data.selectWeapon).toString())) {
|
||||
let obj = {}
|
||||
obj[weapon[1]] = weapon[2]
|
||||
arr.push(obj)
|
||||
} else if (data.selectWeapon === 'All') {
|
||||
let obj = {}
|
||||
obj[weapon[1]] = weapon[2]
|
||||
arr.push(obj)
|
||||
}
|
||||
} else if (by === 'weapon') {
|
||||
if (Object.values(weapon)[0] === parseInt(Object.keys(data.selectWeapon).toString())) {
|
||||
let obj = {}
|
||||
obj[weapon[0]] = weapon[2]
|
||||
arr.push(obj)
|
||||
} else if (data.selectWeapon === 'All') {
|
||||
let obj = {}
|
||||
obj[weapon[0]] = weapon[2]
|
||||
arr.push(obj)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
break;
|
||||
|
||||
case "Team 2":
|
||||
props.stats.forEach(player => {
|
||||
store.state.playersArr.forEach(p => {
|
||||
if (p.player.steamid64 === Object.keys(player).toString() && p.team_id === 2)
|
||||
Object.values(player).forEach(enemies => {
|
||||
Object.values(enemies).forEach(weapons => {
|
||||
Object.values(weapons).forEach(weapon => {
|
||||
// 0: weapon
|
||||
// 1: hitgroup
|
||||
// 2: dmg
|
||||
if (weapon) {
|
||||
if (by === 'hitgroup') {
|
||||
if (Object.values(weapon)[0] === parseInt(Object.keys(data.selectWeapon).toString())) {
|
||||
let obj = {}
|
||||
obj[weapon[1]] = weapon[2]
|
||||
arr.push(obj)
|
||||
} else if (data.selectWeapon === 'All') {
|
||||
let obj = {}
|
||||
obj[weapon[1]] = weapon[2]
|
||||
arr.push(obj)
|
||||
}
|
||||
} else if (by === 'weapon') {
|
||||
if (Object.values(weapon)[0] === parseInt(Object.keys(data.selectWeapon).toString())) {
|
||||
let obj = {}
|
||||
obj[weapon[0]] = weapon[2]
|
||||
arr.push(obj)
|
||||
} else if (data.selectWeapon === 'All') {
|
||||
let obj = {}
|
||||
obj[weapon[0]] = weapon[2]
|
||||
arr.push(obj)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
break;
|
||||
|
||||
default:
|
||||
props.stats.forEach(player => {
|
||||
if (Object.keys(player).toString() === data.selectPlayer.steamid64) {
|
||||
Object.values(player).forEach(enemies => {
|
||||
Object.values(enemies).forEach(weapons => {
|
||||
Object.values(weapons).forEach(weapon => {
|
||||
// 0: weapon
|
||||
// 1: hitgroup
|
||||
// 2: dmg
|
||||
if (weapon) {
|
||||
if (by === 'hitgroup') {
|
||||
if (Object.values(weapon)[0] === parseInt(Object.keys(data.selectWeapon).toString())) {
|
||||
let obj = {}
|
||||
obj[weapon[1]] = weapon[2]
|
||||
arr.push(obj)
|
||||
} else if (data.selectWeapon === 'All') {
|
||||
let obj = {}
|
||||
obj[weapon[1]] = weapon[2]
|
||||
arr.push(obj)
|
||||
}
|
||||
} else if (by === 'weapon') {
|
||||
if (Object.values(weapon)[0] === parseInt(Object.keys(data.selectWeapon).toString())) {
|
||||
let obj = {}
|
||||
obj[weapon[0]] = weapon[2]
|
||||
arr.push(obj)
|
||||
} else if (data.selectWeapon === 'All') {
|
||||
let obj = {}
|
||||
obj[weapon[0]] = weapon[2]
|
||||
arr.push(obj)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
arr = []
|
||||
}
|
||||
|
||||
if (by === 'hitgroup') {
|
||||
buildCharts(sumDmgArr(arr))
|
||||
} else if (by === 'weapon') {
|
||||
data.weaponDmg = sumDmgArr(arr, 'weapon')
|
||||
}
|
||||
}
|
||||
|
||||
const sumDmgArr = (arr, by = 'hitgroup') => {
|
||||
let holder = {};
|
||||
|
||||
arr.forEach(function (d) {
|
||||
// eslint-disable-next-line no-prototype-builtins
|
||||
if (holder.hasOwnProperty(parseInt(Object.keys(d).toString()))) {
|
||||
holder[parseInt(Object.keys(d).toString())] = holder[parseInt(Object.keys(d).toString())] + parseInt(Object.values(d).toString());
|
||||
} else {
|
||||
holder[parseInt(Object.keys(d).toString())] = parseInt(Object.values(d).toString());
|
||||
}
|
||||
});
|
||||
|
||||
let arr2 = [];
|
||||
|
||||
if (by === 'hitgroup') {
|
||||
for (let i = 1; i < 8; i++) {
|
||||
if (holder[i] !== undefined) {
|
||||
arr2.push(holder[i])
|
||||
} else {
|
||||
arr2.push(0)
|
||||
}
|
||||
}
|
||||
} else if (by === 'weapon') {
|
||||
for (let i = 1; i < 312; i++) {
|
||||
if (holder[i] !== undefined) {
|
||||
let obj = {}
|
||||
obj[i] = holder[i]
|
||||
arr2.push(obj)
|
||||
}
|
||||
}
|
||||
|
||||
arr2.sort((a, b) => {
|
||||
return Object.values(b).toString() - Object.values(a).toString()
|
||||
})
|
||||
}
|
||||
|
||||
return arr2
|
||||
}
|
||||
|
||||
const getMax = (arr) => {
|
||||
let max = 0
|
||||
for (let i = 0; i < 7; i++) {
|
||||
if (arr[i] > max)
|
||||
max = arr[i]
|
||||
}
|
||||
|
||||
return max
|
||||
}
|
||||
|
||||
const optionGen = (arr = []) => {
|
||||
return {
|
||||
tooltip: {},
|
||||
visualMap: {
|
||||
left: 'center',
|
||||
bottom: '5%',
|
||||
textStyle: {
|
||||
color: 'white',
|
||||
},
|
||||
min: 0,
|
||||
max: getMax(arr) || 100,
|
||||
orient: 'horizontal',
|
||||
realtime: true,
|
||||
calculable: true,
|
||||
inRange: {
|
||||
color: ['#00ff00', '#db6e00', '#cf0000']
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'Hitgroup',
|
||||
type: 'map',
|
||||
map: 'hitgroup-puppet',
|
||||
top: '0%',
|
||||
emphasis: {
|
||||
label: {
|
||||
show: false
|
||||
}
|
||||
},
|
||||
selectedMode: false,
|
||||
data: [
|
||||
{name: 'Head', value: arr[0] || 0},
|
||||
{name: 'Chest', value: arr[1] || 0},
|
||||
{name: 'Stomach', value: arr[2] || 0},
|
||||
{name: 'Left Arm', value: arr[3] || 0},
|
||||
{name: 'Right Arm', value: arr[4] || 0},
|
||||
{name: 'Left Foot', value: arr[5] || 0},
|
||||
{name: 'Right Foot', value: arr[6] || 0}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const disposeCharts = () => {
|
||||
if (myChart1 != null && myChart1 !== '' && myChart1 !== undefined) {
|
||||
myChart1.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
const buildCharts = (arr) => {
|
||||
disposeCharts()
|
||||
|
||||
myChart1 = echarts.init(document.getElementById('hitgroup-puppet'), {}, {width: 300, height: 500})
|
||||
|
||||
const url = '/images/icons/hitgroup-puppet.svg'
|
||||
$.get(url, function (svg) {
|
||||
echarts.registerMap('hitgroup-puppet', {svg: svg})
|
||||
myChart1.setOption(optionGen(arr));
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (store.state.matchDetails.stats) {
|
||||
echarts.use([
|
||||
TooltipComponent,
|
||||
VisualMapComponent,
|
||||
GeoComponent,
|
||||
MapChart,
|
||||
CanvasRenderer
|
||||
]);
|
||||
|
||||
buildCharts()
|
||||
|
||||
watch(() => props.stats, () => {
|
||||
processDmg()
|
||||
processDmg('weapon')
|
||||
processPlayerWeapon()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disposeCharts()
|
||||
})
|
||||
|
||||
window.onresize = () => {
|
||||
if (window.innerWidth <= 750) {
|
||||
width.value = getWindowWidth() - 20
|
||||
height.value = setHeight()
|
||||
dmgWidth.value = setDmgWidth()
|
||||
}
|
||||
|
||||
buildCharts()
|
||||
}
|
||||
|
||||
watch(() => data.selectPlayer, () => {
|
||||
data.selectWeapon = 'All'
|
||||
processPlayerWeapon()
|
||||
processDmg()
|
||||
processDmg('weapon')
|
||||
})
|
||||
|
||||
watch(() => data.selectWeapon, () => {
|
||||
processDmg()
|
||||
processDmg('weapon')
|
||||
})
|
||||
|
||||
return {props, data, store, dmgWidth, processPlayerWeapon, processWeaponDmg, DisplayWeapon}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.select-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
|
||||
.form-select {
|
||||
background: var(--bs-secondary);
|
||||
color: var(--bs-primary);
|
||||
width: 250px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.select-group {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,88 +0,0 @@
|
||||
<template>
|
||||
<div v-if="infos.data" id="modal">
|
||||
<div v-for="(info, id) in infos.data" :key="id" class="custom-modal">
|
||||
<div :class="info.type === 'error'
|
||||
? 'bg-danger text-white'
|
||||
: info.type === 'warning'
|
||||
? 'bg-warning text-secondary'
|
||||
: info.type === 'success'
|
||||
? 'bg-success text-white'
|
||||
: 'bg-secondary text-white'"
|
||||
class="card">
|
||||
<div class="card-body d-flex justify-content-between">
|
||||
<span class="info-text">{{ info.message }}</span>
|
||||
<button aria-label="Close" class="btn-close" type="button" @click="closeModal(id)"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {useStore} from "vuex";
|
||||
import {onMounted, reactive} from "vue";
|
||||
|
||||
export default {
|
||||
name: "InfoModal",
|
||||
setup() {
|
||||
const store = useStore()
|
||||
const infos = reactive({
|
||||
data: []
|
||||
})
|
||||
|
||||
const closeModal = (id) => {
|
||||
store.commit('removeInfoState', id)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.subscribe(((mutation, state) => {
|
||||
if (mutation.type === 'changeInfoState') {
|
||||
infos.data = state.info
|
||||
|
||||
setTimeout(() => {
|
||||
closeModal(store.state.info.length - 1)
|
||||
}, 5000)
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
return {infos, closeModal}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
#modal {
|
||||
--height: 56px;
|
||||
|
||||
.card {
|
||||
z-index: 10;
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
opacity: .8;
|
||||
width: min(100vw - 2rem, 50ch);
|
||||
height: var(--height);
|
||||
|
||||
.btn-close {
|
||||
background-color: white;
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
font-size: .8rem;
|
||||
}
|
||||
}
|
||||
|
||||
@for $i from 1 through 10 {
|
||||
.custom-modal:nth-of-type(#{$i}) {
|
||||
.card {
|
||||
@if $i == 1 {
|
||||
margin: 1rem 0;
|
||||
} @else {
|
||||
margin-top: calc(#{$i}rem + (#{$i} - 1) * var(--height));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,274 +0,0 @@
|
||||
<template>
|
||||
<div class="container w-50">
|
||||
<TranslateChatButton
|
||||
v-if="data.chat.length > 0"
|
||||
:translated="data.translatedText.length > 0"
|
||||
class="translate-btn"
|
||||
@translated="handleTranslatedText"
|
||||
/>
|
||||
<div v-if="data.chat.length > 0" class="chat-history mt-2">
|
||||
<table id="chat" :style="`max-width: ${data.clientWidth}px; width: ${data.clientWidth}px`" class="table table-borderless">
|
||||
<tbody>
|
||||
<tr v-for="(m, id) in data.chat" :key="id">
|
||||
<td class="td-time">
|
||||
{{ ConvertTickToTime(m.tick, m.tick_rate) }}
|
||||
</td>
|
||||
<td class="td-avatar">
|
||||
<img :class="'team-color-' + m.color"
|
||||
:src="constructAvatarUrl(m.avatar)"
|
||||
alt="Player avatar"
|
||||
class="avatar">
|
||||
</td>
|
||||
<td :class="m.startSide === 1 ? 'text-info' : 'text-warning'"
|
||||
class="td-name d-flex"
|
||||
@click="GoToPlayer(m.steamid64)">
|
||||
<span>
|
||||
<i v-if="m.tracked" class="fa fa-dot-circle-o text-success tracked" title="Tracked user"/>
|
||||
<span :class="(m.vac && FormatVacDate(m.vac_date, store.state.matchDetails.date) !== '')
|
||||
|| (!m.vac && m.game_ban && FormatVacDate(m.game_ban_date, store.state.matchDetails.date) !== '')
|
||||
? 'ban-shadow'
|
||||
: ''"
|
||||
:title="!m.vac && m.game_ban
|
||||
? 'Game-banned: ' + FormatVacDate(m.game_ban_date, store.state.matchDetails.date)
|
||||
: m.vac && !m.game_ban
|
||||
? 'Vac-banned: ' + FormatVacDate(m.vac_date, store.state.matchDetails.date)
|
||||
: ''">
|
||||
{{ m.player }}
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="td-icon">
|
||||
<i class="fa fa-caret-right"/>
|
||||
<span v-if="!m.all_chat" class="ms-1">
|
||||
(team)
|
||||
</span>
|
||||
</td>
|
||||
<td class="td-message">
|
||||
{{ data.translatedText.length === 0 ? m.message : data.originalChat[id].message }}
|
||||
<span v-if="m.translated_from"
|
||||
:class="m.translated_from ? 'text-success' : ''"
|
||||
:title="`Translated from ${ISO6391.getName(m.translated_from)}`"
|
||||
class="ms-2 helpicon">
|
||||
<br/>
|
||||
{{ m.message }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else>
|
||||
<h3>No chat available</h3>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {useStore} from "vuex";
|
||||
import {onMounted, reactive} from "vue";
|
||||
import {constructAvatarUrl, ConvertTickToTime, FormatVacDate, GetChatHistory, GoToPlayer, truncate} from "@/utils";
|
||||
import TranslateChatButton from "@/components/TranslateChatButton";
|
||||
import ISO6391 from 'iso-639-1'
|
||||
|
||||
export default {
|
||||
name: "MatchChatHistory",
|
||||
components: {TranslateChatButton},
|
||||
setup() {
|
||||
const store = useStore()
|
||||
|
||||
const data = reactive({
|
||||
chat: [],
|
||||
translatedText: [],
|
||||
originalChat: [],
|
||||
clientWidth: 0
|
||||
})
|
||||
|
||||
const handleTranslatedText = async (e) => {
|
||||
const [res, toggle] = await e
|
||||
|
||||
if (res !== null) {
|
||||
if (toggle === 'translated') {
|
||||
data.translatedText = await setPlayer(sortChatHistory(res, true))
|
||||
data.chat = data.translatedText
|
||||
} else if (toggle === 'original') {
|
||||
data.chat = data.originalChat
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getChatHistory = async () => {
|
||||
const resData = await GetChatHistory(store, store.state.matchDetails.match_id)
|
||||
if (resData !== null) {
|
||||
data.chat = await setPlayer(sortChatHistory(resData))
|
||||
data.originalChat = data.chat
|
||||
}
|
||||
}
|
||||
|
||||
const sortChatHistory = (res = {}, translated = false) => {
|
||||
let arr = []
|
||||
if (res !== {}) {
|
||||
Object.keys(res).forEach(i => {
|
||||
res[i].forEach(o => {
|
||||
let obj = Object.assign({
|
||||
player: i,
|
||||
tick: o.tick,
|
||||
all_chat: o.all_chat,
|
||||
message: o.message,
|
||||
translated_from: translated ? o.translated_from : null,
|
||||
translated_to: translated ? o.translated_to : null
|
||||
})
|
||||
arr.push(obj)
|
||||
})
|
||||
})
|
||||
}
|
||||
arr.sort((a, b) => a.tick - b.tick)
|
||||
return arr
|
||||
}
|
||||
|
||||
const setPlayer = async (chat) => {
|
||||
let arr = []
|
||||
for (const o of chat) {
|
||||
for (const p of store.state.matchDetails.stats) {
|
||||
if (o.player === p.player.steamid64) {
|
||||
const obj = Object.assign({
|
||||
player: truncate(p.player.name, 20),
|
||||
steamid64: p.player.steamid64,
|
||||
avatar: p.player.avatar,
|
||||
color: p.color,
|
||||
startSide: p.team_id,
|
||||
tracked: p.player.tracked,
|
||||
vac: p.player.vac,
|
||||
vac_date: p.player.vac_date,
|
||||
game_ban: p.player.game_ban,
|
||||
game_ban_date: p.player.game_ban_date,
|
||||
tick: o.tick,
|
||||
tick_rate: store.state.matchDetails.tick_rate && store.state.matchDetails.tick_rate !== -1 ? store.state.matchDetails.tick_rate : 64,
|
||||
all_chat: o.all_chat,
|
||||
message: o.message,
|
||||
translated_from: o.translated_from,
|
||||
translated_to: o.translated_to
|
||||
})
|
||||
arr.push(obj)
|
||||
}
|
||||
}
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
const sizeTable = () => {
|
||||
if (document.documentElement.clientWidth <= 768) {
|
||||
data.clientWidth = document.documentElement.clientWidth - 32
|
||||
} else {
|
||||
data.clientWidth = 700
|
||||
}
|
||||
}
|
||||
|
||||
window.onresize = () => {
|
||||
sizeTable()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getChatHistory()
|
||||
sizeTable()
|
||||
})
|
||||
|
||||
return {
|
||||
data,
|
||||
store,
|
||||
ISO6391,
|
||||
constructAvatarUrl,
|
||||
GoToPlayer,
|
||||
ConvertTickToTime,
|
||||
FormatVacDate,
|
||||
handleTranslatedText
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.translate-btn {
|
||||
margin-top: .5rem;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: .5rem;
|
||||
}
|
||||
|
||||
.td-time {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.td-avatar {
|
||||
width: 30px;
|
||||
|
||||
.avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.td-name {
|
||||
width: 200px;
|
||||
max-width: 200px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
.tracked {
|
||||
font-size: .8rem;
|
||||
margin-right: .2rem;
|
||||
}
|
||||
|
||||
.ban-shadow {
|
||||
color: red;
|
||||
text-shadow: 0 0 1rem orangered;
|
||||
}
|
||||
}
|
||||
|
||||
.td-icon {
|
||||
width: 20px;
|
||||
|
||||
.fa-caret-right {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.td-message {
|
||||
width: 400px !important;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.container {
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
.td-name {
|
||||
width: 120px !important;
|
||||
max-width: 120px !important;
|
||||
}
|
||||
.td-message {
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 576px) {
|
||||
.container {
|
||||
margin-left: 0;
|
||||
}
|
||||
.td-avatar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,532 +0,0 @@
|
||||
<template>
|
||||
<div v-if="props.matches.length === 0" id="matches-placeholder">
|
||||
<span v-for="i in 20" :key="i" :class="i % 2 === 1 ? 'placeholder-wave' : 'placeholder-wave-alt'"
|
||||
class="placeholder col-12"></span>
|
||||
</div>
|
||||
|
||||
<div v-else id="matches">
|
||||
<table class="table table-borderless">
|
||||
<thead class="border-bottom">
|
||||
<tr>
|
||||
<th class="text-center map" scope="col">Map</th>
|
||||
<th class="text-center rank" scope="col">Rank</th>
|
||||
<th class="text-center length" scope="col" title="Match Length">
|
||||
<img alt="Match length" class="match-len helpicon" src="/images/icons/timer_both.svg">
|
||||
</th>
|
||||
<th class="text-center score" scope="col">Score</th>
|
||||
<th v-if="!props.explore" class="text-center kills" scope="col">K</th>
|
||||
<th v-if="!props.explore" class="text-center assists" scope="col">A</th>
|
||||
<th v-if="!props.explore" class="text-center deaths" scope="col">D</th>
|
||||
<th v-if="!props.explore" class="text-center kdiff helptext" scope="col" title="Kill-to-death difference">+/-</th>
|
||||
<th v-if="!props.explore" class="text-center hltv helptext" scope="col" title="HLTV 1.0 Rating">Rating</th>
|
||||
<th class="text-center duration" scope="col">Duration</th>
|
||||
<th class="date" scope="col">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="match in props.matches"
|
||||
:key="match.match_id"
|
||||
:class="props.colorFront ? (GetWinLoss(match.match_result, match.stats.team_id) + (match.vac || match.game_ban ? ' ban' : '')) : (match.vac || match.game_ban ? ' matches_ban' : '')"
|
||||
:title="match.vac ? 'VAC-banned player in this game' : match.game_ban ? 'Game-banned player in this game' : ''"
|
||||
class="match default"
|
||||
@click="GoToMatch(match.match_id)"
|
||||
>
|
||||
<td class="td-map text-center">
|
||||
<i v-if="match.parsed" class="fa fa-bar-chart parsed helpicon"
|
||||
title="Demo has been parsed for additional data"></i>
|
||||
<i v-if="!match.parsed && MatchNotParsedTime(match.date)" class="fa fa-hourglass-half not-yet-parsed helpicon"
|
||||
title="Match has not been parsed yet"></i>
|
||||
<img v-if="match.map !== ''"
|
||||
:alt="match.map"
|
||||
:src="'/images/map_icons/map_icon_' + match.map + '.svg'"
|
||||
:title="FixMapName(match.map)"
|
||||
class="map-icon">
|
||||
<i v-else class="fa fa-question-circle-o map-not-found" title="Match not parsed"></i>
|
||||
</td>
|
||||
<td class="td-rank text-center">
|
||||
<img v-if="props.explore"
|
||||
:alt="DisplayRank(Math.floor(match.avg_rank || 0))[1]"
|
||||
:src="DisplayRank(Math.floor(match.avg_rank || 0))[0]"
|
||||
:title="DisplayRank(Math.floor(match.avg_rank || 0))[1]" class="rank-icon">
|
||||
<img v-else
|
||||
:alt="DisplayRank(match.stats.rank?.new)[1]"
|
||||
:class="match.stats.rank?.new > match.stats.rank?.old ? 'uprank' : match.stats.rank?.new < match.stats.rank?.old ? 'downrank' : ''"
|
||||
:src="DisplayRank(match.stats.rank?.new)[0]"
|
||||
:title="DisplayRank(match.stats.rank?.new)[1]" class="rank-icon">
|
||||
</td>
|
||||
<td class="td-length text-center">
|
||||
<img v-if="match.max_rounds === 30 || !match.max_rounds"
|
||||
alt="Match long"
|
||||
class="match-len"
|
||||
src="/images/icons/timer_long.svg"
|
||||
title="Long Match">
|
||||
<img v-if="match.max_rounds === 16"
|
||||
alt="Match short"
|
||||
class="match-len"
|
||||
src="/images/icons/timer_short.svg"
|
||||
title="Short Match">
|
||||
</td>
|
||||
<td class="td-score text-center fw-bold">
|
||||
<span
|
||||
:class="match.match_result === 1 ? 'text-success' : match.match_result === 0 ? 'text-warning' : 'text-danger'">{{
|
||||
match.score[0]
|
||||
}}</span> - <span
|
||||
:class="match.match_result === 2 ? 'text-success' : match.match_result === 0 ? 'text-warning' : 'text-danger'">{{
|
||||
match.score[1]
|
||||
}}</span>
|
||||
</td>
|
||||
<td v-if="match.stats" class="td-kills text-center">
|
||||
{{ match.stats.kills ? match.stats.kills : "0" }}
|
||||
</td>
|
||||
<td v-if="match.stats" class="td-assists text-center">
|
||||
{{ match.stats.assists ? match.stats.assists : "0" }}
|
||||
</td>
|
||||
<td v-if="match.stats" class="td-deaths text-center">
|
||||
{{ match.stats.deaths ? match.stats.deaths : "0" }}
|
||||
</td>
|
||||
<td v-if="match.stats"
|
||||
:class="(match.stats.kills ? match.stats.kills : 0) - (match.stats.deaths ? match.stats.deaths : 0) >= 0 ? 'text-success' : 'text-danger'"
|
||||
class="td-plus text-center">
|
||||
{{
|
||||
(match.stats.kills ? match.stats.kills : 0) - (match.stats.deaths ? match.stats.deaths : 0)
|
||||
}}
|
||||
</td>
|
||||
<td v-if="match.stats"
|
||||
:class="GetHLTV_1(
|
||||
match.stats.kills,
|
||||
match.score[0] + match.score[1],
|
||||
match.stats.deaths,
|
||||
match.stats.multi_kills?.duo,
|
||||
match.stats.multi_kills?.triple,
|
||||
match.stats.multi_kills?.quad,
|
||||
match.stats.multi_kills?.pent) >= 1 ? 'text-success' : 'text-warning'"
|
||||
class="td-hltv text-center fw-bold">
|
||||
{{
|
||||
GetHLTV_1(
|
||||
match.stats.kills,
|
||||
match.score[0] + match.score[1],
|
||||
match.stats.deaths,
|
||||
match.stats.multi_kills?.duo,
|
||||
match.stats.multi_kills?.triple,
|
||||
match.stats.multi_kills?.quad,
|
||||
match.stats.multi_kills?.pent)
|
||||
}}
|
||||
</td>
|
||||
<td :title="FormatFullDuration(match.duration)" class="td-duration text-center">
|
||||
{{ FormatDuration(match.duration) }}
|
||||
|
||||
</td>
|
||||
<td :title="FormatFullDate(match.date)" class="td-date">
|
||||
{{ FormatDate(match.date) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
DisplayRank,
|
||||
FixMapName,
|
||||
FormatDate,
|
||||
FormatDuration,
|
||||
FormatFullDate,
|
||||
FormatFullDuration,
|
||||
GetHLTV_1,
|
||||
GetWinLoss,
|
||||
GoToMatch,
|
||||
MatchNotParsedTime
|
||||
} from "@/utils";
|
||||
|
||||
export default {
|
||||
name: "MatchesTable",
|
||||
props: {
|
||||
colorFront: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
matches: {
|
||||
type: Array,
|
||||
required: false
|
||||
},
|
||||
explore: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
return {
|
||||
props,
|
||||
FormatDate,
|
||||
FormatFullDate,
|
||||
FormatDuration,
|
||||
FormatFullDuration,
|
||||
GetHLTV_1,
|
||||
GetWinLoss,
|
||||
GoToMatch,
|
||||
MatchNotParsedTime,
|
||||
DisplayRank,
|
||||
FixMapName
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
#matches-placeholder {
|
||||
.placeholder {
|
||||
height: 78px;
|
||||
margin: 1px 0;
|
||||
}
|
||||
}
|
||||
|
||||
table {
|
||||
margin-bottom: 0;
|
||||
|
||||
tr {
|
||||
th {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
td {
|
||||
line-height: 60px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
th:last-child, td:last-child {
|
||||
text-align: right;
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.map {
|
||||
padding-left: 3rem;
|
||||
}
|
||||
|
||||
.match-len {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.td-map {
|
||||
position: relative;
|
||||
padding-left: 3rem;
|
||||
text-align: left !important;
|
||||
width: 50px;
|
||||
|
||||
.parsed {
|
||||
position: absolute;
|
||||
left: 7px;
|
||||
bottom: 23px;
|
||||
color: var(--bs-warning);
|
||||
font-size: 1.7rem;
|
||||
}
|
||||
|
||||
.not-yet-parsed {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
bottom: 25px;
|
||||
color: darkgrey;
|
||||
font-size: 1.7rem;
|
||||
}
|
||||
|
||||
.map-not-found {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 48px;
|
||||
font-size: 4.35rem;
|
||||
color: rgba(255, 193, 7, .86);
|
||||
}
|
||||
|
||||
img {
|
||||
width: 60px;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.td-rank {
|
||||
img {
|
||||
width: 70px;
|
||||
height: auto;
|
||||
|
||||
.rank-icon {
|
||||
height: 35px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.td-score {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.td-date, .date {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.match {
|
||||
$first: rgb(0, 0, 0);
|
||||
$last: rgb(0, 0, 0);
|
||||
$win: false;
|
||||
$loss: false;
|
||||
$draw: false;
|
||||
$ban: false;
|
||||
|
||||
&.default {
|
||||
background: linear-gradient(to right,
|
||||
rgba($first, 0.2) 0%,
|
||||
rgba($first, 0.1) 15%,
|
||||
rgba(0, 0, 0, 0.4) 30%,
|
||||
rgba(0, 0, 0, 0.4) 70%,
|
||||
rgba($last, 0.6) 80%,
|
||||
rgba($last, 0.6) 100%
|
||||
);
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(to right,
|
||||
rgba($first, 0.3) 0%,
|
||||
rgba($first, 0.2) 15%,
|
||||
rgba(0, 0, 0, 0.5) 30%,
|
||||
rgba(0, 0, 0, 0.5) 70%,
|
||||
rgba($last, 0.7) 80%,
|
||||
rgba($last, 0.7) 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
&.win {
|
||||
$first: rgb(0, 255, 0);
|
||||
background: linear-gradient(to right,
|
||||
rgba($first, 0.2) 0%,
|
||||
rgba($first, 0.1) 15%,
|
||||
rgba(0, 0, 0, 0.4) 30%,
|
||||
rgba(0, 0, 0, 0.4) 70%,
|
||||
rgba($last, 0.6) 80%,
|
||||
rgba($last, 0.6) 100%
|
||||
);
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(to right,
|
||||
rgba($first, 0.3) 0%,
|
||||
rgba($first, 0.2) 15%,
|
||||
rgba(0, 0, 0, 0.5) 30%,
|
||||
rgba(0, 0, 0, 0.5) 70%,
|
||||
rgba($last, 0.7) 80%,
|
||||
rgba($last, 0.7) 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
&.draw {
|
||||
$first: rgb(255, 255, 0);
|
||||
background: linear-gradient(to right,
|
||||
rgba($first, 0.2) 0%,
|
||||
rgba($first, 0.1) 15%,
|
||||
rgba(0, 0, 0, 0.4) 30%,
|
||||
rgba(0, 0, 0, 0.4) 70%,
|
||||
rgba($last, 0.6) 80%,
|
||||
rgba($last, 0.6) 100%
|
||||
);
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(to right,
|
||||
rgba($first, 0.3) 0%,
|
||||
rgba($first, 0.2) 15%,
|
||||
rgba(0, 0, 0, 0.5) 30%,
|
||||
rgba(0, 0, 0, 0.5) 70%,
|
||||
rgba($last, 0.7) 80%,
|
||||
rgba($last, 0.7) 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
&.loss {
|
||||
$first: rgb(255, 0, 0);
|
||||
background: linear-gradient(to right,
|
||||
rgba($first, 0.2) 0%,
|
||||
rgba($first, 0.1) 15%,
|
||||
rgba(0, 0, 0, 0.4) 30%,
|
||||
rgba(0, 0, 0, 0.4) 70%,
|
||||
rgba($last, 0.6) 80%,
|
||||
rgba($last, 0.6) 100%
|
||||
);
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(to right,
|
||||
rgba($first, 0.3) 0%,
|
||||
rgba($first, 0.2) 15%,
|
||||
rgba(0, 0, 0, 0.5) 30%,
|
||||
rgba(0, 0, 0, 0.5) 70%,
|
||||
rgba($last, 0.7) 80%,
|
||||
rgba($last, 0.7) 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
&.ban {
|
||||
$last: rgb(93, 3, 3);
|
||||
background: linear-gradient(to right,
|
||||
rgba($first, 0.2) 0%,
|
||||
rgba($first, 0.1) 15%,
|
||||
rgba(0, 0, 0, 0.4) 30%,
|
||||
rgba(0, 0, 0, 0.4) 70%,
|
||||
rgba($last, 0.6) 80%,
|
||||
rgba($last, 0.6) 100%
|
||||
);
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(to right,
|
||||
rgba($first, 0.3) 0%,
|
||||
rgba($first, 0.2) 15%,
|
||||
rgba(0, 0, 0, 0.5) 30%,
|
||||
rgba(0, 0, 0, 0.5) 70%,
|
||||
rgba($last, 0.7) 80%,
|
||||
rgba($last, 0.7) 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
&.matches_ban {
|
||||
$first: rgb(0, 0, 0);
|
||||
$last: rgb(93, 3, 3);
|
||||
background: linear-gradient(to right,
|
||||
rgba($first, 0.2) 0%,
|
||||
rgba($first, 0.1) 15%,
|
||||
rgba(0, 0, 0, 0.4) 30%,
|
||||
rgba(0, 0, 0, 0.4) 70%,
|
||||
rgba($last, 0.6) 80%,
|
||||
rgba($last, 0.6) 100%
|
||||
);
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(to right,
|
||||
rgba($first, 0.3) 0%,
|
||||
rgba($first, 0.2) 15%,
|
||||
rgba(0, 0, 0, 0.5) 30%,
|
||||
rgba(0, 0, 0, 0.5) 70%,
|
||||
rgba($last, 0.7) 80%,
|
||||
rgba($last, 0.7) 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
border-bottom: 1px solid rgba(73, 73, 73, 0.73);
|
||||
|
||||
&:last-child {
|
||||
border: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 400px) {
|
||||
table tr {
|
||||
.map-icon {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
.map {
|
||||
padding: 0.5rem !important;
|
||||
}
|
||||
.td-map {
|
||||
padding: 0 1rem !important;
|
||||
|
||||
.parsed {
|
||||
display: none;
|
||||
}
|
||||
.not-yet-parsed {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.map-icon {
|
||||
margin-left: -1.32em !important;
|
||||
}
|
||||
|
||||
.td-map {
|
||||
position: relative;
|
||||
width: 35px !important;
|
||||
|
||||
.parsed {
|
||||
position: absolute;
|
||||
left: .3rem !important;
|
||||
}
|
||||
|
||||
.not-yet-parsed {
|
||||
position: absolute;
|
||||
left: .3rem !important;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 35px !important;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
.td-rank img {
|
||||
width: 50px !important;
|
||||
height: auto;
|
||||
max-width: 50px !important;
|
||||
margin-left: -0.5rem !important;
|
||||
}
|
||||
|
||||
.td-score {
|
||||
font-size: .7rem !important;
|
||||
//width: 110px !important;
|
||||
}
|
||||
|
||||
.td-date {
|
||||
font-size: .8rem !important;
|
||||
}
|
||||
|
||||
.kills, .deaths, .assists, .kdiff, .duration, .hltv, .length,
|
||||
.td-kills, .td-deaths, .td-assists, .td-plus, .td-duration, .td-hltv, .td-length {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 992px) {
|
||||
.avatar {
|
||||
width: 100px !important;
|
||||
height: 100px !important;
|
||||
}
|
||||
.trackme-btn {
|
||||
top: 25px;
|
||||
}
|
||||
.map, .td-map {
|
||||
padding-left: 4rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1200px) {
|
||||
.td-plus, .kdiff {
|
||||
display: none;
|
||||
}
|
||||
.td-rank img {
|
||||
width: 60px !important;
|
||||
height: auto;
|
||||
max-width: 60px;
|
||||
}
|
||||
.td-map img {
|
||||
width: 50px !important;
|
||||
height: auto;
|
||||
}
|
||||
.td-score {
|
||||
font-size: 1.1rem !important;
|
||||
width: 130px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,183 +0,0 @@
|
||||
<template>
|
||||
<div class="charts">
|
||||
<div id="multi-kills-chart-1"></div>
|
||||
<div id="multi-kills-chart-2"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as echarts from 'echarts/core';
|
||||
import {GridComponent, TooltipComponent, VisualMapComponent} from 'echarts/components';
|
||||
import {HeatmapChart} from 'echarts/charts';
|
||||
import {CanvasRenderer} from 'echarts/renderers';
|
||||
import {onMounted, onUnmounted, ref} from "vue";
|
||||
import {checkStatEmpty, getPlayerArr} from "../utils";
|
||||
import {useStore} from "vuex";
|
||||
|
||||
export default {
|
||||
name: "MultiKillsChart",
|
||||
setup() {
|
||||
const store = useStore()
|
||||
|
||||
const multiKills = ['2k', '3k', '4k', '5k']
|
||||
let myChart1, myChart2
|
||||
const width = ref(window.innerWidth <= 500 ? window.innerWidth : 500)
|
||||
const height = ref(width.value)
|
||||
|
||||
const multiKillArr = (stats, team) => {
|
||||
let arr = []
|
||||
for (let i = (team - 1) * 5; i < team * 5; i++) {
|
||||
for (let j = 0; j < multiKills.length; j++) {
|
||||
if (j === 0)
|
||||
arr.push([i % 5, j, checkStatEmpty(stats[i].multi_kills.duo) === 0 ? null : stats[i].multi_kills.duo])
|
||||
if (j === 1)
|
||||
arr.push([i % 5, j, checkStatEmpty(stats[i].multi_kills.triple) === 0 ? null : stats[i].multi_kills.triple])
|
||||
if (j === 2)
|
||||
arr.push([i % 5, j, checkStatEmpty(stats[i].multi_kills.quad) === 0 ? null : stats[i].multi_kills.quad])
|
||||
if (j === 3)
|
||||
arr.push([i % 5, j, checkStatEmpty(stats[i].multi_kills.pent) === 0 ? null : stats[i].multi_kills.pent])
|
||||
}
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
const getMax = (stats, team) => {
|
||||
let max = 0
|
||||
for (let i = (team - 1) * 5; i < team * 5; i++) {
|
||||
if (stats[i].multi_kills.duo > max)
|
||||
max = stats[i].multi_kills.duo
|
||||
if (stats[i].multi_kills.triple > max)
|
||||
max = stats[i].multi_kills.triple
|
||||
if (stats[i].multi_kills.quad > max)
|
||||
max = stats[i].multi_kills.quad
|
||||
if (stats[i].multi_kills.pent > max)
|
||||
max = stats[i].multi_kills.pent
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
const optionGen = (team) => {
|
||||
return {
|
||||
tooltip: {},
|
||||
grid: {
|
||||
height: '65%',
|
||||
top: '0%',
|
||||
bottom: '10%'
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: getPlayerArr(store.state.matchDetails.stats, team, true).reverse(),
|
||||
splitArea: {
|
||||
show: true
|
||||
},
|
||||
axisLabel: {
|
||||
fontSize: 14,
|
||||
color: 'white',
|
||||
rotate: 50
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: multiKills,
|
||||
splitArea: {
|
||||
show: true
|
||||
},
|
||||
axisLabel: {
|
||||
color: 'white'
|
||||
}
|
||||
},
|
||||
visualMap: {
|
||||
min: 0,
|
||||
max: getMax(store.state.matchDetails.stats, team),
|
||||
calculable: true,
|
||||
orient: 'horizontal',
|
||||
left: 'center',
|
||||
bottom: '5%',
|
||||
textStyle: {
|
||||
color: 'white'
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'heatmap',
|
||||
data: multiKillArr(store.state.matchDetails.stats, team),
|
||||
label: {
|
||||
fontSize: 14,
|
||||
show: true
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const disposeCharts = () => {
|
||||
if (myChart1 != null && myChart1 !== '' && myChart1 !== undefined) {
|
||||
myChart1.dispose()
|
||||
}
|
||||
if (myChart2 != null && myChart2 !== '' && myChart2 !== undefined) {
|
||||
myChart2.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
const buildCharts = () => {
|
||||
disposeCharts()
|
||||
|
||||
myChart1 = echarts.init(document.getElementById('multi-kills-chart-1'), {}, {
|
||||
width: width.value,
|
||||
height: height.value
|
||||
});
|
||||
myChart1.setOption(optionGen(1));
|
||||
|
||||
myChart2 = echarts.init(document.getElementById('multi-kills-chart-2'), {}, {
|
||||
width: width.value,
|
||||
height: height.value
|
||||
});
|
||||
myChart2.setOption(optionGen(2));
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (store.state.matchDetails.stats) {
|
||||
echarts.use([
|
||||
TooltipComponent,
|
||||
GridComponent,
|
||||
VisualMapComponent,
|
||||
HeatmapChart,
|
||||
CanvasRenderer
|
||||
]);
|
||||
|
||||
buildCharts()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disposeCharts()
|
||||
})
|
||||
|
||||
window.onresize = () => {
|
||||
if (window.innerWidth <= 500) {
|
||||
width.value = window.innerWidth - 20
|
||||
height.value = width.value
|
||||
|
||||
buildCharts()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.charts {
|
||||
display: flex;
|
||||
|
||||
#multi-kills-chart-1,
|
||||
#multi-kills-chart-2 {
|
||||
flex-basis: 50%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,373 +0,0 @@
|
||||
<template>
|
||||
<div class="side-info">
|
||||
|
||||
<div v-if="props.player_meta.most_mates" class="side-info-box most-played-with">
|
||||
<div class="heading">
|
||||
<h5>Most played with</h5>
|
||||
</div>
|
||||
<hr>
|
||||
<ul v-for="mate in props.player_meta.most_mates" :key="mate.player.steamid64" class="list-unstyled">
|
||||
<li @click="GoToPlayer(mate.player.vanity_url || mate.player.steamid64)">
|
||||
<span class="start">
|
||||
<img :class="mate.player.tracked ? 'tracked' : ''" :src="constructAvatarUrl(mate.player.avatar)"
|
||||
:title="mate.player.tracked ? 'Tracked' : ''" alt="Player avatar">
|
||||
<span class="text">{{ mate.player.name }}</span>
|
||||
</span>
|
||||
<span class="end">
|
||||
{{ mate.total }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-else-if="mostMatesLoading" class="side-info-box most-played-with">
|
||||
<div class="heading">
|
||||
<h5>Most played with</h5>
|
||||
</div>
|
||||
<hr>
|
||||
<ul class="list-unstyled placeholder-glow">
|
||||
<li class="placeholder col-11"></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="props.player_meta.best_mates" class="side-info-box best-mate">
|
||||
<div class="heading">
|
||||
<h5>Best Mate <span class="text-muted">(by winrate)</span></h5>
|
||||
</div>
|
||||
<hr>
|
||||
<ul v-for="mate in props.player_meta.best_mates" :key="mate.player.steamid64" class="list-unstyled">
|
||||
<li @click="GoToPlayer(mate.player.vanity_url || mate.player.steamid64)">
|
||||
<span class="start">
|
||||
<img :class="mate.player.tracked ? 'tracked' : ''" :src="constructAvatarUrl(mate.player.avatar)"
|
||||
:title="mate.player.tracked ? 'Tracked' : ''" alt="Player avatar">
|
||||
<span class="text">{{ mate.player.name }}</span>
|
||||
</span>
|
||||
<span class="end">
|
||||
{{ mate.win_rate ? (mate.win_rate * 100).toFixed(0) : 0 }} %
|
||||
<span v-if="mate.total" class="total text-muted">({{ mate.total }})</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-else-if="bestMatesLoading" class="side-info-box best-mate">
|
||||
<div class="heading">
|
||||
<h5>Best Mate <span class="text-muted">(by winrate)</span></h5>
|
||||
</div>
|
||||
<hr>
|
||||
<ul class="list-unstyled placeholder-glow">
|
||||
<li class="placeholder col-11"></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="props.player_meta.eq_map && props.player_meta.weapon_dmg" class="side-info-box preferred-weapons">
|
||||
<div class="heading">
|
||||
<h5>Weapons <span class="text-muted">(by dmg)</span></h5>
|
||||
</div>
|
||||
<hr>
|
||||
<ul v-for="(id, key) in data.best_weapons" :key="id[0]" class="list-unstyled">
|
||||
<li>
|
||||
<span class="start">
|
||||
<span class="text">{{ id[0] }}</span>
|
||||
</span>
|
||||
<span :title="id[0] + ' - ' + id[1] + ' dmg'" class="end">
|
||||
<span :class="'dmg-chart-' + key">
|
||||
{{ id[1] }}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
{{ setDmgGraphWidth() }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="weaponsLoading" class="side-info-box preferred-weapons">
|
||||
<div class="heading">
|
||||
<h5>Weapons <span class="text-muted">(by dmg)</span></h5>
|
||||
</div>
|
||||
<hr>
|
||||
<ul class="list-unstyled placeholder-glow">
|
||||
<li class="placeholder col-11"></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="props.player_meta.win_maps" class="side-info-box best-map">
|
||||
<div class="heading">
|
||||
<h5>Best Map <span class="text-muted">(by winrate)</span></h5>
|
||||
</div>
|
||||
<hr>
|
||||
<ul v-for="map in data.best_maps" :key="map[0]" class="list-unstyled">
|
||||
<li>
|
||||
<span class="start">
|
||||
<img :src="'/images/map_icons/map_icon_' + map[0] + '.svg'" alt="Player avatar">
|
||||
<span class="text">{{ FixMapName(map[0]) }}</span>
|
||||
</span>
|
||||
<span class="end">
|
||||
{{ (map[1] * 100).toFixed(0) }} %
|
||||
<span v-if="props.player_meta.total_maps[map[0]]"
|
||||
class="total text-muted">({{ props.player_meta.total_maps[map[0]] }})</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-else-if="mapsLoading" class="side-info-box best-map">
|
||||
<div class="heading">
|
||||
<h5>Best Map <span class="text-muted">(by winrate)</span></h5>
|
||||
</div>
|
||||
<hr>
|
||||
<ul class="list-unstyled placeholder-glow">
|
||||
<li class="placeholder col-11"></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import {constructAvatarUrl, FixMapName, GoToPlayer, sortObjectValue} from "@/utils";
|
||||
import {reactive, ref, watch} from "vue";
|
||||
|
||||
export default {
|
||||
name: "PlayerSideInfo",
|
||||
props: {
|
||||
player_meta: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const displayCounter = 3
|
||||
|
||||
const mostMatesLoading = ref(true)
|
||||
const bestMatesLoading = ref(true)
|
||||
const weaponsLoading = ref(true)
|
||||
const mapsLoading = ref(true)
|
||||
|
||||
const data = reactive({
|
||||
best_maps: [],
|
||||
best_weapons_tmp: [],
|
||||
best_weapons: []
|
||||
})
|
||||
|
||||
const mapWeaponDamage = () => {
|
||||
if (props.player_meta.eq_map && props.player_meta.weapon_dmg) {
|
||||
Object.keys(props.player_meta.eq_map).forEach((key) => {
|
||||
for (const id in props.player_meta.weapon_dmg) {
|
||||
Object.keys(props.player_meta.weapon_dmg[id]).forEach((k) => {
|
||||
if (k === 'eq') {
|
||||
if (props.player_meta.weapon_dmg[id][k] === key * 1) {
|
||||
data.best_weapons_tmp.push([props.player_meta.eq_map[key], props.player_meta.weapon_dmg[id]['dmg']])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
data.best_weapons_tmp.sort((a, b) => {
|
||||
return b[1] - a[1]
|
||||
})
|
||||
|
||||
data.best_weapons = data.best_weapons_tmp
|
||||
data.best_weapons_tmp = []
|
||||
}
|
||||
}
|
||||
|
||||
const setDmgGraphWidth = () => {
|
||||
setTimeout(() => {
|
||||
let weaponsContainer
|
||||
const dmg100 = ref(0)
|
||||
const dmg = ref(0)
|
||||
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
weaponsContainer = document.querySelector('.dmg-chart-' + i)
|
||||
if (weaponsContainer !== null) {
|
||||
if (i === 0) {
|
||||
dmg100.value = weaponsContainer.innerHTML * 1
|
||||
weaponsContainer.style.width = '100%'
|
||||
}
|
||||
|
||||
dmg.value = weaponsContainer.innerHTML * 1
|
||||
weaponsContainer.style.width = dmg.value * 100 / dmg100.value + '%'
|
||||
}
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
watch(() => props.player_meta, () => {
|
||||
mapWeaponDamage()
|
||||
|
||||
data.best_maps = sortObjectValue(props.player_meta.win_maps, 'desc')
|
||||
|
||||
if (data.best_maps.length > displayCounter)
|
||||
data.best_maps.splice(displayCounter, data.best_maps.length - displayCounter)
|
||||
|
||||
if (!props.player_meta.most_mates) {
|
||||
mostMatesLoading.value = false
|
||||
}
|
||||
if (!props.player_meta.best_mates) {
|
||||
bestMatesLoading.value = false
|
||||
}
|
||||
if (!props.player_meta.win_maps) {
|
||||
mapsLoading.value = false
|
||||
}
|
||||
if (!props.player_meta.eq_map || !props.player_meta.weapon_dmg) {
|
||||
weaponsLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
props,
|
||||
data,
|
||||
weaponsLoading,
|
||||
mapsLoading,
|
||||
mostMatesLoading,
|
||||
bestMatesLoading,
|
||||
setDmgGraphWidth,
|
||||
GoToPlayer,
|
||||
constructAvatarUrl,
|
||||
FixMapName
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.side-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin-top: 30px;
|
||||
|
||||
.placeholder {
|
||||
height: 25px;
|
||||
padding: 0 10px !important;
|
||||
margin: 14px auto !important;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.side-info-box {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: rgba(20, 20, 20, .8);
|
||||
border: 1px solid rgba(white, .3);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
ol, ul, dl {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.best-mate,
|
||||
.preferred-weapons,
|
||||
.most-played-with,
|
||||
.best-map {
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
height: 30px;
|
||||
|
||||
h5 {
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 0 0 5px 0;
|
||||
border-color: rgba(white, .3);
|
||||
}
|
||||
|
||||
ul li {
|
||||
line-height: 25px;
|
||||
font-size: .9rem;
|
||||
padding: 0 10px;
|
||||
margin: 10px 0;
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
|
||||
.start {
|
||||
width: 50%;
|
||||
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
.tracked {
|
||||
font-size: .8rem;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
border-radius: 50%;
|
||||
margin-right: 5px;
|
||||
margin-left: 5px;
|
||||
|
||||
&.tracked {
|
||||
border: 2px solid var(--bs-success);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.end {
|
||||
display: flex;
|
||||
width: 45%;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.best-map, .best-mate {
|
||||
ul li {
|
||||
.start {
|
||||
width: 75%;
|
||||
}
|
||||
|
||||
.end {
|
||||
.total {
|
||||
padding-left: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.preferred-weapons,
|
||||
.best-map {
|
||||
ul li {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.preferred-weapons {
|
||||
.end {
|
||||
position: relative;
|
||||
|
||||
@for $i from 0 through 3 {
|
||||
.dmg-chart-#{$i} {
|
||||
position: absolute;
|
||||
background: rgba(150, 50, 50, 1);
|
||||
border-radius: 15px;
|
||||
color: transparent;
|
||||
user-select: none;
|
||||
cursor: help;
|
||||
|
||||
&:hover {
|
||||
background: rgba(220, 50, 50, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,311 +0,0 @@
|
||||
<template>
|
||||
<div class="scoreboard">
|
||||
<table>
|
||||
<caption>
|
||||
<div v-if="store.state.matchDetails.max_rounds === 16" id="short-match">
|
||||
<div class="team-1">
|
||||
<div class="score-text">
|
||||
<span v-if="store.state.matchDetails.score[0] < 10"
|
||||
:style="store.state.matchDetails.score[0] < 10 ? 'margin-left: -10px;' : ''"
|
||||
class="hidden">0</span><span
|
||||
:class="store.state.matchDetails.score[0] === 9 ? 'text-success' : store.state.matchDetails.score[0] === 8 ? 'text-warning' : 'text-danger'">{{
|
||||
store.state.matchDetails.score[0]
|
||||
}}</span>
|
||||
</div>
|
||||
<img alt="CT logo" src="/images/icons/ct_logo.svg">
|
||||
<img alt="T logo" src="/images/icons/t_logo.svg">
|
||||
</div>
|
||||
<div class="team-2">
|
||||
<div class="score-text">
|
||||
<span v-if="store.state.matchDetails.score[1] < 10"
|
||||
:style="store.state.matchDetails.score[1] < 10 ? 'margin-left: -10px;' : ''"
|
||||
class="hidden">0</span><span
|
||||
:class="store.state.matchDetails.score[1] === 9 ? 'text-success' : store.state.matchDetails.score[1] === 8 ? 'text-warning' : 'text-danger'">{{
|
||||
store.state.matchDetails.score[1]
|
||||
}}</span>
|
||||
</div>
|
||||
<img alt="T logo" src="/images/icons/t_logo.svg">
|
||||
<img alt="CT logo" src="/images/icons/ct_logo.svg">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="store.state.matchDetails.max_rounds === 30 || !store.state.matchDetails.max_rounds" id="long-match">
|
||||
<div class="team-1">
|
||||
<div class="score-text">
|
||||
<span v-if="store.state.matchDetails.score[0] < 10"
|
||||
:style="store.state.matchDetails.score[0] < 10 ? 'margin-left: -10px;' : ''"
|
||||
class="hidden">0</span><span
|
||||
:class="store.state.matchDetails.match_result === 1 ? 'text-success' : store.state.matchDetails.match_result === 0 ? 'text-warning' : 'text-danger'">{{
|
||||
store.state.matchDetails.score[0]
|
||||
}}</span>
|
||||
</div>
|
||||
<img alt="CT logo" src="/images/icons/ct_logo.svg">
|
||||
<img alt="T logo" src="/images/icons/t_logo.svg">
|
||||
</div>
|
||||
<div class="team-2">
|
||||
<div class="score-text">
|
||||
<span v-if="store.state.matchDetails.score[1] < 10"
|
||||
:style="store.state.matchDetails.score[1] < 10 ? 'margin-left: -10px;' : ''"
|
||||
class="hidden">0</span><span
|
||||
:class="store.state.matchDetails.match_result === 2 ? 'text-success' : store.state.matchDetails.match_result === 0 ? 'text-warning' : 'text-danger'">{{
|
||||
store.state.matchDetails.score[1]
|
||||
}}</span>
|
||||
</div>
|
||||
<img alt="T logo" src="/images/icons/t_logo.svg">
|
||||
<img alt="CT logo" src="/images/icons/ct_logo.svg">
|
||||
</div>
|
||||
</div>
|
||||
</caption>
|
||||
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="player__vac"></th>
|
||||
<th class="player__avatar"></th>
|
||||
<th class="player__name"></th>
|
||||
<th class="player__rank"></th>
|
||||
<th class="player__kills">K</th>
|
||||
<th class="player__assist">A</th>
|
||||
<th class="player__deaths">D</th>
|
||||
<th class="player__diff helptext" title="Kill death difference">+/-</th>
|
||||
<th class="player__kd">K/D</th>
|
||||
<th v-if="store.state.matchDetails.parsed" class="player__adr helptext" title="Average damage per round">
|
||||
ADR
|
||||
</th>
|
||||
<th class="player__hs helptext" title="Percentage of kills with a headshot">HS%</th>
|
||||
<th class="player__rating helptext" title="Estimated HLTV Rating 1.0">Rating</th>
|
||||
<th class="player__mvp helptext" title="Most valuable player">MVP</th>
|
||||
<th class="player__score">Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr v-for="player in teamStats(1)"
|
||||
:key="player.player.steamid64"
|
||||
class="team-1">
|
||||
<ScoreTeamPlayer :assists="player.assists"
|
||||
:avatar="player.player.avatar"
|
||||
:color="player.color"
|
||||
:deaths="player.deaths"
|
||||
:dmg="player.dmg?.enemy"
|
||||
:game_ban="player.player.game_ban"
|
||||
:game_ban_date="player.player.game_ban_date"
|
||||
:hs="player.headshot"
|
||||
:kdiff="player.kills - player.deaths"
|
||||
:kills="player.kills"
|
||||
:mk_duo="player.multi_kills?.duo"
|
||||
:mk_pent="player.multi_kills?.pent"
|
||||
:mk_quad="player.multi_kills?.quad"
|
||||
:mk_triple="player.multi_kills?.triple"
|
||||
:mvp="player.mvp"
|
||||
:name="player.player.name"
|
||||
:parsed="store.state.matchDetails.parsed"
|
||||
:player_score="player.score"
|
||||
:rank_new="player.rank?.new"
|
||||
:rank_old="player.rank?.old"
|
||||
:rounds_played="store.state.matchDetails.score.reduce((a, b) => a + b)"
|
||||
:steamid64="player.player.steamid64"
|
||||
:tracked="player.player.tracked"
|
||||
:vac="player.player.vac"
|
||||
:vac_date="player.player.vac_date"
|
||||
/>
|
||||
</tr>
|
||||
|
||||
<tr class="hr_outer">
|
||||
<td colspan="14"></td>
|
||||
</tr>
|
||||
<tr class="hr">
|
||||
<td colspan="14"></td>
|
||||
</tr>
|
||||
<tr class="hr_outer">
|
||||
<td colspan="14"></td>
|
||||
</tr>
|
||||
|
||||
<tr v-for="player in teamStats(2)"
|
||||
:key="player.player.steamid64"
|
||||
class="team-2">
|
||||
<ScoreTeamPlayer :assists="player.assists"
|
||||
:avatar="player.player.avatar"
|
||||
:color="player.color"
|
||||
:deaths="player.deaths"
|
||||
:dmg="player.dmg?.enemy"
|
||||
:game_ban="player.player.game_ban"
|
||||
:game_ban_date="player.player.game_ban_date"
|
||||
:hs="player.headshot"
|
||||
:kdiff="player.kills - player.deaths"
|
||||
:kills="player.kills"
|
||||
:mk_duo="player.multi_kills?.duo"
|
||||
:mk_pent="player.multi_kills?.pent"
|
||||
:mk_quad="player.multi_kills?.quad"
|
||||
:mk_triple="player.multi_kills?.triple"
|
||||
:mvp="player.mvp"
|
||||
:name="player.player.name"
|
||||
:parsed="store.state.matchDetails.parsed"
|
||||
:player_score="player.score"
|
||||
:rank_new="player.rank?.new"
|
||||
:rank_old="player.rank?.old"
|
||||
:rounds_played="store.state.matchDetails.score.reduce((a, b) => a + b)"
|
||||
:steamid64="player.player.steamid64"
|
||||
:tracked="player.player.tracked"
|
||||
:vac="player.player.vac"
|
||||
:vac_date="player.player.vac_date"
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ScoreTeamPlayer from '@/components/ScoreTeamPlayer.vue'
|
||||
import {useStore} from "vuex";
|
||||
|
||||
export default {
|
||||
name: 'ScoreTeam',
|
||||
components: {ScoreTeamPlayer},
|
||||
setup() {
|
||||
const store = useStore()
|
||||
|
||||
const teamStats = (team) => {
|
||||
let arr = []
|
||||
|
||||
if (team === 1) {
|
||||
arr = []
|
||||
for (let i = 0; i < 5; i++) {
|
||||
arr.push(store.state.matchDetails.stats[i])
|
||||
}
|
||||
} else if (team === 2) {
|
||||
arr = []
|
||||
for (let i = 5; i < store.state.matchDetails.stats.length; i++) {
|
||||
arr.push(store.state.matchDetails.stats[i])
|
||||
}
|
||||
}
|
||||
|
||||
return arr
|
||||
}
|
||||
|
||||
return {store, teamStats}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.scoreboard {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
hr {
|
||||
width: 900px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 900px;
|
||||
text-align: center;
|
||||
|
||||
caption {
|
||||
position: relative;
|
||||
color: white;
|
||||
caption-side: top;
|
||||
padding: 0;
|
||||
|
||||
z-index: 0;
|
||||
|
||||
.hidden {
|
||||
color: transparent;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.score-text {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.team-1,
|
||||
.team-2 {
|
||||
position: absolute;
|
||||
font-size: 3rem;
|
||||
opacity: .8;
|
||||
|
||||
margin-left: -100px;
|
||||
|
||||
img {
|
||||
position: absolute;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin-top: 22px;
|
||||
margin-left: 10px;
|
||||
|
||||
&:first-child {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-left: 30px;
|
||||
z-index: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.team-1 {
|
||||
top: 85px;
|
||||
|
||||
.score-text {
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.team-2 {
|
||||
top: 180px;
|
||||
|
||||
.score-text {
|
||||
top: 150px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tbody {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
tr.team-1, tr.team-2 {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.hr {
|
||||
td {
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
background: white;
|
||||
}
|
||||
}
|
||||
|
||||
.hr_outer {
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.player__vac {
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.scoreboard {
|
||||
margin-left: 65px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.scoreboard {
|
||||
margin-left: 2px;
|
||||
|
||||
caption {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,268 +0,0 @@
|
||||
<template>
|
||||
<td class="player__vac">
|
||||
<div v-if="!props.vac && !props.game_ban" class="vac-placeholder"></div>
|
||||
<img v-if="props.vac && FormatVacDate(props.vac_date, store.state.matchDetails.date) !== ''"
|
||||
:title="'Vac-banned: ' + FormatVacDate(props.vac_date, store.state.matchDetails.date)"
|
||||
alt="VAC-Ban"
|
||||
src="/images/icons/vac_banned.svg">
|
||||
<img v-if="!props.vac && props.game_ban && FormatVacDate(props.game_ban_date, store.state.matchDetails.date) !== ''"
|
||||
:title="'Game-banned: ' + FormatVacDate(props.game_ban_date, store.state.matchDetails.date)"
|
||||
alt="Game-Ban"
|
||||
src="/images/icons/game_banned.svg">
|
||||
</td>
|
||||
<td>
|
||||
<img :class="'team-color-' + props.color" :src="constructAvatarUrl(props.avatar)" alt="Player avatar"
|
||||
class="player__avatar">
|
||||
</td>
|
||||
<td class="player__name" @click="GoToPlayer(props.steamid64)">
|
||||
<i v-if="props.tracked" class="fa fa-dot-circle-o text-success tracked" title="Tracked user"></i>
|
||||
{{ props.name }}
|
||||
<i class="fa fa-external-link"></i>
|
||||
</td>
|
||||
<td v-if="props.parsed" class="player__rank">
|
||||
<img :alt="DisplayRank(props.rank_old)[1]"
|
||||
:class="props.rank_new > props.rank_old ? 'uprank' : props.rank_new < props.rank_old ? 'downrank' : ''"
|
||||
:src="DisplayRank(props.rank_old)[0]"
|
||||
:title="props.rank_new > props.rank_old ? 'Uprank to ' + DisplayRank(props.rank_new)[1] : props.rank_new < props.rank_old ? 'Downrank to ' + DisplayRank(props.rank_new)[1] : DisplayRank(props.rank_old)[1]">
|
||||
</td>
|
||||
<td v-if="!props.parsed" class="rank-placeholder"></td>
|
||||
<td class="player__kills">
|
||||
{{ props.kills }}
|
||||
</td>
|
||||
<td class="player__assist">
|
||||
{{ props.assists }}
|
||||
</td>
|
||||
<td class="player__deaths">
|
||||
{{ props.deaths }}
|
||||
</td>
|
||||
<td :class="props.kdiff >= 0 ? 'text-success' : 'text-danger'" class="player__kdiff">
|
||||
{{ props.kdiff }}
|
||||
</td>
|
||||
<td class="player__kd">
|
||||
{{
|
||||
(props.kills > 0 && props.deaths > 0) ? (props.kills / props.deaths).toFixed(2) : (props.kills > 0 && props.deaths === 0) ? props.kills : 0.00
|
||||
}}
|
||||
</td>
|
||||
<td v-if="props.parsed" class="player__adr">
|
||||
{{ (props.dmg / props.rounds_played).toFixed(2) }}
|
||||
</td>
|
||||
<td class="player__hs">
|
||||
{{ (props.hs > 0 && props.kills > 0) ? (props.hs * 100 / props.kills).toFixed(0) + "%" : "0%" }}
|
||||
</td>
|
||||
<td class="player__rating">
|
||||
{{
|
||||
GetHLTV_1(props.kills, props.rounds_played, props.deaths, props.mk_duo, props.mk_triple, props.mk_quad, props.mk_pent)
|
||||
}}
|
||||
</td>
|
||||
<td class="player__mvp">
|
||||
{{ props.mvp }}
|
||||
</td>
|
||||
<td class="player__score">
|
||||
{{ props.player_score }}
|
||||
</td>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {constructAvatarUrl, DisplayRank, FormatVacDate, GetHLTV_1, GoToPlayer} from "@/utils";
|
||||
import {useStore} from "vuex";
|
||||
|
||||
export default {
|
||||
name: 'ScoreTeamPlayer',
|
||||
props: {
|
||||
steamid64: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: ''
|
||||
},
|
||||
avatar: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: 'Avatar'
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: 'Name'
|
||||
},
|
||||
rank_old: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0
|
||||
},
|
||||
rank_new: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0
|
||||
},
|
||||
kills: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0
|
||||
},
|
||||
assists: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0
|
||||
},
|
||||
deaths: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0
|
||||
},
|
||||
kdiff: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0
|
||||
},
|
||||
hs: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0
|
||||
},
|
||||
rounds_played: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0
|
||||
},
|
||||
mk_duo: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0
|
||||
},
|
||||
mk_triple: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0
|
||||
},
|
||||
mk_quad: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0
|
||||
},
|
||||
mk_pent: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0
|
||||
},
|
||||
dmg: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0
|
||||
},
|
||||
mvp: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0
|
||||
},
|
||||
player_score: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: ''
|
||||
},
|
||||
tracked: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false
|
||||
},
|
||||
parsed: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false
|
||||
},
|
||||
vac: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false
|
||||
},
|
||||
vac_date: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 0
|
||||
},
|
||||
game_ban: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false
|
||||
},
|
||||
game_ban_date: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const store = useStore()
|
||||
return {props, GetHLTV_1, GoToPlayer, DisplayRank, constructAvatarUrl, FormatVacDate, store}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.player__vac,
|
||||
.vac-placeholder {
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.player__vac {
|
||||
img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.player__avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.player__name {
|
||||
text-align: left;
|
||||
width: 150px;
|
||||
max-width: 150px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
|
||||
.tracked {
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
.fa-external-link {
|
||||
font-size: .8rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
.player__rank,
|
||||
.rank-placeholder {
|
||||
width: 100px;
|
||||
|
||||
img {
|
||||
width: 60px;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.player__kills, .player__assist, .player__deaths, .player__kdiff, .player__mvp {
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.player__kd, .player__hs, .player__rating, .player__score {
|
||||
width: 75px;
|
||||
}
|
||||
|
||||
.player__adr {
|
||||
width: 85px;
|
||||
}
|
||||
|
||||
.player__rating {
|
||||
border-radius: 25% 25%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,26 +0,0 @@
|
||||
<template>
|
||||
<h3>This Graph will be available soon</h3>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {watch} from "vue";
|
||||
|
||||
export default {
|
||||
name: "SprayGraph",
|
||||
props: {
|
||||
spray: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
watch(() => props.spray, () => {
|
||||
// console.log(props.spray)
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
@@ -1,191 +0,0 @@
|
||||
<template>
|
||||
<div class="player-dmg">
|
||||
<div id="dmg-chart-1"></div>
|
||||
<div id="dmg-chart-2"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as echarts from 'echarts/core';
|
||||
import {GridComponent, LegendComponent, TooltipComponent} from 'echarts/components';
|
||||
import {BarChart} from 'echarts/charts';
|
||||
import {CanvasRenderer} from 'echarts/renderers';
|
||||
import {onMounted, onUnmounted, ref} from "vue";
|
||||
import {checkStatEmpty, getPlayerArr} from "../utils";
|
||||
import {useStore} from "vuex";
|
||||
|
||||
export default {
|
||||
name: "FlashChart",
|
||||
setup() {
|
||||
const store = useStore()
|
||||
|
||||
let myChart1, myChart2
|
||||
|
||||
const getWindowWidth = () => {
|
||||
const windowWidth = window.innerWidth
|
||||
if (windowWidth <= 750)
|
||||
return windowWidth
|
||||
else
|
||||
return 650
|
||||
}
|
||||
|
||||
const setHeight = () => {
|
||||
const windowWidth = getWindowWidth()
|
||||
if (windowWidth >= 751)
|
||||
return windowWidth * 3 / 7.5
|
||||
else if (windowWidth >= 501 && windowWidth <= 750)
|
||||
return windowWidth * 3 / 6.5
|
||||
else
|
||||
return windowWidth * 3 / 5.5
|
||||
}
|
||||
|
||||
const width = ref(getWindowWidth())
|
||||
const height = ref(setHeight())
|
||||
|
||||
const dataArr = (stats, team, prop) => {
|
||||
if (['team', 'enemy', 'self'].indexOf(prop) > -1) {
|
||||
let arr = []
|
||||
for (let i = (team - 1) * 5; i < team * 5; i++) {
|
||||
arr.push({
|
||||
value: checkStatEmpty(Function('return(function(stats, i){ return stats[i].dmg.' + prop + '})')()(stats, i)) * (prop === 'enemy' ? 1 : -1),
|
||||
itemStyle: {
|
||||
color: prop === 'enemy' ? getComputedStyle(document.documentElement).getPropertyValue(`--csgo-${stats[i].color}`) : 'firebrick'
|
||||
}
|
||||
})
|
||||
}
|
||||
arr.reverse()
|
||||
return arr
|
||||
}
|
||||
}
|
||||
|
||||
const optionGen = (team) => {
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow'
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
show: false
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
min: -300
|
||||
}
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
type: 'category',
|
||||
axisTick: {
|
||||
show: false
|
||||
},
|
||||
data: getPlayerArr(store.state.matchDetails.stats, team)
|
||||
}
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: 'Team',
|
||||
type: 'bar',
|
||||
stack: 'Total',
|
||||
label: {
|
||||
show: true,
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series'
|
||||
},
|
||||
data: dataArr(store.state.matchDetails.stats, team, 'team')
|
||||
},
|
||||
{
|
||||
name: 'Enemy',
|
||||
type: 'bar',
|
||||
stack: 'Total',
|
||||
label: {
|
||||
show: true,
|
||||
position: 'inside'
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series'
|
||||
},
|
||||
data: dataArr(store.state.matchDetails.stats, team, 'enemy')
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const disposeCharts = () => {
|
||||
if (myChart1 != null && myChart1 !== '' && myChart1 !== undefined) {
|
||||
myChart1.dispose()
|
||||
}
|
||||
if (myChart2 != null && myChart2 !== '' && myChart2 !== undefined) {
|
||||
myChart2.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
const buildCharts = () => {
|
||||
disposeCharts()
|
||||
|
||||
myChart1 = echarts.init(document.getElementById('dmg-chart-1'), {}, {width: width.value, height: height.value});
|
||||
myChart1.setOption(optionGen(1));
|
||||
|
||||
myChart2 = echarts.init(document.getElementById('dmg-chart-2'), {}, {width: width.value, height: height.value});
|
||||
myChart2.setOption(optionGen(2));
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (store.state.matchDetails.stats) {
|
||||
echarts.use([
|
||||
TooltipComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
BarChart,
|
||||
CanvasRenderer
|
||||
]);
|
||||
|
||||
buildCharts()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disposeCharts()
|
||||
})
|
||||
|
||||
window.onresize = () => {
|
||||
if (window.innerWidth <= 750) {
|
||||
width.value = getWindowWidth() - 20
|
||||
height.value = setHeight()
|
||||
}
|
||||
|
||||
buildCharts()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.player-dmg {
|
||||
display: flex;
|
||||
margin-bottom: 4rem;
|
||||
|
||||
#dmg-chart-1,
|
||||
#dmg-chart-2 {
|
||||
flex-basis: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.player-dmg {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
@@ -1,117 +0,0 @@
|
||||
<template>
|
||||
<div class="toggle-btn text-muted">
|
||||
<div @click.prevent="$emit('translated', handleBtnClick())"
|
||||
class="d-flex">
|
||||
<span class="text-center mx-2">
|
||||
<i id="toggle-off" class="fa fa-toggle-off show"/>
|
||||
<i id="toggle-on" class="fa fa-toggle-on"/>
|
||||
</span>
|
||||
<div>
|
||||
<span :class="toggle === 'translated' ? 'text-warning' : ''"
|
||||
class="float-start">
|
||||
<span class="text-uppercase">Translate to {{data.browserLang}}</span>
|
||||
<span class="loading-icon ms-2" title="Translating..">
|
||||
<i class="fa fa-spinner fa-pulse fa-fw"/>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {onMounted, reactive, ref} from "vue";
|
||||
import ISO6391 from 'iso-639-1'
|
||||
import {GetChatHistoryTranslated} from "@/utils";
|
||||
import {useStore} from "vuex";
|
||||
|
||||
export default {
|
||||
name: 'TranslateChatButton',
|
||||
props: {
|
||||
translated: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
setup() {
|
||||
const store = useStore()
|
||||
|
||||
const data = reactive({
|
||||
browserIsoCode: '',
|
||||
browserLangCode: '',
|
||||
browserLang: '',
|
||||
})
|
||||
|
||||
const toggle = ref('original')
|
||||
|
||||
const setLanguageVariables = () => {
|
||||
const navLangs = navigator.languages
|
||||
|
||||
data.browserIsoCode = navLangs.find((l) => l.length === 5)
|
||||
data.browserLangCode = navLangs[0]
|
||||
|
||||
if (ISO6391.validate(data.browserLangCode)) {
|
||||
data.browserLang = ISO6391.getNativeName(data.browserLangCode)
|
||||
} else {
|
||||
data.browserIsoCode = 'en-US'
|
||||
data.browserLangCode = 'en'
|
||||
data.browserLang = 'English'
|
||||
}
|
||||
}
|
||||
|
||||
const handleBtnClick = async () => {
|
||||
let response
|
||||
|
||||
const refreshButton = document.querySelector('.loading-icon .fa-spinner')
|
||||
refreshButton.classList.add('show')
|
||||
|
||||
toggleShow()
|
||||
|
||||
response = await GetChatHistoryTranslated(store, store.state.matchDetails.match_id)
|
||||
|
||||
if (refreshButton.classList.contains('show'))
|
||||
refreshButton.classList.remove('show')
|
||||
|
||||
return [response, toggle.value]
|
||||
}
|
||||
|
||||
const toggleShow = () => {
|
||||
const offBtn = document.getElementById('toggle-off')
|
||||
const onBtn = document.getElementById('toggle-on')
|
||||
|
||||
if (offBtn.classList.contains('show')) {
|
||||
offBtn.classList.remove('show')
|
||||
onBtn.classList.add('show')
|
||||
toggle.value = 'translated'
|
||||
} else if (onBtn.classList.contains('show')) {
|
||||
onBtn.classList.remove('show')
|
||||
offBtn.classList.add('show')
|
||||
toggle.value = 'original'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setLanguageVariables()
|
||||
})
|
||||
return {data, toggle, handleBtnClick}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.toggle-btn {
|
||||
margin: 0 auto;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
|
||||
.fa {
|
||||
display: none;
|
||||
font-size: 1.2rem;
|
||||
vertical-align: middle;
|
||||
|
||||
&.show {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,169 +0,0 @@
|
||||
<template>
|
||||
<div :style="props.ud.flames || props.ud.flash || props.ud.he ? 'display: flex' : 'display: none'"
|
||||
class="player-utility">
|
||||
<div class="heading">
|
||||
<img :src="props.avatar" alt="Player avatar" class="avatar">
|
||||
<h4>{{ props.name }}</h4>
|
||||
</div>
|
||||
<div :id="'utility-chart-' + props.id"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as echarts from 'echarts/core';
|
||||
import {LegendComponent, TooltipComponent} from 'echarts/components';
|
||||
import {PieChart} from 'echarts/charts';
|
||||
import {LabelLayout} from 'echarts/features';
|
||||
import {CanvasRenderer} from 'echarts/renderers';
|
||||
import { TitleComponent } from 'echarts/components';
|
||||
import {onMounted} from "vue";
|
||||
|
||||
export default {
|
||||
name: "FlashChart",
|
||||
props: {
|
||||
id: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
required: true
|
||||
},
|
||||
avatar: {
|
||||
type: String,
|
||||
default: '',
|
||||
required: true
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
required: true
|
||||
},
|
||||
ud: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
onMounted(() => {
|
||||
echarts.use([
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
PieChart,
|
||||
CanvasRenderer,
|
||||
TitleComponent,
|
||||
LabelLayout
|
||||
]);
|
||||
|
||||
let myChart = echarts.init(document.getElementById(`utility-chart-${props.id}`), {}, {width: 500, height: 300});
|
||||
let option
|
||||
|
||||
option = {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
show: false
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'Utility Damage',
|
||||
type: 'pie',
|
||||
radius: [0, '65%'],
|
||||
avoidLabelOverlap: true,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#000',
|
||||
borderWidth: 3
|
||||
},
|
||||
label: {
|
||||
position: 'inside',
|
||||
fontsize: 36,
|
||||
fontWeight: 'bold'
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: [
|
||||
(props.ud.flames ? {
|
||||
value: props.ud.flames ? props.ud.flames : null,
|
||||
name: 'Flames',
|
||||
itemStyle: {
|
||||
color: '#FF4343FF'
|
||||
}
|
||||
} : {}),
|
||||
(props.ud.he ? {
|
||||
value: props.ud.he ? props.ud.he : null,
|
||||
name: 'HE',
|
||||
itemStyle: {
|
||||
color: '#62c265'
|
||||
}
|
||||
} : {})
|
||||
,
|
||||
(props.ud.flash ? {
|
||||
value: props.ud.flash ? props.ud.flash : null,
|
||||
name: 'Flash',
|
||||
itemStyle: {
|
||||
color: '#18cff3'
|
||||
}
|
||||
} : {}),
|
||||
(props.ud.smoke ? {
|
||||
value: props.ud.smoke ? props.ud.smoke : null,
|
||||
name: 'Smoke',
|
||||
itemStyle: {
|
||||
color: '#6e6b78'
|
||||
}
|
||||
} : {}),
|
||||
(props.ud.decoy ? {
|
||||
value: props.ud.decoy ? props.ud.decoy : null,
|
||||
name: 'Decoy',
|
||||
itemStyle: {
|
||||
color: '#e28428'
|
||||
}
|
||||
} : {})
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
myChart.setOption(option);
|
||||
})
|
||||
|
||||
return {props}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.player-utility {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
margin-top: 10px;
|
||||
margin-bottom: -30px;
|
||||
|
||||
.avatar {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
margin-right: 20px;
|
||||
color: #ff4343;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin-top: 7px;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
padding-top: 40px;
|
||||
margin-bottom: -20px;
|
||||
}
|
||||
}
|
||||
|
||||
@for $i from 0 through 9 {
|
||||
#utility-chart-#{$i} {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,141 +0,0 @@
|
||||
<template>
|
||||
<div class="utility-chart-total" v-if="props.stats">
|
||||
<div class="heading">
|
||||
<h4>Total Utility Damage</h4>
|
||||
</div>
|
||||
<div id="utility-chart-total"></div>
|
||||
<hr>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as echarts from 'echarts/core';
|
||||
import {GridComponent, LegendComponent, TooltipComponent} from 'echarts/components';
|
||||
import {BarChart} from 'echarts/charts';
|
||||
import {CanvasRenderer} from 'echarts/renderers';
|
||||
import {onMounted} from "vue";
|
||||
|
||||
export default {
|
||||
name: "FlashChart",
|
||||
props: {
|
||||
stats: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const checkStatEmpty = (stat) => {
|
||||
if (stat)
|
||||
return stat
|
||||
else
|
||||
return 0
|
||||
}
|
||||
|
||||
const seriesArr = (stats) => {
|
||||
let arr = []
|
||||
for (let i = 0; i < stats.length; i++) {
|
||||
const sum = checkStatEmpty(stats[i].dmg.ud.flames) + checkStatEmpty(stats[i].dmg.ud.flash) + checkStatEmpty(stats[i].dmg.ud.he) + checkStatEmpty(stats[i].dmg.ud.smoke)
|
||||
|
||||
if (sum !== 0) {
|
||||
arr.push({
|
||||
name: stats[i].player.name,
|
||||
type: 'bar',
|
||||
stack: 'total',
|
||||
label: {
|
||||
show: true
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series'
|
||||
},
|
||||
data: [sum]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
arr.sort((a, b) => parseFloat(b.data[0]) - parseFloat(a.data[0]))
|
||||
|
||||
return arr
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
echarts.use([
|
||||
TooltipComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
BarChart,
|
||||
CanvasRenderer
|
||||
]);
|
||||
|
||||
let myChart = echarts.init(document.getElementById('utility-chart-total'), {}, {width: 800, height: 200});
|
||||
let option
|
||||
|
||||
option = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
// Use axis to trigger tooltip
|
||||
type: 'shadow' // 'shadow' as default; can also be 'line'
|
||||
}
|
||||
},
|
||||
// color: ['#143147', '#39546c', '#617a94', '#89a2bd', '#b3cce8', '#eac65c', '#bd9d2c', '#917501', '#685000', '#412c00'],
|
||||
// color: ['#003470', '#005a9b', '#0982c7', '#4bace5', '#90d3fe', '#febf4a', '#d7931c', '#ac6a01', '#804400', '#572000'],
|
||||
// color: ['#888F98', '#10121A', '#1B2732', '#5F7892', '#C3A235'],
|
||||
legend: {
|
||||
textStyle: {
|
||||
color: 'white'
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'value'
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: ['Total']
|
||||
},
|
||||
aria: {
|
||||
enabled: true,
|
||||
show: true,
|
||||
decal: {
|
||||
show: true
|
||||
}
|
||||
},
|
||||
|
||||
series: seriesArr(props.stats)
|
||||
};
|
||||
|
||||
myChart.setOption(option);
|
||||
})
|
||||
|
||||
return {props}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.utility-chart-total {
|
||||
.heading {
|
||||
display: flex;
|
||||
margin-top: 10px;
|
||||
margin-bottom: -30px;
|
||||
|
||||
h4 {
|
||||
margin: 7px auto 0;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
padding-top: 40px;
|
||||
margin-bottom: -20px;
|
||||
}
|
||||
}
|
||||
|
||||
#utility-chart-total {
|
||||
margin: 40px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +0,0 @@
|
||||
export const SHARECODE_REGEX = /^CSGO(?:-?[ABCDEFGHJKLMNOPQRSTUVWXYZabcdefhijkmnopqrstuvwxyz23456789]{5}){5}$/
|
||||
export const AUTHCODE_REGEX = /^[ABCDEFGHJKLMNOPQRSTUVWXYZ23456789]{4}-[ABCDEFGHJKLMNOPQRSTUVWXYZ23456789]{5}-[ABCDEFGHJKLMNOPQRSTUVWXYZ23456789]{4}$/
|
||||
|
||||
export const NAV_HEIGHT = 70
|
||||
export const FOOTER_HEIGHT = 200
|
||||
187
src/lib/api/client.ts
Normal file
187
src/lib/api/client.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import axios from 'axios';
|
||||
import type { AxiosInstance, AxiosRequestConfig, AxiosError } from 'axios';
|
||||
import { APIException } from '$lib/types';
|
||||
|
||||
/**
|
||||
* API Client Configuration
|
||||
*
|
||||
* Uses SvelteKit server routes (/api/[...path]/+server.ts) to proxy requests to the backend.
|
||||
* This approach:
|
||||
* - Works in all environments (dev, preview, production)
|
||||
* - No CORS issues
|
||||
* - Single code path for consistency
|
||||
* - Can add caching, rate limiting, auth in the future
|
||||
*
|
||||
* Backend selection is controlled by VITE_API_BASE_URL environment variable:
|
||||
* - Local development: VITE_API_BASE_URL=http://localhost:8000
|
||||
* - Production: VITE_API_BASE_URL=https://api.csgow.tf
|
||||
*
|
||||
* Note: During SSR, we call the backend directly since relative URLs don't work server-side.
|
||||
*/
|
||||
function getAPIBaseURL(): string {
|
||||
// During SSR, call backend API directly (relative URLs don't work server-side)
|
||||
if (import.meta.env.SSR) {
|
||||
return import.meta.env.VITE_API_BASE_URL || 'https://api.csgow.tf';
|
||||
}
|
||||
// In browser, use SvelteKit route
|
||||
return '/api';
|
||||
}
|
||||
|
||||
const API_BASE_URL = getAPIBaseURL();
|
||||
const API_TIMEOUT = Number(import.meta.env?.VITE_API_TIMEOUT) || 10000;
|
||||
|
||||
/**
|
||||
* Base API Client
|
||||
* Provides centralized HTTP communication with error handling
|
||||
*/
|
||||
class APIClient {
|
||||
private client: AxiosInstance;
|
||||
private abortControllers: Map<string, AbortController>;
|
||||
|
||||
constructor() {
|
||||
this.client = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: API_TIMEOUT,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
this.abortControllers = new Map();
|
||||
|
||||
// Request interceptor
|
||||
this.client.interceptors.request.use(
|
||||
(config) => {
|
||||
// Add request ID for tracking
|
||||
const requestId = `${config.method}_${config.url}_${Date.now()}`;
|
||||
config.headers['X-Request-ID'] = requestId;
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// Response interceptor for error handling
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError) => {
|
||||
const apiError = this.handleError(error);
|
||||
return Promise.reject(apiError);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle API errors and convert to APIException
|
||||
*/
|
||||
private handleError(error: AxiosError): APIException {
|
||||
// Network error (no response from server)
|
||||
if (!error.response) {
|
||||
if (error.code === 'ECONNABORTED' || error.message.includes('timeout')) {
|
||||
return APIException.timeout('Request timed out. Please try again.');
|
||||
}
|
||||
return APIException.networkError(
|
||||
'Unable to connect to the server. Please check your internet connection.'
|
||||
);
|
||||
}
|
||||
|
||||
// Server responded with error status
|
||||
const { status, data } = error.response;
|
||||
return APIException.fromResponse(status, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET request
|
||||
*/
|
||||
async get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await this.client.get<T>(url, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST request
|
||||
*/
|
||||
async post<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await this.client.post<T>(url, data, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT request
|
||||
*/
|
||||
async put<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await this.client.put<T>(url, data, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE request
|
||||
*/
|
||||
async delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await this.client.delete<T>(url, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancelable GET request
|
||||
* Automatically cancels previous request with same key
|
||||
*/
|
||||
async getCancelable<T>(url: string, key: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
// Cancel previous request with same key
|
||||
if (this.abortControllers.has(key)) {
|
||||
this.abortControllers.get(key)?.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
const controller = new AbortController();
|
||||
this.abortControllers.set(key, controller);
|
||||
|
||||
try {
|
||||
const response = await this.client.get<T>(url, {
|
||||
...config,
|
||||
signal: controller.signal
|
||||
});
|
||||
this.abortControllers.delete(key);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.abortControllers.delete(key);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a specific request by key
|
||||
*/
|
||||
cancelRequest(key: string): void {
|
||||
const controller = this.abortControllers.get(key);
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
this.abortControllers.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all pending requests
|
||||
*/
|
||||
cancelAllRequests(): void {
|
||||
this.abortControllers.forEach((controller) => controller.abort());
|
||||
this.abortControllers.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base URL for constructing full URLs
|
||||
*/
|
||||
getBaseURL(): string {
|
||||
return API_BASE_URL;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton API client instance
|
||||
*/
|
||||
export const apiClient = new APIClient();
|
||||
|
||||
/**
|
||||
* Export for testing/mocking
|
||||
*/
|
||||
export { APIClient };
|
||||
30
src/lib/api/index.ts
Normal file
30
src/lib/api/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* CS2.WTF API Client
|
||||
* Central export for all API endpoints
|
||||
*/
|
||||
|
||||
export { apiClient, APIClient } from './client';
|
||||
export { playersAPI } from './players';
|
||||
export { matchesAPI } from './matches';
|
||||
|
||||
/**
|
||||
* Convenience re-exports
|
||||
*/
|
||||
export { APIException, APIErrorType } from '$lib/types';
|
||||
|
||||
// Import for combined API object
|
||||
import { playersAPI } from './players';
|
||||
import { matchesAPI } from './matches';
|
||||
|
||||
/**
|
||||
* Combined API object for convenience
|
||||
*/
|
||||
export const api = {
|
||||
players: playersAPI,
|
||||
matches: matchesAPI
|
||||
};
|
||||
|
||||
/**
|
||||
* Default export
|
||||
*/
|
||||
export default api;
|
||||
237
src/lib/api/matches.ts
Normal file
237
src/lib/api/matches.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { apiClient } from './client';
|
||||
import {
|
||||
parseMatchRoundsSafe,
|
||||
parseMatchWeaponsSafe,
|
||||
parseMatchChatSafe,
|
||||
parseMatchParseResponse
|
||||
} from '$lib/schemas';
|
||||
import {
|
||||
transformMatchesListResponse,
|
||||
transformMatchDetail,
|
||||
type LegacyMatchListItem,
|
||||
type LegacyMatchDetail
|
||||
} from './transformers';
|
||||
import { transformRoundsResponse } from './transformers/roundsTransformer';
|
||||
import { transformWeaponsResponse } from './transformers/weaponsTransformer';
|
||||
import { transformChatResponse } from './transformers/chatTransformer';
|
||||
import type {
|
||||
Match,
|
||||
MatchesListResponse,
|
||||
MatchesQueryParams,
|
||||
MatchParseResponse,
|
||||
MatchRoundsResponse,
|
||||
MatchWeaponsResponse,
|
||||
MatchChatResponse
|
||||
} from '$lib/types';
|
||||
|
||||
/**
|
||||
* Match API endpoints
|
||||
*/
|
||||
export const matchesAPI = {
|
||||
/**
|
||||
* Parse match from share code
|
||||
* @param shareCode - CS:GO/CS2 match share code
|
||||
* @returns Parse status response
|
||||
*/
|
||||
async parseMatch(shareCode: string): Promise<MatchParseResponse> {
|
||||
const url = `/match/parse/${shareCode}`;
|
||||
const data = await apiClient.get<MatchParseResponse>(url);
|
||||
|
||||
// Validate with Zod schema
|
||||
return parseMatchParseResponse(data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get match details with player statistics
|
||||
* @param matchId - Match ID (uint64 as string)
|
||||
* @returns Complete match data
|
||||
*/
|
||||
async getMatch(matchId: string): Promise<Match> {
|
||||
const url = `/match/${matchId}`;
|
||||
// API returns legacy format
|
||||
const data = await apiClient.get<LegacyMatchDetail>(url);
|
||||
|
||||
// Transform legacy API response to new format
|
||||
return transformMatchDetail(data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get match weapons statistics
|
||||
* @param matchId - Match ID
|
||||
* @param match - Optional match data for player name mapping
|
||||
* @returns Weapon statistics for all players
|
||||
* @throws Error if data is invalid or demo not parsed yet
|
||||
*/
|
||||
async getMatchWeapons(matchId: string | number, match?: Match): Promise<MatchWeaponsResponse> {
|
||||
const url = `/match/${matchId}/weapons`;
|
||||
const data = await apiClient.get<unknown>(url);
|
||||
|
||||
// Validate with Zod schema using safe parse
|
||||
// This handles cases where the demo hasn't been parsed yet
|
||||
const result = parseMatchWeaponsSafe(data);
|
||||
|
||||
if (!result.success) {
|
||||
// If validation fails, it's likely the demo hasn't been parsed yet
|
||||
throw new Error('Demo not parsed yet or invalid response format');
|
||||
}
|
||||
|
||||
// Transform raw API response to structured format
|
||||
return transformWeaponsResponse(result.data, String(matchId), match);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get match round-by-round statistics
|
||||
* @param matchId - Match ID
|
||||
* @param match - Optional match data for player name mapping
|
||||
* @returns Round statistics and economy data
|
||||
* @throws Error if data is invalid or demo not parsed yet
|
||||
*/
|
||||
async getMatchRounds(matchId: string | number, match?: Match): Promise<MatchRoundsResponse> {
|
||||
const url = `/match/${matchId}/rounds`;
|
||||
const data = await apiClient.get<unknown>(url);
|
||||
|
||||
// Validate with Zod schema using safe parse
|
||||
// This handles cases where the demo hasn't been parsed yet
|
||||
const result = parseMatchRoundsSafe(data);
|
||||
|
||||
if (!result.success) {
|
||||
// If validation fails, it's likely the demo hasn't been parsed yet
|
||||
throw new Error('Demo not parsed yet or invalid response format');
|
||||
}
|
||||
|
||||
// Transform raw API response to structured format
|
||||
return transformRoundsResponse(result.data, String(matchId), match);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get match chat messages
|
||||
* @param matchId - Match ID
|
||||
* @param match - Optional match data for player name mapping
|
||||
* @returns Chat messages from the match
|
||||
* @throws Error if data is invalid or demo not parsed yet
|
||||
*/
|
||||
async getMatchChat(matchId: string | number, match?: Match): Promise<MatchChatResponse> {
|
||||
const url = `/match/${matchId}/chat`;
|
||||
const data = await apiClient.get<unknown>(url);
|
||||
|
||||
// Validate with Zod schema using safe parse
|
||||
// This handles cases where the demo hasn't been parsed yet
|
||||
const result = parseMatchChatSafe(data);
|
||||
|
||||
if (!result.success) {
|
||||
// If validation fails, it's likely the demo hasn't been parsed yet
|
||||
throw new Error('Demo not parsed yet or invalid response format');
|
||||
}
|
||||
|
||||
// Transform raw API response to structured format
|
||||
return transformChatResponse(result.data, String(matchId), match);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get paginated list of matches
|
||||
*
|
||||
* IMPORTANT: The API returns a plain array, not an object with properties.
|
||||
* We must manually implement pagination by:
|
||||
* 1. Requesting limit + 1 matches
|
||||
* 2. Checking if we got more than limit (means there are more pages)
|
||||
* 3. Extracting timestamp from last match for next page
|
||||
*
|
||||
* Pagination flow:
|
||||
* - First call: GET /matches?limit=20 → returns array of up to 20 matches
|
||||
* - Next call: GET /matches/next/{timestamp}?limit=20 → returns next 20 matches
|
||||
* - Continue until response.length < limit (reached the end)
|
||||
*
|
||||
* @param params - Query parameters (filters, pagination)
|
||||
* @param params.limit - Number of matches to return (default: 50)
|
||||
* @param params.before_time - Unix timestamp for pagination (get matches before this time)
|
||||
* @param params.map - Filter by map name (e.g., "de_inferno")
|
||||
* @param params.player_id - Filter by player Steam ID
|
||||
* @returns List of matches with pagination metadata
|
||||
*/
|
||||
async getMatches(params?: MatchesQueryParams): Promise<MatchesListResponse> {
|
||||
const url = params?.before_time ? `/matches/next/${params.before_time}` : '/matches';
|
||||
const limit = params?.limit || 50;
|
||||
|
||||
// CRITICAL: API returns a plain array, not a wrapped object
|
||||
// NOTE: Backend has a hard limit of 20 matches per request
|
||||
// We assume hasMore = true if we get exactly the limit we requested
|
||||
const data = await apiClient.get<LegacyMatchListItem[]>(url, {
|
||||
params: {
|
||||
limit: limit,
|
||||
map: params?.map,
|
||||
player_id: params?.player_id
|
||||
}
|
||||
});
|
||||
|
||||
// Handle null or empty response
|
||||
if (!data || !Array.isArray(data)) {
|
||||
console.warn('[API] getMatches received null or invalid data');
|
||||
return transformMatchesListResponse([], false, undefined);
|
||||
}
|
||||
|
||||
// If we got exactly the limit, assume there might be more
|
||||
// If we got less, we've reached the end
|
||||
const hasMore = data.length === limit;
|
||||
|
||||
// Get the timestamp from the LAST match BEFORE transformation
|
||||
// The legacy API format has `date` as a Unix timestamp (number)
|
||||
const lastLegacyMatch = data.length > 0 ? data[data.length - 1] : undefined;
|
||||
const nextPageTime = hasMore && lastLegacyMatch ? lastLegacyMatch.date : undefined;
|
||||
|
||||
// Transform legacy API response to new format
|
||||
return transformMatchesListResponse(data, hasMore, nextPageTime);
|
||||
},
|
||||
|
||||
/**
|
||||
* Search matches (cancelable for live search)
|
||||
* @param params - Search parameters
|
||||
* @returns List of matching matches
|
||||
*/
|
||||
async searchMatches(params?: MatchesQueryParams): Promise<MatchesListResponse> {
|
||||
const url = params?.before_time ? `/matches/next/${params.before_time}` : '/matches';
|
||||
const limit = params?.limit || 20;
|
||||
|
||||
// API returns a plain array, not a wrapped object
|
||||
// Backend has a hard limit of 20 matches per request
|
||||
const data = await apiClient.getCancelable<LegacyMatchListItem[]>(url, 'match-search', {
|
||||
params: {
|
||||
limit: limit,
|
||||
map: params?.map,
|
||||
player_id: params?.player_id
|
||||
}
|
||||
});
|
||||
|
||||
// If we got exactly the limit, assume there might be more
|
||||
const hasMore = data.length === limit;
|
||||
|
||||
// Get the timestamp from the LAST match BEFORE transformation
|
||||
// The legacy API format has `date` as a Unix timestamp (number)
|
||||
const lastLegacyMatch = data.length > 0 ? data[data.length - 1] : undefined;
|
||||
const nextPageTime = hasMore && lastLegacyMatch ? lastLegacyMatch.date : undefined;
|
||||
|
||||
// Transform legacy API response to new format
|
||||
return transformMatchesListResponse(data, hasMore, nextPageTime);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get match by share code
|
||||
* Convenience method that extracts match ID from share code if needed
|
||||
* @param shareCodeOrId - Share code or match ID
|
||||
* @returns Match data
|
||||
*/
|
||||
async getMatchByShareCode(shareCodeOrId: string): Promise<Match> {
|
||||
// If it looks like a share code, parse it first
|
||||
if (shareCodeOrId.startsWith('CSGO-')) {
|
||||
const parseResult = await this.parseMatch(shareCodeOrId);
|
||||
return this.getMatch(parseResult.match_id);
|
||||
}
|
||||
|
||||
// Otherwise treat as match ID
|
||||
return this.getMatch(shareCodeOrId);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Match API with default export
|
||||
*/
|
||||
export default matchesAPI;
|
||||
127
src/lib/api/players.ts
Normal file
127
src/lib/api/players.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { apiClient } from './client';
|
||||
import { parsePlayer } from '$lib/schemas';
|
||||
import type { Player, PlayerMeta, TrackPlayerResponse } from '$lib/types';
|
||||
import { transformPlayerProfile, type LegacyPlayerProfile } from './transformers';
|
||||
|
||||
/**
|
||||
* Player API endpoints
|
||||
*/
|
||||
export const playersAPI = {
|
||||
/**
|
||||
* Get player profile with match history
|
||||
* @param steamId - Steam ID (uint64 as string to preserve precision)
|
||||
* @param beforeTime - Optional Unix timestamp for pagination
|
||||
* @returns Player profile with recent matches
|
||||
*/
|
||||
async getPlayer(steamId: string, beforeTime?: number): Promise<Player> {
|
||||
const url = beforeTime ? `/player/${steamId}/next/${beforeTime}` : `/player/${steamId}`;
|
||||
const data = await apiClient.get<Player>(url);
|
||||
|
||||
// Validate with Zod schema
|
||||
return parsePlayer(data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get lightweight player metadata
|
||||
* @param steamId - Steam ID (uint64 as string to preserve precision)
|
||||
* @param limit - Number of recent matches to include (default: 10)
|
||||
* @returns Player metadata
|
||||
*/
|
||||
async getPlayerMeta(steamId: string, limit = 10): Promise<PlayerMeta> {
|
||||
// Use the /player/{id} endpoint which has the data we need
|
||||
const url = `/player/${steamId}`;
|
||||
const legacyData = await apiClient.get<LegacyPlayerProfile>(url);
|
||||
|
||||
// Transform legacy API format to our schema format
|
||||
const transformedData = transformPlayerProfile(legacyData);
|
||||
|
||||
// Validate the player data
|
||||
// parsePlayer throws on validation failure, so player is always defined if we reach this point
|
||||
const player = parsePlayer(transformedData);
|
||||
|
||||
// Calculate aggregated stats from matches
|
||||
const matches = player.matches || [];
|
||||
const recentMatches = matches.slice(0, limit);
|
||||
|
||||
const totalKills = recentMatches.reduce((sum, m) => sum + (m.stats?.kills || 0), 0);
|
||||
const totalDeaths = recentMatches.reduce((sum, m) => sum + (m.stats?.deaths || 0), 0);
|
||||
const totalKast = recentMatches.reduce((sum, _m) => {
|
||||
// KAST is a percentage, we need to calculate it
|
||||
// For now, we'll use a placeholder
|
||||
return sum + 0;
|
||||
}, 0);
|
||||
|
||||
const wins = recentMatches.filter((m) => {
|
||||
// match_result 1 = win, 2 = loss
|
||||
return m.match_result === 1;
|
||||
}).length;
|
||||
|
||||
const avgKills = recentMatches.length > 0 ? totalKills / recentMatches.length : 0;
|
||||
const avgDeaths = recentMatches.length > 0 ? totalDeaths / recentMatches.length : 0;
|
||||
const winRate = recentMatches.length > 0 ? wins / recentMatches.length : 0;
|
||||
|
||||
// Find the most recent match date
|
||||
const lastMatchDate =
|
||||
matches.length > 0 && matches[0] ? matches[0].date : new Date().toISOString();
|
||||
|
||||
// Transform to PlayerMeta format
|
||||
const playerMeta: PlayerMeta = {
|
||||
id: player.id, // Keep as string for uint64 precision
|
||||
name: player.name,
|
||||
avatar: player.avatar, // Already transformed by transformPlayerProfile
|
||||
recent_matches: recentMatches.length,
|
||||
last_match_date: lastMatchDate,
|
||||
avg_kills: avgKills,
|
||||
avg_deaths: avgDeaths,
|
||||
avg_kast: recentMatches.length > 0 ? totalKast / recentMatches.length : 0, // Placeholder KAST calculation
|
||||
win_rate: winRate,
|
||||
vac_count: player.vac_count,
|
||||
vac_date: player.vac_date,
|
||||
game_ban_count: player.game_ban_count,
|
||||
game_ban_date: player.game_ban_date,
|
||||
tracked: player.tracked
|
||||
};
|
||||
|
||||
return playerMeta;
|
||||
},
|
||||
|
||||
/**
|
||||
* Add player to tracking system
|
||||
* @param steamId - Steam ID (uint64 as string to preserve precision)
|
||||
* @param authCode - Steam authentication code
|
||||
* @returns Success response
|
||||
*/
|
||||
async trackPlayer(steamId: string, authCode: string): Promise<TrackPlayerResponse> {
|
||||
const url = `/player/${steamId}/track`;
|
||||
return apiClient.post<TrackPlayerResponse>(url, { auth_code: authCode });
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove player from tracking system
|
||||
* @param steamId - Steam ID (uint64 as string to preserve precision)
|
||||
* @returns Success response
|
||||
*/
|
||||
async untrackPlayer(steamId: string): Promise<TrackPlayerResponse> {
|
||||
const url = `/player/${steamId}/track`;
|
||||
return apiClient.delete<TrackPlayerResponse>(url);
|
||||
},
|
||||
|
||||
/**
|
||||
* Search players by name (cancelable)
|
||||
* @param query - Search query
|
||||
* @param limit - Maximum results
|
||||
* @returns Array of player matches
|
||||
*/
|
||||
async searchPlayers(query: string, limit = 10): Promise<PlayerMeta[]> {
|
||||
const url = `/players/search`;
|
||||
const data = await apiClient.getCancelable<PlayerMeta[]>(url, 'player-search', {
|
||||
params: { q: query, limit }
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Player API with default export
|
||||
*/
|
||||
export default playersAPI;
|
||||
335
src/lib/api/transformers.ts
Normal file
335
src/lib/api/transformers.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* API Response Transformers
|
||||
* Converts legacy CSGO:WTF API responses to the new CS2.WTF format
|
||||
*
|
||||
* IMPORTANT: The backend API returns data in a legacy format that differs from our TypeScript schemas.
|
||||
* These transformers bridge that gap by:
|
||||
* 1. Converting Unix timestamps to ISO 8601 strings
|
||||
* 2. Splitting score arrays [team_a, team_b] into separate fields
|
||||
* 3. Renaming fields (parsed → demo_parsed, vac → vac_present, etc.)
|
||||
* 4. Constructing full avatar URLs from hashes
|
||||
* 5. Normalizing team IDs (1/2 → 2/3)
|
||||
*
|
||||
* Always use these transformers before passing API data to Zod schemas or TypeScript types.
|
||||
*/
|
||||
|
||||
import type { MatchListItem, MatchesListResponse, Match, MatchPlayer } from '$lib/types';
|
||||
|
||||
/**
|
||||
* Legacy API match list item format (from api.csgow.tf)
|
||||
*
|
||||
* VERIFIED: This interface matches the actual API response from GET /matches
|
||||
* Tested: 2025-11-12 via curl https://api.csgow.tf/matches?limit=2
|
||||
*/
|
||||
export interface LegacyMatchListItem {
|
||||
match_id: string; // uint64 as string
|
||||
map: string; // Can be empty string if not parsed
|
||||
date: number; // Unix timestamp (seconds since epoch)
|
||||
score: [number, number]; // [team_a_score, team_b_score]
|
||||
duration: number; // Match duration in seconds
|
||||
match_result: number; // 0 = tie, 1 = team_a win, 2 = team_b win
|
||||
max_rounds: number; // 24 for MR12, 30 for MR15
|
||||
parsed: boolean; // Whether demo has been parsed (NOT demo_parsed)
|
||||
vac: boolean; // Whether any player has VAC ban (NOT vac_present)
|
||||
game_ban: boolean; // Whether any player has game ban (NOT gameban_present)
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy API match detail format (from GET /match/:id)
|
||||
*
|
||||
* VERIFIED: This interface matches the actual API response
|
||||
* Tested: 2025-11-12 via curl https://api.csgow.tf/match/3589487716842078322
|
||||
*
|
||||
* Note: Uses 'stats' array, not 'players' array
|
||||
*/
|
||||
export interface LegacyMatchDetail {
|
||||
match_id: string;
|
||||
share_code?: string;
|
||||
map: string;
|
||||
date: number; // Unix timestamp
|
||||
score: [number, number]; // [team_a, team_b]
|
||||
duration: number;
|
||||
match_result: number;
|
||||
max_rounds: number;
|
||||
parsed: boolean; // NOT demo_parsed
|
||||
vac: boolean; // NOT vac_present
|
||||
game_ban: boolean; // NOT gameban_present
|
||||
stats?: LegacyPlayerStats[]; // Player stats array
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy player stats format (nested within match detail)
|
||||
*
|
||||
* VERIFIED: Matches actual API response structure
|
||||
* - Player info nested under 'player' object
|
||||
* - Rank as object with 'old' and 'new' properties
|
||||
* - Multi-kills as object with 'duo', 'triple', 'quad', 'ace'
|
||||
* - Damage as object with 'enemy' and 'team'
|
||||
* - Flash stats with nested 'duration' and 'total' objects
|
||||
*/
|
||||
export interface LegacyPlayerStats {
|
||||
team_id: number;
|
||||
kills: number;
|
||||
deaths: number;
|
||||
assists: number;
|
||||
headshot: number;
|
||||
mvp: number;
|
||||
score: number;
|
||||
player: {
|
||||
steamid64: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
vac: boolean;
|
||||
game_ban: boolean;
|
||||
vanity_url?: string;
|
||||
};
|
||||
rank: Record<string, unknown>;
|
||||
multi_kills?: {
|
||||
duo?: number;
|
||||
triple?: number;
|
||||
quad?: number;
|
||||
ace?: number;
|
||||
};
|
||||
dmg?: Record<string, unknown>;
|
||||
flash?: {
|
||||
duration?: {
|
||||
self?: number;
|
||||
team?: number;
|
||||
enemy?: number;
|
||||
};
|
||||
total?: {
|
||||
self?: number;
|
||||
team?: number;
|
||||
enemy?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform legacy match list item to new format
|
||||
*
|
||||
* Converts a single match from the API's legacy format to our schema format.
|
||||
*
|
||||
* Key transformations:
|
||||
* - date: Unix timestamp → ISO 8601 string
|
||||
* - score: [a, b] array → score_team_a, score_team_b fields
|
||||
* - parsed → demo_parsed (rename)
|
||||
*
|
||||
* @param legacy - Match data from API in legacy format
|
||||
* @returns Match data in schema-compatible format
|
||||
*/
|
||||
export function transformMatchListItem(legacy: LegacyMatchListItem): MatchListItem {
|
||||
return {
|
||||
match_id: legacy.match_id, // Keep as string to preserve uint64 precision
|
||||
map: legacy.map || 'unknown', // Handle empty map names
|
||||
date: new Date(legacy.date * 1000).toISOString(), // Convert Unix timestamp to ISO string
|
||||
score_team_a: legacy.score[0],
|
||||
score_team_b: legacy.score[1],
|
||||
duration: legacy.duration,
|
||||
demo_parsed: legacy.parsed // Rename: parsed → demo_parsed
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform legacy matches list response to new format
|
||||
*
|
||||
* IMPORTANT: The API returns a plain array, NOT an object with properties.
|
||||
* This function wraps the array and adds pagination metadata that we calculate ourselves.
|
||||
*
|
||||
* How pagination works:
|
||||
* 1. API returns plain array: [match1, match2, ...]
|
||||
* 2. We request limit + 1 to check if there are more matches
|
||||
* 3. If we get > limit matches, hasMore = true
|
||||
* 4. We extract timestamp from last match for next page: matches[length-1].date
|
||||
*
|
||||
* @param legacyMatches - Array of matches from API (already requested limit + 1)
|
||||
* @param hasMore - Whether there are more matches available (calculated by caller)
|
||||
* @param nextPageTime - Unix timestamp for next page (extracted from last match by caller)
|
||||
* @returns Wrapped response with pagination metadata
|
||||
*/
|
||||
export function transformMatchesListResponse(
|
||||
legacyMatches: LegacyMatchListItem[],
|
||||
hasMore: boolean = false,
|
||||
nextPageTime?: number
|
||||
): MatchesListResponse {
|
||||
return {
|
||||
matches: legacyMatches.map(transformMatchListItem),
|
||||
has_more: hasMore,
|
||||
next_page_time: nextPageTime
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform legacy player stats to new format
|
||||
*/
|
||||
export function transformPlayerStats(legacy: LegacyPlayerStats): MatchPlayer {
|
||||
// Extract Premier rating from rank object
|
||||
// API provides rank as { old: number, new: number }
|
||||
const rankOld =
|
||||
legacy.rank && typeof legacy.rank.old === 'number' ? (legacy.rank.old as number) : undefined;
|
||||
const rankNew =
|
||||
legacy.rank && typeof legacy.rank.new === 'number' ? (legacy.rank.new as number) : undefined;
|
||||
|
||||
return {
|
||||
id: legacy.player.steamid64,
|
||||
name: legacy.player.name,
|
||||
avatar: `https://avatars.steamstatic.com/${legacy.player.avatar}_full.jpg`,
|
||||
team_id: legacy.team_id,
|
||||
kills: legacy.kills,
|
||||
deaths: legacy.deaths,
|
||||
assists: legacy.assists,
|
||||
headshot: legacy.headshot,
|
||||
mvp: legacy.mvp,
|
||||
score: legacy.score,
|
||||
// Premier rating (CS2: 0-30000)
|
||||
rank_old: rankOld,
|
||||
rank_new: rankNew,
|
||||
// Multi-kills: map legacy names to new format
|
||||
mk_2: legacy.multi_kills?.duo,
|
||||
mk_3: legacy.multi_kills?.triple,
|
||||
mk_4: legacy.multi_kills?.quad,
|
||||
mk_5: legacy.multi_kills?.ace,
|
||||
// Flash stats
|
||||
flash_duration_self: legacy.flash?.duration?.self,
|
||||
flash_duration_team: legacy.flash?.duration?.team,
|
||||
flash_duration_enemy: legacy.flash?.duration?.enemy,
|
||||
flash_total_self: legacy.flash?.total?.self,
|
||||
flash_total_team: legacy.flash?.total?.team,
|
||||
flash_total_enemy: legacy.flash?.total?.enemy,
|
||||
// Ban status
|
||||
vac: legacy.player.vac,
|
||||
game_ban: legacy.player.game_ban
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform legacy match detail to new format
|
||||
*/
|
||||
export function transformMatchDetail(legacy: LegacyMatchDetail): Match {
|
||||
return {
|
||||
match_id: legacy.match_id,
|
||||
share_code: legacy.share_code || undefined,
|
||||
map: legacy.map || 'unknown',
|
||||
date: new Date(legacy.date * 1000).toISOString(),
|
||||
score_team_a: legacy.score[0],
|
||||
score_team_b: legacy.score[1],
|
||||
duration: legacy.duration,
|
||||
match_result: legacy.match_result,
|
||||
max_rounds: legacy.max_rounds,
|
||||
demo_parsed: legacy.parsed,
|
||||
vac_present: legacy.vac,
|
||||
gameban_present: legacy.game_ban,
|
||||
players: legacy.stats?.map(transformPlayerStats)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy player profile format from API
|
||||
*/
|
||||
export interface LegacyPlayerProfile {
|
||||
steamid64: string;
|
||||
name: string;
|
||||
avatar: string; // Hash, not full URL
|
||||
vac: boolean;
|
||||
vac_date: number; // Unix timestamp
|
||||
game_ban: boolean;
|
||||
game_ban_date: number; // Unix timestamp
|
||||
tracked: boolean;
|
||||
match_stats?: {
|
||||
win: number;
|
||||
loss: number;
|
||||
};
|
||||
matches?: Array<{
|
||||
match_id: string;
|
||||
map: string;
|
||||
date: number;
|
||||
score: [number, number];
|
||||
duration: number;
|
||||
match_result: number;
|
||||
max_rounds: number;
|
||||
parsed: boolean;
|
||||
vac: boolean;
|
||||
game_ban: boolean;
|
||||
stats: {
|
||||
team_id: number;
|
||||
kills: number;
|
||||
deaths: number;
|
||||
assists: number;
|
||||
headshot: number;
|
||||
mvp: number;
|
||||
score: number;
|
||||
rank: Record<string, unknown>;
|
||||
multi_kills?: Record<string, number>;
|
||||
dmg?: Record<string, unknown>;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform legacy player profile to schema-compatible format
|
||||
*/
|
||||
export function transformPlayerProfile(legacy: LegacyPlayerProfile) {
|
||||
// Unix timestamp -62135596800 represents "no date" (year 0)
|
||||
const hasVacDate = legacy.vac_date && legacy.vac_date > 0;
|
||||
const hasGameBanDate = legacy.game_ban_date && legacy.game_ban_date > 0;
|
||||
|
||||
return {
|
||||
id: legacy.steamid64,
|
||||
name: legacy.name,
|
||||
avatar: `https://avatars.steamstatic.com/${legacy.avatar}_full.jpg`,
|
||||
vac_count: legacy.vac ? 1 : 0,
|
||||
vac_date: hasVacDate ? new Date(legacy.vac_date * 1000).toISOString() : null,
|
||||
game_ban_count: legacy.game_ban ? 1 : 0,
|
||||
game_ban_date: hasGameBanDate ? new Date(legacy.game_ban_date * 1000).toISOString() : null,
|
||||
tracked: legacy.tracked,
|
||||
wins: legacy.match_stats?.win,
|
||||
losses: legacy.match_stats?.loss,
|
||||
matches: legacy.matches?.map((match) => {
|
||||
// Extract Premier rating from rank object
|
||||
const rankOld =
|
||||
match.stats.rank && typeof match.stats.rank.old === 'number'
|
||||
? (match.stats.rank.old as number)
|
||||
: undefined;
|
||||
const rankNew =
|
||||
match.stats.rank && typeof match.stats.rank.new === 'number'
|
||||
? (match.stats.rank.new as number)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
match_id: match.match_id,
|
||||
map: match.map || 'unknown',
|
||||
date: new Date(match.date * 1000).toISOString(),
|
||||
score_team_a: match.score[0],
|
||||
score_team_b: match.score[1],
|
||||
duration: match.duration,
|
||||
match_result: match.match_result,
|
||||
max_rounds: match.max_rounds,
|
||||
demo_parsed: match.parsed,
|
||||
vac_present: match.vac,
|
||||
gameban_present: match.game_ban,
|
||||
stats: {
|
||||
id: legacy.steamid64,
|
||||
name: legacy.name,
|
||||
avatar: `https://avatars.steamstatic.com/${legacy.avatar}_full.jpg`,
|
||||
// Fix team_id: API returns 1/2, but schema expects min 2
|
||||
// Map: 1 -> 2 (Terrorists), 2 -> 3 (Counter-Terrorists)
|
||||
team_id:
|
||||
match.stats.team_id === 1 ? 2 : match.stats.team_id === 2 ? 3 : match.stats.team_id,
|
||||
kills: match.stats.kills,
|
||||
deaths: match.stats.deaths,
|
||||
assists: match.stats.assists,
|
||||
headshot: match.stats.headshot,
|
||||
mvp: match.stats.mvp,
|
||||
score: match.stats.score,
|
||||
// Premier rating (CS2: 0-30000)
|
||||
rank_old: rankOld,
|
||||
rank_new: rankNew,
|
||||
mk_2: match.stats.multi_kills?.duo,
|
||||
mk_3: match.stats.multi_kills?.triple,
|
||||
mk_4: match.stats.multi_kills?.quad,
|
||||
mk_5: match.stats.multi_kills?.ace
|
||||
}
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
46
src/lib/api/transformers/chatTransformer.ts
Normal file
46
src/lib/api/transformers/chatTransformer.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { ChatAPIResponse } from '$lib/types/api/ChatAPIResponse';
|
||||
import type { MatchChatResponse, Message, Match } from '$lib/types';
|
||||
|
||||
/**
|
||||
* Transform raw chat API response into structured format
|
||||
* @param rawData - Raw API response
|
||||
* @param matchId - Match ID
|
||||
* @param match - Match data with player information
|
||||
* @returns Structured chat data
|
||||
*/
|
||||
export function transformChatResponse(
|
||||
rawData: ChatAPIResponse,
|
||||
matchId: string,
|
||||
match?: Match
|
||||
): MatchChatResponse {
|
||||
const messages: Message[] = [];
|
||||
|
||||
// Create player ID to name mapping
|
||||
const playerMap = new Map<string, string>();
|
||||
if (match?.players) {
|
||||
for (const player of match.players) {
|
||||
playerMap.set(player.id, player.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Flatten all player messages into a single array
|
||||
for (const [playerId, playerMessages] of Object.entries(rawData)) {
|
||||
const playerName = playerMap.get(playerId) || `Player ${playerId}`;
|
||||
|
||||
for (const message of playerMessages) {
|
||||
messages.push({
|
||||
...message,
|
||||
player_id: Number(playerId),
|
||||
player_name: playerName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by tick
|
||||
messages.sort((a, b) => a.tick - b.tick);
|
||||
|
||||
return {
|
||||
match_id: matchId,
|
||||
messages
|
||||
};
|
||||
}
|
||||
60
src/lib/api/transformers/roundsTransformer.ts
Normal file
60
src/lib/api/transformers/roundsTransformer.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { RoundsAPIResponse } from '$lib/types/api/RoundsAPIResponse';
|
||||
import type { MatchRoundsResponse, RoundDetail, RoundStats, Match } from '$lib/types';
|
||||
|
||||
/**
|
||||
* Transform raw rounds API response into structured format
|
||||
* @param rawData - Raw API response
|
||||
* @param matchId - Match ID
|
||||
* @param match - Match data with player information
|
||||
* @returns Structured rounds data
|
||||
*/
|
||||
export function transformRoundsResponse(
|
||||
rawData: RoundsAPIResponse,
|
||||
matchId: string,
|
||||
match?: Match
|
||||
): MatchRoundsResponse {
|
||||
const rounds: RoundDetail[] = [];
|
||||
|
||||
// Create player ID to team mapping
|
||||
const playerTeamMap = new Map<string, number>();
|
||||
if (match?.players) {
|
||||
for (const player of match.players) {
|
||||
playerTeamMap.set(player.id, player.team_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert object keys to sorted round numbers
|
||||
const roundNumbers = Object.keys(rawData)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
for (const roundNum of roundNumbers) {
|
||||
const roundData = rawData[String(roundNum)];
|
||||
if (!roundData) continue;
|
||||
|
||||
const players: RoundStats[] = [];
|
||||
|
||||
// Convert player data
|
||||
for (const [playerId, [bank, equipment, spent]] of Object.entries(roundData)) {
|
||||
players.push({
|
||||
round: roundNum + 1, // API uses 0-indexed, we use 1-indexed
|
||||
bank,
|
||||
equipment,
|
||||
spent,
|
||||
player_id: Number(playerId)
|
||||
});
|
||||
}
|
||||
|
||||
rounds.push({
|
||||
round: roundNum + 1,
|
||||
winner: 0, // TODO: Determine winner from data if available
|
||||
win_reason: '', // TODO: Determine win reason if available
|
||||
players
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
match_id: matchId,
|
||||
rounds
|
||||
};
|
||||
}
|
||||
99
src/lib/api/transformers/weaponsTransformer.ts
Normal file
99
src/lib/api/transformers/weaponsTransformer.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { WeaponsAPIResponse } from '$lib/types/api/WeaponsAPIResponse';
|
||||
import type { MatchWeaponsResponse, PlayerWeaponStats, WeaponStats, Match } from '$lib/types';
|
||||
|
||||
/**
|
||||
* Transform raw weapons API response into structured format
|
||||
* @param rawData - Raw API response
|
||||
* @param matchId - Match ID
|
||||
* @param match - Match data with player information
|
||||
* @returns Structured weapons data
|
||||
*/
|
||||
export function transformWeaponsResponse(
|
||||
rawData: WeaponsAPIResponse,
|
||||
matchId: string,
|
||||
match?: Match
|
||||
): MatchWeaponsResponse {
|
||||
const playerWeaponsMap = new Map<
|
||||
string,
|
||||
Map<number, { damage: number; hits: number; hitGroups: number[] }>
|
||||
>();
|
||||
|
||||
// Create player ID to name mapping
|
||||
const playerMap = new Map<string, string>();
|
||||
if (match?.players) {
|
||||
for (const player of match.players) {
|
||||
playerMap.set(player.id, player.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Process all stats
|
||||
for (const roundStats of rawData.stats) {
|
||||
for (const [attackerId, victims] of Object.entries(roundStats)) {
|
||||
if (!playerWeaponsMap.has(attackerId)) {
|
||||
playerWeaponsMap.set(attackerId, new Map());
|
||||
}
|
||||
const weaponsMap = playerWeaponsMap.get(attackerId)!;
|
||||
|
||||
for (const [_, hits] of Object.entries(victims)) {
|
||||
for (const [eqType, hitGroup, damage] of hits) {
|
||||
if (!weaponsMap.has(eqType)) {
|
||||
weaponsMap.set(eqType, { damage: 0, hits: 0, hitGroups: [] });
|
||||
}
|
||||
const weaponStats = weaponsMap.get(eqType)!;
|
||||
weaponStats.damage += damage;
|
||||
weaponStats.hits++;
|
||||
weaponStats.hitGroups.push(hitGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to output format
|
||||
const weapons: PlayerWeaponStats[] = [];
|
||||
for (const [playerId, weaponsMap] of playerWeaponsMap.entries()) {
|
||||
const playerName = playerMap.get(playerId) || `Player ${playerId}`;
|
||||
const weapon_stats: WeaponStats[] = [];
|
||||
|
||||
for (const [eqType, stats] of weaponsMap.entries()) {
|
||||
const hitGroupCounts = {
|
||||
head: 0,
|
||||
chest: 0,
|
||||
stomach: 0,
|
||||
left_arm: 0,
|
||||
right_arm: 0,
|
||||
left_leg: 0,
|
||||
right_leg: 0
|
||||
};
|
||||
for (const hitGroup of stats.hitGroups) {
|
||||
if (hitGroup === 1) hitGroupCounts.head++;
|
||||
else if (hitGroup === 2) hitGroupCounts.chest++;
|
||||
else if (hitGroup === 3) hitGroupCounts.stomach++;
|
||||
else if (hitGroup === 4) hitGroupCounts.left_arm++;
|
||||
else if (hitGroup === 5) hitGroupCounts.right_arm++;
|
||||
else if (hitGroup === 6) hitGroupCounts.left_leg++;
|
||||
else if (hitGroup === 7) hitGroupCounts.right_leg++;
|
||||
}
|
||||
|
||||
weapon_stats.push({
|
||||
eq_type: eqType,
|
||||
weapon_name: rawData.equipment_map[String(eqType)] || `Weapon ${eqType}`,
|
||||
kills: 0, // TODO: Calculate kills if needed
|
||||
damage: stats.damage,
|
||||
hits: stats.hits,
|
||||
hit_groups: hitGroupCounts,
|
||||
headshot_pct: hitGroupCounts.head > 0 ? (hitGroupCounts.head / stats.hits) * 100 : 0
|
||||
});
|
||||
}
|
||||
|
||||
weapons.push({
|
||||
player_id: Number(playerId),
|
||||
player_name: playerName,
|
||||
weapon_stats
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
match_id: matchId,
|
||||
weapons
|
||||
};
|
||||
}
|
||||
276
src/lib/components/RoundTimeline.svelte
Normal file
276
src/lib/components/RoundTimeline.svelte
Normal file
@@ -0,0 +1,276 @@
|
||||
<script lang="ts">
|
||||
import { Bomb, Shield, Clock, Target, Skull } from 'lucide-svelte';
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import type { RoundDetail } from '$lib/types/RoundStats';
|
||||
|
||||
let { rounds, maxRounds = 24 }: { rounds: RoundDetail[]; maxRounds?: number } = $props();
|
||||
|
||||
// Calculate halftime round based on max_rounds
|
||||
// MR12 (24 rounds): halftime after round 12
|
||||
// MR15 (30 rounds): halftime after round 15
|
||||
const halftimeRound = $derived(maxRounds === 30 ? 15 : 12);
|
||||
|
||||
// State for hover/click details
|
||||
let selectedRound = $state<number | null>(null);
|
||||
|
||||
// Helper to get win reason icon
|
||||
const getWinReasonIcon = (reason: string) => {
|
||||
const reasonLower = reason.toLowerCase();
|
||||
if (reasonLower.includes('bomb') && reasonLower.includes('exploded')) return Bomb;
|
||||
if (reasonLower.includes('defused')) return Shield;
|
||||
if (reasonLower.includes('elimination')) return Skull;
|
||||
if (reasonLower.includes('time')) return Clock;
|
||||
if (reasonLower.includes('target')) return Target;
|
||||
return null;
|
||||
};
|
||||
|
||||
// Helper to get win reason display text
|
||||
const getWinReasonText = (reason: string) => {
|
||||
const reasonLower = reason.toLowerCase();
|
||||
if (reasonLower.includes('bomb') && reasonLower.includes('exploded')) return 'Bomb Exploded';
|
||||
if (reasonLower.includes('defused')) return 'Bomb Defused';
|
||||
if (reasonLower.includes('elimination')) return 'Elimination';
|
||||
if (reasonLower.includes('time')) return 'Time Expired';
|
||||
if (reasonLower.includes('target')) return 'Target Saved';
|
||||
return reason;
|
||||
};
|
||||
|
||||
// Helper to format win reason for badge
|
||||
const formatWinReason = (reason: string): string => {
|
||||
const reasonLower = reason.toLowerCase();
|
||||
if (reasonLower.includes('bomb') && reasonLower.includes('exploded')) return 'BOOM';
|
||||
if (reasonLower.includes('defused')) return 'DEF';
|
||||
if (reasonLower.includes('elimination')) return 'ELIM';
|
||||
if (reasonLower.includes('time')) return 'TIME';
|
||||
if (reasonLower.includes('target')) return 'SAVE';
|
||||
return 'WIN';
|
||||
};
|
||||
|
||||
// Toggle round selection
|
||||
const toggleRound = (roundNum: number) => {
|
||||
selectedRound = selectedRound === roundNum ? null : roundNum;
|
||||
};
|
||||
|
||||
// Calculate team scores up to a given round
|
||||
const getScoreAtRound = (roundNumber: number): { teamA: number; teamB: number } => {
|
||||
let teamA = 0;
|
||||
let teamB = 0;
|
||||
for (let i = 0; i < roundNumber && i < rounds.length; i++) {
|
||||
const round = rounds[i];
|
||||
if (round && round.winner === 2) teamA++;
|
||||
else if (round && round.winner === 3) teamB++;
|
||||
}
|
||||
return { teamA, teamB };
|
||||
};
|
||||
|
||||
// Get selected round details
|
||||
const selectedRoundData = $derived(
|
||||
selectedRound ? rounds.find((r) => r.round === selectedRound) : null
|
||||
);
|
||||
</script>
|
||||
|
||||
<Card padding="lg">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-bold text-base-content">Round Timeline</h2>
|
||||
<p class="mt-2 text-sm text-base-content/60">
|
||||
Click on a round to see detailed information. T = Terrorists, CT = Counter-Terrorists
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Timeline -->
|
||||
<div class="relative">
|
||||
<!-- Horizontal scroll container for mobile -->
|
||||
<div class="overflow-x-auto pb-4">
|
||||
<div class="min-w-max">
|
||||
<!-- Round markers -->
|
||||
<div class="flex gap-1">
|
||||
{#each rounds as round (round.round)}
|
||||
{@const isWinner2 = round.winner === 2}
|
||||
{@const isWinner3 = round.winner === 3}
|
||||
{@const isSelected = selectedRound === round.round}
|
||||
{@const Icon = getWinReasonIcon(round.win_reason)}
|
||||
{@const scoreAtRound = getScoreAtRound(round.round)}
|
||||
|
||||
<button
|
||||
class="group relative flex flex-col items-center transition-all hover:scale-110"
|
||||
style="width: 60px;"
|
||||
onclick={() => toggleRound(round.round)}
|
||||
aria-label={`Round ${round.round}`}
|
||||
>
|
||||
<!-- Round number -->
|
||||
<div
|
||||
class="mb-2 text-xs font-semibold transition-colors"
|
||||
class:text-primary={isSelected}
|
||||
class:opacity-60={!isSelected}
|
||||
>
|
||||
{round.round}
|
||||
</div>
|
||||
|
||||
<!-- Round indicator circle -->
|
||||
<div
|
||||
class="relative flex h-12 w-12 items-center justify-center rounded-full border-2 transition-all"
|
||||
class:border-terrorist={isWinner2}
|
||||
class:bg-terrorist={isWinner2}
|
||||
class:bg-opacity-20={isWinner2 || isWinner3}
|
||||
class:border-ct={isWinner3}
|
||||
class:bg-ct={isWinner3}
|
||||
class:ring-4={isSelected}
|
||||
class:ring-primary={isSelected}
|
||||
class:ring-opacity-30={isSelected}
|
||||
class:scale-110={isSelected}
|
||||
>
|
||||
<!-- Win reason icon or T/CT badge -->
|
||||
{#if Icon}
|
||||
<Icon class={`h-5 w-5 ${isWinner2 ? 'text-terrorist' : 'text-ct'}`} />
|
||||
{:else}
|
||||
<span
|
||||
class="text-sm font-bold"
|
||||
class:text-terrorist={isWinner2}
|
||||
class:text-ct={isWinner3}
|
||||
>
|
||||
{isWinner2 ? 'T' : 'CT'}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Small win reason badge on bottom -->
|
||||
<div
|
||||
class="absolute -bottom-1 rounded px-1 py-0.5 text-[9px] font-bold leading-none"
|
||||
class:bg-terrorist={isWinner2}
|
||||
class:bg-ct={isWinner3}
|
||||
class:text-white={true}
|
||||
>
|
||||
{formatWinReason(round.win_reason)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connecting line to next round -->
|
||||
{#if round.round < rounds.length}
|
||||
<div
|
||||
class="absolute left-[60px] top-[34px] h-0.5 w-[calc(100%-60px)] bg-base-300"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- Hover tooltip -->
|
||||
<div
|
||||
class="pointer-events-none absolute top-full z-10 mt-2 hidden w-48 rounded-lg bg-base-100 p-3 text-left shadow-xl ring-1 ring-base-300 group-hover:block"
|
||||
>
|
||||
<div class="text-xs font-semibold text-base-content">
|
||||
Round {round.round}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-base-content/80">
|
||||
Winner:
|
||||
<span
|
||||
class="font-bold"
|
||||
class:text-terrorist={isWinner2}
|
||||
class:text-ct={isWinner3}
|
||||
>
|
||||
{isWinner2 ? 'Terrorists' : 'Counter-Terrorists'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-base-content/60">
|
||||
{getWinReasonText(round.win_reason)}
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-base-content/60">
|
||||
Score: {scoreAtRound.teamA} - {scoreAtRound.teamB}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Half marker (dynamic based on MR12/MR15) -->
|
||||
{#if rounds.length > halftimeRound}
|
||||
<div class="relative mt-2 flex gap-1">
|
||||
<div
|
||||
class="w-[60px] text-center"
|
||||
style="margin-left: calc(60px * {halftimeRound} - 30px);"
|
||||
>
|
||||
<Badge variant="info" size="sm">Halftime</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected Round Details -->
|
||||
{#if selectedRoundData}
|
||||
<div class="mt-6 border-t border-base-300 pt-6">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-xl font-bold text-base-content">
|
||||
Round {selectedRoundData.round} Details
|
||||
</h3>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onclick={() => (selectedRound = null)}
|
||||
aria-label="Close details"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-sm text-base-content/60">Winner</div>
|
||||
<div
|
||||
class="text-lg font-bold"
|
||||
class:text-terrorist={selectedRoundData.winner === 2}
|
||||
class:text-ct={selectedRoundData.winner === 3}
|
||||
>
|
||||
{selectedRoundData.winner === 2 ? 'Terrorists' : 'Counter-Terrorists'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-base-content/60">Win Reason</div>
|
||||
<div class="text-lg font-semibold text-base-content">
|
||||
{getWinReasonText(selectedRoundData.win_reason)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Player stats for the round if available -->
|
||||
{#if selectedRoundData.players && selectedRoundData.players.length > 0}
|
||||
<div class="mt-4">
|
||||
<h4 class="mb-2 text-sm font-semibold text-base-content">Round Economy</h4>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr class="border-base-300">
|
||||
<th>Player</th>
|
||||
<th>Bank</th>
|
||||
<th>Equipment</th>
|
||||
<th>Spent</th>
|
||||
{#if selectedRoundData.players.some((p) => p.kills_in_round !== undefined)}
|
||||
<th>Kills</th>
|
||||
{/if}
|
||||
{#if selectedRoundData.players.some((p) => p.damage_in_round !== undefined)}
|
||||
<th>Damage</th>
|
||||
{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each selectedRoundData.players as player}
|
||||
<tr class="border-base-300">
|
||||
<td class="font-medium"
|
||||
>Player {player.player_id || player.match_player_id || '?'}</td
|
||||
>
|
||||
<td class="font-mono text-success">${player.bank.toLocaleString()}</td>
|
||||
<td class="font-mono">${player.equipment.toLocaleString()}</td>
|
||||
<td class="font-mono text-error">${player.spent.toLocaleString()}</td>
|
||||
{#if selectedRoundData.players.some((p) => p.kills_in_round !== undefined)}
|
||||
<td class="font-mono">{player.kills_in_round || 0}</td>
|
||||
{/if}
|
||||
{#if selectedRoundData.players.some((p) => p.damage_in_round !== undefined)}
|
||||
<td class="font-mono">{player.damage_in_round || 0}</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
130
src/lib/components/charts/BarChart.svelte
Normal file
130
src/lib/components/charts/BarChart.svelte
Normal file
@@ -0,0 +1,130 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import {
|
||||
Chart,
|
||||
BarController,
|
||||
BarElement,
|
||||
LinearScale,
|
||||
CategoryScale,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
type ChartConfiguration
|
||||
} from 'chart.js';
|
||||
|
||||
// Register Chart.js components
|
||||
Chart.register(BarController, BarElement, LinearScale, CategoryScale, Title, Tooltip, Legend);
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
labels: string[];
|
||||
datasets: Array<{
|
||||
label: string;
|
||||
data: number[];
|
||||
backgroundColor?: string | string[];
|
||||
borderColor?: string | string[];
|
||||
borderWidth?: number;
|
||||
}>;
|
||||
};
|
||||
options?: Partial<ChartConfiguration<'bar'>['options']>;
|
||||
height?: number;
|
||||
horizontal?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
data,
|
||||
options = {},
|
||||
height = 300,
|
||||
horizontal = false,
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
|
||||
let canvas: HTMLCanvasElement;
|
||||
let chart: Chart<'bar'> | null = null;
|
||||
|
||||
// Convert Svelte 5 $state proxy to plain object for Chart.js compatibility
|
||||
// Using JSON parse/stringify to handle Svelte proxies that structuredClone can't handle
|
||||
const plainData = $derived(JSON.parse(JSON.stringify(data)));
|
||||
|
||||
const defaultOptions: ChartConfiguration<'bar'>['options'] = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
indexAxis: horizontal ? 'y' : 'x',
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
labels: {
|
||||
color: 'rgb(156, 163, 175)',
|
||||
font: {
|
||||
family: 'Inter, system-ui, sans-serif',
|
||||
size: 12
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
padding: 12,
|
||||
titleColor: '#fff',
|
||||
bodyColor: '#fff',
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
borderWidth: 1
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: 'rgba(156, 163, 175, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgb(156, 163, 175)',
|
||||
font: {
|
||||
size: 11
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: 'rgba(156, 163, 175, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgb(156, 163, 175)',
|
||||
font: {
|
||||
size: 11
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
chart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: plainData,
|
||||
options: { ...defaultOptions, ...options }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for data changes and update chart
|
||||
$effect(() => {
|
||||
if (chart && plainData) {
|
||||
chart.data = plainData;
|
||||
chart.options = { ...defaultOptions, ...options };
|
||||
chart.update();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative w-full {className}" style="height: {height}px">
|
||||
<canvas bind:this={canvas}></canvas>
|
||||
</div>
|
||||
139
src/lib/components/charts/LineChart.svelte
Normal file
139
src/lib/components/charts/LineChart.svelte
Normal file
@@ -0,0 +1,139 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import {
|
||||
Chart,
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
LinearScale,
|
||||
CategoryScale,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
type ChartConfiguration
|
||||
} from 'chart.js';
|
||||
|
||||
// Register Chart.js components
|
||||
Chart.register(
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
LinearScale,
|
||||
CategoryScale,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
);
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
labels: string[];
|
||||
datasets: Array<{
|
||||
label: string;
|
||||
data: number[];
|
||||
borderColor?: string;
|
||||
backgroundColor?: string;
|
||||
fill?: boolean;
|
||||
tension?: number;
|
||||
}>;
|
||||
};
|
||||
options?: Partial<ChartConfiguration<'line'>['options']>;
|
||||
height?: number;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { data, options = {}, height = 300, class: className = '' }: Props = $props();
|
||||
|
||||
let canvas: HTMLCanvasElement;
|
||||
let chart: Chart<'line'> | null = null;
|
||||
|
||||
// Convert Svelte 5 $state proxy to plain object for Chart.js compatibility
|
||||
// Using JSON parse/stringify to handle Svelte proxies that structuredClone can't handle
|
||||
const plainData = $derived(JSON.parse(JSON.stringify(data)));
|
||||
|
||||
const defaultOptions: ChartConfiguration<'line'>['options'] = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
labels: {
|
||||
color: 'rgb(156, 163, 175)',
|
||||
font: {
|
||||
family: 'Inter, system-ui, sans-serif',
|
||||
size: 12
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
padding: 12,
|
||||
titleColor: '#fff',
|
||||
bodyColor: '#fff',
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
borderWidth: 1
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: 'rgba(156, 163, 175, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgb(156, 163, 175)',
|
||||
font: {
|
||||
size: 11
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: 'rgba(156, 163, 175, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgb(156, 163, 175)',
|
||||
font: {
|
||||
size: 11
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: plainData,
|
||||
options: { ...defaultOptions, ...options }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for data changes and update chart
|
||||
$effect(() => {
|
||||
if (chart && plainData) {
|
||||
chart.data = plainData;
|
||||
chart.options = { ...defaultOptions, ...options };
|
||||
chart.update();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative w-full {className}" style="height: {height}px">
|
||||
<canvas bind:this={canvas}></canvas>
|
||||
</div>
|
||||
105
src/lib/components/charts/PieChart.svelte
Normal file
105
src/lib/components/charts/PieChart.svelte
Normal file
@@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import {
|
||||
Chart,
|
||||
DoughnutController,
|
||||
ArcElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
type ChartConfiguration
|
||||
} from 'chart.js';
|
||||
|
||||
// Register Chart.js components
|
||||
Chart.register(DoughnutController, ArcElement, Title, Tooltip, Legend);
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
labels: string[];
|
||||
datasets: Array<{
|
||||
label?: string;
|
||||
data: number[];
|
||||
backgroundColor?: string[];
|
||||
borderColor?: string[];
|
||||
borderWidth?: number;
|
||||
}>;
|
||||
};
|
||||
options?: Partial<ChartConfiguration<'doughnut'>['options']>;
|
||||
height?: number;
|
||||
doughnut?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
data,
|
||||
options = {},
|
||||
height = 300,
|
||||
doughnut = true,
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
|
||||
let canvas: HTMLCanvasElement;
|
||||
let chart: Chart<'doughnut'> | null = null;
|
||||
|
||||
// Convert Svelte 5 $state proxy to plain object for Chart.js compatibility
|
||||
// Using JSON parse/stringify to handle Svelte proxies that structuredClone can't handle
|
||||
const plainData = $derived(JSON.parse(JSON.stringify(data)));
|
||||
|
||||
const defaultOptions: ChartConfiguration<'doughnut'>['options'] = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: doughnut ? '60%' : '0%',
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: 'rgb(156, 163, 175)',
|
||||
font: {
|
||||
family: 'Inter, system-ui, sans-serif',
|
||||
size: 12
|
||||
},
|
||||
padding: 15
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
padding: 12,
|
||||
titleColor: '#fff',
|
||||
bodyColor: '#fff',
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
borderWidth: 1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
chart = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: plainData,
|
||||
options: { ...defaultOptions, ...options }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for data changes and update chart
|
||||
$effect(() => {
|
||||
if (chart && plainData) {
|
||||
chart.data = plainData;
|
||||
chart.options = { ...defaultOptions, ...options };
|
||||
chart.update();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative w-full {className}" style="height: {height}px">
|
||||
<canvas bind:this={canvas}></canvas>
|
||||
</div>
|
||||
131
src/lib/components/data-display/DataTable.svelte
Normal file
131
src/lib/components/data-display/DataTable.svelte
Normal file
@@ -0,0 +1,131 @@
|
||||
<script lang="ts" generics="T">
|
||||
/* eslint-disable no-undef */
|
||||
import { ArrowUp, ArrowDown } from 'lucide-svelte';
|
||||
|
||||
interface Column<T> {
|
||||
key: keyof T;
|
||||
label: string;
|
||||
sortable?: boolean;
|
||||
format?: (value: T[keyof T], row: T) => string;
|
||||
render?: (value: T[keyof T], row: T) => unknown;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
class?: string;
|
||||
width?: string; // e.g., '200px', '30%', 'auto'
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: T[];
|
||||
columns: Column<T>[];
|
||||
class?: string;
|
||||
striped?: boolean;
|
||||
hoverable?: boolean;
|
||||
compact?: boolean;
|
||||
fixedLayout?: boolean; // Use table-layout: fixed for consistent column widths
|
||||
}
|
||||
|
||||
let {
|
||||
data,
|
||||
columns,
|
||||
class: className = '',
|
||||
striped = false,
|
||||
hoverable = true,
|
||||
compact = false,
|
||||
fixedLayout = false
|
||||
}: Props = $props();
|
||||
|
||||
let sortKey = $state<keyof T | null>(null);
|
||||
let sortDirection = $state<'asc' | 'desc'>('asc');
|
||||
|
||||
const handleSort = (column: Column<T>) => {
|
||||
if (!column.sortable) return;
|
||||
|
||||
if (sortKey === column.key) {
|
||||
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
sortKey = column.key;
|
||||
sortDirection = 'asc';
|
||||
}
|
||||
};
|
||||
|
||||
const sortedData = $derived(
|
||||
!sortKey
|
||||
? data
|
||||
: [...data].sort((a, b) => {
|
||||
const aVal = a[sortKey as keyof T];
|
||||
const bVal = b[sortKey as keyof T];
|
||||
|
||||
if (aVal === bVal) return 0;
|
||||
|
||||
const comparison = aVal < bVal ? -1 : 1;
|
||||
return sortDirection === 'asc' ? comparison : -comparison;
|
||||
})
|
||||
);
|
||||
|
||||
const getValue = (row: T, column: Column<T>) => {
|
||||
const value = row[column.key];
|
||||
if (column.format) {
|
||||
return column.format(value, row);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="overflow-x-auto {className}">
|
||||
<table
|
||||
class="table"
|
||||
class:table-zebra={striped}
|
||||
class:table-xs={compact}
|
||||
style={fixedLayout ? 'table-layout: fixed;' : ''}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
{#each columns as column}
|
||||
<th
|
||||
class:cursor-pointer={column.sortable}
|
||||
class:hover:bg-base-200={column.sortable}
|
||||
class="text-{column.align || 'left'} {column.class || ''}"
|
||||
style={column.width ? `width: ${column.width}` : ''}
|
||||
onclick={() => handleSort(column)}
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2"
|
||||
class:justify-end={column.align === 'right'}
|
||||
class:justify-center={column.align === 'center'}
|
||||
>
|
||||
<span>{column.label}</span>
|
||||
{#if column.sortable}
|
||||
<div class="flex flex-col opacity-40">
|
||||
<ArrowUp
|
||||
class="h-3 w-3 {sortKey === column.key && sortDirection === 'asc'
|
||||
? 'text-primary opacity-100'
|
||||
: ''}"
|
||||
/>
|
||||
<ArrowDown
|
||||
class="-mt-1 h-3 w-3 {sortKey === column.key && sortDirection === 'desc'
|
||||
? 'text-primary opacity-100'
|
||||
: ''}"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sortedData as row}
|
||||
<tr class:hover={hoverable}>
|
||||
{#each columns as column}
|
||||
<td class="text-{column.align || 'left'} {column.class || ''}">
|
||||
{#if column.render}
|
||||
{@html column.render(row[column.key], row)}
|
||||
{:else}
|
||||
{getValue(row, column)}
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
132
src/lib/components/layout/Footer.svelte
Normal file
132
src/lib/components/layout/Footer.svelte
Normal file
@@ -0,0 +1,132 @@
|
||||
<script lang="ts">
|
||||
import { Github, Heart } from 'lucide-svelte';
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const links = {
|
||||
main: [
|
||||
{ name: 'Home', href: '/' },
|
||||
{ name: 'Matches', href: '/matches' },
|
||||
{ name: 'Players', href: '/players' },
|
||||
{ name: 'API Docs', href: '/docs/api' }
|
||||
],
|
||||
about: [
|
||||
{ name: 'About', href: '/about' },
|
||||
{ name: 'FAQ', href: '/faq' },
|
||||
{ name: 'Privacy', href: '/privacy' },
|
||||
{ name: 'Terms', href: '/terms' }
|
||||
],
|
||||
resources: [
|
||||
{ name: 'GitHub', href: 'https://somegit.dev/CSGOWTF/csgowtf', external: true },
|
||||
{ name: 'Backend', href: 'https://somegit.dev/CSGOWTF/csgowtfd', external: true },
|
||||
{
|
||||
name: 'Donate',
|
||||
href: 'https://liberapay.com/CSGOWTF/',
|
||||
external: true
|
||||
}
|
||||
]
|
||||
};
|
||||
</script>
|
||||
|
||||
<footer class="border-t border-base-300 bg-base-100">
|
||||
<div class="container mx-auto px-4 py-12">
|
||||
<div class="grid gap-8 md:grid-cols-4">
|
||||
<!-- Brand -->
|
||||
<div class="md:col-span-1">
|
||||
<a href="/" class="mb-4 inline-block text-2xl font-bold">
|
||||
<span class="text-primary">CS2</span><span class="text-secondary">.WTF</span>
|
||||
</a>
|
||||
<p class="mb-4 text-sm text-base-content/60">
|
||||
Statistics for CS2 matchmaking matches. Free and open source.
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<a
|
||||
href="https://somegit.dev/CSGOWTF/csgowtf"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-base-content/60 transition-colors hover:text-primary"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
<Github class="h-5 w-5" />
|
||||
</a>
|
||||
<a
|
||||
href="https://liberapay.com/CSGOWTF/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-base-content/60 transition-colors hover:text-error"
|
||||
aria-label="Support on Liberapay"
|
||||
>
|
||||
<Heart class="h-5 w-5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Links -->
|
||||
<div>
|
||||
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wider text-base-content/80">
|
||||
Navigate
|
||||
</h3>
|
||||
<ul class="space-y-2">
|
||||
{#each links.main as link}
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-sm text-base-content/60 transition-colors hover:text-primary"
|
||||
>
|
||||
{link.name}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wider text-base-content/80">
|
||||
About
|
||||
</h3>
|
||||
<ul class="space-y-2">
|
||||
{#each links.about as link}
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-sm text-base-content/60 transition-colors hover:text-primary"
|
||||
>
|
||||
{link.name}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wider text-base-content/80">
|
||||
Resources
|
||||
</h3>
|
||||
<ul class="space-y-2">
|
||||
{#each links.resources as link}
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-sm text-base-content/60 transition-colors hover:text-primary"
|
||||
{...link.external ? { target: '_blank', rel: 'noopener noreferrer' } : {}}
|
||||
>
|
||||
{link.name}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom -->
|
||||
<div class="mt-12 border-t border-base-300 pt-8 text-center text-sm text-base-content/60">
|
||||
<p>
|
||||
© {currentYear} CSGOW.TF Team. Licensed under
|
||||
<a href="/license" class="hover:text-primary">GPL-3.0</a>
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
Made with <Heart class="inline h-4 w-4 text-error" /> by the community, for the community.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
73
src/lib/components/layout/Header.svelte
Normal file
73
src/lib/components/layout/Header.svelte
Normal file
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import { Menu, X } from 'lucide-svelte';
|
||||
import SearchBar from './SearchBar.svelte';
|
||||
import ThemeToggle from './ThemeToggle.svelte';
|
||||
|
||||
let mobileMenuOpen = $state(false);
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Home', href: '/' },
|
||||
{ name: 'Matches', href: '/matches' },
|
||||
{ name: 'Players', href: '/players' },
|
||||
{ name: 'About', href: '/about' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<header class="sticky top-0 z-50 w-full border-b border-base-300 bg-base-100/95 backdrop-blur-md">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex h-16 items-center justify-between">
|
||||
<!-- Logo -->
|
||||
<a href="/" class="transition-transform hover:scale-105" aria-label="CS2.WTF Home">
|
||||
<h1 class="text-2xl font-bold">
|
||||
<span class="text-primary">CS2</span><span class="text-secondary">.WTF</span>
|
||||
</h1>
|
||||
</a>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<nav class="hidden items-center gap-6 md:flex">
|
||||
{#each navigation as item}
|
||||
<a
|
||||
href={item.href}
|
||||
class="text-sm font-medium text-base-content/70 transition-colors hover:text-primary"
|
||||
>
|
||||
{item.name}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- Search & Actions -->
|
||||
<div class="flex items-center gap-2">
|
||||
<SearchBar />
|
||||
<ThemeToggle />
|
||||
|
||||
<!-- Mobile Menu Toggle -->
|
||||
<button
|
||||
class="btn btn-ghost btn-sm md:hidden"
|
||||
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{#if mobileMenuOpen}
|
||||
<X class="h-5 w-5" />
|
||||
{:else}
|
||||
<Menu class="h-5 w-5" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Navigation -->
|
||||
{#if mobileMenuOpen}
|
||||
<nav class="animate-fade-in border-t border-base-300 py-4 md:hidden">
|
||||
{#each navigation as item}
|
||||
<a
|
||||
href={item.href}
|
||||
class="block px-4 py-2 text-sm font-medium text-base-content transition-colors hover:bg-base-200"
|
||||
onclick={() => (mobileMenuOpen = false)}
|
||||
>
|
||||
{item.name}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
116
src/lib/components/layout/SearchBar.svelte
Normal file
116
src/lib/components/layout/SearchBar.svelte
Normal file
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Search, Command } from 'lucide-svelte';
|
||||
import { search } from '$lib/stores';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
|
||||
let open = $state(false);
|
||||
let query = $state('');
|
||||
let searchInput: HTMLInputElement;
|
||||
|
||||
// Keyboard shortcut: Cmd/Ctrl + K
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
open = true;
|
||||
setTimeout(() => searchInput?.focus(), 100);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (e: Event) => {
|
||||
e.preventDefault();
|
||||
if (!query.trim()) return;
|
||||
|
||||
// Add to recent searches
|
||||
search.addRecentSearch(query);
|
||||
|
||||
// Navigate to matches page with search query
|
||||
goto(`/matches?search=${encodeURIComponent(query)}`);
|
||||
|
||||
// Close modal and clear
|
||||
open = false;
|
||||
query = '';
|
||||
};
|
||||
|
||||
const handleRecentClick = (recentQuery: string) => {
|
||||
query = recentQuery;
|
||||
handleSearch(new Event('submit'));
|
||||
};
|
||||
|
||||
const handleClearRecent = () => {
|
||||
search.clearRecentSearches();
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- Search Button (Header) -->
|
||||
<button
|
||||
class="btn btn-ghost gap-2"
|
||||
onclick={() => {
|
||||
open = true;
|
||||
setTimeout(() => searchInput?.focus(), 100);
|
||||
}}
|
||||
aria-label="Search"
|
||||
>
|
||||
<Search class="h-5 w-5" />
|
||||
<span class="hidden md:inline">Search</span>
|
||||
<kbd class="kbd kbd-sm hidden lg:inline-flex">
|
||||
<Command class="h-3 w-3" />
|
||||
K
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
<!-- Search Modal -->
|
||||
<Modal bind:open size="lg">
|
||||
<div class="space-y-4">
|
||||
<form onsubmit={handleSearch}>
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
<Search class="h-5 w-5 text-base-content/60" />
|
||||
<input
|
||||
bind:this={searchInput}
|
||||
bind:value={query}
|
||||
type="text"
|
||||
class="grow"
|
||||
placeholder="Search matches, players, share codes..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
<kbd class="kbd kbd-sm">
|
||||
<Command class="h-3 w-3" />
|
||||
K
|
||||
</kbd>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<!-- Recent Searches -->
|
||||
{#if $search.recentSearches.length > 0}
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-base-content/70">Recent Searches</h3>
|
||||
<button class="btn btn-ghost btn-xs" onclick={handleClearRecent}>Clear</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each $search.recentSearches as recent}
|
||||
<button
|
||||
class="badge badge-outline badge-lg gap-2 hover:badge-primary"
|
||||
onclick={() => handleRecentClick(recent)}
|
||||
>
|
||||
<Search class="h-3 w-3" />
|
||||
{recent}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Search Tips -->
|
||||
<div class="rounded-lg bg-base-200 p-4">
|
||||
<h4 class="mb-2 text-sm font-semibold text-base-content">Search Tips</h4>
|
||||
<ul class="space-y-1 text-xs text-base-content/70">
|
||||
<li>• Search by player name or Steam ID</li>
|
||||
<li>• Enter share code to find specific match</li>
|
||||
<li>• Use map name to filter matches (e.g., "de_dust2")</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
71
src/lib/components/layout/ThemeToggle.svelte
Normal file
71
src/lib/components/layout/ThemeToggle.svelte
Normal file
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import { Moon, Sun, Monitor } from 'lucide-svelte';
|
||||
import { preferences } from '$lib/stores';
|
||||
import { browser } from '$app/environment';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
const themes = [
|
||||
{ value: 'cs2light', label: 'Light', icon: Sun },
|
||||
{ value: 'cs2dark', label: 'Dark', icon: Moon },
|
||||
{ value: 'auto', label: 'Auto', icon: Monitor }
|
||||
] as const;
|
||||
|
||||
// Get current theme data
|
||||
const currentTheme = $derived(themes.find((t) => t.value === $preferences.theme) || themes[2]);
|
||||
|
||||
const applyTheme = (theme: 'cs2light' | 'cs2dark' | 'auto') => {
|
||||
if (!browser) return;
|
||||
|
||||
let actualTheme = theme;
|
||||
|
||||
if (theme === 'auto') {
|
||||
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
actualTheme = isDark ? 'cs2dark' : 'cs2light';
|
||||
}
|
||||
|
||||
document.documentElement.setAttribute('data-theme', actualTheme);
|
||||
};
|
||||
|
||||
const handleThemeChange = (theme: 'cs2light' | 'cs2dark' | 'auto') => {
|
||||
preferences.setTheme(theme);
|
||||
applyTheme(theme);
|
||||
};
|
||||
|
||||
// Apply theme on mount and when system preference changes
|
||||
onMount(() => {
|
||||
applyTheme($preferences.theme);
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = () => {
|
||||
if ($preferences.theme === 'auto') {
|
||||
applyTheme('auto');
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handler);
|
||||
return () => mediaQuery.removeEventListener('change', handler);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Theme Toggle Dropdown -->
|
||||
<div class="dropdown dropdown-end">
|
||||
<button tabindex="0" class="btn btn-circle btn-ghost" aria-label="Theme">
|
||||
<currentTheme.icon class="h-5 w-5" />
|
||||
</button>
|
||||
<ul class="menu dropdown-content z-[1] mt-3 w-52 rounded-box bg-base-100 p-2 shadow-lg">
|
||||
{#each themes as theme}
|
||||
<li>
|
||||
<button
|
||||
class:active={$preferences.theme === theme.value}
|
||||
onclick={() => handleThemeChange(theme.value)}
|
||||
>
|
||||
<theme.icon class="h-4 w-4" />
|
||||
{theme.label}
|
||||
{#if theme.value === 'auto'}
|
||||
<span class="text-xs text-base-content/60">(System)</span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
99
src/lib/components/match/MatchCard.svelte
Normal file
99
src/lib/components/match/MatchCard.svelte
Normal file
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import type { MatchListItem } from '$lib/types';
|
||||
import { storeMatchesState } from '$lib/utils/navigation';
|
||||
import { getMapBackground, formatMapName } from '$lib/utils/mapAssets';
|
||||
|
||||
interface Props {
|
||||
match: MatchListItem;
|
||||
loadedCount?: number;
|
||||
}
|
||||
|
||||
let { match, loadedCount = 0 }: Props = $props();
|
||||
|
||||
const formattedDate = new Date(match.date).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
const mapName = formatMapName(match.map);
|
||||
const mapBg = getMapBackground(match.map);
|
||||
|
||||
function handleClick() {
|
||||
// Store navigation state before navigating
|
||||
storeMatchesState(match.match_id, loadedCount);
|
||||
}
|
||||
|
||||
function handleImageError(event: Event) {
|
||||
const img = event.target as HTMLImageElement;
|
||||
img.src = '/images/map_screenshots/default.webp';
|
||||
}
|
||||
</script>
|
||||
|
||||
<a
|
||||
href={`/match/${match.match_id}`}
|
||||
class="block transition-transform hover:scale-[1.02]"
|
||||
data-match-id={match.match_id}
|
||||
onclick={handleClick}
|
||||
>
|
||||
<div
|
||||
class="overflow-hidden rounded-lg border border-base-300 bg-base-100 shadow-md transition-shadow hover:shadow-xl"
|
||||
>
|
||||
<!-- Map Header with Background Image -->
|
||||
<div class="relative h-32 overflow-hidden">
|
||||
<!-- Background Image -->
|
||||
<img
|
||||
src={mapBg}
|
||||
alt={mapName}
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
onerror={handleImageError}
|
||||
/>
|
||||
<!-- Overlay for better text contrast -->
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-black/20"></div>
|
||||
<!-- Content -->
|
||||
<div class="relative flex h-full items-end justify-between p-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
{#if match.map}
|
||||
<Badge variant="default">{match.map}</Badge>
|
||||
{/if}
|
||||
<span class="text-lg font-bold text-white drop-shadow-lg">{mapName}</span>
|
||||
</div>
|
||||
{#if match.demo_parsed}
|
||||
<Badge variant="success" size="sm">Parsed</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Match Info -->
|
||||
<div class="p-4">
|
||||
<!-- Score -->
|
||||
<div class="mb-3 flex items-center justify-center gap-3">
|
||||
<span class="font-mono text-2xl font-bold text-terrorist">{match.score_team_a}</span>
|
||||
<span class="text-base-content/40">-</span>
|
||||
<span class="font-mono text-2xl font-bold text-ct">{match.score_team_b}</span>
|
||||
</div>
|
||||
|
||||
<!-- Meta -->
|
||||
<div class="flex items-center justify-between text-sm text-base-content/60">
|
||||
<span>{formattedDate}</span>
|
||||
{#if match.duration}
|
||||
<span>{Math.floor(match.duration / 60)}m</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Result Badge (inferred from score) -->
|
||||
<div class="mt-3 flex justify-center">
|
||||
{#if match.score_team_a === match.score_team_b}
|
||||
<Badge variant="warning" size="sm">Tie</Badge>
|
||||
{:else if match.score_team_a > match.score_team_b}
|
||||
<Badge variant="success" size="sm">Team A Win</Badge>
|
||||
{:else}
|
||||
<Badge variant="error" size="sm">Team B Win</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
155
src/lib/components/match/ShareCodeInput.svelte
Normal file
155
src/lib/components/match/ShareCodeInput.svelte
Normal file
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
import { Upload, Check, AlertCircle, Loader2 } from 'lucide-svelte';
|
||||
import { matchesAPI } from '$lib/api/matches';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let shareCode = $state('');
|
||||
let isLoading = $state(false);
|
||||
let parseStatus: 'idle' | 'parsing' | 'success' | 'error' = $state('idle');
|
||||
let statusMessage = $state('');
|
||||
let parsedMatchId = $state('');
|
||||
|
||||
// Validate share code format
|
||||
function isValidShareCode(code: string): boolean {
|
||||
// Format: CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX
|
||||
const pattern = /^CSGO-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}$/;
|
||||
return pattern.test(code.toUpperCase());
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const trimmedCode = shareCode.trim().toUpperCase();
|
||||
|
||||
if (!trimmedCode) {
|
||||
toast.error('Please enter a share code');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidShareCode(trimmedCode)) {
|
||||
toast.error('Invalid share code format');
|
||||
parseStatus = 'error';
|
||||
statusMessage = 'Share code must be in format: CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX';
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
parseStatus = 'parsing';
|
||||
statusMessage = 'Submitting share code for parsing...';
|
||||
|
||||
try {
|
||||
const response = await matchesAPI.parseMatch(trimmedCode);
|
||||
|
||||
if (response.match_id) {
|
||||
parsedMatchId = response.match_id;
|
||||
parseStatus = 'success';
|
||||
statusMessage =
|
||||
response.message ||
|
||||
'Match submitted successfully! Parsing may take a few minutes. You can view the match once parsing is complete.';
|
||||
toast.success('Match submitted for parsing!');
|
||||
|
||||
// Wait a moment then redirect to the match page
|
||||
setTimeout(() => {
|
||||
goto(`/match/${response.match_id}`);
|
||||
}, 2000);
|
||||
} else {
|
||||
parseStatus = 'error';
|
||||
statusMessage = response.message || 'Failed to parse share code';
|
||||
toast.error(statusMessage);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
parseStatus = 'error';
|
||||
statusMessage = error instanceof Error ? error.message : 'Failed to parse share code';
|
||||
toast.error(statusMessage);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
shareCode = '';
|
||||
parseStatus = 'idle';
|
||||
statusMessage = '';
|
||||
parsedMatchId = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Input Section -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="shareCode">
|
||||
<span class="label-text font-medium">Submit Match Share Code</span>
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
id="shareCode"
|
||||
type="text"
|
||||
placeholder="CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX"
|
||||
class="input input-bordered flex-1"
|
||||
bind:value={shareCode}
|
||||
disabled={isLoading}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleSubmit()}
|
||||
/>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick={handleSubmit}
|
||||
disabled={isLoading || !shareCode.trim()}
|
||||
>
|
||||
{#if isLoading}
|
||||
<Loader2 class="h-5 w-5 animate-spin" />
|
||||
{:else}
|
||||
<Upload class="h-5 w-5" />
|
||||
{/if}
|
||||
Parse
|
||||
</button>
|
||||
</div>
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/60">
|
||||
Submit a CS2 match share code to add it to the database
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Messages -->
|
||||
{#if parseStatus !== 'idle'}
|
||||
<div
|
||||
class="alert {parseStatus === 'success'
|
||||
? 'alert-success'
|
||||
: parseStatus === 'error'
|
||||
? 'alert-error'
|
||||
: 'alert-info'}"
|
||||
>
|
||||
{#if parseStatus === 'parsing'}
|
||||
<Loader2 class="h-6 w-6 shrink-0 animate-spin stroke-current" />
|
||||
{:else if parseStatus === 'success'}
|
||||
<Check class="h-6 w-6 shrink-0 stroke-current" />
|
||||
{:else}
|
||||
<AlertCircle class="h-6 w-6 shrink-0 stroke-current" />
|
||||
{/if}
|
||||
<div class="flex-1">
|
||||
<p>{statusMessage}</p>
|
||||
{#if parseStatus === 'success' && parsedMatchId}
|
||||
<p class="mt-1 text-sm">Redirecting to match page...</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if parseStatus !== 'parsing'}
|
||||
<button class="btn btn-ghost btn-sm" onclick={resetForm}>Dismiss</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Help Text -->
|
||||
<div class="text-sm text-base-content/70">
|
||||
<p class="mb-2 font-medium">How to get your match share code:</p>
|
||||
<ol class="list-inside list-decimal space-y-1">
|
||||
<li>Open CS2 and navigate to your Matches tab</li>
|
||||
<li>Click on a match you want to analyze</li>
|
||||
<li>Click the "Copy Share Link" button</li>
|
||||
<li>Paste the share code here</li>
|
||||
</ol>
|
||||
<p class="mt-2 text-xs">
|
||||
Note: Demo parsing can take 1-5 minutes depending on match length. You'll be able to view
|
||||
basic match info immediately, but detailed statistics will be available after parsing
|
||||
completes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
73
src/lib/components/player/PlayerCard.svelte
Normal file
73
src/lib/components/player/PlayerCard.svelte
Normal file
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import { User, TrendingUp, Target } from 'lucide-svelte';
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import type { PlayerMeta } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
player: PlayerMeta;
|
||||
showStats?: boolean;
|
||||
}
|
||||
|
||||
let { player, showStats = true }: Props = $props();
|
||||
|
||||
const kd =
|
||||
player.avg_deaths > 0
|
||||
? (player.avg_kills / player.avg_deaths).toFixed(2)
|
||||
: player.avg_kills.toFixed(2);
|
||||
const winRate = (player.win_rate * 100).toFixed(1);
|
||||
</script>
|
||||
|
||||
<a
|
||||
href={`/player/${player.id}`}
|
||||
class="block overflow-hidden rounded-lg border border-base-300 bg-base-100 shadow-md transition-all hover:scale-[1.02] hover:shadow-xl"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="bg-gradient-to-r from-primary/20 to-secondary/20 p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-base-100">
|
||||
<User class="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="truncate text-lg font-bold text-base-content">{player.name}</h3>
|
||||
<p class="text-sm text-base-content/60">ID: {player.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showStats}
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-3 gap-4 p-4">
|
||||
<div class="text-center">
|
||||
<div class="mb-1 flex items-center justify-center">
|
||||
<Target class="mr-1 h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div class="text-xl font-bold text-base-content">{kd}</div>
|
||||
<div class="text-xs text-base-content/60">K/D</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="mb-1 flex items-center justify-center">
|
||||
<TrendingUp class="mr-1 h-4 w-4 text-success" />
|
||||
</div>
|
||||
<div class="text-xl font-bold text-base-content">{winRate}%</div>
|
||||
<div class="text-xs text-base-content/60">Win Rate</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="mb-1 flex items-center justify-center">
|
||||
<User class="mr-1 h-4 w-4 text-info" />
|
||||
</div>
|
||||
<div class="text-xl font-bold text-base-content">{player.recent_matches}</div>
|
||||
<div class="text-xs text-base-content/60">Matches</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="border-t border-base-300 bg-base-200 px-4 py-3">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-base-content/60">Avg KAST:</span>
|
||||
<Badge variant="info" size="sm">{player.avg_kast.toFixed(1)}%</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
81
src/lib/components/player/RecentPlayers.svelte
Normal file
81
src/lib/components/player/RecentPlayers.svelte
Normal file
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import { Clock, X } from 'lucide-svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
getRecentPlayers,
|
||||
removeRecentPlayer,
|
||||
type RecentPlayer
|
||||
} from '$lib/utils/recentPlayers';
|
||||
|
||||
let recentPlayers = $state<RecentPlayer[]>([]);
|
||||
|
||||
onMount(() => {
|
||||
recentPlayers = getRecentPlayers();
|
||||
});
|
||||
|
||||
function handleRemove(playerId: string) {
|
||||
removeRecentPlayer(playerId);
|
||||
recentPlayers = getRecentPlayers();
|
||||
}
|
||||
|
||||
function formatTimeAgo(timestamp: number): string {
|
||||
const now = Date.now();
|
||||
const diff = now - timestamp;
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
if (minutes < 1) return 'Just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${days}d ago`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if recentPlayers.length > 0}
|
||||
<Card padding="lg">
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<Clock class="h-5 w-5 text-primary" />
|
||||
<h2 class="text-xl font-bold text-base-content">Recently Visited Players</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{#each recentPlayers as player (player.id)}
|
||||
<div
|
||||
class="group relative rounded-lg border border-base-300 bg-base-200 p-3 transition-all hover:border-primary hover:shadow-lg"
|
||||
>
|
||||
<a href="/player/{player.id}" class="flex items-center gap-3">
|
||||
<img
|
||||
src={player.avatar}
|
||||
alt={player.name}
|
||||
class="h-12 w-12 rounded-full border-2 border-base-300"
|
||||
/>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<div class="truncate font-medium text-base-content">{player.name}</div>
|
||||
<div class="text-xs text-base-content/60">{formatTimeAgo(player.visitedAt)}</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Remove button -->
|
||||
<button
|
||||
class="btn btn-circle btn-ghost btn-xs absolute right-1 top-1 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
handleRemove(player.id);
|
||||
}}
|
||||
aria-label="Remove from recent players"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-center text-xs text-base-content/60">
|
||||
Showing up to {recentPlayers.length} recently visited player{recentPlayers.length !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
180
src/lib/components/player/TrackPlayerModal.svelte
Normal file
180
src/lib/components/player/TrackPlayerModal.svelte
Normal file
@@ -0,0 +1,180 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import { playersAPI } from '$lib/api/players';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
|
||||
interface Props {
|
||||
playerId: string;
|
||||
playerName: string;
|
||||
isTracked: boolean;
|
||||
open: boolean;
|
||||
ontracked?: () => void;
|
||||
onuntracked?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
playerId,
|
||||
playerName,
|
||||
isTracked,
|
||||
open = $bindable(),
|
||||
ontracked,
|
||||
onuntracked
|
||||
}: Props = $props();
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let authCode = $state('');
|
||||
let isLoading = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
async function handleTrack() {
|
||||
if (!authCode.trim()) {
|
||||
error = 'Auth code is required';
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
await playersAPI.trackPlayer(playerId, authCode);
|
||||
toast.success('Player tracking activated successfully!');
|
||||
open = false;
|
||||
dispatch('tracked');
|
||||
ontracked?.();
|
||||
} catch (err: unknown) {
|
||||
error = err instanceof Error ? err.message : 'Failed to track player';
|
||||
toast.error(error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUntrack() {
|
||||
isLoading = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
await playersAPI.untrackPlayer(playerId);
|
||||
toast.success('Player tracking removed successfully');
|
||||
open = false;
|
||||
dispatch('untracked');
|
||||
onuntracked?.();
|
||||
} catch (err: unknown) {
|
||||
error = err instanceof Error ? err.message : 'Failed to untrack player';
|
||||
toast.error(error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
open = false;
|
||||
authCode = '';
|
||||
error = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:open onClose={handleClose} title={isTracked ? 'Untrack Player' : 'Track Player'}>
|
||||
<div class="space-y-4">
|
||||
<div class="alert alert-info">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<div class="text-sm">
|
||||
{#if isTracked}
|
||||
<p>Remove <strong>{playerName}</strong> from automatic match tracking.</p>
|
||||
{:else}
|
||||
<p>
|
||||
Add <strong>{playerName}</strong> to the tracking system to automatically fetch new matches.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auth Code Input (only for tracking, untrack doesn't need auth) -->
|
||||
{#if !isTracked}
|
||||
<div class="form-control">
|
||||
<label class="label" for="authCode">
|
||||
<span class="label-text font-medium">Authentication Code *</span>
|
||||
</label>
|
||||
<input
|
||||
id="authCode"
|
||||
type="text"
|
||||
placeholder="Enter your auth code"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={authCode}
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/60">
|
||||
Required to verify ownership of this Steam account
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error Message -->
|
||||
{#if error}
|
||||
<div class="alert alert-error">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Help Text -->
|
||||
<div class="text-sm text-base-content/70">
|
||||
<p class="mb-2 font-medium">How to get your authentication code:</p>
|
||||
<ol class="list-inside list-decimal space-y-1">
|
||||
<li>Open CS2 and go to Settings → Game</li>
|
||||
<li>Enable the Developer Console</li>
|
||||
<li>Press <kbd class="kbd kbd-sm">~</kbd> to open the console</li>
|
||||
<li>Type: <code class="rounded bg-base-300 px-1">status</code></li>
|
||||
<li>Copy the code shown next to "Account:"</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#snippet actions()}
|
||||
<button class="btn" onclick={handleClose} disabled={isLoading}>Cancel</button>
|
||||
{#if isTracked}
|
||||
<button class="btn btn-error" onclick={handleUntrack} disabled={isLoading}>
|
||||
{#if isLoading}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{/if}
|
||||
Untrack Player
|
||||
</button>
|
||||
{:else}
|
||||
<button class="btn btn-primary" onclick={handleTrack} disabled={isLoading}>
|
||||
{#if isLoading}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{/if}
|
||||
Track Player
|
||||
</button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Modal>
|
||||
37
src/lib/components/ui/Badge.svelte
Normal file
37
src/lib/components/ui/Badge.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
variant?: 'default' | 't-side' | 'ct-side' | 'success' | 'warning' | 'error' | 'info';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { variant = 'default', size = 'md', class: className = '', children }: Props = $props();
|
||||
|
||||
const baseClasses = 'inline-flex items-center justify-center font-medium border rounded';
|
||||
|
||||
const variantClasses = {
|
||||
default: 'bg-base-300/50 border-base-content/20 text-base-content',
|
||||
't-side':
|
||||
'bg-terrorist/10 border-terrorist/30 text-terrorist-light backdrop-blur-sm font-semibold',
|
||||
'ct-side': 'bg-ct/10 border-ct/30 text-ct-light backdrop-blur-sm font-semibold',
|
||||
success: 'bg-success/10 border-success/30 text-success',
|
||||
warning: 'bg-warning/10 border-warning/30 text-warning',
|
||||
error: 'bg-error/10 border-error/30 text-error',
|
||||
info: 'bg-info/10 border-info/30 text-info'
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-2 py-0.5 text-xs',
|
||||
md: 'px-2.5 py-1 text-sm',
|
||||
lg: 'px-3 py-1.5 text-base'
|
||||
};
|
||||
|
||||
const classes = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`;
|
||||
</script>
|
||||
|
||||
<span class={classes}>
|
||||
{@render children()}
|
||||
</span>
|
||||
60
src/lib/components/ui/Button.svelte
Normal file
60
src/lib/components/ui/Button.svelte
Normal file
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
href?: string;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
disabled?: boolean;
|
||||
class?: string;
|
||||
onclick?: () => void;
|
||||
target?: string;
|
||||
rel?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
href,
|
||||
type = 'button',
|
||||
disabled = false,
|
||||
class: className = '',
|
||||
onclick,
|
||||
target,
|
||||
rel,
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
const baseClasses =
|
||||
'inline-flex items-center justify-center font-semibold transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-base-100 disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
const variantClasses = {
|
||||
primary:
|
||||
'bg-primary text-white hover:bg-primary-focus focus:ring-primary shadow-sm hover:shadow-lg hover:shadow-primary/30',
|
||||
secondary:
|
||||
'bg-secondary text-white hover:bg-secondary-focus focus:ring-secondary shadow-sm hover:shadow-lg hover:shadow-secondary/30',
|
||||
ghost:
|
||||
'bg-transparent border border-base-300 text-base-content hover:bg-base-300 hover:border-primary focus:ring-primary',
|
||||
danger: 'bg-error text-white hover:bg-error/90 focus:ring-error shadow-sm hover:shadow-lg'
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-3 py-1.5 text-sm rounded',
|
||||
md: 'px-4 py-2 text-base rounded-md',
|
||||
lg: 'px-6 py-3 text-lg rounded-lg'
|
||||
};
|
||||
|
||||
const classes = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`;
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a {href} {target} {rel} class={classes} aria-disabled={disabled}>
|
||||
{@render children()}
|
||||
</a>
|
||||
{:else}
|
||||
<button {type} {disabled} {onclick} class={classes}>
|
||||
{@render children()}
|
||||
</button>
|
||||
{/if}
|
||||
49
src/lib/components/ui/Card.svelte
Normal file
49
src/lib/components/ui/Card.svelte
Normal file
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
variant?: 'default' | 'elevated' | 'interactive';
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg';
|
||||
class?: string;
|
||||
onclick?: () => void;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
variant = 'default',
|
||||
padding = 'md',
|
||||
class: className = '',
|
||||
onclick,
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
const baseClasses = 'bg-base-200 border border-base-300 rounded-md transition-all duration-200';
|
||||
|
||||
const variantClasses = {
|
||||
default: 'shadow-sm',
|
||||
elevated: 'shadow-lg shadow-black/10',
|
||||
interactive:
|
||||
'cursor-pointer hover:border-primary hover:shadow-lg hover:shadow-primary/20 hover:-translate-y-0.5'
|
||||
};
|
||||
|
||||
const paddingClasses = {
|
||||
none: '',
|
||||
sm: 'p-3',
|
||||
md: 'p-4',
|
||||
lg: 'p-6'
|
||||
};
|
||||
|
||||
const classes =
|
||||
`${baseClasses} ${variantClasses[variant]} ${paddingClasses[padding]} ${className}` +
|
||||
(onclick ? ' cursor-pointer' : '');
|
||||
</script>
|
||||
|
||||
{#if onclick}
|
||||
<button class={classes} {onclick}>
|
||||
{@render children()}
|
||||
</button>
|
||||
{:else}
|
||||
<div class={classes}>
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
102
src/lib/components/ui/Modal.svelte
Normal file
102
src/lib/components/ui/Modal.svelte
Normal file
@@ -0,0 +1,102 @@
|
||||
<script lang="ts">
|
||||
import { X } from 'lucide-svelte';
|
||||
import { fly, fade } from 'svelte/transition';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
title?: string;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
onClose?: () => void;
|
||||
children?: Snippet;
|
||||
actions?: Snippet;
|
||||
}
|
||||
|
||||
let { open = $bindable(false), title, size = 'md', onClose, children, actions }: Props = $props();
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-2xl',
|
||||
lg: 'max-w-4xl',
|
||||
xl: 'max-w-6xl'
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
open = false;
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const handleBackdropClick = (e: MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && open) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
transition:fade={{ duration: 200 }}
|
||||
onclick={handleBackdropClick}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={title ? 'modal-title' : undefined}
|
||||
tabindex="-1"
|
||||
>
|
||||
<!-- Backdrop -->
|
||||
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm"></div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div
|
||||
class="relative w-full {sizeClasses[size]} rounded-lg bg-base-100 shadow-xl"
|
||||
transition:fly={{ y: -20, duration: 300 }}
|
||||
>
|
||||
<!-- Header -->
|
||||
{#if title}
|
||||
<div class="flex items-center justify-between border-b border-base-300 p-6">
|
||||
<h2 id="modal-title" class="text-2xl font-bold text-base-content">{title}</h2>
|
||||
<button
|
||||
class="btn btn-circle btn-ghost btn-sm"
|
||||
onclick={handleClose}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="btn btn-circle btn-ghost btn-sm absolute right-4 top-4 z-10"
|
||||
onclick={handleClose}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-6">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
{#if actions}
|
||||
<div class="flex justify-end gap-2 border-t border-base-300 p-6">
|
||||
{@render actions()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
96
src/lib/components/ui/PremierRatingBadge.svelte
Normal file
96
src/lib/components/ui/PremierRatingBadge.svelte
Normal file
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import { formatPremierRating, getPremierRatingChange } from '$lib/utils/formatters';
|
||||
import { usesSkillGroup } from '$lib/utils/rankingSystem';
|
||||
import { Trophy, TrendingUp, TrendingDown } from 'lucide-svelte';
|
||||
import RankIcon from './RankIcon.svelte';
|
||||
import type { Match } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
rating: number | undefined | null;
|
||||
oldRating?: number | undefined | null;
|
||||
/** Match data for determining ranking system (date + game_mode) */
|
||||
match?: Pick<Match, 'date' | 'game_mode'>;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showTier?: boolean;
|
||||
showChange?: boolean;
|
||||
showIcon?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
rating,
|
||||
oldRating,
|
||||
match,
|
||||
size = 'md',
|
||||
showTier = false,
|
||||
showChange = false,
|
||||
showIcon = true,
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
|
||||
/**
|
||||
* Determine if this rating should be displayed as a Skill Group (0-18)
|
||||
* Uses the new ranking system detection logic based on:
|
||||
* 1. Match date (CS:GO legacy vs CS2)
|
||||
* 2. Game mode (Premier vs Competitive/Wingman)
|
||||
* 3. Fallback heuristic (0-18 = Skill Group, >1000 = CS Rating)
|
||||
*/
|
||||
const shouldShowSkillGroup = $derived(
|
||||
match
|
||||
? usesSkillGroup(match, rating)
|
||||
: rating !== null && rating !== undefined && rating >= 0 && rating <= 18
|
||||
);
|
||||
|
||||
const tierInfo = $derived(formatPremierRating(rating));
|
||||
const changeInfo = $derived(showChange ? getPremierRatingChange(oldRating, rating) : null);
|
||||
|
||||
const baseClasses = 'inline-flex items-center gap-1.5 border rounded-lg font-medium';
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-2 py-0.5 text-xs',
|
||||
md: 'px-3 py-1 text-sm',
|
||||
lg: 'px-4 py-2 text-base'
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
sm: 'h-3 w-3',
|
||||
md: 'h-4 w-4',
|
||||
lg: 'h-5 w-5'
|
||||
};
|
||||
|
||||
const classes = $derived(
|
||||
`${baseClasses} ${tierInfo.cssClasses} ${sizeClasses[size]} ${className}`
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if shouldShowSkillGroup}
|
||||
<!-- Show Skill Group icon (CS:GO legacy OR CS2 Competitive/Wingman mode) -->
|
||||
<RankIcon skillGroup={rating} {size} class={className} />
|
||||
{:else if !rating || rating === 0}
|
||||
<!-- No rating available -->
|
||||
<span class="text-sm text-base-content/50">Unranked</span>
|
||||
{:else}
|
||||
<!-- Show CS Rating for CS2 Premier mode -->
|
||||
<div class={classes}>
|
||||
{#if showIcon}
|
||||
<Trophy class={iconSizes[size]} />
|
||||
{/if}
|
||||
|
||||
<span>{tierInfo.formatted}</span>
|
||||
|
||||
{#if showTier}
|
||||
<span class="opacity-75">({tierInfo.tier})</span>
|
||||
{/if}
|
||||
|
||||
{#if showChange && changeInfo}
|
||||
<span class="ml-1 flex items-center gap-0.5 {changeInfo.cssClasses}">
|
||||
{#if changeInfo.isPositive}
|
||||
<TrendingUp class={iconSizes[size]} />
|
||||
{:else if changeInfo.change < 0}
|
||||
<TrendingDown class={iconSizes[size]} />
|
||||
{/if}
|
||||
{changeInfo.display}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
74
src/lib/components/ui/RankIcon.svelte
Normal file
74
src/lib/components/ui/RankIcon.svelte
Normal file
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* CS:GO Skill Group Rank Icon Component
|
||||
* Displays the appropriate rank icon based on skill group (0-18)
|
||||
*/
|
||||
interface Props {
|
||||
/** CS:GO skill group (0-18) */
|
||||
skillGroup: number | undefined | null;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showLabel?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { skillGroup, size = 'md', showLabel = false, class: className = '' }: Props = $props();
|
||||
|
||||
// Map skill groups to rank names
|
||||
const rankNames: Record<number, string> = {
|
||||
0: 'Unranked',
|
||||
1: 'Silver I',
|
||||
2: 'Silver II',
|
||||
3: 'Silver III',
|
||||
4: 'Silver IV',
|
||||
5: 'Silver Elite',
|
||||
6: 'Silver Elite Master',
|
||||
7: 'Gold Nova I',
|
||||
8: 'Gold Nova II',
|
||||
9: 'Gold Nova III',
|
||||
10: 'Gold Nova Master',
|
||||
11: 'Master Guardian I',
|
||||
12: 'Master Guardian II',
|
||||
13: 'Master Guardian Elite',
|
||||
14: 'Distinguished Master Guardian',
|
||||
15: 'Legendary Eagle',
|
||||
16: 'Legendary Eagle Master',
|
||||
17: 'Supreme Master First Class',
|
||||
18: 'The Global Elite'
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-11 w-11 max-h-11',
|
||||
md: 'h-16 w-16',
|
||||
lg: 'h-20 w-20'
|
||||
};
|
||||
|
||||
const labelSizeClasses = {
|
||||
sm: 'text-xs',
|
||||
md: 'text-sm',
|
||||
lg: 'text-base'
|
||||
};
|
||||
|
||||
const iconPath = $derived(
|
||||
skillGroup !== undefined && skillGroup !== null && skillGroup >= 0 && skillGroup <= 18
|
||||
? `/images/rank_icons/skillgroup${skillGroup}.svg`
|
||||
: '/images/rank_icons/skillgroup_none.svg'
|
||||
);
|
||||
|
||||
const rankName = $derived(
|
||||
skillGroup !== undefined && skillGroup !== null ? rankNames[skillGroup] || 'Unknown' : 'Unknown'
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if showLabel}
|
||||
<div class="inline-flex items-center gap-2 {className}">
|
||||
<img src={iconPath} alt={rankName} class="{sizeClasses[size]} object-contain" />
|
||||
<span class="font-medium {labelSizeClasses[size]}">{rankName}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<img
|
||||
src={iconPath}
|
||||
alt={rankName}
|
||||
title={rankName}
|
||||
class="{sizeClasses[size]} {className} inline-block object-contain align-middle"
|
||||
/>
|
||||
{/if}
|
||||
26
src/lib/components/ui/Skeleton.svelte
Normal file
26
src/lib/components/ui/Skeleton.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
variant?: 'text' | 'circular' | 'rectangular';
|
||||
width?: string;
|
||||
height?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { variant = 'rectangular', width, height, class: className = '' }: Props = $props();
|
||||
|
||||
const baseClasses = 'animate-pulse bg-base-300';
|
||||
|
||||
const variantClasses = {
|
||||
text: 'rounded h-4',
|
||||
circular: 'rounded-full',
|
||||
rectangular: 'rounded'
|
||||
};
|
||||
|
||||
const style = [width ? `width: ${width};` : '', height ? `height: ${height};` : '']
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
</script>
|
||||
|
||||
<div class="{baseClasses} {variantClasses[variant]} {className}" {style} role="status">
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
78
src/lib/components/ui/Tabs.svelte
Normal file
78
src/lib/components/ui/Tabs.svelte
Normal file
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
|
||||
interface Tab {
|
||||
label: string;
|
||||
href?: string;
|
||||
value?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tabs: Tab[];
|
||||
activeTab?: string;
|
||||
onTabChange?: (value: string) => void;
|
||||
variant?: 'boxed' | 'bordered' | 'lifted';
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
tabs,
|
||||
activeTab = $bindable(),
|
||||
onTabChange,
|
||||
variant = 'bordered',
|
||||
size = 'md',
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
|
||||
// If using href-based tabs, derive active from current route
|
||||
const isActive = (tab: Tab): boolean => {
|
||||
if (tab.href) {
|
||||
return $page.url.pathname === tab.href || $page.url.pathname.startsWith(tab.href + '/');
|
||||
}
|
||||
return activeTab === tab.value;
|
||||
};
|
||||
|
||||
const handleTabClick = (tab: Tab) => {
|
||||
if (tab.disabled) return;
|
||||
|
||||
if (tab.value && !tab.href) {
|
||||
activeTab = tab.value;
|
||||
onTabChange?.(tab.value);
|
||||
}
|
||||
};
|
||||
|
||||
const variantClass =
|
||||
variant === 'boxed' ? 'tabs-boxed' : variant === 'lifted' ? 'tabs-lifted' : '';
|
||||
const sizeClass =
|
||||
size === 'xs' ? 'tabs-xs' : size === 'sm' ? 'tabs-sm' : size === 'lg' ? 'tabs-lg' : '';
|
||||
</script>
|
||||
|
||||
<div role="tablist" class="tabs {variantClass} {sizeClass} {className}">
|
||||
{#each tabs as tab}
|
||||
{#if tab.href}
|
||||
<a
|
||||
href={tab.href}
|
||||
role="tab"
|
||||
class="tab"
|
||||
class:tab-active={isActive(tab)}
|
||||
class:tab-disabled={tab.disabled}
|
||||
aria-disabled={tab.disabled}
|
||||
>
|
||||
{tab.label}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
role="tab"
|
||||
class="tab"
|
||||
class:tab-active={isActive(tab)}
|
||||
class:tab-disabled={tab.disabled}
|
||||
disabled={tab.disabled}
|
||||
onclick={() => handleTabClick(tab)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
49
src/lib/components/ui/Toast.svelte
Normal file
49
src/lib/components/ui/Toast.svelte
Normal file
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import { fly } from 'svelte/transition';
|
||||
import { CheckCircle, XCircle, AlertTriangle, Info, X } from 'lucide-svelte';
|
||||
import type { Toast } from '$lib/stores';
|
||||
|
||||
interface Props {
|
||||
toast: Toast;
|
||||
onDismiss: (id: string) => void;
|
||||
}
|
||||
|
||||
let { toast, onDismiss }: Props = $props();
|
||||
|
||||
// Icon mapping
|
||||
const icons = {
|
||||
success: CheckCircle,
|
||||
error: XCircle,
|
||||
warning: AlertTriangle,
|
||||
info: Info
|
||||
};
|
||||
|
||||
// Color mapping for DaisyUI
|
||||
const alertClasses = {
|
||||
success: 'alert-success',
|
||||
error: 'alert-error',
|
||||
warning: 'alert-warning',
|
||||
info: 'alert-info'
|
||||
};
|
||||
|
||||
const IconComponent = icons[toast.type];
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="alert"
|
||||
class="alert {alertClasses[toast.type]} shadow-lg"
|
||||
transition:fly={{ y: -20, duration: 300 }}
|
||||
>
|
||||
<IconComponent class="h-6 w-6" />
|
||||
<span>{toast.message}</span>
|
||||
|
||||
{#if toast.dismissible}
|
||||
<button
|
||||
class="btn btn-circle btn-ghost btn-sm"
|
||||
onclick={() => onDismiss(toast.id)}
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
11
src/lib/components/ui/ToastContainer.svelte
Normal file
11
src/lib/components/ui/ToastContainer.svelte
Normal file
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { toast } from '$lib/stores';
|
||||
import Toast from './Toast.svelte';
|
||||
</script>
|
||||
|
||||
<!-- Toast Container - Fixed position at top-right -->
|
||||
<div class="toast toast-end toast-top z-50">
|
||||
{#each $toast as toastItem (toastItem.id)}
|
||||
<Toast toast={toastItem} onDismiss={toast.dismiss} />
|
||||
{/each}
|
||||
</div>
|
||||
22
src/lib/components/ui/Tooltip.svelte
Normal file
22
src/lib/components/ui/Tooltip.svelte
Normal file
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
position?: 'top' | 'bottom' | 'left' | 'right';
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { text, position = 'top', children }: Props = $props();
|
||||
|
||||
const positionClass = {
|
||||
top: 'tooltip-top',
|
||||
bottom: 'tooltip-bottom',
|
||||
left: 'tooltip-left',
|
||||
right: 'tooltip-right'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="tooltip {positionClass[position]}" data-tip={text}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
79
src/lib/schemas/api.schema.ts
Normal file
79
src/lib/schemas/api.schema.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { z } from 'zod';
|
||||
import { matchListItemSchema } from './match.schema';
|
||||
|
||||
/**
|
||||
* Zod schemas for API responses and error handling
|
||||
*/
|
||||
|
||||
/** APIError schema */
|
||||
export const apiErrorSchema = z.object({
|
||||
error: z.string(),
|
||||
message: z.string(),
|
||||
status_code: z.number().int(),
|
||||
timestamp: z.string().datetime().optional()
|
||||
});
|
||||
|
||||
/** Generic APIResponse schema */
|
||||
export const apiResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) =>
|
||||
z.object({
|
||||
data: dataSchema,
|
||||
success: z.boolean(),
|
||||
error: apiErrorSchema.optional()
|
||||
});
|
||||
|
||||
/** MatchParseResponse schema */
|
||||
export const matchParseResponseSchema = z.object({
|
||||
match_id: z.string().min(1), // uint64 as string to preserve precision
|
||||
status: z.enum(['parsing', 'queued', 'completed', 'error']),
|
||||
message: z.string(),
|
||||
estimated_time: z.number().int().positive().optional()
|
||||
});
|
||||
|
||||
/** MatchParseStatus schema */
|
||||
export const matchParseStatusSchema = z.object({
|
||||
match_id: z.string().min(1), // uint64 as string to preserve precision
|
||||
status: z.enum(['pending', 'parsing', 'completed', 'error']),
|
||||
progress: z.number().int().min(0).max(100).optional(),
|
||||
error_message: z.string().optional()
|
||||
});
|
||||
|
||||
/** MatchesListResponse schema */
|
||||
export const matchesListResponseSchema = z.object({
|
||||
matches: z.array(matchListItemSchema),
|
||||
next_page_time: z.number().int().optional(),
|
||||
has_more: z.boolean(),
|
||||
total_count: z.number().int().nonnegative().optional()
|
||||
});
|
||||
|
||||
/** MatchesQueryParams schema */
|
||||
export const matchesQueryParamsSchema = z.object({
|
||||
limit: z.number().int().min(1).max(100).optional(),
|
||||
map: z.string().optional(),
|
||||
player_id: z.number().positive().optional(),
|
||||
before_time: z.number().int().positive().optional()
|
||||
});
|
||||
|
||||
/** TrackPlayerResponse schema */
|
||||
export const trackPlayerResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string()
|
||||
});
|
||||
|
||||
/** Parser functions */
|
||||
export const parseAPIError = (data: unknown) => apiErrorSchema.parse(data);
|
||||
export const parseMatchParseResponse = (data: unknown) => matchParseResponseSchema.parse(data);
|
||||
export const parseMatchesList = (data: unknown) => matchesListResponseSchema.parse(data);
|
||||
export const parseMatchesQueryParams = (data: unknown) => matchesQueryParamsSchema.parse(data);
|
||||
export const parseTrackPlayerResponse = (data: unknown) => trackPlayerResponseSchema.parse(data);
|
||||
|
||||
/** Safe parser functions */
|
||||
export const parseMatchesListSafe = (data: unknown) => matchesListResponseSchema.safeParse(data);
|
||||
export const parseAPIErrorSafe = (data: unknown) => apiErrorSchema.safeParse(data);
|
||||
|
||||
/** Infer TypeScript types */
|
||||
export type APIErrorSchema = z.infer<typeof apiErrorSchema>;
|
||||
export type MatchParseResponseSchema = z.infer<typeof matchParseResponseSchema>;
|
||||
export type MatchParseStatusSchema = z.infer<typeof matchParseStatusSchema>;
|
||||
export type MatchesListResponseSchema = z.infer<typeof matchesListResponseSchema>;
|
||||
export type MatchesQueryParamsSchema = z.infer<typeof matchesQueryParamsSchema>;
|
||||
export type TrackPlayerResponseSchema = z.infer<typeof trackPlayerResponseSchema>;
|
||||
116
src/lib/schemas/index.ts
Normal file
116
src/lib/schemas/index.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Central export for all Zod schemas
|
||||
* Provides runtime validation for CS2.WTF data models
|
||||
*/
|
||||
|
||||
// Match schemas
|
||||
export {
|
||||
matchSchema,
|
||||
matchPlayerSchema,
|
||||
matchListItemSchema,
|
||||
parseMatch,
|
||||
parseMatchSafe,
|
||||
parseMatchPlayer,
|
||||
parseMatchListItem,
|
||||
type MatchSchema,
|
||||
type MatchPlayerSchema,
|
||||
type MatchListItemSchema
|
||||
} from './match.schema';
|
||||
|
||||
// Player schemas
|
||||
export {
|
||||
playerSchema,
|
||||
playerMetaSchema,
|
||||
playerProfileSchema,
|
||||
parsePlayer,
|
||||
parsePlayerSafe,
|
||||
parsePlayerMeta,
|
||||
parsePlayerProfile,
|
||||
normalizePlayerData,
|
||||
type PlayerSchema,
|
||||
type PlayerMetaSchema,
|
||||
type PlayerProfileSchema
|
||||
} from './player.schema';
|
||||
|
||||
// Round statistics schemas
|
||||
export {
|
||||
roundStatsSchema,
|
||||
roundDetailSchema,
|
||||
matchRoundsResponseSchema,
|
||||
teamRoundStatsSchema,
|
||||
parseRoundStats,
|
||||
parseRoundDetail,
|
||||
parseMatchRounds,
|
||||
parseTeamRoundStats,
|
||||
parseRoundStatsSafe,
|
||||
parseMatchRoundsSafe,
|
||||
type RoundStatsSchema,
|
||||
type RoundDetailSchema,
|
||||
type MatchRoundsResponseSchema,
|
||||
type TeamRoundStatsSchema
|
||||
} from './roundStats.schema';
|
||||
|
||||
// Weapon schemas
|
||||
export {
|
||||
weaponSchema,
|
||||
hitGroupsSchema,
|
||||
weaponStatsSchema,
|
||||
playerWeaponStatsSchema,
|
||||
matchWeaponsResponseSchema,
|
||||
parseWeapon,
|
||||
parseWeaponStats,
|
||||
parsePlayerWeaponStats,
|
||||
parseMatchWeapons,
|
||||
parseWeaponSafe,
|
||||
parseMatchWeaponsSafe,
|
||||
type WeaponSchema,
|
||||
type HitGroupsSchema,
|
||||
type WeaponStatsSchema,
|
||||
type PlayerWeaponStatsSchema,
|
||||
type MatchWeaponsResponseSchema
|
||||
} from './weapon.schema';
|
||||
|
||||
// Message/Chat schemas
|
||||
export {
|
||||
messageSchema,
|
||||
matchChatResponseSchema,
|
||||
enrichedMessageSchema,
|
||||
chatFilterSchema,
|
||||
chatStatsSchema,
|
||||
parseMessage,
|
||||
parseMatchChat,
|
||||
parseEnrichedMessage,
|
||||
parseChatFilter,
|
||||
parseChatStats,
|
||||
parseMessageSafe,
|
||||
parseMatchChatSafe,
|
||||
type MessageSchema,
|
||||
type MatchChatResponseSchema,
|
||||
type EnrichedMessageSchema,
|
||||
type ChatFilterSchema,
|
||||
type ChatStatsSchema
|
||||
} from './message.schema';
|
||||
|
||||
// API schemas
|
||||
export {
|
||||
apiErrorSchema,
|
||||
apiResponseSchema,
|
||||
matchParseResponseSchema,
|
||||
matchParseStatusSchema,
|
||||
matchesListResponseSchema,
|
||||
matchesQueryParamsSchema,
|
||||
trackPlayerResponseSchema,
|
||||
parseAPIError,
|
||||
parseMatchParseResponse,
|
||||
parseMatchesList,
|
||||
parseMatchesQueryParams,
|
||||
parseTrackPlayerResponse,
|
||||
parseMatchesListSafe,
|
||||
parseAPIErrorSafe,
|
||||
type APIErrorSchema,
|
||||
type MatchParseResponseSchema,
|
||||
type MatchParseStatusSchema,
|
||||
type MatchesListResponseSchema,
|
||||
type MatchesQueryParamsSchema,
|
||||
type TrackPlayerResponseSchema
|
||||
} from './api.schema';
|
||||
108
src/lib/schemas/match.schema.ts
Normal file
108
src/lib/schemas/match.schema.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Zod schemas for Match data models
|
||||
* Provides runtime validation and type safety
|
||||
*/
|
||||
|
||||
/** MatchPlayer schema */
|
||||
export const matchPlayerSchema = z.object({
|
||||
id: z.string().min(1), // Steam ID uint64 as string to preserve precision
|
||||
name: z.string().min(1),
|
||||
avatar: z.string().url(),
|
||||
team_id: z.number().int().min(2).max(3), // 2 = T, 3 = CT
|
||||
|
||||
// Performance metrics
|
||||
kills: z.number().int().nonnegative(),
|
||||
deaths: z.number().int().nonnegative(),
|
||||
assists: z.number().int().nonnegative(),
|
||||
headshot: z.number().int().nonnegative(),
|
||||
mvp: z.number().int().nonnegative(),
|
||||
score: z.number().int().nonnegative(),
|
||||
kast: z.number().int().min(0).max(100).optional(),
|
||||
|
||||
// Rank (interpretation depends on game mode and date)
|
||||
// Premier Mode: CS Rating (0-30000+), Competitive/Wingman: Skill Group (0-18)
|
||||
rank_old: z.number().int().min(0).max(30000).optional(),
|
||||
rank_new: z.number().int().min(0).max(30000).optional(),
|
||||
|
||||
// Damage
|
||||
dmg_enemy: z.number().int().nonnegative().optional(),
|
||||
dmg_team: z.number().int().nonnegative().optional(),
|
||||
|
||||
// Multi-kills
|
||||
mk_2: z.number().int().nonnegative().optional(),
|
||||
mk_3: z.number().int().nonnegative().optional(),
|
||||
mk_4: z.number().int().nonnegative().optional(),
|
||||
mk_5: z.number().int().nonnegative().optional(),
|
||||
|
||||
// Utility damage
|
||||
ud_he: z.number().int().nonnegative().optional(),
|
||||
ud_flames: z.number().int().nonnegative().optional(),
|
||||
ud_flash: z.number().int().nonnegative().optional(),
|
||||
ud_smoke: z.number().int().nonnegative().optional(),
|
||||
ud_decoy: z.number().int().nonnegative().optional(),
|
||||
|
||||
// Flash statistics
|
||||
flash_assists: z.number().int().nonnegative().optional(),
|
||||
flash_duration_enemy: z.number().nonnegative().optional(),
|
||||
flash_duration_team: z.number().nonnegative().optional(),
|
||||
flash_duration_self: z.number().nonnegative().optional(),
|
||||
flash_total_enemy: z.number().int().nonnegative().optional(),
|
||||
flash_total_team: z.number().int().nonnegative().optional(),
|
||||
flash_total_self: z.number().int().nonnegative().optional(),
|
||||
|
||||
// Other
|
||||
crosshair: z.string().optional(),
|
||||
color: z.enum(['green', 'yellow', 'purple', 'blue', 'orange', 'grey']).optional(),
|
||||
avg_ping: z.number().nonnegative().optional(),
|
||||
|
||||
// Ban status
|
||||
vac: z.boolean().optional(),
|
||||
game_ban: z.boolean().optional()
|
||||
});
|
||||
|
||||
/** Match schema */
|
||||
export const matchSchema = z.object({
|
||||
match_id: z.string().min(1), // uint64 as string to preserve precision
|
||||
share_code: z
|
||||
.string()
|
||||
.regex(/^(CSGO-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5})?$/)
|
||||
.optional(),
|
||||
map: z.string().min(1),
|
||||
date: z.string().datetime(),
|
||||
score_team_a: z.number().int().nonnegative(),
|
||||
score_team_b: z.number().int().nonnegative(),
|
||||
duration: z.number().int().positive(),
|
||||
match_result: z.number().int().min(0).max(2), // 0 = tie, 1 = team_a win, 2 = team_b win
|
||||
max_rounds: z.number().int().positive(),
|
||||
demo_parsed: z.boolean(),
|
||||
vac_present: z.boolean(),
|
||||
gameban_present: z.boolean(),
|
||||
tick_rate: z.number().positive().optional(),
|
||||
game_mode: z.enum(['premier', 'competitive', 'wingman']).optional(),
|
||||
players: z.array(matchPlayerSchema).optional()
|
||||
});
|
||||
|
||||
/** MatchListItem schema */
|
||||
export const matchListItemSchema = z.object({
|
||||
match_id: z.string().min(1), // uint64 as string to preserve precision
|
||||
map: z.string().min(1),
|
||||
date: z.string().datetime(),
|
||||
score_team_a: z.number().int().nonnegative(),
|
||||
score_team_b: z.number().int().nonnegative(),
|
||||
duration: z.number().int().positive(),
|
||||
demo_parsed: z.boolean(),
|
||||
player_count: z.number().int().min(2).max(10).optional()
|
||||
});
|
||||
|
||||
/** Parser functions for safe data validation */
|
||||
export const parseMatch = (data: unknown) => matchSchema.parse(data);
|
||||
export const parseMatchSafe = (data: unknown) => matchSchema.safeParse(data);
|
||||
export const parseMatchPlayer = (data: unknown) => matchPlayerSchema.parse(data);
|
||||
export const parseMatchListItem = (data: unknown) => matchListItemSchema.parse(data);
|
||||
|
||||
/** Infer TypeScript types from schemas */
|
||||
export type MatchSchema = z.infer<typeof matchSchema>;
|
||||
export type MatchPlayerSchema = z.infer<typeof matchPlayerSchema>;
|
||||
export type MatchListItemSchema = z.infer<typeof matchListItemSchema>;
|
||||
70
src/lib/schemas/message.schema.ts
Normal file
70
src/lib/schemas/message.schema.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Zod schemas for Message/Chat data models
|
||||
*/
|
||||
|
||||
/** Message schema */
|
||||
export const messageSchema = z.object({
|
||||
message: z.string(),
|
||||
all_chat: z.boolean(),
|
||||
tick: z.number().int().nonnegative(),
|
||||
match_player_id: z.number().positive().optional(),
|
||||
player_id: z.number().positive().optional(),
|
||||
player_name: z.string().optional(),
|
||||
round: z.number().int().positive().optional(),
|
||||
timestamp: z.string().datetime().optional()
|
||||
});
|
||||
|
||||
/** MatchChatResponse schema - matches actual API format */
|
||||
// API returns: { "player_id": [{ message, all_chat, tick }, ...], ... }
|
||||
export const matchChatResponseSchema = z.record(
|
||||
z.string(), // player Steam ID as string key
|
||||
z.array(messageSchema)
|
||||
);
|
||||
|
||||
/** EnrichedMessage schema (with player data) */
|
||||
export const enrichedMessageSchema = messageSchema.extend({
|
||||
player_name: z.string().min(1),
|
||||
player_avatar: z.string().url().optional(),
|
||||
team_id: z.number().int().min(2).max(3).optional(),
|
||||
round: z.number().int().positive()
|
||||
});
|
||||
|
||||
/** ChatFilter schema */
|
||||
export const chatFilterSchema = z.object({
|
||||
player_id: z.number().positive().optional(),
|
||||
chat_type: z.enum(['all', 'team', 'all_chat']).optional(),
|
||||
round: z.number().int().positive().optional(),
|
||||
search: z.string().optional()
|
||||
});
|
||||
|
||||
/** ChatStats schema */
|
||||
export const chatStatsSchema = z.object({
|
||||
total_messages: z.number().int().nonnegative(),
|
||||
team_chat_count: z.number().int().nonnegative(),
|
||||
all_chat_count: z.number().int().nonnegative(),
|
||||
messages_per_player: z.record(z.number().int().nonnegative()),
|
||||
most_active_player: z.object({
|
||||
player_id: z.number().positive(),
|
||||
message_count: z.number().int().positive()
|
||||
})
|
||||
});
|
||||
|
||||
/** Parser functions */
|
||||
export const parseMessage = (data: unknown) => messageSchema.parse(data);
|
||||
export const parseMatchChat = (data: unknown) => matchChatResponseSchema.parse(data);
|
||||
export const parseEnrichedMessage = (data: unknown) => enrichedMessageSchema.parse(data);
|
||||
export const parseChatFilter = (data: unknown) => chatFilterSchema.parse(data);
|
||||
export const parseChatStats = (data: unknown) => chatStatsSchema.parse(data);
|
||||
|
||||
/** Safe parser functions */
|
||||
export const parseMessageSafe = (data: unknown) => messageSchema.safeParse(data);
|
||||
export const parseMatchChatSafe = (data: unknown) => matchChatResponseSchema.safeParse(data);
|
||||
|
||||
/** Infer TypeScript types */
|
||||
export type MessageSchema = z.infer<typeof messageSchema>;
|
||||
export type MatchChatResponseSchema = z.infer<typeof matchChatResponseSchema>;
|
||||
export type EnrichedMessageSchema = z.infer<typeof enrichedMessageSchema>;
|
||||
export type ChatFilterSchema = z.infer<typeof chatFilterSchema>;
|
||||
export type ChatStatsSchema = z.infer<typeof chatStatsSchema>;
|
||||
89
src/lib/schemas/player.schema.ts
Normal file
89
src/lib/schemas/player.schema.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { z } from 'zod';
|
||||
import { matchSchema, matchPlayerSchema } from './match.schema';
|
||||
|
||||
/**
|
||||
* Zod schemas for Player data models
|
||||
*/
|
||||
|
||||
/** Player schema */
|
||||
export const playerSchema = z.object({
|
||||
id: z.string().min(1), // Steam ID uint64 as string to preserve precision
|
||||
name: z.string().min(1),
|
||||
avatar: z.string().url(),
|
||||
vanity_url: z.string().optional(),
|
||||
vanity_url_real: z.string().optional(),
|
||||
steam_updated: z.string().datetime().optional(),
|
||||
profile_created: z.string().datetime().optional(),
|
||||
wins: z.number().int().nonnegative().optional(),
|
||||
losses: z.number().int().nonnegative().optional(),
|
||||
// Also support backend's typo "looses"
|
||||
looses: z.number().int().nonnegative().optional(),
|
||||
ties: z.number().int().nonnegative().optional(),
|
||||
vac_count: z.number().int().nonnegative().optional(),
|
||||
vac_date: z.string().datetime().nullable().optional(),
|
||||
game_ban_count: z.number().int().nonnegative().optional(),
|
||||
game_ban_date: z.string().datetime().nullable().optional(),
|
||||
oldest_sharecode_seen: z.string().optional(),
|
||||
tracked: z.boolean().optional(),
|
||||
matches: z
|
||||
.array(
|
||||
matchSchema.extend({
|
||||
stats: matchPlayerSchema
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
});
|
||||
|
||||
/** Transform player data to normalize "looses" to "losses" */
|
||||
export const normalizePlayerData = (data: z.infer<typeof playerSchema>) => {
|
||||
if (data.looses !== undefined && data.losses === undefined) {
|
||||
return { ...data, losses: data.looses };
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
/** PlayerMeta schema */
|
||||
export const playerMetaSchema = z.object({
|
||||
id: z.number().positive(),
|
||||
name: z.string().min(1),
|
||||
avatar: z.string().url(),
|
||||
recent_matches: z.number().int().nonnegative(),
|
||||
last_match_date: z.string().datetime(),
|
||||
avg_kills: z.number().nonnegative(),
|
||||
avg_deaths: z.number().nonnegative(),
|
||||
avg_kast: z.number().nonnegative(),
|
||||
win_rate: z.number().nonnegative()
|
||||
});
|
||||
|
||||
/** PlayerProfile schema (extended with calculated stats) */
|
||||
export const playerProfileSchema = playerSchema.extend({
|
||||
total_matches: z.number().int().nonnegative(),
|
||||
kd_ratio: z.number().nonnegative(),
|
||||
win_rate: z.number().nonnegative(),
|
||||
avg_headshot_pct: z.number().nonnegative(),
|
||||
avg_kast: z.number().nonnegative(),
|
||||
current_rating: z.number().int().min(0).max(30000).optional(),
|
||||
peak_rating: z.number().int().min(0).max(30000).optional()
|
||||
});
|
||||
|
||||
/** Parser functions */
|
||||
export const parsePlayer = (data: unknown) => {
|
||||
const parsed = playerSchema.parse(data);
|
||||
return normalizePlayerData(parsed);
|
||||
};
|
||||
|
||||
export const parsePlayerSafe = (data: unknown) => {
|
||||
const result = playerSchema.safeParse(data);
|
||||
if (result.success) {
|
||||
return { ...result, data: normalizePlayerData(result.data) };
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const parsePlayerMeta = (data: unknown) => playerMetaSchema.parse(data);
|
||||
export const parsePlayerProfile = (data: unknown) => playerProfileSchema.parse(data);
|
||||
|
||||
/** Infer TypeScript types */
|
||||
export type PlayerSchema = z.infer<typeof playerSchema>;
|
||||
export type PlayerMetaSchema = z.infer<typeof playerMetaSchema>;
|
||||
export type PlayerProfileSchema = z.infer<typeof playerProfileSchema>;
|
||||
70
src/lib/schemas/roundStats.schema.ts
Normal file
70
src/lib/schemas/roundStats.schema.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Zod schemas for Round Statistics data models
|
||||
*/
|
||||
|
||||
/** RoundStats schema */
|
||||
export const roundStatsSchema = z.object({
|
||||
round: z.number().int().positive(),
|
||||
bank: z.number().int().nonnegative(),
|
||||
equipment: z.number().int().nonnegative(),
|
||||
spent: z.number().int().nonnegative(),
|
||||
kills_in_round: z.number().int().nonnegative().optional(),
|
||||
damage_in_round: z.number().int().nonnegative().optional(),
|
||||
match_player_id: z.number().positive().optional(),
|
||||
player_id: z.number().positive().optional()
|
||||
});
|
||||
|
||||
/** RoundDetail schema (with player breakdown) */
|
||||
export const roundDetailSchema = z.object({
|
||||
round: z.number().int().positive(),
|
||||
winner: z.number().int().min(2).max(3), // 2 = T, 3 = CT
|
||||
win_reason: z.string(),
|
||||
players: z.array(roundStatsSchema)
|
||||
});
|
||||
|
||||
/** MatchRoundsResponse schema - matches actual API format */
|
||||
// API returns: { "0": { "player_id": [bank, equipment, spent] }, "1": {...}, ... }
|
||||
export const matchRoundsResponseSchema = z.record(
|
||||
z.string(), // round number as string key
|
||||
z.record(
|
||||
z.string(), // player Steam ID as string key
|
||||
z.tuple([
|
||||
z.number().int().nonnegative(), // bank
|
||||
z.number().int().nonnegative(), // equipment value
|
||||
z.number().int().nonnegative() // spent
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
/** TeamRoundStats schema */
|
||||
export const teamRoundStatsSchema = z.object({
|
||||
round: z.number().int().positive(),
|
||||
team_id: z.number().int().min(2).max(3),
|
||||
total_bank: z.number().int().nonnegative(),
|
||||
total_equipment: z.number().int().nonnegative(),
|
||||
avg_equipment: z.number().nonnegative(),
|
||||
total_spent: z.number().int().nonnegative(),
|
||||
winner: z.number().int().min(2).max(3).optional(),
|
||||
win_reason: z
|
||||
.enum(['elimination', 'bomb_defused', 'bomb_exploded', 'time', 'target_saved'])
|
||||
.optional(),
|
||||
buy_type: z.enum(['eco', 'semi-eco', 'force', 'full']).optional()
|
||||
});
|
||||
|
||||
/** Parser functions */
|
||||
export const parseRoundStats = (data: unknown) => roundStatsSchema.parse(data);
|
||||
export const parseRoundDetail = (data: unknown) => roundDetailSchema.parse(data);
|
||||
export const parseMatchRounds = (data: unknown) => matchRoundsResponseSchema.parse(data);
|
||||
export const parseTeamRoundStats = (data: unknown) => teamRoundStatsSchema.parse(data);
|
||||
|
||||
/** Safe parser functions */
|
||||
export const parseRoundStatsSafe = (data: unknown) => roundStatsSchema.safeParse(data);
|
||||
export const parseMatchRoundsSafe = (data: unknown) => matchRoundsResponseSchema.safeParse(data);
|
||||
|
||||
/** Infer TypeScript types */
|
||||
export type RoundStatsSchema = z.infer<typeof roundStatsSchema>;
|
||||
export type RoundDetailSchema = z.infer<typeof roundDetailSchema>;
|
||||
export type MatchRoundsResponseSchema = z.infer<typeof matchRoundsResponseSchema>;
|
||||
export type TeamRoundStatsSchema = z.infer<typeof teamRoundStatsSchema>;
|
||||
81
src/lib/schemas/weapon.schema.ts
Normal file
81
src/lib/schemas/weapon.schema.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Zod schemas for Weapon data models
|
||||
*/
|
||||
|
||||
/** Weapon schema */
|
||||
export const weaponSchema = z.object({
|
||||
victim: z.number().positive(),
|
||||
dmg: z.number().int().nonnegative(),
|
||||
eq_type: z.number().int().positive(),
|
||||
hit_group: z.number().int().min(0).max(7), // 0-7 hit groups
|
||||
match_player_id: z.number().positive().optional()
|
||||
});
|
||||
|
||||
/** Hit groups breakdown schema */
|
||||
export const hitGroupsSchema = z.object({
|
||||
head: z.number().int().nonnegative(),
|
||||
chest: z.number().int().nonnegative(),
|
||||
stomach: z.number().int().nonnegative(),
|
||||
left_arm: z.number().int().nonnegative(),
|
||||
right_arm: z.number().int().nonnegative(),
|
||||
left_leg: z.number().int().nonnegative(),
|
||||
right_leg: z.number().int().nonnegative()
|
||||
});
|
||||
|
||||
/** WeaponStats schema */
|
||||
export const weaponStatsSchema = z.object({
|
||||
eq_type: z.number().int().positive(),
|
||||
weapon_name: z.string().min(1),
|
||||
kills: z.number().int().nonnegative(),
|
||||
damage: z.number().int().nonnegative(),
|
||||
hits: z.number().int().nonnegative(),
|
||||
hit_groups: hitGroupsSchema,
|
||||
headshot_pct: z.number().nonnegative().optional(),
|
||||
accuracy: z.number().nonnegative().optional()
|
||||
});
|
||||
|
||||
/** PlayerWeaponStats schema */
|
||||
export const playerWeaponStatsSchema = z.object({
|
||||
player_id: z.number().positive(),
|
||||
weapon_stats: z.array(weaponStatsSchema)
|
||||
});
|
||||
|
||||
/** MatchWeaponsResponse schema - matches actual API format */
|
||||
// API returns: { equipment_map: { "1": "P2000", ... }, stats: [...] }
|
||||
export const matchWeaponsResponseSchema = z.object({
|
||||
equipment_map: z.record(z.string(), z.string()), // eq_type ID -> weapon name
|
||||
stats: z.array(
|
||||
z.record(
|
||||
z.string(), // attacker Steam ID
|
||||
z.record(
|
||||
z.string(), // victim Steam ID
|
||||
z.array(
|
||||
z.tuple([
|
||||
z.number().int().nonnegative(), // eq_type
|
||||
z.number().int().min(0).max(7), // hit_group
|
||||
z.number().int().nonnegative() // damage
|
||||
])
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
});
|
||||
|
||||
/** Parser functions */
|
||||
export const parseWeapon = (data: unknown) => weaponSchema.parse(data);
|
||||
export const parseWeaponStats = (data: unknown) => weaponStatsSchema.parse(data);
|
||||
export const parsePlayerWeaponStats = (data: unknown) => playerWeaponStatsSchema.parse(data);
|
||||
export const parseMatchWeapons = (data: unknown) => matchWeaponsResponseSchema.parse(data);
|
||||
|
||||
/** Safe parser functions */
|
||||
export const parseWeaponSafe = (data: unknown) => weaponSchema.safeParse(data);
|
||||
export const parseMatchWeaponsSafe = (data: unknown) => matchWeaponsResponseSchema.safeParse(data);
|
||||
|
||||
/** Infer TypeScript types */
|
||||
export type WeaponSchema = z.infer<typeof weaponSchema>;
|
||||
export type HitGroupsSchema = z.infer<typeof hitGroupsSchema>;
|
||||
export type WeaponStatsSchema = z.infer<typeof weaponStatsSchema>;
|
||||
export type PlayerWeaponStatsSchema = z.infer<typeof playerWeaponStatsSchema>;
|
||||
export type MatchWeaponsResponseSchema = z.infer<typeof matchWeaponsResponseSchema>;
|
||||
12
src/lib/stores/index.ts
Normal file
12
src/lib/stores/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Central export for all Svelte stores
|
||||
*/
|
||||
|
||||
export { preferences } from './preferences';
|
||||
export type { UserPreferences } from './preferences';
|
||||
|
||||
export { search, isSearchActive } from './search';
|
||||
export type { SearchState } from './search';
|
||||
|
||||
export { toast } from './toast';
|
||||
export type { Toast } from './toast';
|
||||
100
src/lib/stores/preferences.ts
Normal file
100
src/lib/stores/preferences.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
/**
|
||||
* User preferences store
|
||||
* Persisted to localStorage
|
||||
*/
|
||||
|
||||
export interface UserPreferences {
|
||||
theme: 'cs2dark' | 'cs2light' | 'auto';
|
||||
language: string;
|
||||
favoriteMap?: string;
|
||||
favoritePlayers: string[]; // Steam IDs as strings to preserve uint64 precision
|
||||
showAdvancedStats: boolean;
|
||||
dateFormat: 'relative' | 'absolute';
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
const defaultPreferences: UserPreferences = {
|
||||
theme: 'cs2dark',
|
||||
language: 'en',
|
||||
favoritePlayers: [],
|
||||
showAdvancedStats: false,
|
||||
dateFormat: 'relative',
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
};
|
||||
|
||||
// Load preferences from localStorage
|
||||
const loadPreferences = (): UserPreferences => {
|
||||
if (!browser) return defaultPreferences;
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem('cs2wtf-preferences');
|
||||
if (stored) {
|
||||
return { ...defaultPreferences, ...JSON.parse(stored) };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load preferences:', error);
|
||||
}
|
||||
|
||||
return defaultPreferences;
|
||||
};
|
||||
|
||||
// Create the store
|
||||
const createPreferencesStore = () => {
|
||||
const { subscribe, set, update } = writable<UserPreferences>(loadPreferences());
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
set: (value: UserPreferences) => {
|
||||
if (browser) {
|
||||
localStorage.setItem('cs2wtf-preferences', JSON.stringify(value));
|
||||
}
|
||||
set(value);
|
||||
},
|
||||
update: (fn: (value: UserPreferences) => UserPreferences) => {
|
||||
update((current) => {
|
||||
const newValue = fn(current);
|
||||
if (browser) {
|
||||
localStorage.setItem('cs2wtf-preferences', JSON.stringify(newValue));
|
||||
}
|
||||
return newValue;
|
||||
});
|
||||
},
|
||||
reset: () => {
|
||||
if (browser) {
|
||||
localStorage.removeItem('cs2wtf-preferences');
|
||||
}
|
||||
set(defaultPreferences);
|
||||
},
|
||||
|
||||
// Convenience methods
|
||||
setTheme: (theme: UserPreferences['theme']) => {
|
||||
update((prefs) => ({ ...prefs, theme }));
|
||||
},
|
||||
setLanguage: (language: string) => {
|
||||
update((prefs) => ({ ...prefs, language }));
|
||||
},
|
||||
addFavoritePlayer: (playerId: string) => {
|
||||
update((prefs) => ({
|
||||
...prefs,
|
||||
favoritePlayers: [...new Set([...prefs.favoritePlayers, playerId])]
|
||||
}));
|
||||
},
|
||||
removeFavoritePlayer: (playerId: string) => {
|
||||
update((prefs) => ({
|
||||
...prefs,
|
||||
favoritePlayers: prefs.favoritePlayers.filter((id) => id !== playerId)
|
||||
}));
|
||||
},
|
||||
toggleAdvancedStats: () => {
|
||||
update((prefs) => ({
|
||||
...prefs,
|
||||
showAdvancedStats: !prefs.showAdvancedStats
|
||||
}));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const preferences = createPreferencesStore();
|
||||
118
src/lib/stores/search.ts
Normal file
118
src/lib/stores/search.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
/**
|
||||
* Search state store
|
||||
* Manages search queries and recent searches
|
||||
*/
|
||||
|
||||
export interface SearchState {
|
||||
query: string;
|
||||
recentSearches: string[];
|
||||
filters: {
|
||||
map?: string;
|
||||
playerId?: number;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const defaultState: SearchState = {
|
||||
query: '',
|
||||
recentSearches: [],
|
||||
filters: {}
|
||||
};
|
||||
|
||||
// Load recent searches from localStorage
|
||||
const loadRecentSearches = (): string[] => {
|
||||
if (!browser) return [];
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem('cs2wtf-recent-searches');
|
||||
if (stored) {
|
||||
return JSON.parse(stored);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load recent searches:', error);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
// Create the store
|
||||
const createSearchStore = () => {
|
||||
const { subscribe, set, update } = writable<SearchState>({
|
||||
...defaultState,
|
||||
recentSearches: loadRecentSearches()
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
set,
|
||||
update,
|
||||
|
||||
// Set search query
|
||||
setQuery: (query: string) => {
|
||||
update((state) => ({ ...state, query }));
|
||||
},
|
||||
|
||||
// Clear search query
|
||||
clearQuery: () => {
|
||||
update((state) => ({ ...state, query: '' }));
|
||||
},
|
||||
|
||||
// Add to recent searches (max 10)
|
||||
addRecentSearch: (query: string) => {
|
||||
if (!query.trim()) return;
|
||||
|
||||
update((state) => {
|
||||
const recent = [query, ...state.recentSearches.filter((q) => q !== query)].slice(0, 10);
|
||||
|
||||
if (browser) {
|
||||
localStorage.setItem('cs2wtf-recent-searches', JSON.stringify(recent));
|
||||
}
|
||||
|
||||
return { ...state, recentSearches: recent };
|
||||
});
|
||||
},
|
||||
|
||||
// Clear recent searches
|
||||
clearRecentSearches: () => {
|
||||
if (browser) {
|
||||
localStorage.removeItem('cs2wtf-recent-searches');
|
||||
}
|
||||
update((state) => ({ ...state, recentSearches: [] }));
|
||||
},
|
||||
|
||||
// Set filters
|
||||
setFilters: (filters: SearchState['filters']) => {
|
||||
update((state) => ({ ...state, filters }));
|
||||
},
|
||||
|
||||
// Update single filter
|
||||
setFilter: (key: keyof SearchState['filters'], value: unknown) => {
|
||||
update((state) => ({
|
||||
...state,
|
||||
filters: { ...state.filters, [key]: value }
|
||||
}));
|
||||
},
|
||||
|
||||
// Clear filters
|
||||
clearFilters: () => {
|
||||
update((state) => ({ ...state, filters: {} }));
|
||||
},
|
||||
|
||||
// Reset entire search state
|
||||
reset: () => {
|
||||
set({ ...defaultState, recentSearches: loadRecentSearches() });
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const search = createSearchStore();
|
||||
|
||||
// Derived store: is search active?
|
||||
export const isSearchActive = derived(
|
||||
search,
|
||||
($search) => $search.query.length > 0 || Object.keys($search.filters).length > 0
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user