From 8f3b6527400a3befa7a3c2224d30ace2852419d2 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Wed, 12 Nov 2025 19:31:18 +0100 Subject: [PATCH] feat: Implement Phase 1 critical features and fix API integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit completes the first phase of feature parity implementation and resolves all API integration issues to match the backend API format. ## API Integration Fixes - Remove all hardcoded default values from transformers (tick_rate, kast, player_count, steam_updated) - Update TypeScript types to make fields optional where backend doesn't guarantee them - Update Zod schemas to validate optional fields correctly - Fix mock data to match real API response format (plain arrays, not wrapped objects) - Update UI components to handle undefined values with proper fallbacks - Add comprehensive API documentation for Match and Player endpoints ## Phase 1 Features Implemented (3/6) ### 1. Player Tracking System ✅ - Created TrackPlayerModal.svelte with auth code input - Integrated track/untrack player API endpoints - Added UI for providing optional share code - Displays tracked status on player profiles - Full validation and error handling ### 2. Share Code Parsing ✅ - Created ShareCodeInput.svelte component - Added to matches page for easy match submission - Real-time validation of share code format - Parse status feedback with loading states - Auto-redirect to match page on success ### 3. VAC/Game Ban Status ✅ - Added VAC and game ban count/date fields to Player type - Display status badges on player profile pages - Show ban count and date when available - Visual indicators using DaisyUI badge components ## Component Improvements - Modal.svelte: Added Svelte 5 Snippet types, actions slot support - ThemeToggle.svelte: Removed deprecated svelte:component usage - Tooltip.svelte: Fixed type safety with Snippet type - All new components follow Svelte 5 runes pattern ($state, $derived, $bindable) ## Type Safety & Linting - Fixed all ESLint errors (any types → proper types) - Fixed form label accessibility issues - Replaced error: any with error: unknown + proper type guards - Added Snippet type imports where needed - Updated all catch blocks to use instanceof Error checks ## Static Assets - Migrated all files from public/ to static/ directory per SvelteKit best practices - Moved 200+ map icons, screenshots, and other assets - Updated all import paths to use /images/ (served from static/) ## Documentation - Created IMPLEMENTATION_STATUS.md tracking all 15 missing features - Updated API.md with optional field annotations - Created MATCHES_API.md with comprehensive endpoint documentation - Added inline comments marking optional vs required fields ## Testing - Updated mock fixtures to remove default values - Fixed mock handlers to return plain arrays like real API - Ensured all components handle undefined gracefully ## Remaining Phase 1 Tasks - [ ] Add VAC status column to match scoreboard - [ ] Create weapons statistics tab for matches - [ ] Implement recently visited players on home page 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .woodpecker.yml | 14 +- docs/API.md | 937 +-- docs/CORS_PROXY.md | 469 +- docs/IMPLEMENTATION_STATUS.md | 480 ++ docs/LOCAL_DEVELOPMENT.md | 145 +- docs/MATCHES_API.md | 460 ++ public/index.html | 66 - public/site.webmanifest | 1 - src/app.css | 35 + src/lib/api/client.ts | 44 +- src/lib/api/matches.ts | 62 +- src/lib/api/players.ts | 8 +- src/lib/api/transformers.ts | 189 +- src/lib/components/RoundTimeline.svelte | 268 + src/lib/components/charts/LineChart.svelte | 7 +- src/lib/components/layout/Header.svelte | 9 +- src/lib/components/layout/SearchBar.svelte | 2 +- src/lib/components/layout/ThemeToggle.svelte | 23 +- src/lib/components/match/MatchCard.svelte | 60 +- .../components/match/ShareCodeInput.svelte | 155 + .../components/player/TrackPlayerModal.svelte | 196 + src/lib/components/ui/Modal.svelte | 19 +- .../components/ui/PremierRatingBadge.svelte | 68 + src/lib/components/ui/Tabs.svelte | 6 +- src/lib/components/ui/Tooltip.svelte | 4 +- src/lib/schemas/match.schema.ts | 6 +- src/lib/schemas/player.schema.ts | 3 +- src/lib/types/Match.ts | 16 +- src/lib/types/Player.ts | 7 +- src/lib/utils/export.ts | 154 + src/lib/utils/formatters.ts | 196 + src/lib/utils/mapAssets.ts | 75 + src/lib/utils/navigation.ts | 102 + src/mocks/fixtures.ts | 34 +- src/mocks/handlers/matches.ts | 25 +- src/mocks/handlers/players.ts | 4 +- src/routes/+page.svelte | 200 +- src/routes/+page.ts | 6 +- src/routes/api/[...path]/+server.ts | 163 + src/routes/match/[id]/+layout.svelte | 64 +- src/routes/match/[id]/+page.svelte | 65 +- src/routes/match/[id]/+page.ts | 21 + src/routes/match/[id]/chat/+page.svelte | 28 +- src/routes/match/[id]/damage/+page.svelte | 289 +- src/routes/match/[id]/details/+page.svelte | 188 +- src/routes/match/[id]/economy/+page.svelte | 69 +- src/routes/match/[id]/flashes/+page.svelte | 27 +- src/routes/matches/+page.svelte | 651 +- src/routes/matches/+page.ts | 10 +- src/routes/player/[id]/+page.svelte | 110 +- {public => static}/favicon.ico | Bin {public => static}/fonts/cs_regular.ttf | Bin {public => static}/fonts/cs_regular.woff2 | Bin .../images/android-chrome-192x192.png | Bin .../images/android-chrome-512x512.png | Bin .../images/apple-touch-icon.png | Bin {public => static}/images/favicon-16x16.png | Bin {public => static}/images/favicon-32x32.png | Bin .../images/icons/ct_char_bg.svg | 0 {public => static}/images/icons/ct_logo.svg | 0 .../images/icons/ct_logo_1c.svg | 0 .../images/icons/game_banned.svg | 0 .../images/icons/hitgroup-puppet.svg | 0 {public => static}/images/icons/t_char_bg.svg | 0 {public => static}/images/icons/t_logo.svg | 0 {public => static}/images/icons/t_logo_1c.svg | 0 .../images/icons/timer_both.svg | 0 .../images/icons/timer_long.svg | 0 .../images/icons/timer_short.svg | 0 .../images/icons/vac_banned.svg | 0 {public => static}/images/logo-alt.svg | 0 {public => static}/images/logo.png | Bin {public => static}/images/logo.svg | 0 .../images/map_icons/map_icon_ar_baggage.svg | 418 +- .../images/map_icons/map_icon_ar_dizzy.svg | 1028 +-- .../images/map_icons/map_icon_ar_lunacy.svg | 1972 +++--- .../map_icons/map_icon_ar_monastery.svg | 610 +- .../images/map_icons/map_icon_ar_shoots.svg | 598 +- .../images/map_icons/map_icon_coop_kasbah.svg | 480 +- .../map_icons/map_icon_coop_strike_map.svg | 530 +- .../images/map_icons/map_icon_cs_agency.svg | 4170 ++++++------ .../images/map_icons/map_icon_cs_apollo.svg | 4108 ++++++------ .../images/map_icons/map_icon_cs_assault.svg | 624 +- .../images/map_icons/map_icon_cs_climb.svg | 4108 ++++++------ .../map_icons/map_icon_cs_insertion.svg | 4224 ++++++------ .../map_icons/map_icon_cs_insertion2.svg | 4170 ++++++------ .../images/map_icons/map_icon_cs_italy.svg | 674 +- .../images/map_icons/map_icon_cs_militia.svg | 180 +- .../images/map_icons/map_icon_cs_office.svg | 392 +- .../images/map_icons/map_icon_cs_workout.svg | 4080 ++++++------ .../images/map_icons/map_icon_de_abbey.svg | 4138 ++++++------ .../images/map_icons/map_icon_de_ancient.svg | 870 +-- .../images/map_icons/map_icon_de_anubis.svg | 0 .../images/map_icons/map_icon_de_austria.svg | 4184 ++++++------ .../images/map_icons/map_icon_de_aztec.svg | 548 +- .../images/map_icons/map_icon_de_bank.svg | 282 +- .../images/map_icons/map_icon_de_basalt.svg | 4118 ++++++------ .../images/map_icons/map_icon_de_biome.svg | 4058 ++++++------ .../images/map_icons/map_icon_de_blagai.svg | 4120 ++++++------ .../images/map_icons/map_icon_de_breach.svg | 4132 ++++++------ .../images/map_icons/map_icon_de_cache.svg | 426 +- .../images/map_icons/map_icon_de_calavera.svg | 4152 ++++++------ .../images/map_icons/map_icon_de_canals.svg | 5174 +++++++-------- .../images/map_icons/map_icon_de_cbble.svg | 1176 ++-- .../images/map_icons/map_icon_de_chlorine.svg | 4148 ++++++------ .../images/map_icons/map_icon_de_crete.svg | 4122 ++++++------ .../images/map_icons/map_icon_de_dust.svg | 532 +- .../images/map_icons/map_icon_de_dust2.svg | 5702 ++++++++--------- .../images/map_icons/map_icon_de_elysion.svg | 4132 ++++++------ .../images/map_icons/map_icon_de_engage.svg | 4130 ++++++------ .../map_icons/map_icon_de_extraction.svg | 4180 ++++++------ .../images/map_icons/map_icon_de_grind.svg | 4110 ++++++------ .../images/map_icons/map_icon_de_guard.svg | 4114 ++++++------ .../images/map_icons/map_icon_de_hive.svg | 4106 ++++++------ .../images/map_icons/map_icon_de_inferno.svg | 1754 ++--- .../images/map_icons/map_icon_de_iris.svg | 4102 ++++++------ .../images/map_icons/map_icon_de_lake.svg | 358 +- .../images/map_icons/map_icon_de_mirage.svg | 398 +- .../images/map_icons/map_icon_de_mocha.svg | 4112 ++++++------ .../images/map_icons/map_icon_de_mutiny.svg | 4122 ++++++------ .../images/map_icons/map_icon_de_nuke.svg | 1424 ++-- .../images/map_icons/map_icon_de_overpass.svg | 642 +- .../images/map_icons/map_icon_de_pitstop.svg | 4124 ++++++------ .../images/map_icons/map_icon_de_prime.svg | 4138 ++++++------ .../images/map_icons/map_icon_de_ravine.svg | 4126 ++++++------ .../images/map_icons/map_icon_de_ruby.svg | 4056 ++++++------ .../map_icons/map_icon_de_safehouse.svg | 392 +- .../images/map_icons/map_icon_de_seaside.svg | 4136 ++++++------ .../images/map_icons/map_icon_de_shipped.svg | 4184 ++++++------ .../map_icons/map_icon_de_shortdust.svg | 532 +- .../map_icons/map_icon_de_shortnuke.svg | 1424 ++-- .../images/map_icons/map_icon_de_stmarc.svg | 616 +- .../images/map_icons/map_icon_de_studio.svg | 4064 ++++++------ .../images/map_icons/map_icon_de_subzero.svg | 4086 ++++++------ .../map_icons/map_icon_de_sugarcane.svg | 950 +-- .../images/map_icons/map_icon_de_swamp.svg | 4064 ++++++------ .../images/map_icons/map_icon_de_train.svg | 272 +- .../images/map_icons/map_icon_de_tuscan.svg | 4120 ++++++------ .../images/map_icons/map_icon_de_vertigo.svg | 1564 ++--- .../images/map_icons/map_icon_de_zoo.svg | 4238 ++++++------ .../map_icons/map_icon_dz_blacksite.svg | 332 +- .../images/map_icons/map_icon_dz_county.svg | 4116 ++++++------ .../images/map_icons/map_icon_dz_ember.svg | 4128 ++++++------ .../map_icons/map_icon_dz_frostbite.svg | 4162 ++++++------ .../images/map_icons/map_icon_dz_junglety.svg | 4074 ++++++------ .../images/map_icons/map_icon_dz_sirocco.svg | 480 +- .../images/map_icons/map_icon_dz_vineyard.svg | 4154 ++++++------ .../images/map_icons/map_icon_gd_cbble.svg | 1176 ++-- .../images/map_icons/map_icon_gd_rialto.svg | 1902 +++--- .../map_icons/map_icon_lobby_mapveto.svg | 478 +- .../images/map_screenshots/ar_baggage.png | Bin .../images/map_screenshots/ar_baggage.webp | Bin .../images/map_screenshots/ar_dizzy.png | Bin .../images/map_screenshots/ar_dizzy.webp | Bin .../images/map_screenshots/ar_lunacy.png | Bin .../images/map_screenshots/ar_lunacy.webp | Bin .../images/map_screenshots/ar_monastery.png | Bin .../images/map_screenshots/ar_monastery.webp | Bin .../images/map_screenshots/ar_shoots.png | Bin .../images/map_screenshots/ar_shoots.webp | Bin .../images/map_screenshots/coop_autumn.png | Bin .../images/map_screenshots/coop_autumn.webp | Bin .../images/map_screenshots/coop_fall.png | Bin .../images/map_screenshots/coop_fall.webp | Bin .../images/map_screenshots/coop_kasbah.png | Bin .../images/map_screenshots/coop_kasbah.webp | Bin .../images/map_screenshots/cs_agency.png | Bin .../images/map_screenshots/cs_agency.webp | Bin .../images/map_screenshots/cs_apollo.png | Bin .../images/map_screenshots/cs_apollo.webp | Bin .../images/map_screenshots/cs_assault.png | Bin .../images/map_screenshots/cs_assault.webp | Bin .../images/map_screenshots/cs_climb.png | Bin .../images/map_screenshots/cs_climb.webp | Bin .../images/map_screenshots/cs_cruise.png | Bin .../images/map_screenshots/cs_cruise.webp | Bin .../images/map_screenshots/cs_downtown.png | Bin .../images/map_screenshots/cs_downtown.webp | Bin .../images/map_screenshots/cs_insertion.png | Bin .../images/map_screenshots/cs_insertion.webp | Bin .../images/map_screenshots/cs_insertion2.png | Bin .../images/map_screenshots/cs_insertion2.webp | Bin .../images/map_screenshots/cs_italy.png | Bin .../images/map_screenshots/cs_italy.webp | Bin .../images/map_screenshots/cs_militia.png | Bin .../images/map_screenshots/cs_militia.webp | Bin .../images/map_screenshots/cs_motel.png | Bin .../images/map_screenshots/cs_motel.webp | Bin .../images/map_screenshots/cs_museum.png | Bin .../images/map_screenshots/cs_museum.webp | Bin .../images/map_screenshots/cs_office.png | Bin .../images/map_screenshots/cs_office.webp | Bin .../images/map_screenshots/cs_rush.png | Bin .../images/map_screenshots/cs_rush.webp | Bin .../images/map_screenshots/cs_seaside.png | Bin .../images/map_screenshots/cs_seaside.webp | Bin .../images/map_screenshots/cs_thunder.png | Bin .../images/map_screenshots/cs_thunder.webp | Bin .../images/map_screenshots/cs_workout.png | Bin .../images/map_screenshots/cs_workout.webp | Bin .../images/map_screenshots/de_abbey.png | Bin .../images/map_screenshots/de_abbey.webp | Bin .../images/map_screenshots/de_ancient.png | Bin .../images/map_screenshots/de_ancient.webp | Bin .../images/map_screenshots/de_anubis.png | Bin .../images/map_screenshots/de_anubis.webp | Bin .../images/map_screenshots/de_austria.png | Bin .../images/map_screenshots/de_austria.webp | Bin .../images/map_screenshots/de_aztec.png | Bin .../images/map_screenshots/de_aztec.webp | Bin .../images/map_screenshots/de_bank.png | Bin .../images/map_screenshots/de_bank.webp | Bin .../images/map_screenshots/de_basalt.png | Bin .../images/map_screenshots/de_basalt.webp | Bin .../images/map_screenshots/de_bazaar.png | Bin .../images/map_screenshots/de_bazaar.webp | Bin .../images/map_screenshots/de_biome.png | Bin .../images/map_screenshots/de_biome.webp | Bin .../images/map_screenshots/de_blackgold.png | Bin .../images/map_screenshots/de_blackgold.webp | Bin .../images/map_screenshots/de_blagai.png | Bin .../images/map_screenshots/de_blagai.webp | Bin .../images/map_screenshots/de_breach.png | Bin .../images/map_screenshots/de_breach.webp | Bin .../images/map_screenshots/de_cache.png | Bin .../images/map_screenshots/de_cache.webp | Bin .../images/map_screenshots/de_calavera.png | Bin .../images/map_screenshots/de_calavera.webp | Bin .../images/map_screenshots/de_canals.png | Bin .../images/map_screenshots/de_canals.webp | Bin .../images/map_screenshots/de_castle.png | Bin .../images/map_screenshots/de_castle.webp | Bin .../images/map_screenshots/de_cbble.png | Bin .../images/map_screenshots/de_cbble.webp | Bin .../images/map_screenshots/de_chlorine.png | Bin .../images/map_screenshots/de_chlorine.webp | Bin .../images/map_screenshots/de_coast.png | Bin .../images/map_screenshots/de_coast.webp | Bin .../images/map_screenshots/de_crete.png | Bin .../images/map_screenshots/de_crete.webp | Bin .../images/map_screenshots/de_dust.png | Bin .../images/map_screenshots/de_dust.webp | Bin .../images/map_screenshots/de_dust2.png | Bin .../images/map_screenshots/de_dust2.webp | Bin .../images/map_screenshots/de_elysion.png | Bin .../images/map_screenshots/de_elysion.webp | Bin .../images/map_screenshots/de_empire.png | Bin .../images/map_screenshots/de_empire.webp | Bin .../images/map_screenshots/de_engage.png | Bin .../images/map_screenshots/de_engage.webp | Bin .../images/map_screenshots/de_extraction.png | Bin .../images/map_screenshots/de_extraction.webp | Bin .../images/map_screenshots/de_facade.png | Bin .../images/map_screenshots/de_facade.webp | Bin .../images/map_screenshots/de_favela.png | Bin .../images/map_screenshots/de_favela.webp | Bin .../images/map_screenshots/de_grind.png | Bin .../images/map_screenshots/de_grind.webp | Bin .../images/map_screenshots/de_guard.png | Bin .../images/map_screenshots/de_guard.webp | Bin .../images/map_screenshots/de_hive.png | Bin .../images/map_screenshots/de_hive.webp | Bin .../images/map_screenshots/de_inferno.png | Bin .../images/map_screenshots/de_inferno.webp | Bin .../images/map_screenshots/de_iris.png | Bin .../images/map_screenshots/de_iris.webp | Bin .../images/map_screenshots/de_lake.png | Bin .../images/map_screenshots/de_lake.webp | Bin .../images/map_screenshots/de_library.png | Bin .../images/map_screenshots/de_library.webp | Bin .../images/map_screenshots/de_lite.png | Bin .../images/map_screenshots/de_lite.webp | Bin .../images/map_screenshots/de_marquis.png | Bin .../images/map_screenshots/de_marquis.webp | Bin .../images/map_screenshots/de_mikla.png | Bin .../images/map_screenshots/de_mikla.webp | Bin .../images/map_screenshots/de_mirage.png | Bin .../images/map_screenshots/de_mirage.webp | Bin .../images/map_screenshots/de_mist.png | Bin .../images/map_screenshots/de_mist.webp | Bin .../images/map_screenshots/de_mocha.png | Bin .../images/map_screenshots/de_mocha.webp | Bin .../images/map_screenshots/de_mutiny.png | Bin .../images/map_screenshots/de_mutiny.webp | Bin .../images/map_screenshots/de_nuke.png | Bin .../images/map_screenshots/de_nuke.webp | Bin .../images/map_screenshots/de_overgrown.png | Bin .../images/map_screenshots/de_overgrown.webp | Bin .../images/map_screenshots/de_overpass.png | Bin .../images/map_screenshots/de_overpass.webp | Bin .../images/map_screenshots/de_pitstop.png | Bin .../images/map_screenshots/de_pitstop.webp | Bin .../images/map_screenshots/de_prime.png | Bin .../images/map_screenshots/de_prime.webp | Bin .../images/map_screenshots/de_ravine.png | Bin .../images/map_screenshots/de_ravine.webp | Bin .../images/map_screenshots/de_royal.png | Bin .../images/map_screenshots/de_royal.webp | Bin .../images/map_screenshots/de_ruby.png | Bin .../images/map_screenshots/de_ruby.webp | Bin .../images/map_screenshots/de_safehouse.png | Bin .../images/map_screenshots/de_safehouse.webp | Bin .../images/map_screenshots/de_santorini.png | Bin .../images/map_screenshots/de_santorini.webp | Bin .../images/map_screenshots/de_seaside.png | Bin .../images/map_screenshots/de_seaside.webp | Bin .../images/map_screenshots/de_season.png | Bin .../images/map_screenshots/de_season.webp | Bin .../images/map_screenshots/de_shipped.png | Bin .../images/map_screenshots/de_shipped.webp | Bin .../images/map_screenshots/de_shortdust.png | Bin .../images/map_screenshots/de_shortdust.webp | Bin .../images/map_screenshots/de_shortnuke.png | Bin .../images/map_screenshots/de_shortnuke.webp | Bin .../images/map_screenshots/de_stmarc.png | Bin .../images/map_screenshots/de_stmarc.webp | Bin .../images/map_screenshots/de_studio.png | Bin .../images/map_screenshots/de_studio.webp | Bin .../images/map_screenshots/de_subzero.png | Bin .../images/map_screenshots/de_subzero.webp | Bin .../images/map_screenshots/de_sugarcane.png | Bin .../images/map_screenshots/de_sugarcane.webp | Bin .../images/map_screenshots/de_swamp.png | Bin .../images/map_screenshots/de_swamp.webp | Bin .../images/map_screenshots/de_thrill.png | Bin .../images/map_screenshots/de_thrill.webp | Bin .../images/map_screenshots/de_train.png | Bin .../images/map_screenshots/de_train.webp | Bin .../images/map_screenshots/de_tulip.png | Bin .../images/map_screenshots/de_tulip.webp | Bin .../images/map_screenshots/de_tuscan.png | Bin .../images/map_screenshots/de_tuscan.webp | Bin .../images/map_screenshots/de_vertigo.png | Bin .../images/map_screenshots/de_vertigo.webp | Bin .../images/map_screenshots/de_zoo.png | Bin .../images/map_screenshots/de_zoo.webp | Bin .../images/map_screenshots/default.png | Bin .../images/map_screenshots/default.webp | Bin .../images/map_screenshots/dz_blacksite.png | Bin .../images/map_screenshots/dz_blacksite.webp | Bin .../images/map_screenshots/dz_county.png | Bin .../images/map_screenshots/dz_county.webp | Bin .../images/map_screenshots/dz_ember.png | Bin .../images/map_screenshots/dz_ember.webp | Bin .../images/map_screenshots/dz_frostbite.png | Bin .../images/map_screenshots/dz_frostbite.webp | Bin .../images/map_screenshots/dz_junglety.png | Bin .../images/map_screenshots/dz_junglety.webp | Bin .../images/map_screenshots/dz_sirocco.png | Bin .../images/map_screenshots/dz_sirocco.webp | Bin .../images/map_screenshots/dz_vineyard.png | Bin .../images/map_screenshots/dz_vineyard.webp | Bin .../images/map_screenshots/gd_rialto.png | Bin .../images/map_screenshots/gd_rialto.webp | Bin .../images/map_screenshots/lobby_mapveto.png | Bin .../images/map_screenshots/lobby_mapveto.webp | Bin .../images/map_screenshots/random.png | Bin .../images/map_screenshots/random.webp | Bin .../images/map_screenshots/training.png | Bin .../images/map_screenshots/training.webp | Bin .../map_screenshots/vs_background_00.png | Bin .../map_screenshots/vs_background_00.webp | Bin .../images/rank_icons/skillgroup0.svg | 0 .../images/rank_icons/skillgroup1.svg | 0 .../images/rank_icons/skillgroup10.svg | 0 .../images/rank_icons/skillgroup11.svg | 0 .../images/rank_icons/skillgroup12.svg | 0 .../images/rank_icons/skillgroup13.svg | 0 .../images/rank_icons/skillgroup14.svg | 0 .../images/rank_icons/skillgroup15.svg | 0 .../images/rank_icons/skillgroup16.svg | 0 .../images/rank_icons/skillgroup17.svg | 0 .../images/rank_icons/skillgroup18.svg | 0 .../images/rank_icons/skillgroup2.svg | 0 .../images/rank_icons/skillgroup3.svg | 0 .../images/rank_icons/skillgroup4.svg | 0 .../images/rank_icons/skillgroup5.svg | 0 .../images/rank_icons/skillgroup6.svg | 0 .../images/rank_icons/skillgroup7.svg | 0 .../images/rank_icons/skillgroup8.svg | 0 .../images/rank_icons/skillgroup9.svg | 0 .../images/rank_icons/skillgroup_expired.svg | 0 .../images/rank_icons/skillgroup_none.svg | 0 {public => static}/images/weapons/ak47.svg | 0 {public => static}/images/weapons/aug.svg | 0 {public => static}/images/weapons/awp.svg | 0 {public => static}/images/weapons/bizon.svg | 0 {public => static}/images/weapons/cz75a.svg | 0 {public => static}/images/weapons/deagle.svg | 0 {public => static}/images/weapons/elite.svg | 0 {public => static}/images/weapons/famas.svg | 0 .../images/weapons/fiveseven.svg | 0 {public => static}/images/weapons/g3sg1.svg | 0 {public => static}/images/weapons/galilar.svg | 0 {public => static}/images/weapons/glock.svg | 0 {public => static}/images/weapons/m249.svg | 0 {public => static}/images/weapons/m4a1.svg | 0 .../images/weapons/m4a1_silencer.svg | 0 {public => static}/images/weapons/mac10.svg | 0 {public => static}/images/weapons/mag7.svg | 0 {public => static}/images/weapons/mp5sd.svg | 0 {public => static}/images/weapons/mp7.svg | 0 {public => static}/images/weapons/mp9.svg | 0 {public => static}/images/weapons/negev.svg | 0 {public => static}/images/weapons/nova.svg | 0 {public => static}/images/weapons/p2000.svg | 0 {public => static}/images/weapons/p250.svg | 0 {public => static}/images/weapons/p90.svg | 0 .../images/weapons/revolver.svg | 0 .../images/weapons/sawedoff.svg | 0 {public => static}/images/weapons/scar20.svg | 0 {public => static}/images/weapons/sg556.svg | 0 {public => static}/images/weapons/shield.svg | 0 {public => static}/images/weapons/ssg08.svg | 0 {public => static}/images/weapons/taser.svg | 0 {public => static}/images/weapons/tec9.svg | 0 {public => static}/images/weapons/ump45.svg | 0 .../images/weapons/usp_silencer.svg | 0 {public => static}/images/weapons/xm1014.svg | 0 static/index.html | 68 + static/site.webmanifest | 11 + vite.config.ts | 28 +- 422 files changed, 106174 insertions(+), 102193 deletions(-) create mode 100644 docs/IMPLEMENTATION_STATUS.md create mode 100644 docs/MATCHES_API.md delete mode 100644 public/index.html delete mode 100644 public/site.webmanifest create mode 100644 src/lib/components/RoundTimeline.svelte create mode 100644 src/lib/components/match/ShareCodeInput.svelte create mode 100644 src/lib/components/player/TrackPlayerModal.svelte create mode 100644 src/lib/components/ui/PremierRatingBadge.svelte create mode 100644 src/lib/utils/export.ts create mode 100644 src/lib/utils/formatters.ts create mode 100644 src/lib/utils/mapAssets.ts create mode 100644 src/lib/utils/navigation.ts create mode 100644 src/routes/api/[...path]/+server.ts create mode 100644 src/routes/match/[id]/+page.ts rename {public => static}/favicon.ico (100%) rename {public => static}/fonts/cs_regular.ttf (100%) rename {public => static}/fonts/cs_regular.woff2 (100%) rename {public => static}/images/android-chrome-192x192.png (100%) rename {public => static}/images/android-chrome-512x512.png (100%) rename {public => static}/images/apple-touch-icon.png (100%) rename {public => static}/images/favicon-16x16.png (100%) rename {public => static}/images/favicon-32x32.png (100%) rename {public => static}/images/icons/ct_char_bg.svg (100%) rename {public => static}/images/icons/ct_logo.svg (100%) rename {public => static}/images/icons/ct_logo_1c.svg (100%) rename {public => static}/images/icons/game_banned.svg (100%) rename {public => static}/images/icons/hitgroup-puppet.svg (100%) rename {public => static}/images/icons/t_char_bg.svg (100%) rename {public => static}/images/icons/t_logo.svg (100%) rename {public => static}/images/icons/t_logo_1c.svg (100%) rename {public => static}/images/icons/timer_both.svg (100%) rename {public => static}/images/icons/timer_long.svg (100%) rename {public => static}/images/icons/timer_short.svg (100%) rename {public => static}/images/icons/vac_banned.svg (100%) rename {public => static}/images/logo-alt.svg (100%) rename {public => static}/images/logo.png (100%) rename {public => static}/images/logo.svg (100%) rename {public => static}/images/map_icons/map_icon_ar_baggage.svg (99%) rename {public => static}/images/map_icons/map_icon_ar_dizzy.svg (99%) rename {public => static}/images/map_icons/map_icon_ar_lunacy.svg (98%) rename {public => static}/images/map_icons/map_icon_ar_monastery.svg (98%) rename {public => static}/images/map_icons/map_icon_ar_shoots.svg (98%) rename {public => static}/images/map_icons/map_icon_coop_kasbah.svg (98%) rename {public => static}/images/map_icons/map_icon_coop_strike_map.svg (98%) rename {public => static}/images/map_icons/map_icon_cs_agency.svg (99%) rename {public => static}/images/map_icons/map_icon_cs_apollo.svg (99%) rename {public => static}/images/map_icons/map_icon_cs_assault.svg (98%) rename {public => static}/images/map_icons/map_icon_cs_climb.svg (99%) rename {public => static}/images/map_icons/map_icon_cs_insertion.svg (99%) rename {public => static}/images/map_icons/map_icon_cs_insertion2.svg (99%) rename {public => static}/images/map_icons/map_icon_cs_italy.svg (98%) rename {public => static}/images/map_icons/map_icon_cs_militia.svg (98%) rename {public => static}/images/map_icons/map_icon_cs_office.svg (98%) rename {public => static}/images/map_icons/map_icon_cs_workout.svg (99%) rename {public => static}/images/map_icons/map_icon_de_abbey.svg (99%) rename {public => static}/images/map_icons/map_icon_de_ancient.svg (99%) rename {public => static}/images/map_icons/map_icon_de_anubis.svg (100%) rename {public => static}/images/map_icons/map_icon_de_austria.svg (99%) rename {public => static}/images/map_icons/map_icon_de_aztec.svg (99%) rename {public => static}/images/map_icons/map_icon_de_bank.svg (98%) rename {public => static}/images/map_icons/map_icon_de_basalt.svg (99%) rename {public => static}/images/map_icons/map_icon_de_biome.svg (99%) rename {public => static}/images/map_icons/map_icon_de_blagai.svg (99%) rename {public => static}/images/map_icons/map_icon_de_breach.svg (99%) rename {public => static}/images/map_icons/map_icon_de_cache.svg (98%) rename {public => static}/images/map_icons/map_icon_de_calavera.svg (99%) rename {public => static}/images/map_icons/map_icon_de_canals.svg (99%) rename {public => static}/images/map_icons/map_icon_de_cbble.svg (99%) rename {public => static}/images/map_icons/map_icon_de_chlorine.svg (99%) rename {public => static}/images/map_icons/map_icon_de_crete.svg (99%) rename {public => static}/images/map_icons/map_icon_de_dust.svg (99%) rename {public => static}/images/map_icons/map_icon_de_dust2.svg (98%) rename {public => static}/images/map_icons/map_icon_de_elysion.svg (99%) rename {public => static}/images/map_icons/map_icon_de_engage.svg (99%) rename {public => static}/images/map_icons/map_icon_de_extraction.svg (99%) rename {public => static}/images/map_icons/map_icon_de_grind.svg (99%) rename {public => static}/images/map_icons/map_icon_de_guard.svg (99%) rename {public => static}/images/map_icons/map_icon_de_hive.svg (99%) rename {public => static}/images/map_icons/map_icon_de_inferno.svg (99%) rename {public => static}/images/map_icons/map_icon_de_iris.svg (99%) rename {public => static}/images/map_icons/map_icon_de_lake.svg (98%) rename {public => static}/images/map_icons/map_icon_de_mirage.svg (98%) rename {public => static}/images/map_icons/map_icon_de_mocha.svg (99%) rename {public => static}/images/map_icons/map_icon_de_mutiny.svg (99%) rename {public => static}/images/map_icons/map_icon_de_nuke.svg (98%) rename {public => static}/images/map_icons/map_icon_de_overpass.svg (98%) rename {public => static}/images/map_icons/map_icon_de_pitstop.svg (99%) rename {public => static}/images/map_icons/map_icon_de_prime.svg (99%) rename {public => static}/images/map_icons/map_icon_de_ravine.svg (99%) rename {public => static}/images/map_icons/map_icon_de_ruby.svg (99%) rename {public => static}/images/map_icons/map_icon_de_safehouse.svg (98%) rename {public => static}/images/map_icons/map_icon_de_seaside.svg (99%) rename {public => static}/images/map_icons/map_icon_de_shipped.svg (99%) rename {public => static}/images/map_icons/map_icon_de_shortdust.svg (99%) rename {public => static}/images/map_icons/map_icon_de_shortnuke.svg (98%) rename {public => static}/images/map_icons/map_icon_de_stmarc.svg (98%) rename {public => static}/images/map_icons/map_icon_de_studio.svg (99%) rename {public => static}/images/map_icons/map_icon_de_subzero.svg (99%) rename {public => static}/images/map_icons/map_icon_de_sugarcane.svg (98%) rename {public => static}/images/map_icons/map_icon_de_swamp.svg (99%) rename {public => static}/images/map_icons/map_icon_de_train.svg (98%) rename {public => static}/images/map_icons/map_icon_de_tuscan.svg (99%) rename {public => static}/images/map_icons/map_icon_de_vertigo.svg (98%) rename {public => static}/images/map_icons/map_icon_de_zoo.svg (99%) rename {public => static}/images/map_icons/map_icon_dz_blacksite.svg (98%) rename {public => static}/images/map_icons/map_icon_dz_county.svg (99%) rename {public => static}/images/map_icons/map_icon_dz_ember.svg (99%) rename {public => static}/images/map_icons/map_icon_dz_frostbite.svg (99%) rename {public => static}/images/map_icons/map_icon_dz_junglety.svg (99%) rename {public => static}/images/map_icons/map_icon_dz_sirocco.svg (98%) rename {public => static}/images/map_icons/map_icon_dz_vineyard.svg (99%) rename {public => static}/images/map_icons/map_icon_gd_cbble.svg (99%) rename {public => static}/images/map_icons/map_icon_gd_rialto.svg (99%) rename {public => static}/images/map_icons/map_icon_lobby_mapveto.svg (99%) rename {public => static}/images/map_screenshots/ar_baggage.png (100%) rename {public => static}/images/map_screenshots/ar_baggage.webp (100%) rename {public => static}/images/map_screenshots/ar_dizzy.png (100%) rename {public => static}/images/map_screenshots/ar_dizzy.webp (100%) rename {public => static}/images/map_screenshots/ar_lunacy.png (100%) rename {public => static}/images/map_screenshots/ar_lunacy.webp (100%) rename {public => static}/images/map_screenshots/ar_monastery.png (100%) rename {public => static}/images/map_screenshots/ar_monastery.webp (100%) rename {public => static}/images/map_screenshots/ar_shoots.png (100%) rename {public => static}/images/map_screenshots/ar_shoots.webp (100%) rename {public => static}/images/map_screenshots/coop_autumn.png (100%) rename {public => static}/images/map_screenshots/coop_autumn.webp (100%) rename {public => static}/images/map_screenshots/coop_fall.png (100%) rename {public => static}/images/map_screenshots/coop_fall.webp (100%) rename {public => static}/images/map_screenshots/coop_kasbah.png (100%) rename {public => static}/images/map_screenshots/coop_kasbah.webp (100%) rename {public => static}/images/map_screenshots/cs_agency.png (100%) rename {public => static}/images/map_screenshots/cs_agency.webp (100%) rename {public => static}/images/map_screenshots/cs_apollo.png (100%) rename {public => static}/images/map_screenshots/cs_apollo.webp (100%) rename {public => static}/images/map_screenshots/cs_assault.png (100%) rename {public => static}/images/map_screenshots/cs_assault.webp (100%) rename {public => static}/images/map_screenshots/cs_climb.png (100%) rename {public => static}/images/map_screenshots/cs_climb.webp (100%) rename {public => static}/images/map_screenshots/cs_cruise.png (100%) rename {public => static}/images/map_screenshots/cs_cruise.webp (100%) rename {public => static}/images/map_screenshots/cs_downtown.png (100%) rename {public => static}/images/map_screenshots/cs_downtown.webp (100%) rename {public => static}/images/map_screenshots/cs_insertion.png (100%) rename {public => static}/images/map_screenshots/cs_insertion.webp (100%) rename {public => static}/images/map_screenshots/cs_insertion2.png (100%) rename {public => static}/images/map_screenshots/cs_insertion2.webp (100%) rename {public => static}/images/map_screenshots/cs_italy.png (100%) rename {public => static}/images/map_screenshots/cs_italy.webp (100%) rename {public => static}/images/map_screenshots/cs_militia.png (100%) rename {public => static}/images/map_screenshots/cs_militia.webp (100%) rename {public => static}/images/map_screenshots/cs_motel.png (100%) rename {public => static}/images/map_screenshots/cs_motel.webp (100%) rename {public => static}/images/map_screenshots/cs_museum.png (100%) rename {public => static}/images/map_screenshots/cs_museum.webp (100%) rename {public => static}/images/map_screenshots/cs_office.png (100%) rename {public => static}/images/map_screenshots/cs_office.webp (100%) rename {public => static}/images/map_screenshots/cs_rush.png (100%) rename {public => static}/images/map_screenshots/cs_rush.webp (100%) rename {public => static}/images/map_screenshots/cs_seaside.png (100%) rename {public => static}/images/map_screenshots/cs_seaside.webp (100%) rename {public => static}/images/map_screenshots/cs_thunder.png (100%) rename {public => static}/images/map_screenshots/cs_thunder.webp (100%) rename {public => static}/images/map_screenshots/cs_workout.png (100%) rename {public => static}/images/map_screenshots/cs_workout.webp (100%) rename {public => static}/images/map_screenshots/de_abbey.png (100%) rename {public => static}/images/map_screenshots/de_abbey.webp (100%) rename {public => static}/images/map_screenshots/de_ancient.png (100%) rename {public => static}/images/map_screenshots/de_ancient.webp (100%) rename {public => static}/images/map_screenshots/de_anubis.png (100%) rename {public => static}/images/map_screenshots/de_anubis.webp (100%) rename {public => static}/images/map_screenshots/de_austria.png (100%) rename {public => static}/images/map_screenshots/de_austria.webp (100%) rename {public => static}/images/map_screenshots/de_aztec.png (100%) rename {public => static}/images/map_screenshots/de_aztec.webp (100%) rename {public => static}/images/map_screenshots/de_bank.png (100%) rename {public => static}/images/map_screenshots/de_bank.webp (100%) rename {public => static}/images/map_screenshots/de_basalt.png (100%) rename {public => static}/images/map_screenshots/de_basalt.webp (100%) rename {public => static}/images/map_screenshots/de_bazaar.png (100%) rename {public => static}/images/map_screenshots/de_bazaar.webp (100%) rename {public => static}/images/map_screenshots/de_biome.png (100%) rename {public => static}/images/map_screenshots/de_biome.webp (100%) rename {public => static}/images/map_screenshots/de_blackgold.png (100%) rename {public => static}/images/map_screenshots/de_blackgold.webp (100%) rename {public => static}/images/map_screenshots/de_blagai.png (100%) rename {public => static}/images/map_screenshots/de_blagai.webp (100%) rename {public => static}/images/map_screenshots/de_breach.png (100%) rename {public => static}/images/map_screenshots/de_breach.webp (100%) rename {public => static}/images/map_screenshots/de_cache.png (100%) rename {public => static}/images/map_screenshots/de_cache.webp (100%) rename {public => static}/images/map_screenshots/de_calavera.png (100%) rename {public => static}/images/map_screenshots/de_calavera.webp (100%) rename {public => static}/images/map_screenshots/de_canals.png (100%) rename {public => static}/images/map_screenshots/de_canals.webp (100%) rename {public => static}/images/map_screenshots/de_castle.png (100%) rename {public => static}/images/map_screenshots/de_castle.webp (100%) rename {public => static}/images/map_screenshots/de_cbble.png (100%) rename {public => static}/images/map_screenshots/de_cbble.webp (100%) rename {public => static}/images/map_screenshots/de_chlorine.png (100%) rename {public => static}/images/map_screenshots/de_chlorine.webp (100%) rename {public => static}/images/map_screenshots/de_coast.png (100%) rename {public => static}/images/map_screenshots/de_coast.webp (100%) rename {public => static}/images/map_screenshots/de_crete.png (100%) rename {public => static}/images/map_screenshots/de_crete.webp (100%) rename {public => static}/images/map_screenshots/de_dust.png (100%) rename {public => static}/images/map_screenshots/de_dust.webp (100%) rename {public => static}/images/map_screenshots/de_dust2.png (100%) rename {public => static}/images/map_screenshots/de_dust2.webp (100%) rename {public => static}/images/map_screenshots/de_elysion.png (100%) rename {public => static}/images/map_screenshots/de_elysion.webp (100%) rename {public => static}/images/map_screenshots/de_empire.png (100%) rename {public => static}/images/map_screenshots/de_empire.webp (100%) rename {public => static}/images/map_screenshots/de_engage.png (100%) rename {public => static}/images/map_screenshots/de_engage.webp (100%) rename {public => static}/images/map_screenshots/de_extraction.png (100%) rename {public => static}/images/map_screenshots/de_extraction.webp (100%) rename {public => static}/images/map_screenshots/de_facade.png (100%) rename {public => static}/images/map_screenshots/de_facade.webp (100%) rename {public => static}/images/map_screenshots/de_favela.png (100%) rename {public => static}/images/map_screenshots/de_favela.webp (100%) rename {public => static}/images/map_screenshots/de_grind.png (100%) rename {public => static}/images/map_screenshots/de_grind.webp (100%) rename {public => static}/images/map_screenshots/de_guard.png (100%) rename {public => static}/images/map_screenshots/de_guard.webp (100%) rename {public => static}/images/map_screenshots/de_hive.png (100%) rename {public => static}/images/map_screenshots/de_hive.webp (100%) rename {public => static}/images/map_screenshots/de_inferno.png (100%) rename {public => static}/images/map_screenshots/de_inferno.webp (100%) rename {public => static}/images/map_screenshots/de_iris.png (100%) rename {public => static}/images/map_screenshots/de_iris.webp (100%) rename {public => static}/images/map_screenshots/de_lake.png (100%) rename {public => static}/images/map_screenshots/de_lake.webp (100%) rename {public => static}/images/map_screenshots/de_library.png (100%) rename {public => static}/images/map_screenshots/de_library.webp (100%) rename {public => static}/images/map_screenshots/de_lite.png (100%) rename {public => static}/images/map_screenshots/de_lite.webp (100%) rename {public => static}/images/map_screenshots/de_marquis.png (100%) rename {public => static}/images/map_screenshots/de_marquis.webp (100%) rename {public => static}/images/map_screenshots/de_mikla.png (100%) rename {public => static}/images/map_screenshots/de_mikla.webp (100%) rename {public => static}/images/map_screenshots/de_mirage.png (100%) rename {public => static}/images/map_screenshots/de_mirage.webp (100%) rename {public => static}/images/map_screenshots/de_mist.png (100%) rename {public => static}/images/map_screenshots/de_mist.webp (100%) rename {public => static}/images/map_screenshots/de_mocha.png (100%) rename {public => static}/images/map_screenshots/de_mocha.webp (100%) rename {public => static}/images/map_screenshots/de_mutiny.png (100%) rename {public => static}/images/map_screenshots/de_mutiny.webp (100%) rename {public => static}/images/map_screenshots/de_nuke.png (100%) rename {public => static}/images/map_screenshots/de_nuke.webp (100%) rename {public => static}/images/map_screenshots/de_overgrown.png (100%) rename {public => static}/images/map_screenshots/de_overgrown.webp (100%) rename {public => static}/images/map_screenshots/de_overpass.png (100%) rename {public => static}/images/map_screenshots/de_overpass.webp (100%) rename {public => static}/images/map_screenshots/de_pitstop.png (100%) rename {public => static}/images/map_screenshots/de_pitstop.webp (100%) rename {public => static}/images/map_screenshots/de_prime.png (100%) rename {public => static}/images/map_screenshots/de_prime.webp (100%) rename {public => static}/images/map_screenshots/de_ravine.png (100%) rename {public => static}/images/map_screenshots/de_ravine.webp (100%) rename {public => static}/images/map_screenshots/de_royal.png (100%) rename {public => static}/images/map_screenshots/de_royal.webp (100%) rename {public => static}/images/map_screenshots/de_ruby.png (100%) rename {public => static}/images/map_screenshots/de_ruby.webp (100%) rename {public => static}/images/map_screenshots/de_safehouse.png (100%) rename {public => static}/images/map_screenshots/de_safehouse.webp (100%) rename {public => static}/images/map_screenshots/de_santorini.png (100%) rename {public => static}/images/map_screenshots/de_santorini.webp (100%) rename {public => static}/images/map_screenshots/de_seaside.png (100%) rename {public => static}/images/map_screenshots/de_seaside.webp (100%) rename {public => static}/images/map_screenshots/de_season.png (100%) rename {public => static}/images/map_screenshots/de_season.webp (100%) rename {public => static}/images/map_screenshots/de_shipped.png (100%) rename {public => static}/images/map_screenshots/de_shipped.webp (100%) rename {public => static}/images/map_screenshots/de_shortdust.png (100%) rename {public => static}/images/map_screenshots/de_shortdust.webp (100%) rename {public => static}/images/map_screenshots/de_shortnuke.png (100%) rename {public => static}/images/map_screenshots/de_shortnuke.webp (100%) rename {public => static}/images/map_screenshots/de_stmarc.png (100%) rename {public => static}/images/map_screenshots/de_stmarc.webp (100%) rename {public => static}/images/map_screenshots/de_studio.png (100%) rename {public => static}/images/map_screenshots/de_studio.webp (100%) rename {public => static}/images/map_screenshots/de_subzero.png (100%) rename {public => static}/images/map_screenshots/de_subzero.webp (100%) rename {public => static}/images/map_screenshots/de_sugarcane.png (100%) rename {public => static}/images/map_screenshots/de_sugarcane.webp (100%) rename {public => static}/images/map_screenshots/de_swamp.png (100%) rename {public => static}/images/map_screenshots/de_swamp.webp (100%) rename {public => static}/images/map_screenshots/de_thrill.png (100%) rename {public => static}/images/map_screenshots/de_thrill.webp (100%) rename {public => static}/images/map_screenshots/de_train.png (100%) rename {public => static}/images/map_screenshots/de_train.webp (100%) rename {public => static}/images/map_screenshots/de_tulip.png (100%) rename {public => static}/images/map_screenshots/de_tulip.webp (100%) rename {public => static}/images/map_screenshots/de_tuscan.png (100%) rename {public => static}/images/map_screenshots/de_tuscan.webp (100%) rename {public => static}/images/map_screenshots/de_vertigo.png (100%) rename {public => static}/images/map_screenshots/de_vertigo.webp (100%) rename {public => static}/images/map_screenshots/de_zoo.png (100%) rename {public => static}/images/map_screenshots/de_zoo.webp (100%) rename {public => static}/images/map_screenshots/default.png (100%) rename {public => static}/images/map_screenshots/default.webp (100%) rename {public => static}/images/map_screenshots/dz_blacksite.png (100%) rename {public => static}/images/map_screenshots/dz_blacksite.webp (100%) rename {public => static}/images/map_screenshots/dz_county.png (100%) rename {public => static}/images/map_screenshots/dz_county.webp (100%) rename {public => static}/images/map_screenshots/dz_ember.png (100%) rename {public => static}/images/map_screenshots/dz_ember.webp (100%) rename {public => static}/images/map_screenshots/dz_frostbite.png (100%) rename {public => static}/images/map_screenshots/dz_frostbite.webp (100%) rename {public => static}/images/map_screenshots/dz_junglety.png (100%) rename {public => static}/images/map_screenshots/dz_junglety.webp (100%) rename {public => static}/images/map_screenshots/dz_sirocco.png (100%) rename {public => static}/images/map_screenshots/dz_sirocco.webp (100%) rename {public => static}/images/map_screenshots/dz_vineyard.png (100%) rename {public => static}/images/map_screenshots/dz_vineyard.webp (100%) rename {public => static}/images/map_screenshots/gd_rialto.png (100%) rename {public => static}/images/map_screenshots/gd_rialto.webp (100%) rename {public => static}/images/map_screenshots/lobby_mapveto.png (100%) rename {public => static}/images/map_screenshots/lobby_mapveto.webp (100%) rename {public => static}/images/map_screenshots/random.png (100%) rename {public => static}/images/map_screenshots/random.webp (100%) rename {public => static}/images/map_screenshots/training.png (100%) rename {public => static}/images/map_screenshots/training.webp (100%) rename {public => static}/images/map_screenshots/vs_background_00.png (100%) rename {public => static}/images/map_screenshots/vs_background_00.webp (100%) rename {public => static}/images/rank_icons/skillgroup0.svg (100%) rename {public => static}/images/rank_icons/skillgroup1.svg (100%) rename {public => static}/images/rank_icons/skillgroup10.svg (100%) rename {public => static}/images/rank_icons/skillgroup11.svg (100%) rename {public => static}/images/rank_icons/skillgroup12.svg (100%) rename {public => static}/images/rank_icons/skillgroup13.svg (100%) rename {public => static}/images/rank_icons/skillgroup14.svg (100%) rename {public => static}/images/rank_icons/skillgroup15.svg (100%) rename {public => static}/images/rank_icons/skillgroup16.svg (100%) rename {public => static}/images/rank_icons/skillgroup17.svg (100%) rename {public => static}/images/rank_icons/skillgroup18.svg (100%) rename {public => static}/images/rank_icons/skillgroup2.svg (100%) rename {public => static}/images/rank_icons/skillgroup3.svg (100%) rename {public => static}/images/rank_icons/skillgroup4.svg (100%) rename {public => static}/images/rank_icons/skillgroup5.svg (100%) rename {public => static}/images/rank_icons/skillgroup6.svg (100%) rename {public => static}/images/rank_icons/skillgroup7.svg (100%) rename {public => static}/images/rank_icons/skillgroup8.svg (100%) rename {public => static}/images/rank_icons/skillgroup9.svg (100%) rename {public => static}/images/rank_icons/skillgroup_expired.svg (100%) rename {public => static}/images/rank_icons/skillgroup_none.svg (100%) rename {public => static}/images/weapons/ak47.svg (100%) rename {public => static}/images/weapons/aug.svg (100%) rename {public => static}/images/weapons/awp.svg (100%) rename {public => static}/images/weapons/bizon.svg (100%) rename {public => static}/images/weapons/cz75a.svg (100%) rename {public => static}/images/weapons/deagle.svg (100%) rename {public => static}/images/weapons/elite.svg (100%) rename {public => static}/images/weapons/famas.svg (100%) rename {public => static}/images/weapons/fiveseven.svg (100%) rename {public => static}/images/weapons/g3sg1.svg (100%) rename {public => static}/images/weapons/galilar.svg (100%) rename {public => static}/images/weapons/glock.svg (100%) rename {public => static}/images/weapons/m249.svg (100%) rename {public => static}/images/weapons/m4a1.svg (100%) rename {public => static}/images/weapons/m4a1_silencer.svg (100%) rename {public => static}/images/weapons/mac10.svg (100%) rename {public => static}/images/weapons/mag7.svg (100%) rename {public => static}/images/weapons/mp5sd.svg (100%) rename {public => static}/images/weapons/mp7.svg (100%) rename {public => static}/images/weapons/mp9.svg (100%) rename {public => static}/images/weapons/negev.svg (100%) rename {public => static}/images/weapons/nova.svg (100%) rename {public => static}/images/weapons/p2000.svg (100%) rename {public => static}/images/weapons/p250.svg (100%) rename {public => static}/images/weapons/p90.svg (100%) rename {public => static}/images/weapons/revolver.svg (100%) rename {public => static}/images/weapons/sawedoff.svg (100%) rename {public => static}/images/weapons/scar20.svg (100%) rename {public => static}/images/weapons/sg556.svg (100%) rename {public => static}/images/weapons/shield.svg (100%) rename {public => static}/images/weapons/ssg08.svg (100%) rename {public => static}/images/weapons/taser.svg (100%) rename {public => static}/images/weapons/tec9.svg (100%) rename {public => static}/images/weapons/ump45.svg (100%) rename {public => static}/images/weapons/usp_silencer.svg (100%) rename {public => static}/images/weapons/xm1014.svg (100%) create mode 100644 static/index.html create mode 100644 static/site.webmanifest diff --git a/.woodpecker.yml b/.woodpecker.yml index 246f2d1..c10406f 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -57,12 +57,12 @@ pipeline: settings: hostname: from_secret: ftp_host - src_dir: "/build/" + src_dir: '/build/' clean_dir: true - secrets: [ ftp_username, ftp_password ] + secrets: [ftp_username, ftp_password] when: branch: master - event: [ push, tag ] + event: [push, tag] status: success deploy-dev: @@ -70,7 +70,7 @@ pipeline: settings: hostname: from_secret: ftp_host - src_dir: "/build/" + src_dir: '/build/' clean_dir: true secrets: - source: ftp_username_dev @@ -79,7 +79,7 @@ pipeline: target: ftp_password when: branch: dev - event: [ push, tag ] + event: [push, tag] status: success deploy-cs2: @@ -87,7 +87,7 @@ pipeline: settings: hostname: from_secret: ftp_host_cs2 - src_dir: "/build/" + src_dir: '/build/' clean_dir: true secrets: - source: ftp_username_cs2 @@ -96,5 +96,5 @@ pipeline: target: ftp_password when: branch: cs2-port - event: [ push ] + event: [push] status: success diff --git a/docs/API.md b/docs/API.md index 4aa474c..bc8d6e5 100644 --- a/docs/API.md +++ b/docs/API.md @@ -10,6 +10,21 @@ ## Table of Contents +- [Overview](#overview) +- [API Endpoints](#api-endpoints) + - [Player Endpoints](#player-endpoints) + - [Match Endpoints](#match-endpoints) + - [Matches Listing](#matches-listing) +- [Data Models](#data-models) +- [Integration Guide](#integration-guide) +- [Error Handling](#error-handling) +- [CS2 Migration Notes](#cs2-migration-notes) +- [Rate Limiting](#rate-limiting) +- [Caching Strategy](#caching-strategy) +- [Testing](#testing) +- [Swagger Documentation](#swagger-documentation) +- [Support & Updates](#support--updates) + 1. [Overview](#overview) 2. [API Endpoints](#api-endpoints) - [Player Endpoints](#player-endpoints) @@ -35,17 +50,18 @@ The CSGOWTFD backend is a REST API service that provides Counter-Strike match st ### Configuration Default backend configuration: + ```yaml httpd: - listen: ":8000" - cors_allow_domains: ["*"] + listen: ':8000' + cors_allow_domains: ['*'] database: - driver: "pgx" - connection: "postgres://username:password@localhost:5432/database_name" + driver: 'pgx' + connection: 'postgres://username:password@localhost:5432/database_name' redis: - addr: "localhost:6379" + addr: 'localhost:6379' ``` ### CORS @@ -56,6 +72,10 @@ The backend supports CORS with configurable allowed domains. By default, all ori ## API Endpoints +For detailed documentation on the matches API specifically, see [MATCHES_API.md](MATCHES_API.md). + +## API Endpoints + ### Player Endpoints #### 1. Get Player Profile @@ -66,66 +86,68 @@ Retrieves comprehensive player statistics and match history. **Alternative**: `GET /player/:id/next/:time` **Parameters**: + - `id` (path, required): Steam ID (uint64) - `time` (path, optional): Unix timestamp for pagination (get matches before this time) **Response** (200 OK): + ```json { - "id": 76561198012345678, - "name": "PlayerName", - "avatar": "https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/...", - "vanity_url": "custom-url", - "vanity_url_real": "custom-url", - "steam_updated": "2024-11-04T10:30:00Z", - "profile_created": "2015-03-12T00:00:00Z", - "wins": 1250, - "looses": 980, - "ties": 45, - "vac_count": 0, - "vac_date": null, - "game_ban_count": 0, - "game_ban_date": null, - "oldest_sharecode_seen": "CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX", - "matches": [ - { - "match_id": 3589487716842078322, - "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, - "stats": { - "team_id": 2, - "kills": 24, - "deaths": 18, - "assists": 6, - "headshot": 12, - "mvp": 3, - "score": 56, - "kast": 78, - "rank_old": 18500, - "rank_new": 18650, - "dmg_enemy": 2450, - "dmg_team": 120, - "flash_assists": 4, - "flash_duration_enemy": 15.6, - "flash_total_enemy": 8, - "ud_he": 450, - "ud_flames": 230, - "ud_flash": 5, - "ud_smoke": 3, - "avg_ping": 25.5, - "color": "yellow" - } - } - ] + "id": 76561198012345678, + "name": "PlayerName", + "avatar": "https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/...", + "vanity_url": "custom-url", + "vanity_url_real": "custom-url", + "steam_updated": "2024-11-04T10:30:00Z", // Optional: may not always be provided + "profile_created": "2015-03-12T00:00:00Z", + "wins": 1250, + "looses": 980, + "ties": 45, + "vac_count": 0, + "vac_date": null, + "game_ban_count": 0, + "game_ban_date": null, + "oldest_sharecode_seen": "CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX", + "matches": [ + { + "match_id": 3589487716842078322, + "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 + "stats": { + "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" + } + } + ] } ``` @@ -141,21 +163,23 @@ Retrieves lightweight player metadata (recent matches summary). **Alternative**: `GET /player/:id/meta/:limit` **Parameters**: + - `id` (path, required): Steam ID - `limit` (path, optional): Number of recent matches to include (default: 10) **Response** (200 OK): + ```json { - "id": 76561198012345678, - "name": "PlayerName", - "avatar": "...", - "recent_matches": 25, - "last_match_date": "2024-11-01T18:45:00Z", - "avg_kills": 21.3, - "avg_deaths": 17.8, - "avg_kast": 75.2, - "win_rate": 56.5 + "id": 76561198012345678, + "name": "PlayerName", + "avatar": "...", + "recent_matches": 25, + "last_match_date": "2024-11-01T18:45:00Z", + "avg_kills": 21.3, + "avg_deaths": 17.8, + "avg_kast": 75.2, + "win_rate": 56.5 } ``` @@ -170,20 +194,23 @@ Adds a player to the tracking system for automatic match updates. **Endpoint**: `POST /player/:id/track` **Parameters**: + - `id` (path, required): Steam ID **Request Body**: + ```json { - "auth_code": "XXXX-XXXXX-XXXX" + "auth_code": "XXXX-XXXXX-XXXX" } ``` **Response** (200 OK): + ```json { - "success": true, - "message": "Player added to tracking queue" + "success": true, + "message": "Player added to tracking queue" } ``` @@ -198,13 +225,15 @@ Removes a player from the tracking system. **Endpoint**: `DELETE /player/:id/track` **Parameters**: + - `id` (path, required): Steam ID **Response** (200 OK): + ```json { - "success": true, - "message": "Player removed from tracking" + "success": true, + "message": "Player removed from tracking" } ``` @@ -219,23 +248,26 @@ Triggers parsing of a CS:GO/CS2 match from a share code. **Endpoint**: `GET /match/parse/:sharecode` **Parameters**: + - `sharecode` (path, required): CS:GO match share code (e.g., `CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX`) **Response** (200 OK): + ```json { - "match_id": 3589487716842078322, - "status": "parsing", - "message": "Demo download and parsing initiated" + "match_id": 3589487716842078322, + "status": "parsing", + "message": "Demo download and parsing initiated" } ``` **Response** (202 Accepted): + ```json { - "match_id": 3589487716842078322, - "status": "queued", - "estimated_time": 120 + "match_id": 3589487716842078322, + "status": "queued", + "estimated_time": 120 } ``` @@ -250,62 +282,64 @@ Retrieves full match details including all player statistics. **Endpoint**: `GET /match/:id` **Parameters**: + - `id` (path, required): Match ID (uint64) **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, - "players": [ - { - "id": 76561198012345678, - "name": "Player1", - "avatar": "...", - "team_id": 2, - "kills": 24, - "deaths": 18, - "assists": 6, - "headshot": 12, - "mvp": 3, - "score": 56, - "kast": 78, - "rank_old": 18500, - "rank_new": 18650, - "dmg_enemy": 2450, - "dmg_team": 120, - "flash_assists": 4, - "flash_duration_enemy": 15.6, - "flash_duration_team": 2.3, - "flash_duration_self": 1.2, - "flash_total_enemy": 8, - "flash_total_team": 1, - "flash_total_self": 1, - "ud_he": 450, - "ud_flames": 230, - "ud_flash": 5, - "ud_smoke": 3, - "ud_decoy": 0, - "mk_2": 4, - "mk_3": 2, - "mk_4": 1, - "mk_5": 0, - "avg_ping": 25.5, - "color": "yellow", - "crosshair": "CSGO_XXXXXXXXXXXX" - } - ] + "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, + "players": [ + { + "id": 76561198012345678, + "name": "Player1", + "avatar": "...", + "team_id": 2, + "kills": 24, + "deaths": 18, + "assists": 6, + "headshot": 12, + "mvp": 3, + "score": 56, + "kast": 78, + "rank_old": 18500, + "rank_new": 18650, + "dmg_enemy": 2450, + "dmg_team": 120, + "flash_assists": 4, + "flash_duration_enemy": 15.6, + "flash_duration_team": 2.3, + "flash_duration_self": 1.2, + "flash_total_enemy": 8, + "flash_total_team": 1, + "flash_total_self": 1, + "ud_he": 450, + "ud_flames": 230, + "ud_flash": 5, + "ud_smoke": 3, + "ud_decoy": 0, + "mk_2": 4, + "mk_3": 2, + "mk_4": 1, + "mk_5": 0, + "avg_ping": 25.5, + "color": "yellow", + "crosshair": "CSGO_XXXXXXXXXXXX" + } + ] } ``` @@ -320,35 +354,37 @@ Retrieves weapon statistics for all players in a match. **Endpoint**: `GET /match/:id/weapons` **Parameters**: + - `id` (path, required): Match ID **Response** (200 OK): + ```json { - "match_id": 3589487716842078322, - "weapons": [ - { - "player_id": 76561198012345678, - "weapon_stats": [ - { - "eq_type": 7, - "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 - } - } - ] - } - ] + "match_id": 3589487716842078322, + "weapons": [ + { + "player_id": 76561198012345678, + "weapon_stats": [ + { + "eq_type": 7, + "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 + } + } + ] + } + ] } ``` @@ -363,29 +399,31 @@ Retrieves round-by-round statistics for a match. **Endpoint**: `GET /match/:id/rounds` **Parameters**: + - `id` (path, required): Match ID **Response** (200 OK): + ```json { - "match_id": 3589487716842078322, - "rounds": [ - { - "round": 1, - "winner": 2, - "win_reason": "elimination", - "players": [ - { - "player_id": 76561198012345678, - "bank": 800, - "equipment": 650, - "spent": 650, - "kills_in_round": 2, - "damage_in_round": 234 - } - ] - } - ] + "match_id": 3589487716842078322, + "rounds": [ + { + "round": 1, + "winner": 2, + "win_reason": "elimination", + "players": [ + { + "player_id": 76561198012345678, + "bank": 800, + "equipment": 650, + "spent": 650, + "kills_in_round": 2, + "damage_in_round": 234 + } + ] + } + ] } ``` @@ -400,23 +438,25 @@ Retrieves in-game chat messages from a match. **Endpoint**: `GET /match/:id/chat` **Parameters**: + - `id` (path, required): Match ID **Response** (200 OK): + ```json { - "match_id": 3589487716842078322, - "messages": [ - { - "player_id": 76561198012345678, - "player_name": "Player1", - "message": "nice shot!", - "tick": 15840, - "round": 8, - "all_chat": true, - "timestamp": "2024-11-01T19:12:34Z" - } - ] + "match_id": 3589487716842078322, + "messages": [ + { + "player_id": 76561198012345678, + "player_name": "Player1", + "message": "nice shot!", + "tick": 15840, + "round": 8, + "all_chat": true, + "timestamp": "2024-11-01T19:12:34Z" + } + ] } ``` @@ -434,32 +474,53 @@ Retrieves a paginated list of matches. **Alternative**: `GET /matches/next/:time` **Parameters**: -- `time` (path, optional): Unix timestamp for pagination + +- `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 -{ - "matches": [ - { - "match_id": 3589487716842078322, - "map": "de_inferno", - "date": "2024-11-01T18:45:00Z", - "score_team_a": 13, - "score_team_b": 10, - "duration": 2456, - "demo_parsed": true, - "player_count": 10 - } - ], - "next_page_time": 1698871200, - "has_more": true -} +[ + { + "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**: + +- 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 requested + **Use Case**: Display matches listing page with filters. --- @@ -473,6 +534,7 @@ Returns XML sitemap index for SEO. **Endpoint**: `GET /sitemap.xml` **Response** (200 OK): + ```xml @@ -492,9 +554,11 @@ Returns XML sitemap for specific page range. **Endpoint**: `GET /sitemap/:id` **Parameters**: + - `id` (path, required): Sitemap page number **Response** (200 OK): + ```xml @@ -515,20 +579,20 @@ Returns XML sitemap for specific page range. ```typescript interface Match { - match_id: number; // uint64, unique identifier - share_code: string; // CS:GO share code - map: string; // Map name (e.g., "de_inferno") - date: string; // ISO 8601 timestamp - score_team_a: number; // Final score for team A (T/CT) - score_team_b: number; // Final score for team B (CT/T) - duration: number; // Match duration in seconds - match_result: number; // 0 = tie, 1 = team_a win, 2 = team_b win - max_rounds: number; // Max rounds (24 for MR12, 30 for MR15) - demo_parsed: boolean; // Demo parsing status - vac_present: boolean; // Any VAC bans in match - gameban_present: boolean; // Any game bans in match - tick_rate: number; // Server tick rate (64 or 128) - players?: MatchPlayer[]; // Array of player stats + match_id: number; // uint64, unique identifier + share_code: string; // CS:GO share code + map: string; // Map name (e.g., "de_inferno") + date: string; // ISO 8601 timestamp + score_team_a: number; // Final score for team A (T/CT) + score_team_b: number; // Final score for team B (CT/T) + duration: number; // Match duration in seconds + match_result: number; // 0 = tie, 1 = team_a win, 2 = team_b win + max_rounds: number; // Max rounds (24 for MR12, 30 for MR15) + demo_parsed: boolean; // Demo parsing status + vac_present: boolean; // Any VAC bans in match + gameban_present: boolean; // Any game bans in match + tick_rate: number; // Server tick rate (64 or 128) + players?: MatchPlayer[]; // Array of player stats } ``` @@ -536,22 +600,22 @@ interface Match { ```typescript interface Player { - id: number; // uint64, Steam ID - name: string; // Steam display name - avatar: string; // Avatar URL - vanity_url?: string; // Custom Steam URL - vanity_url_real?: string; // Actual vanity URL - steam_updated: string; // Last Steam profile update - profile_created?: string; // Steam account creation date - wins?: number; // Total competitive wins - looses?: number; // Total competitive losses (note: typo in backend) - ties?: number; // Total ties - vac_count?: number; // Number of VAC bans - vac_date?: string; // Date of last VAC ban - game_ban_count?: number; // Number of game bans - game_ban_date?: string; // Date of last game ban - oldest_sharecode_seen?: string; // Oldest match on record - matches?: Match[]; // Recent matches + id: number; // uint64, Steam ID + name: string; // Steam display name + avatar: string; // Avatar URL + vanity_url?: string; // Custom Steam URL + vanity_url_real?: string; // Actual vanity URL + steam_updated: string; // Last Steam profile update + profile_created?: string; // Steam account creation date + wins?: number; // Total competitive wins + looses?: number; // Total competitive losses (note: typo in backend) + ties?: number; // Total ties + vac_count?: number; // Number of VAC bans + vac_date?: string; // Date of last VAC ban + game_ban_count?: number; // Number of game bans + game_ban_date?: string; // Date of last game ban + oldest_sharecode_seen?: string; // Oldest match on record + matches?: Match[]; // Recent matches } ``` @@ -559,53 +623,53 @@ interface Player { ```typescript interface MatchPlayer { - match_stats: number; // Match ID reference - player_stats: number; // Player ID reference - team_id: number; // 2 = T, 3 = CT + match_stats: number; // Match ID reference + player_stats: number; // Player ID reference + team_id: number; // 2 = T, 3 = CT - // Performance - kills: number; - deaths: number; - assists: number; - headshot: number; // Headshot kills - mvp: number; // MVP stars - score: number; // In-game score - kast: number; // KAST percentage (0-100) + // Performance + kills: number; + deaths: number; + assists: number; + headshot: number; // Headshot kills + mvp: number; // MVP stars + score: number; // In-game score + kast: number; // KAST percentage (0-100) - // Rank - rank_old?: number; // Rating before match (CS2: 0-30000) - rank_new?: number; // Rating after match + // Rank + rank_old?: number; // Rating before match (CS2: 0-30000) + rank_new?: number; // Rating after match - // Damage - dmg_enemy?: number; // Damage to enemies - dmg_team?: number; // Team damage + // Damage + dmg_enemy?: number; // Damage to enemies + dmg_team?: number; // Team damage - // Multi-kills - mk_2?: number; // Double kills - mk_3?: number; // Triple kills - mk_4?: number; // Quad kills - mk_5?: number; // Ace (5k) + // Multi-kills + mk_2?: number; // Double kills + mk_3?: number; // Triple kills + mk_4?: number; // Quad kills + mk_5?: number; // Ace (5k) - // Utility damage - ud_he?: number; // HE grenade damage - ud_flames?: number; // Molotov/Incendiary damage - ud_flash?: number; // Flash grenades used - ud_smoke?: number; // Smoke grenades used - ud_decoy?: number; // Decoy grenades used + // Utility damage + ud_he?: number; // HE grenade damage + ud_flames?: number; // Molotov/Incendiary damage + ud_flash?: number; // Flash grenades used + ud_smoke?: number; // Smoke grenades used + ud_decoy?: number; // Decoy grenades used - // Flash statistics - flash_assists?: number; // Assists from flashes - flash_duration_enemy?: number; // Total enemy blind time - flash_duration_team?: number; // Total team blind time - flash_duration_self?: number; // Self-flash time - flash_total_enemy?: number; // Enemies flashed count - flash_total_team?: number; // Teammates flashed count - flash_total_self?: number; // Self-flash count + // Flash statistics + flash_assists?: number; // Assists from flashes + flash_duration_enemy?: number; // Total enemy blind time + flash_duration_team?: number; // Total team blind time + flash_duration_self?: number; // Self-flash time + flash_total_enemy?: number; // Enemies flashed count + flash_total_team?: number; // Teammates flashed count + flash_total_self?: number; // Self-flash count - // Other - crosshair?: string; // Crosshair settings - color?: 'green' | 'yellow' | 'purple' | 'blue' | 'orange' | 'grey'; - avg_ping?: number; // Average ping + // Other + crosshair?: string; // Crosshair settings + color?: 'green' | 'yellow' | 'purple' | 'blue' | 'orange' | 'grey'; + avg_ping?: number; // Average ping } ``` @@ -613,11 +677,11 @@ interface MatchPlayer { ```typescript interface RoundStats { - round: number; // Round number (1-24 for MR12) - bank: number; // Money at round start - equipment: number; // Equipment value purchased - spent: number; // Total money spent - match_player_id: number; // Reference to MatchPlayer + round: number; // Round number (1-24 for MR12) + bank: number; // Money at round start + equipment: number; // Equipment value purchased + spent: number; // Total money spent + match_player_id: number; // Reference to MatchPlayer } ``` @@ -625,11 +689,11 @@ interface RoundStats { ```typescript interface Weapon { - victim: number; // Player ID who was hit/killed - dmg: number; // Damage dealt - eq_type: number; // Weapon type ID - hit_group: number; // Hit location (1=head, 2=chest, etc.) - match_player_id: number; // Reference to MatchPlayer + victim: number; // Player ID who was hit/killed + dmg: number; // Damage dealt + eq_type: number; // Weapon type ID + hit_group: number; // Hit location (1=head, 2=chest, etc.) + match_player_id: number; // Reference to MatchPlayer } ``` @@ -637,10 +701,10 @@ interface Weapon { ```typescript interface Message { - message: string; // Chat message content - all_chat: boolean; // true = all chat, false = team chat - tick: number; // Game tick when sent - match_player_id: number; // Reference to MatchPlayer + message: string; // Chat message content + all_chat: boolean; // true = all chat, false = team chat + tick: number; // Game tick when sent + match_player_id: number; // Reference to MatchPlayer } ``` @@ -666,47 +730,47 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000 const API_TIMEOUT = import.meta.env.VITE_API_TIMEOUT || 10000; class APIClient { - private client: AxiosInstance; + private client: AxiosInstance; - constructor() { - this.client = axios.create({ - baseURL: API_BASE_URL, - timeout: API_TIMEOUT, - headers: { - 'Content-Type': 'application/json', - }, - }); + constructor() { + this.client = axios.create({ + baseURL: API_BASE_URL, + timeout: API_TIMEOUT, + headers: { + 'Content-Type': 'application/json' + } + }); - // Response interceptor for error handling - this.client.interceptors.response.use( - (response) => response, - (error) => { - if (error.response) { - // Server responded with error - console.error('API Error:', error.response.status, error.response.data); - } else if (error.request) { - // Request made but no response - console.error('Network Error:', error.message); - } - return Promise.reject(error); - } - ); - } + // Response interceptor for error handling + this.client.interceptors.response.use( + (response) => response, + (error) => { + if (error.response) { + // Server responded with error + console.error('API Error:', error.response.status, error.response.data); + } else if (error.request) { + // Request made but no response + console.error('Network Error:', error.message); + } + return Promise.reject(error); + } + ); + } - async get(url: string, config?: AxiosRequestConfig): Promise { - const response = await this.client.get(url, config); - return response.data; - } + async get(url: string, config?: AxiosRequestConfig): Promise { + const response = await this.client.get(url, config); + return response.data; + } - async post(url: string, data?: any, config?: AxiosRequestConfig): Promise { - const response = await this.client.post(url, data, config); - return response.data; - } + async post(url: string, data?: any, config?: AxiosRequestConfig): Promise { + const response = await this.client.post(url, data, config); + return response.data; + } - async delete(url: string, config?: AxiosRequestConfig): Promise { - const response = await this.client.delete(url, config); - return response.data; - } + async delete(url: string, config?: AxiosRequestConfig): Promise { + const response = await this.client.delete(url, config); + return response.data; + } } export const apiClient = new APIClient(); @@ -719,24 +783,22 @@ import { apiClient } from './client'; import type { Player, Match } from '$lib/types'; export const playersAPI = { - async getPlayer(steamId: string | number, beforeTime?: number): Promise { - const url = beforeTime - ? `/player/${steamId}/next/${beforeTime}` - : `/player/${steamId}`; - return apiClient.get(url); - }, + async getPlayer(steamId: string | number, beforeTime?: number): Promise { + const url = beforeTime ? `/player/${steamId}/next/${beforeTime}` : `/player/${steamId}`; + return apiClient.get(url); + }, - async getPlayerMeta(steamId: string | number, limit: number = 10) { - return apiClient.get(`/player/${steamId}/meta/${limit}`); - }, + async getPlayerMeta(steamId: string | number, limit: number = 10) { + return apiClient.get(`/player/${steamId}/meta/${limit}`); + }, - async trackPlayer(steamId: string | number, authCode: string) { - return apiClient.post(`/player/${steamId}/track`, { auth_code: authCode }); - }, + async trackPlayer(steamId: string | number, authCode: string) { + return apiClient.post(`/player/${steamId}/track`, { auth_code: authCode }); + }, - async untrackPlayer(steamId: string | number) { - return apiClient.delete(`/player/${steamId}/track`); - }, + async untrackPlayer(steamId: string | number) { + return apiClient.delete(`/player/${steamId}/track`); + } }; ``` @@ -747,38 +809,56 @@ import { apiClient } from './client'; import type { Match } from '$lib/types'; export const matchesAPI = { - async getMatch(matchId: string | number): Promise { - return apiClient.get(`/match/${matchId}`); - }, + async getMatch(matchId: string | number): Promise { + return apiClient.get(`/match/${matchId}`); + }, - async getMatchWeapons(matchId: string | number) { - return apiClient.get(`/match/${matchId}/weapons`); - }, + async getMatchWeapons(matchId: string | number) { + return apiClient.get(`/match/${matchId}/weapons`); + }, - async getMatchRounds(matchId: string | number) { - return apiClient.get(`/match/${matchId}/rounds`); - }, + async getMatchRounds(matchId: string | number) { + return apiClient.get(`/match/${matchId}/rounds`); + }, - async getMatchChat(matchId: string | number) { - return apiClient.get(`/match/${matchId}/chat`); - }, + async getMatchChat(matchId: string | number) { + return apiClient.get(`/match/${matchId}/chat`); + }, - async parseMatch(shareCode: string) { - return apiClient.get(`/match/parse/${shareCode}`); - }, + async parseMatch(shareCode: string) { + return apiClient.get(`/match/parse/${shareCode}`); + }, - async getMatches(params?: { - limit?: number; - time?: number; - map?: string; - player_id?: number; - }) { - const queryString = params ? `?${new URLSearchParams(params as any).toString()}` : ''; - const url = params?.time - ? `/matches/next/${params.time}${queryString}` - : `/matches${queryString}`; - return apiClient.get(url); - }, + async getMatches(params?: { + limit?: number; + before_time?: number; + map?: string; + player_id?: string; + }) { + const url = params?.before_time ? `/matches/next/${params.before_time}` : '/matches'; + const limit = params?.limit || 50; + + // API returns a plain array, not an object + const data = await apiClient.get(url, { + params: { + limit: limit + 1, // Request one extra to check if there are more + map: params?.map, + player_id: params?.player_id + } + }); + + // Check if there are more matches + const hasMore = data.length > limit; + const matches = hasMore ? data.slice(0, limit) : data; + + // Get timestamp for next page + const lastMatch = matches[matches.length - 1]; + const nextPageTime = + hasMore && lastMatch ? Math.floor(new Date(lastMatch.date).getTime() / 1000) : undefined; + + // Transform to new format + return transformMatchesListResponse(matches, hasMore, nextPageTime); + } }; ``` @@ -790,12 +870,12 @@ import { error } from '@sveltejs/kit'; import type { PageLoad } from './$types'; export const load: PageLoad = async ({ params }) => { - try { - const player = await playersAPI.getPlayer(params.id); - return { player }; - } catch (err) { - throw error(404, 'Player not found'); - } + try { + const player = await playersAPI.getPlayer(params.id); + return { player }; + } catch (err) { + throw error(404, 'Player not found'); + } }; ``` @@ -805,25 +885,25 @@ export const load: PageLoad = async ({ params }) => { ### HTTP Status Codes -| Status | Meaning | Common Causes | -|--------|---------|---------------| -| 200 | Success | Request completed successfully | -| 202 | Accepted | Request queued (async operations) | -| 400 | Bad Request | Invalid parameters or malformed request | -| 404 | Not Found | Player/match doesn't exist | -| 429 | Too Many Requests | Rate limit exceeded | -| 500 | Internal Server Error | Backend issue | -| 503 | Service Unavailable | Backend down or maintenance | +| Status | Meaning | Common Causes | +| ------ | --------------------- | --------------------------------------- | +| 200 | Success | Request completed successfully | +| 202 | Accepted | Request queued (async operations) | +| 400 | Bad Request | Invalid parameters or malformed request | +| 404 | Not Found | Player/match doesn't exist | +| 429 | Too Many Requests | Rate limit exceeded | +| 500 | Internal Server Error | Backend issue | +| 503 | Service Unavailable | Backend down or maintenance | ### Error Response Format ```json { - "error": "Match not found", - "code": "MATCH_NOT_FOUND", - "details": { - "match_id": 3589487716842078322 - } + "error": "Match not found", + "code": "MATCH_NOT_FOUND", + "details": { + "match_id": 3589487716842078322 + } } ``` @@ -833,21 +913,21 @@ For transient errors (500, 503), implement exponential backoff: ```typescript async function retryRequest( - fn: () => Promise, - maxRetries: number = 3, - baseDelay: number = 1000 + fn: () => Promise, + maxRetries: number = 3, + baseDelay: number = 1000 ): Promise { - for (let i = 0; i < maxRetries; i++) { - try { - return await fn(); - } catch (error) { - if (i === maxRetries - 1) throw error; + for (let i = 0; i < maxRetries; i++) { + try { + return await fn(); + } catch (error) { + if (i === maxRetries - 1) throw error; - const delay = baseDelay * Math.pow(2, i); - await new Promise(resolve => setTimeout(resolve, delay)); - } - } - throw new Error('Max retries exceeded'); + const delay = baseDelay * Math.pow(2, i); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + throw new Error('Max retries exceeded'); } ``` @@ -858,13 +938,16 @@ async function retryRequest( ### Rank System Changes **CS:GO** used 18 ranks (Silver I to Global Elite): + - Values: 0-18 **CS2** uses Premier Rating: + - Values: 0-30,000 - No traditional ranks in Premier mode **Backend Compatibility**: + - `rank_old` and `rank_new` fields now store Premier rating (0-30000) - Frontend must detect game version and display accordingly @@ -874,12 +957,14 @@ async function retryRequest( **CS2**: MR12 (max 24 rounds) Check `max_rounds` field in Match data: + - `24` = MR12 (CS2) - `30` = MR15 (CS:GO) ### Game Mode Detection To determine if a match is CS:GO or CS2: + 1. Check `date` field (CS2 released Sept 2023) 2. Check `max_rounds` (24 = likely CS2) 3. Backend may add `game_version` field in future updates @@ -889,11 +974,13 @@ To determine if a match is CS:GO or CS2: ## Rate Limiting **Current Limits** (may vary by deployment): + - **Steam API**: 1 request per second (backend handles this) - **Demo Parsing**: Max 6 concurrent parses - **Frontend API**: No explicit limit, but use reasonable request rates **Best Practices**: + - Implement client-side caching (5-15 minutes for match data) - Use debouncing for search inputs (300ms) - Batch requests when possible @@ -905,6 +992,7 @@ To determine if a match is CS:GO or CS2: ### Backend Caching (Redis) The backend uses Redis for: + - Steam API responses (7 days) - Match data (permanent until invalidated) - Player profiles (7 days) @@ -914,32 +1002,32 @@ The backend uses Redis for: ```typescript // In-memory cache with TTL class DataCache { - private cache = new Map(); + private cache = new Map(); - set(key: string, data: T, ttlMs: number) { - this.cache.set(key, { - data, - expires: Date.now() + ttlMs, - }); - } + set(key: string, data: T, ttlMs: number) { + this.cache.set(key, { + data, + expires: Date.now() + ttlMs + }); + } - get(key: string): T | null { - const entry = this.cache.get(key); - if (!entry) return null; - if (Date.now() > entry.expires) { - this.cache.delete(key); - return null; - } - return entry.data; - } + get(key: string): T | null { + const entry = this.cache.get(key); + if (!entry) return null; + if (Date.now() > entry.expires) { + this.cache.delete(key); + return null; + } + return entry.data; + } } // Usage const matchCache = new DataCache(); const cachedMatch = matchCache.get(`match:${matchId}`); if (!cachedMatch) { - const match = await matchesAPI.getMatch(matchId); - matchCache.set(`match:${matchId}`, match, 15 * 60 * 1000); // 15 min + const match = await matchesAPI.getMatch(matchId); + matchCache.set(`match:${matchId}`, match, 15 * 60 * 1000); // 15 min } ``` @@ -956,16 +1044,16 @@ Use MSW (Mock Service Worker) for testing: import { http, HttpResponse } from 'msw'; export const matchHandlers = [ - http.get('/match/:id', ({ params }) => { - return HttpResponse.json({ - match_id: parseInt(params.id as string), - map: 'de_inferno', - date: '2024-11-01T18:45:00Z', - score_team_a: 13, - score_team_b: 10, - // ... rest of mock data - }); - }), + http.get('/match/:id', ({ params }) => { + return HttpResponse.json({ + match_id: parseInt(params.id as string), + map: 'de_inferno', + date: '2024-11-01T18:45:00Z', + score_team_a: 13, + score_team_b: 10 + // ... rest of mock data + }); + }) ]; ``` @@ -978,6 +1066,7 @@ The backend provides interactive API documentation at: **URL**: `{API_BASE_URL}/api/swagger` This Swagger UI allows you to: + - Explore all endpoints - Test API calls directly - View request/response schemas diff --git a/docs/CORS_PROXY.md b/docs/CORS_PROXY.md index 949fe3a..f5839d6 100644 --- a/docs/CORS_PROXY.md +++ b/docs/CORS_PROXY.md @@ -1,234 +1,393 @@ -# CORS Proxy Configuration +# API Proxying with SvelteKit Server Routes -This document explains how the CORS proxy works in the CS2.WTF frontend. +This document explains how API requests are proxied to the backend using SvelteKit server routes. -## Problem: CORS in Development +## Why Use Server Routes? -When developing a frontend that talks to an API on a different origin, browsers enforce CORS (Cross-Origin Resource Sharing) policies. This causes errors like: +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 ``` -Access to fetch at 'https://api.csgow.tf/matches' from origin 'http://localhost:5173' -has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present -on the requested resource. +Browser → /api/matches → SvelteKit Server Route → Backend → Response ``` -## Solution: Vite Development Proxy +**Detailed Flow**: -The Vite dev server includes a built-in proxy that solves this problem by making all API requests appear same-origin. - -### Configuration - -**File**: `vite.config.ts` - -```typescript -import { loadEnv } from 'vite'; - -export default defineConfig(({ mode }) => { - const env = loadEnv(mode, process.cwd(), ''); - const apiBaseUrl = env.VITE_API_BASE_URL || 'http://localhost:8000'; - - return { - server: { - proxy: { - '/api': { - target: apiBaseUrl, - changeOrigin: true, - rewrite: (path) => path.replace(/^\/api/, ''), - secure: false, - ws: true - } - } - } - }; -}); +``` +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!) ``` -### How It Works +**SSR (Server-Side Rendering) Flow**: -1. **API Client** (in development) makes requests to `/api/*`: - ```typescript - // src/lib/api/client.ts - const API_BASE_URL = import.meta.env.DEV ? '/api' : VITE_API_BASE_URL; - ``` +``` +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 +``` -2. **Vite Proxy** intercepts requests to `/api/*` and forwards them: - ``` - Browser Request: GET http://localhost:5173/api/matches?limit=6 - ↓ - Vite Proxy: Intercepts /api/* requests - ↓ - Backend Request: GET https://api.csgow.tf/matches?limit=6 - ↓ - Response: ← Returns data through proxy - ↓ - Browser: ← Receives response (appears same-origin) - ``` +**Note**: SSR bypasses the SvelteKit route and calls the backend directly because relative URLs (`/api`) don't work during server-side rendering. -3. **Browser sees same-origin request** - no CORS error! +### Key Components -### Configuration Options +**1. SvelteKit Server Route** (`src/routes/api/[...path]/+server.ts`) -| Option | Value | Purpose | -|--------|-------|---------| -| `target` | `env.VITE_API_BASE_URL` | Where to forward requests | -| `changeOrigin` | `true` | Updates `Origin` header to match target | -| `rewrite` | Remove `/api` prefix | Maps `/api/matches` → `/matches` | -| `secure` | `false` | Allow self-signed certificates | -| `ws` | `true` | Enable WebSocket proxying | +- 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 -# Proxy will forward /api/* to this URL +# Production API (default) VITE_API_BASE_URL=https://api.csgow.tf -# Or use local backend +# Local backend (for development) # VITE_API_BASE_URL=http://localhost:8000 ``` -### Logging - -The proxy logs all requests for debugging: +**Switching Backends**: ```bash -[Vite Config] API Proxy target: https://api.csgow.tf -[Proxy] GET /api/matches?limit=6 -> https://api.csgow.tf/matches?limit=6 -[Proxy ✓] GET /api/matches?limit=6 -> 200 -[Proxy] GET /api/match/123 -> https://api.csgow.tf/match/123 -[Proxy ✓] GET /api/match/123 -> 200 +# 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 ``` -Error logging: -```bash -[Proxy Error] ECONNREFUSED -[Proxy Error] Make sure backend is running at: http://localhost:8000 +### 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 +### API Client Configuration **File**: `src/lib/api/client.ts` ```typescript -const getAPIBaseURL = (): string => { - // In production builds, use the configured URL directly - if (import.meta.env.PROD) { - return import.meta.env?.VITE_API_BASE_URL || 'https://api.csgow.tf'; - } +// Simple, single configuration +const API_BASE_URL = '/api'; - // In development mode, ALWAYS use the Vite proxy to avoid CORS issues - // The proxy will forward /api requests to VITE_API_BASE_URL - return '/api'; -}; +// Always routes to SvelteKit server routes +// No environment detection needed ``` -This ensures: -- ✅ **Development**: Always uses `/api` (proxy handles CORS) -- ✅ **Production**: Uses direct URL (backend has CORS enabled) +## Testing the Setup -## Testing the Proxy +### 1. Check Environment Variable -### 1. Check Vite Config Loads Environment +```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 -Start dev server and look for: ```bash npm run dev -# Should show: -[Vite Config] API Proxy target: https://api.csgow.tf -``` - -### 2. Check API Client Configuration - -Open browser console, look for: -``` -[API Client] Development mode - using Vite proxy -[API Client] Frontend requests: /api/* -[API Client] Proxy target: https://api.csgow.tf +# Server starts on http://localhost:5173 ``` ### 3. Check Network Requests Open DevTools → Network tab: -- ✅ Requests should go to `/api/*` (not full URL) -- ✅ Response should be `200 OK` + +- ✅ Requests go to `/api/matches`, `/api/player/123`, etc. +- ✅ Status should be `200 OK` - ✅ No CORS errors in console -### 4. Check Proxy Logs +### 4. Test Both Backends -Terminal should show: +**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 ``` -[Proxy] GET /api/matches -> https://api.csgow.tf/matches -[Proxy ✓] GET /api/matches -> 200 + +**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: Proxy Not Loading .env +### Issue 1: 503 Service Unavailable -**Symptom**: Proxy uses default `http://localhost:8000` instead of `.env` value +**Symptom**: API requests return 503 error -**Cause**: `vite.config.ts` not loading environment variables +**Possible Causes**: -**Fix**: Use `loadEnv()` in config: -```typescript -import { loadEnv } from 'vite'; +1. Backend is not running +2. Wrong `VITE_API_BASE_URL` in `.env` +3. Network connectivity issues -export default defineConfig(({ mode }) => { - const env = loadEnv(mode, process.cwd(), ''); - const apiBaseUrl = env.VITE_API_BASE_URL || 'http://localhost:8000'; - // ... -}); +**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: Still Getting CORS Errors +### 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 -**Possible Causes**: -1. API client not using `/api` prefix in development -2. Request bypassing proxy somehow -3. Running production build instead of dev server +**Cause**: API client is not using `/api` prefix **Fix**: -1. Check API client logs show: `Development mode - using Vite proxy` -2. Verify Network tab shows requests to `/api/*` -3. Run `npm run dev` (not `npm run preview`) - -### Issue 3: Connection Refused - -**Symptom**: `[Proxy Error] ECONNREFUSED` - -**Cause**: Backend is not running at the configured URL - -**Fix**: -- If using local backend: Start `csgowtfd` on port 8000 -- If using production API: Check `VITE_API_BASE_URL=https://api.csgow.tf` - -## Production Build - -In production, the proxy is **not used**. The frontend makes direct requests to the backend: +Check `src/lib/api/client.ts`: ```typescript -// Production build -const API_BASE_URL = 'https://api.csgow.tf'; +// Should be: +const API_BASE_URL = '/api'; -// Direct request (no proxy) -fetch('https://api.csgow.tf/matches'); +// Not: +const API_BASE_URL = 'https://api.csgow.tf'; // ❌ Wrong ``` -The production API must have CORS enabled: +## How It Works Compared to Vite Proxy + +### Old Approach (Vite Proxy) + ``` -Access-Control-Allow-Origin: https://cs2.wtf -Access-Control-Allow-Methods: GET, POST, OPTIONS -Access-Control-Allow-Headers: Content-Type, Authorization +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(); + +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 -| Environment | Frontend URL | API Requests | CORS | -|-------------|-------------|--------------|------| -| **Development** | `http://localhost:5173` | `/api/*` → Proxy → Backend | ✅ Proxy handles | -| **Production** | `https://cs2.wtf` | Direct to backend | ✅ Backend CORS | +| 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 | ❌ | ✅ | -The proxy is a **development-only** feature that makes local development smooth and eliminates CORS headaches. +**SvelteKit server routes provide a production-ready, maintainable solution for API proxying that works in all environments.** diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md new file mode 100644 index 0000000..6a56bd1 --- /dev/null +++ b/docs/IMPLEMENTATION_STATUS.md @@ -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** diff --git a/docs/LOCAL_DEVELOPMENT.md b/docs/LOCAL_DEVELOPMENT.md index a8eb71a..cddb42e 100644 --- a/docs/LOCAL_DEVELOPMENT.md +++ b/docs/LOCAL_DEVELOPMENT.md @@ -21,6 +21,7 @@ npm install 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 @@ -30,6 +31,7 @@ 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 @@ -47,13 +49,12 @@ npm run dev The frontend will be available at `http://localhost:5173` You should see output like: + ``` -[Vite Config] API Proxy target: https://api.csgow.tf -[API Client] Development mode - using Vite proxy -[API Client] Frontend requests: /api/* -[API Client] Proxy target: https://api.csgow.tf + VITE v5.x.x ready in xxx ms ➜ Local: http://localhost:5173/ + ➜ Network: use --host to expose ``` ### 4. (Optional) Start Local Backend @@ -67,45 +68,57 @@ go run cmd/csgowtfd/main.go ``` Or use Docker: + ```bash docker-compose up csgowtfd ``` -## How the CORS Proxy Works +## How SvelteKit API Routes Work -The Vite dev server includes a **built-in proxy** that eliminates CORS issues during development: +All API requests go through **SvelteKit server routes** which proxy to the backend. This works consistently in all environments. + +### Request Flow (All Environments) -### Development Mode Flow ``` 1. Browser makes request to: http://localhost:5173/api/matches -2. Vite intercepts and proxies to: ${VITE_API_BASE_URL}/matches -3. Backend responds -4. Vite forwards response to browser +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 appear same-origin to the browser -- ✅ **Works with any backend** - Local or remote -- ✅ **No backend CORS config needed** - Proxy handles it -- ✅ **Simple configuration** - Just set `VITE_API_BASE_URL` -### Proxy Logs +- ✅ **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 -You'll see detailed proxy activity in the terminal: +### Switching Between Backends + +Simply update `.env` and restart the dev server: ```bash -[Proxy] GET /api/matches?limit=6 -> https://api.csgow.tf/matches?limit=6 -[Proxy ✓] GET /api/matches?limit=6 -> 200 -[Proxy] GET /api/match/123 -> https://api.csgow.tf/match/123 -[Proxy ✓] GET /api/match/123 -> 200 +# 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 ``` -### Production vs Development +### Development vs Production -| Mode | API Base URL | CORS | -|------|-------------|------| -| **Development** (`npm run dev`) | `/api` (proxied to `VITE_API_BASE_URL`) | ✅ No issues | -| **Production** (`npm run build`) | `VITE_API_BASE_URL` (direct) | ✅ Backend has CORS enabled | +| 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 @@ -116,34 +129,40 @@ You'll see detailed proxy activity in the terminal: **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 proxy logs**: - - Look at the terminal running `npm run dev` - - You should see `[Proxy]` messages showing requests being forwarded - - If you see `[Proxy Error]`, check the error message - -5. **Check browser console**: +4. **Check browser console**: - Open DevTools → Console tab - - Look for `[API Client]` messages showing proxy configuration + - 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 @@ -151,22 +170,20 @@ You'll see detailed proxy activity in the terminal: npm run dev ``` -### CORS Errors (Should Not Happen) +### CORS Errors (Should Never Happen) -If you see CORS errors in the browser console, the proxy isn't working: +CORS errors should be impossible with SvelteKit server routes since all requests are server-side. -**Symptoms**: -- Browser console shows: `CORS policy: No 'Access-Control-Allow-Origin' header` -- Network tab shows requests going to `https://api.csgow.tf` directly (not `/api`) +**If you somehow see CORS errors:** -**Fix**: -1. Verify you're in development mode (not production build) -2. Check API client logs show: `Development mode - using Vite proxy` -3. Restart dev server with clean cache: - ```bash - rm -rf .svelte-kit - npm run dev - ``` +- 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 @@ -196,6 +213,7 @@ Then restart the dev server. ### 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 @@ -243,33 +261,32 @@ The backend provides these endpoints (see `docs/API.md` for full details): ### How Requests Work -**In Development** (`npm run dev`): +**All Environments** (dev, preview, production): + ``` Frontend code: api.matches.getMatches() ↓ API Client: GET /api/matches ↓ -Vite Proxy: GET https://api.csgow.tf/matches +SvelteKit Route: src/routes/api/[...path]/+server.ts + ↓ +Server Handler: GET ${VITE_API_BASE_URL}/matches ↓ Response: ← Data returned to frontend ``` -**In Production** (deployed app): -``` -Frontend code: api.matches.getMatches() - ↓ -API Client: GET https://api.csgow.tf/matches (direct) - ↓ -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: -The API client automatically uses the correct URL based on environment. +- 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 ``` @@ -294,14 +311,14 @@ The preview server runs on `http://localhost:4173` and uses the production API c ## 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 | +| 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 diff --git a/docs/MATCHES_API.md b/docs/MATCHES_API.md new file mode 100644 index 0000000..7b2afde --- /dev/null +++ b/docs/MATCHES_API.md @@ -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(); +``` diff --git a/public/index.html b/public/index.html deleted file mode 100644 index a9be09c..0000000 --- a/public/index.html +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - <%= htmlWebpackPlugin.options.title %> - - - -
- - - diff --git a/public/site.webmanifest b/public/site.webmanifest deleted file mode 100644 index fa0554c..0000000 --- a/public/site.webmanifest +++ /dev/null @@ -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"} diff --git a/src/app.css b/src/app.css index 9c7fd7c..405d430 100644 --- a/src/app.css +++ b/src/app.css @@ -2,6 +2,17 @@ @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 */ @@ -10,10 +21,34 @@ 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 { diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 8188788..36ed178 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -4,38 +4,32 @@ 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. */ -const getAPIBaseURL = (): string => { - const apiUrl = import.meta.env?.VITE_API_BASE_URL || 'https://api.csgow.tf'; - - // Check if we're running on the server (SSR) or in production - // On the server, we must use the actual API URL, not the proxy - if (import.meta.env.SSR || import.meta.env.PROD) { - return apiUrl; +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 development mode on the client, use the Vite proxy to avoid CORS issues - // The proxy will forward /api requests to VITE_API_BASE_URL + // In browser, use SvelteKit route return '/api'; -}; +} const API_BASE_URL = getAPIBaseURL(); const API_TIMEOUT = Number(import.meta.env?.VITE_API_TIMEOUT) || 10000; -// Log the API configuration -if (import.meta.env.DEV) { - if (import.meta.env.SSR) { - console.log('[API Client] SSR mode - using direct API URL:', API_BASE_URL); - } else { - console.log('[API Client] Browser mode - using Vite proxy'); - console.log('[API Client] Frontend requests: /api/*'); - console.log( - '[API Client] Proxy target:', - import.meta.env?.VITE_API_BASE_URL || 'https://api.csgow.tf' - ); - } -} - /** * Base API Client * Provides centralized HTTP communication with error handling diff --git a/src/lib/api/matches.ts b/src/lib/api/matches.ts index f0bbd0f..f2c7873 100644 --- a/src/lib/api/matches.ts +++ b/src/lib/api/matches.ts @@ -93,23 +93,54 @@ export const matchesAPI = { /** * 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) - * @returns List of matches with 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 { const url = params?.before_time ? `/matches/next/${params.before_time}` : '/matches'; + const limit = params?.limit || 50; - // API returns a plain array, not a wrapped object + // CRITICAL: API returns a plain array, not a wrapped object + // We request limit + 1 to detect if there are more pages const data = await apiClient.get(url, { params: { - limit: params?.limit, + limit: limit + 1, // Request one extra to check if there are more map: params?.map, player_id: params?.player_id } }); + // Check if there are more matches (if we got the extra one) + const hasMore = data.length > limit; + + // Remove the extra match if we have more + const matchesToReturn = hasMore ? data.slice(0, limit) : data; + + // If there are more matches, use the timestamp of the last match for pagination + // This timestamp is used in the next request: /matches/next/{timestamp} + const lastMatch = + matchesToReturn.length > 0 ? matchesToReturn[matchesToReturn.length - 1] : undefined; + const nextPageTime = + hasMore && lastMatch ? Math.floor(new Date(lastMatch.date).getTime() / 1000) : undefined; + // Transform legacy API response to new format - return transformMatchesListResponse(data); + return transformMatchesListResponse(matchesToReturn, hasMore, nextPageTime); }, /** @@ -118,19 +149,32 @@ export const matchesAPI = { * @returns List of matching matches */ async searchMatches(params?: MatchesQueryParams): Promise { - const url = '/matches'; + 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 const data = await apiClient.getCancelable(url, 'match-search', { params: { - limit: params?.limit || 20, + limit: limit + 1, // Request one extra to check if there are more map: params?.map, - player_id: params?.player_id, - before_time: params?.before_time + player_id: params?.player_id } }); + // Check if there are more matches (if we got the extra one) + const hasMore = data.length > limit; + + // Remove the extra match if we have more + const matchesToReturn = hasMore ? data.slice(0, limit) : data; + + // If there are more matches, use the timestamp of the last match for pagination + const lastMatch = + matchesToReturn.length > 0 ? matchesToReturn[matchesToReturn.length - 1] : undefined; + const nextPageTime = + hasMore && lastMatch ? Math.floor(new Date(lastMatch.date).getTime() / 1000) : undefined; + // Transform legacy API response to new format - return transformMatchesListResponse(data); + return transformMatchesListResponse(matchesToReturn, hasMore, nextPageTime); }, /** diff --git a/src/lib/api/players.ts b/src/lib/api/players.ts index 6c4ddab..9c8941e 100644 --- a/src/lib/api/players.ts +++ b/src/lib/api/players.ts @@ -36,6 +36,7 @@ export const playersAPI = { 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 @@ -60,18 +61,19 @@ export const playersAPI = { const winRate = recentMatches.length > 0 ? wins / recentMatches.length : 0; // Find the most recent match date - const lastMatchDate = matches.length > 0 ? matches[0].date : new Date().toISOString(); + const lastMatchDate = + matches.length > 0 && matches[0] ? matches[0].date : new Date().toISOString(); // Transform to PlayerMeta format const playerMeta: PlayerMeta = { - id: parseInt(player.id), + id: parseInt(player.id, 10), 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: totalKast / recentMatches.length || 0, // Placeholder KAST calculation + avg_kast: recentMatches.length > 0 ? totalKast / recentMatches.length : 0, // Placeholder KAST calculation win_rate: winRate }; diff --git a/src/lib/api/transformers.ts b/src/lib/api/transformers.ts index c9b91cf..7581717 100644 --- a/src/lib/api/transformers.ts +++ b/src/lib/api/transformers.ts @@ -1,28 +1,46 @@ /** * 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 format (from api.csgow.tf) + * 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; - map: string; - date: number; // Unix timestamp - score: [number, number]; // [team_a, team_b] - duration: number; - match_result: number; - max_rounds: number; - parsed: boolean; - vac: boolean; - game_ban: boolean; + 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 + * 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; @@ -33,14 +51,21 @@ export interface LegacyMatchDetail { duration: number; match_result: number; max_rounds: number; - parsed: boolean; - vac: boolean; - game_ban: boolean; - stats?: LegacyPlayerStats[]; + 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 + * 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; @@ -82,6 +107,16 @@ export interface LegacyPlayerStats { /** * 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 { @@ -91,21 +126,36 @@ export function transformMatchListItem(legacy: LegacyMatchListItem): MatchListIt score_team_a: legacy.score[0], score_team_b: legacy.score[1], duration: legacy.duration, - demo_parsed: legacy.parsed, - player_count: 10 // Default to 10 players (5v5) + 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[] + legacyMatches: LegacyMatchListItem[], + hasMore: boolean = false, + nextPageTime?: number ): MatchesListResponse { return { matches: legacyMatches.map(transformMatchListItem), - has_more: false, // Legacy API doesn't provide pagination info - next_page_time: undefined + has_more: hasMore, + next_page_time: nextPageTime }; } @@ -113,6 +163,13 @@ export function transformMatchesListResponse( * 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, @@ -124,7 +181,9 @@ export function transformPlayerStats(legacy: LegacyPlayerStats): MatchPlayer { headshot: legacy.headshot, mvp: legacy.mvp, score: legacy.score, - kast: 0, // Not provided by legacy API + // 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, @@ -157,7 +216,6 @@ export function transformMatchDetail(legacy: LegacyMatchDetail): Match { demo_parsed: legacy.parsed, vac_present: legacy.vac, gameban_present: legacy.game_ban, - tick_rate: 64, // Default to 64, not provided by API players: legacy.stats?.map(transformPlayerStats) }; } @@ -216,46 +274,59 @@ export function transformPlayerProfile(legacy: LegacyPlayerProfile) { id: legacy.steamid64, name: legacy.name, avatar: `https://avatars.steamstatic.com/${legacy.avatar}_full.jpg`, - steam_updated: new Date().toISOString(), // Not provided by API 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) => ({ - 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, - tick_rate: 64, // Not provided by API - 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, - kast: 0, - 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 - } - })) + 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 + } + }; + }) }; } diff --git a/src/lib/components/RoundTimeline.svelte b/src/lib/components/RoundTimeline.svelte new file mode 100644 index 0000000..79bb38a --- /dev/null +++ b/src/lib/components/RoundTimeline.svelte @@ -0,0 +1,268 @@ + + + +
+

Round Timeline

+

+ Click on a round to see detailed information. T = Terrorists, CT = Counter-Terrorists +

+
+ + +
+ +
+
+ +
+ {#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)} + + + {/each} +
+ + + {#if rounds.length > 12} +
+
+ Halftime +
+
+ {/if} +
+
+
+ + + {#if selectedRoundData} +
+
+

+ Round {selectedRoundData.round} Details +

+ +
+ +
+
+
Winner
+
+ {selectedRoundData.winner === 2 ? 'Terrorists' : 'Counter-Terrorists'} +
+
+
+
Win Reason
+
+ {getWinReasonText(selectedRoundData.win_reason)} +
+
+
+ + + {#if selectedRoundData.players && selectedRoundData.players.length > 0} +
+

Round Economy

+
+ + + + + + + + {#if selectedRoundData.players.some((p) => p.kills_in_round !== undefined)} + + {/if} + {#if selectedRoundData.players.some((p) => p.damage_in_round !== undefined)} + + {/if} + + + + {#each selectedRoundData.players as player} + + + + + + {#if selectedRoundData.players.some((p) => p.kills_in_round !== undefined)} + + {/if} + {#if selectedRoundData.players.some((p) => p.damage_in_round !== undefined)} + + {/if} + + {/each} + +
PlayerBankEquipmentSpentKillsDamage
Player {player.player_id || player.match_player_id || '?'}${player.bank.toLocaleString()}${player.equipment.toLocaleString()}${player.spent.toLocaleString()}{player.kills_in_round || 0}{player.damage_in_round || 0}
+
+
+ {/if} +
+ {/if} +
diff --git a/src/lib/components/charts/LineChart.svelte b/src/lib/components/charts/LineChart.svelte index bfc0bab..d88f09c 100644 --- a/src/lib/components/charts/LineChart.svelte +++ b/src/lib/components/charts/LineChart.svelte @@ -44,12 +44,7 @@ class?: string; } - let { - data, - options = {}, - height = 300, - class: className = '' - }: Props = $props(); + let { data, options = {}, height = 300, class: className = '' }: Props = $props(); let canvas: HTMLCanvasElement; let chart: Chart<'line'> | null = null; diff --git a/src/lib/components/layout/Header.svelte b/src/lib/components/layout/Header.svelte index 5da2389..98a5587 100644 --- a/src/lib/components/layout/Header.svelte +++ b/src/lib/components/layout/Header.svelte @@ -17,11 +17,10 @@
- - CS2.WTF + +

+ CS2.WTF +

diff --git a/src/lib/components/layout/SearchBar.svelte b/src/lib/components/layout/SearchBar.svelte index f69a10f..6e8ea4c 100644 --- a/src/lib/components/layout/SearchBar.svelte +++ b/src/lib/components/layout/SearchBar.svelte @@ -92,7 +92,7 @@
{#each $search.recentSearches as recent} +
+
+ + Submit a CS2 match share code to add it to the database + +
+
+ + + {#if parseStatus !== 'idle'} +
+ {#if parseStatus === 'parsing'} + + {:else if parseStatus === 'success'} + + {:else} + + {/if} +
+

{statusMessage}

+ {#if parseStatus === 'success' && parsedMatchId} +

Redirecting to match page...

+ {/if} +
+ {#if parseStatus !== 'parsing'} + + {/if} +
+ {/if} + + +
+

How to get your match share code:

+
    +
  1. Open CS2 and navigate to your Matches tab
  2. +
  3. Click on a match you want to analyze
  4. +
  5. Click the "Copy Share Link" button
  6. +
  7. Paste the share code here
  8. +
+

+ 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. +

+
+
diff --git a/src/lib/components/player/TrackPlayerModal.svelte b/src/lib/components/player/TrackPlayerModal.svelte new file mode 100644 index 0000000..1966361 --- /dev/null +++ b/src/lib/components/player/TrackPlayerModal.svelte @@ -0,0 +1,196 @@ + + + +
+
+ + + +
+ {#if isTracked} +

Remove {playerName} from automatic match tracking.

+ {:else} +

+ Add {playerName} to the tracking system to automatically fetch new matches. +

+ {/if} +
+
+ + +
+ + +
+ + Required to verify ownership of this Steam account + +
+
+ + + {#if !isTracked} +
+ + +
+ + Optional: Provide a share code if you have no matches yet + +
+
+ {/if} + + + {#if error} +
+ + + + {error} +
+ {/if} + + +
+

How to get your authentication code:

+
    +
  1. Open CS2 and go to Settings → Game
  2. +
  3. Enable the Developer Console
  4. +
  5. Press ~ to open the console
  6. +
  7. Type: status
  8. +
  9. Copy the code shown next to "Account:"
  10. +
+
+
+ + {#snippet actions()} + + {#if isTracked} + + {:else} + + {/if} + {/snippet} +
diff --git a/src/lib/components/ui/Modal.svelte b/src/lib/components/ui/Modal.svelte index b38131b..595a3f8 100644 --- a/src/lib/components/ui/Modal.svelte +++ b/src/lib/components/ui/Modal.svelte @@ -1,16 +1,18 @@ + +
+ {#if showIcon} + + {/if} + + {tierInfo.formatted} + + {#if showTier} + ({tierInfo.tier}) + {/if} + + {#if showChange && changeInfo} + + {#if changeInfo.isPositive} + + {:else if changeInfo.change < 0} + + {/if} + {changeInfo.display} + + {/if} +
diff --git a/src/lib/components/ui/Tabs.svelte b/src/lib/components/ui/Tabs.svelte index 8928531..6cb7cd4 100644 --- a/src/lib/components/ui/Tabs.svelte +++ b/src/lib/components/ui/Tabs.svelte @@ -43,8 +43,10 @@ } }; - const variantClass = variant === 'boxed' ? 'tabs-boxed' : variant === 'lifted' ? 'tabs-lifted' : ''; - const sizeClass = size === 'xs' ? 'tabs-xs' : size === 'sm' ? 'tabs-sm' : size === 'lg' ? 'tabs-lg' : ''; + const variantClass = + variant === 'boxed' ? 'tabs-boxed' : variant === 'lifted' ? 'tabs-lifted' : ''; + const sizeClass = + size === 'xs' ? 'tabs-xs' : size === 'sm' ? 'tabs-sm' : size === 'lg' ? 'tabs-lg' : '';
diff --git a/src/lib/components/ui/Tooltip.svelte b/src/lib/components/ui/Tooltip.svelte index 6dadf3e..f3c5587 100644 --- a/src/lib/components/ui/Tooltip.svelte +++ b/src/lib/components/ui/Tooltip.svelte @@ -1,8 +1,10 @@ @@ -85,46 +161,72 @@
-
- {#each featuredMatches as match} - - -
-
- {match.mapDisplay} -
-
- {match.map} -
- {#if match.live} -
- - ● LIVE - -
- {/if} -
+ {#if featuredMatches.length > 0} + +
+ + + {/if} +
+ + + {#if totalSlides > 1} +
+ {#each Array(totalSlides) as _, i} + + {/each} +
+ {/if} + {:else} + +
+

No featured matches available at the moment.

+

Check back soon for the latest matches!

+
+ {/if} diff --git a/src/routes/+page.ts b/src/routes/+page.ts index e5069ac..866cd4f 100644 --- a/src/routes/+page.ts +++ b/src/routes/+page.ts @@ -10,11 +10,11 @@ export const load: PageLoad = async ({ parent }) => { await parent(); try { - // Load featured matches (limit to 3 for homepage) - const matchesData = await api.matches.getMatches({ limit: 3 }); + // Load featured matches for homepage carousel + const matchesData = await api.matches.getMatches({ limit: 9 }); return { - featuredMatches: matchesData.matches.slice(0, 3), // Ensure max 3 matches + featuredMatches: matchesData.matches.slice(0, 9), // Get 9 matches for carousel (3 slides) meta: { title: 'CS2.WTF - Statistics for CS2 Matchmaking', description: diff --git a/src/routes/api/[...path]/+server.ts b/src/routes/api/[...path]/+server.ts new file mode 100644 index 0000000..ca402a4 --- /dev/null +++ b/src/routes/api/[...path]/+server.ts @@ -0,0 +1,163 @@ +/** + * SvelteKit API Route Handler + * + * This catch-all route proxies requests to the backend API. + * Benefits over Vite proxy: + * - Works in development, preview, and production + * - Single code path for all environments + * - Can add caching, rate limiting, auth in the future + * - No CORS issues + * + * Backend selection: + * - Set VITE_API_BASE_URL=http://localhost:8000 for local development + * - Set VITE_API_BASE_URL=https://api.csgow.tf for production API + */ + +import { error, json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { env } from '$env/dynamic/private'; + +// Get backend API URL from environment variable +// Note: We use $env/dynamic/private instead of import.meta.env for server-side access +const API_BASE_URL = env.VITE_API_BASE_URL || 'https://api.csgow.tf'; + +/** + * GET request handler + * Forwards GET requests to the backend API + */ +export const GET: RequestHandler = async ({ params, url, request }) => { + const path = params.path; + const queryString = url.search; + + // Construct full backend URL + const backendUrl = `${API_BASE_URL}/${path}${queryString}`; + + try { + // Forward request to backend + const response = await fetch(backendUrl, { + method: 'GET', + headers: { + // Forward relevant headers + Accept: request.headers.get('Accept') || 'application/json', + 'User-Agent': 'CS2.WTF Frontend' + } + }); + + // Check if request was successful + if (!response.ok) { + throw error(response.status, `Backend API returned ${response.status}`); + } + + // Get response data + const data = await response.json(); + + // Return JSON response + return json(data); + } catch (err) { + // Log error for debugging + console.error(`[API Route] Error fetching ${backendUrl}:`, err); + + // Handle fetch errors + if (err instanceof Error && err.message.includes('fetch')) { + throw error(503, `Unable to connect to backend API at ${API_BASE_URL}`); + } + + // Re-throw SvelteKit errors + throw err; + } +}; + +/** + * POST request handler + * Forwards POST requests to the backend API + */ +export const POST: RequestHandler = async ({ params, url, request }) => { + const path = params.path; + const queryString = url.search; + + // Construct full backend URL + const backendUrl = `${API_BASE_URL}/${path}${queryString}`; + + try { + // Get request body + const body = await request.text(); + + // Forward request to backend + const response = await fetch(backendUrl, { + method: 'POST', + headers: { + 'Content-Type': request.headers.get('Content-Type') || 'application/json', + Accept: request.headers.get('Accept') || 'application/json', + 'User-Agent': 'CS2.WTF Frontend' + }, + body + }); + + // Check if request was successful + if (!response.ok) { + throw error(response.status, `Backend API returned ${response.status}`); + } + + // Get response data + const data = await response.json(); + + // Return JSON response + return json(data); + } catch (err) { + // Log error for debugging + console.error(`[API Route] Error fetching ${backendUrl}:`, err); + + // Handle fetch errors + if (err instanceof Error && err.message.includes('fetch')) { + throw error(503, `Unable to connect to backend API at ${API_BASE_URL}`); + } + + // Re-throw SvelteKit errors + throw err; + } +}; + +/** + * DELETE request handler + * Forwards DELETE requests to the backend API + */ +export const DELETE: RequestHandler = async ({ params, url, request }) => { + const path = params.path; + const queryString = url.search; + + // Construct full backend URL + const backendUrl = `${API_BASE_URL}/${path}${queryString}`; + + try { + // Forward request to backend + const response = await fetch(backendUrl, { + method: 'DELETE', + headers: { + Accept: request.headers.get('Accept') || 'application/json', + 'User-Agent': 'CS2.WTF Frontend' + } + }); + + // Check if request was successful + if (!response.ok) { + throw error(response.status, `Backend API returned ${response.status}`); + } + + // Get response data + const data = await response.json(); + + // Return JSON response + return json(data); + } catch (err) { + // Log error for debugging + console.error(`[API Route] Error fetching ${backendUrl}:`, err); + + // Handle fetch errors + if (err instanceof Error && err.message.includes('fetch')) { + throw error(503, `Unable to connect to backend API at ${API_BASE_URL}`); + } + + // Re-throw SvelteKit errors + throw err; + } +}; diff --git a/src/routes/match/[id]/+layout.svelte b/src/routes/match/[id]/+layout.svelte index ae64e89..3a41be5 100644 --- a/src/routes/match/[id]/+layout.svelte +++ b/src/routes/match/[id]/+layout.svelte @@ -1,13 +1,20 @@ - -
-
+ +
+ +
+ {mapName} +
+
+ +
+ +
+ +
+
- {match.map} -

{mapName}

+ {#if match.map} + {match.map} + {/if} +

{mapName}

{#if match.demo_parsed} + + +
{#if showFilters}
+ +
+

Filter by Date Range

+
+ +
+ + + + +
+ +
+
+ + +
+
+ + +
+
+
+
+

Filter by Map

@@ -180,6 +641,51 @@
+ +
+
+

Filter by Rank Tier

+
+ Coming Soon +
+
+ +
+ + +
+
+

Filter by Game Mode

+
+ Coming Soon +
+
+
+ + + + +
+
+

Filter by Result

@@ -241,12 +747,19 @@
+ + +
+ +
{/if} - {#if currentMap || currentPlayerId || currentSearch} + {#if currentMap || currentPlayerId || currentSearch || fromDate || toDate}
Active Filters: {#if currentSearch} @@ -258,31 +771,104 @@ {#if currentPlayerId} Player ID: {currentPlayerId} {/if} - + {#if fromDate} + From: {fromDate} + {/if} + {#if toDate} + To: {toDate} + {/if} +
{/if} - - {#if matches.length > 0 && resultFilter !== 'all'} -
+ +
+ +
+ + +
+ + + {#if matches.length > 0 && resultFilter !== 'all'} Showing {displayMatches.length} of {matches.length} matches -
- {/if} + {/if} +
- + {#if displayMatches.length > 0} -
- {#each displayMatches as match} - - {/each} -
+ {#if viewMode === 'grid'} + +
+ {#each displayMatches as match} + + {/each} +
+ {:else} + +
{ + if (e.key === 'Enter' || e.key === ' ') { + // Create a mock MouseEvent to match the expected type + const mockEvent = { + target: e.target, + currentTarget: e.currentTarget + } as unknown as MouseEvent; + handleTableLinkClick(mockEvent); + e.preventDefault(); + } + }} + > + +
+ {/if} - + {#if hasMore}
+ +
+ + + {#if isLoadingMore} +

Loading more matches...

+ {/if}

- Showing {matches.length} matches + Showing {matches.length} matches {hasMore ? '(more available)' : '(all loaded)'}

{:else if matches.length > 0} @@ -312,10 +901,24 @@

No matches match your current filters. Try adjusting your filter settings.

-
- +
+ {#if resultFilter !== 'all'} + + {/if} + {#if fromDate || toDate} + + {/if} +
diff --git a/src/routes/matches/+page.ts b/src/routes/matches/+page.ts index 341c017..7cf015c 100644 --- a/src/routes/matches/+page.ts +++ b/src/routes/matches/+page.ts @@ -7,9 +7,8 @@ import { api } from '$lib/api'; export const load: PageLoad = async ({ url }) => { // Get query parameters const map = url.searchParams.get('map') || undefined; - const playerIdStr = url.searchParams.get('player_id'); - const playerId = playerIdStr ? Number(playerIdStr) : undefined; - const limit = Number(url.searchParams.get('limit')) || 50; + const playerId = url.searchParams.get('player_id') || undefined; + const limit = Number(url.searchParams.get('limit')) || 20; // Request 20 matches for initial load try { // Load matches with filters @@ -33,7 +32,10 @@ export const load: PageLoad = async ({ url }) => { } }; } catch (error) { - console.error('Failed to load matches:', error instanceof Error ? error.message : String(error)); + console.error( + 'Failed to load matches:', + error instanceof Error ? error.message : String(error) + ); // Return empty state on error return { diff --git a/src/routes/player/[id]/+page.svelte b/src/routes/player/[id]/+page.svelte index ab3ed20..ea3e2ca 100644 --- a/src/routes/player/[id]/+page.svelte +++ b/src/routes/player/[id]/+page.svelte @@ -1,16 +1,40 @@