Add direct link to Steam Community profile for easy access to player's Steam page. ## Changes ### UI Addition - Added "Steam Profile" button to player page actions section - Positioned alongside "Track Player" and "View All Matches" buttons - Uses ExternalLink icon from lucide-svelte - Ghost button variant for secondary action styling ### Link Implementation - Opens Steam Community profile in new tab - Uses player's Steam ID (uint64) to construct profile URL - Format: `https://steamcommunity.com/profiles/{steamid64}` - Includes `target="_blank"` and `rel="noopener noreferrer"` for security ### UX Improvements - Changed actions container to use `flex-wrap` for responsive layout - Buttons wrap on smaller screens to prevent overflow - External link icon clearly indicates opening in new tab **Security Note:** The `rel="noopener noreferrer"` attribute prevents: - Potential security issues with window.opener access - Referrer information leakage to external site This provides users quick access to full Steam profile information including inventory, game library, friends list, and other Steam-specific data not available in CS2.WTF. This completes Phase 3 Feature 1 - Steam profile integration added. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
486 lines
15 KiB
Svelte
486 lines
15 KiB
Svelte
<script lang="ts">
|
|
import {
|
|
User,
|
|
Target,
|
|
TrendingUp,
|
|
Calendar,
|
|
Trophy,
|
|
Heart,
|
|
Crosshair,
|
|
UserCheck,
|
|
ExternalLink
|
|
} from 'lucide-svelte';
|
|
import Card from '$lib/components/ui/Card.svelte';
|
|
import Button from '$lib/components/ui/Button.svelte';
|
|
import MatchCard from '$lib/components/match/MatchCard.svelte';
|
|
import LineChart from '$lib/components/charts/LineChart.svelte';
|
|
import BarChart from '$lib/components/charts/BarChart.svelte';
|
|
import PremierRatingBadge from '$lib/components/ui/PremierRatingBadge.svelte';
|
|
import TrackPlayerModal from '$lib/components/player/TrackPlayerModal.svelte';
|
|
import { preferences } from '$lib/stores';
|
|
import { invalidateAll } from '$app/navigation';
|
|
import { addRecentPlayer } from '$lib/utils/recentPlayers';
|
|
import { onMount } from 'svelte';
|
|
import type { PageData } from './$types';
|
|
|
|
let { data }: { data: PageData } = $props();
|
|
const { profile, recentMatches, playerStats } = data;
|
|
|
|
// Track this player visit
|
|
onMount(() => {
|
|
addRecentPlayer({
|
|
id: profile.id,
|
|
name: profile.name,
|
|
avatar: profile.avatar
|
|
});
|
|
});
|
|
|
|
// Track player modal state
|
|
let isTrackModalOpen = $state(false);
|
|
|
|
// Handle tracking events
|
|
async function handleTracked() {
|
|
await invalidateAll();
|
|
}
|
|
|
|
async function handleUntracked() {
|
|
await invalidateAll();
|
|
}
|
|
|
|
// Calculate stats from PlayerMeta and aggregated match data
|
|
const kd =
|
|
profile.avg_deaths > 0
|
|
? (profile.avg_kills / profile.avg_deaths).toFixed(2)
|
|
: profile.avg_kills.toFixed(2);
|
|
const winRate = (profile.win_rate * 100).toFixed(1);
|
|
|
|
// Get current Premier rating from most recent match
|
|
const currentRating =
|
|
playerStats.length > 0 && playerStats[0] ? playerStats[0].rank_new : undefined;
|
|
const previousRating =
|
|
playerStats.length > 0 && playerStats[0] ? playerStats[0].rank_old : undefined;
|
|
|
|
// Calculate headshot percentage from playerStats if available
|
|
const totalKills = playerStats.reduce((sum, stat) => sum + stat.kills, 0);
|
|
const totalHeadshots = playerStats.reduce((sum, stat) => sum + (stat.headshot || 0), 0);
|
|
const hsPercent =
|
|
totalHeadshots > 0 && totalKills > 0 ? ((totalHeadshots / totalKills) * 100).toFixed(1) : '0.0';
|
|
|
|
// Check if player is favorited
|
|
const isFavorite = $derived($preferences.favoritePlayers.includes(profile.id));
|
|
|
|
const toggleFavorite = () => {
|
|
if (isFavorite) {
|
|
preferences.removeFavoritePlayer(profile.id);
|
|
} else {
|
|
preferences.addFavoritePlayer(profile.id);
|
|
}
|
|
};
|
|
|
|
// Performance trend chart data (K/D ratio over time)
|
|
const performanceTrendData = {
|
|
labels: playerStats.map((_stat, i) => `Match ${playerStats.length - i}`).reverse(),
|
|
datasets: [
|
|
{
|
|
label: 'K/D Ratio',
|
|
data: playerStats
|
|
.map((stat) => (stat.deaths > 0 ? stat.kills / stat.deaths : stat.kills))
|
|
.reverse(),
|
|
borderColor: 'rgb(59, 130, 246)',
|
|
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
|
tension: 0.4,
|
|
fill: true
|
|
},
|
|
{
|
|
label: 'KAST %',
|
|
data: playerStats.map((stat) => stat.kast || 0).reverse(),
|
|
borderColor: 'rgb(34, 197, 94)',
|
|
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
|
tension: 0.4,
|
|
fill: true,
|
|
yAxisID: 'y1'
|
|
}
|
|
]
|
|
};
|
|
|
|
const performanceTrendOptions = {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
interaction: {
|
|
mode: 'index' as const,
|
|
intersect: false
|
|
},
|
|
scales: {
|
|
y: {
|
|
type: 'linear' as const,
|
|
display: true,
|
|
position: 'left' as const,
|
|
title: {
|
|
display: true,
|
|
text: 'K/D Ratio'
|
|
}
|
|
},
|
|
y1: {
|
|
type: 'linear' as const,
|
|
display: true,
|
|
position: 'right' as const,
|
|
title: {
|
|
display: true,
|
|
text: 'KAST %'
|
|
},
|
|
grid: {
|
|
drawOnChartArea: false
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Map performance data (win rate per map)
|
|
const mapStats = playerStats.reduce(
|
|
(acc, stat) => {
|
|
if (!acc[stat.map]) {
|
|
acc[stat.map] = { wins: 0, total: 0 };
|
|
}
|
|
const mapStat = acc[stat.map]!;
|
|
mapStat.total++;
|
|
if (stat.won) mapStat.wins++;
|
|
return acc;
|
|
},
|
|
{} as Record<string, { wins: number; total: number }>
|
|
);
|
|
|
|
const mapPerformanceData = {
|
|
labels: Object.keys(mapStats).map((map) => map.replace('de_', '')),
|
|
datasets: [
|
|
{
|
|
label: 'Win Rate %',
|
|
data: Object.values(mapStats).map((stat) => (stat.wins / stat.total) * 100),
|
|
backgroundColor: Object.values(mapStats).map((stat) => {
|
|
const winRate = stat.wins / stat.total;
|
|
if (winRate >= 0.6) return 'rgba(34, 197, 94, 0.8)'; // Green for high win rate
|
|
if (winRate >= 0.4) return 'rgba(59, 130, 246, 0.8)'; // Blue for medium
|
|
return 'rgba(239, 68, 68, 0.8)'; // Red for low win rate
|
|
})
|
|
}
|
|
]
|
|
};
|
|
|
|
const mapPerformanceOptions = {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
scales: {
|
|
y: {
|
|
beginAtZero: true,
|
|
max: 100,
|
|
title: {
|
|
display: true,
|
|
text: 'Win Rate %'
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Utility effectiveness stats
|
|
const utilityStats = {
|
|
flashAssists: playerStats.reduce((sum, stat) => sum + (stat.flash_assists || 0), 0),
|
|
enemiesBlinded: playerStats.reduce((sum, stat) => sum + (stat.flash_total_enemy || 0), 0),
|
|
heDamage: playerStats.reduce((sum, stat) => sum + (stat.ud_he || 0), 0),
|
|
flameDamage: playerStats.reduce((sum, stat) => sum + (stat.ud_flames || 0), 0),
|
|
totalMatches: playerStats.length
|
|
};
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>{data.meta.title}</title>
|
|
<meta name="description" content={data.meta.description} />
|
|
</svelte:head>
|
|
|
|
<div class="space-y-8">
|
|
<!-- Player Header -->
|
|
<Card variant="elevated" padding="lg">
|
|
<div class="flex flex-col items-start gap-6 md:flex-row md:items-center">
|
|
<!-- Avatar -->
|
|
<div
|
|
class="flex h-24 w-24 items-center justify-center rounded-full bg-gradient-to-br from-primary to-secondary"
|
|
>
|
|
<User class="h-12 w-12 text-white" />
|
|
</div>
|
|
|
|
<!-- Info -->
|
|
<div class="flex-1">
|
|
<div class="mb-2 flex items-center gap-3">
|
|
<h1 class="text-3xl font-bold text-base-content">{profile.name}</h1>
|
|
<button
|
|
onclick={toggleFavorite}
|
|
class="btn btn-circle btn-ghost btn-sm"
|
|
aria-label={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
|
>
|
|
<Heart class="h-5 w-5 {isFavorite ? 'fill-error text-error' : ''}" />
|
|
</button>
|
|
</div>
|
|
<div class="mb-3 flex flex-wrap items-center gap-3">
|
|
<PremierRatingBadge
|
|
rating={currentRating}
|
|
oldRating={previousRating}
|
|
size="lg"
|
|
showTier={true}
|
|
showChange={true}
|
|
/>
|
|
<!-- VAC/Game Ban Status Badges -->
|
|
{#if profile.vac_count && profile.vac_count > 0}
|
|
<div class="badge badge-error badge-lg gap-2">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
class="inline-block h-4 w-4 stroke-current"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M6 18L18 6M6 6l12 12"
|
|
></path>
|
|
</svg>
|
|
VAC Ban{profile.vac_count > 1 ? `s (${profile.vac_count})` : ''}
|
|
{#if profile.vac_date}
|
|
<span class="text-xs opacity-80">
|
|
{new Date(profile.vac_date).toLocaleDateString()}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{#if profile.game_ban_count && profile.game_ban_count > 0}
|
|
<div class="badge badge-warning badge-lg gap-2">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
class="inline-block h-4 w-4 stroke-current"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
|
></path>
|
|
</svg>
|
|
Game Ban{profile.game_ban_count > 1 ? `s (${profile.game_ban_count})` : ''}
|
|
{#if profile.game_ban_date}
|
|
<span class="text-xs opacity-80">
|
|
{new Date(profile.game_ban_date).toLocaleDateString()}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
<div class="flex flex-wrap gap-3 text-sm text-base-content/60">
|
|
<span>Steam ID: {profile.id}</span>
|
|
<span>Last match: {new Date(profile.last_match_date).toLocaleDateString()}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="flex flex-wrap gap-2">
|
|
<Button
|
|
variant={profile.tracked ? 'success' : 'primary'}
|
|
size="sm"
|
|
onclick={() => (isTrackModalOpen = true)}
|
|
>
|
|
<UserCheck class="h-4 w-4" />
|
|
{profile.tracked ? 'Tracked' : 'Track Player'}
|
|
</Button>
|
|
<Button variant="ghost" size="sm" href={`/matches?player_id=${profile.id}`}>
|
|
View All Matches
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
href={`https://steamcommunity.com/profiles/${profile.id}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
<ExternalLink class="h-4 w-4" />
|
|
Steam Profile
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<!-- Track Player Modal -->
|
|
<TrackPlayerModal
|
|
playerId={profile.id}
|
|
playerName={profile.name}
|
|
isTracked={profile.tracked || false}
|
|
bind:isOpen={isTrackModalOpen}
|
|
ontracked={handleTracked}
|
|
onuntracked={handleUntracked}
|
|
/>
|
|
|
|
<!-- Career Statistics -->
|
|
<div>
|
|
<h2 class="mb-4 text-2xl font-bold text-base-content">Career Statistics</h2>
|
|
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
|
<Card padding="lg">
|
|
<div class="mb-2 flex items-center gap-2">
|
|
<Target class="h-5 w-5 text-primary" />
|
|
<span class="text-sm font-medium text-base-content/70">K/D Ratio</span>
|
|
</div>
|
|
<div class="text-3xl font-bold text-base-content">{kd}</div>
|
|
<div class="mt-1 text-xs text-base-content/60">
|
|
{profile.avg_kills.toFixed(1)} K / {profile.avg_deaths.toFixed(1)} D avg
|
|
</div>
|
|
</Card>
|
|
|
|
<Card padding="lg">
|
|
<div class="mb-2 flex items-center gap-2">
|
|
<TrendingUp class="h-5 w-5 text-success" />
|
|
<span class="text-sm font-medium text-base-content/70">Win Rate</span>
|
|
</div>
|
|
<div class="text-3xl font-bold text-base-content">{winRate}%</div>
|
|
<div class="mt-1 text-xs text-base-content/60">
|
|
Last {profile.recent_matches} matches
|
|
</div>
|
|
</Card>
|
|
|
|
<Card padding="lg">
|
|
<div class="mb-2 flex items-center gap-2">
|
|
<Trophy class="h-5 w-5 text-warning" />
|
|
<span class="text-sm font-medium text-base-content/70">KAST %</span>
|
|
</div>
|
|
<div class="text-3xl font-bold text-base-content">{profile.avg_kast.toFixed(1)}%</div>
|
|
<div class="mt-1 text-xs text-base-content/60">Kill/Assist/Survive/Trade average</div>
|
|
</Card>
|
|
|
|
<Card padding="lg">
|
|
<div class="mb-2 flex items-center gap-2">
|
|
<Target class="h-5 w-5 text-error" />
|
|
<span class="text-sm font-medium text-base-content/70">Headshot %</span>
|
|
</div>
|
|
<div class="text-3xl font-bold text-base-content">{hsPercent}%</div>
|
|
<div class="mt-1 text-xs text-base-content/60">
|
|
{totalHeadshots} of {totalKills} kills
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recent Matches -->
|
|
<div>
|
|
<div class="mb-4 flex items-center justify-between">
|
|
<h2 class="text-2xl font-bold text-base-content">Recent Matches</h2>
|
|
<Button variant="ghost" href={`/matches?player_id=${profile.id}`}>View All</Button>
|
|
</div>
|
|
|
|
{#if recentMatches.length > 0}
|
|
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-4">
|
|
{#each recentMatches as match}
|
|
<MatchCard {match} />
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<Card padding="lg">
|
|
<div class="text-center text-base-content/60">
|
|
<Calendar class="mx-auto mb-2 h-12 w-12" />
|
|
<p>No recent matches found for this player.</p>
|
|
</div>
|
|
</Card>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Performance Charts -->
|
|
{#if playerStats.length > 0}
|
|
<div>
|
|
<h2 class="mb-4 text-2xl font-bold text-base-content">Performance Analysis</h2>
|
|
<div class="grid gap-6 lg:grid-cols-2">
|
|
<!-- Performance Trend Chart -->
|
|
<Card padding="lg">
|
|
<h3 class="mb-4 text-lg font-semibold text-base-content">
|
|
<div class="flex items-center gap-2">
|
|
<TrendingUp class="h-5 w-5 text-primary" />
|
|
Performance Trend (Last {playerStats.length} Matches)
|
|
</div>
|
|
</h3>
|
|
<div class="h-64">
|
|
<LineChart data={performanceTrendData} options={performanceTrendOptions} />
|
|
</div>
|
|
<p class="mt-4 text-sm text-base-content/60">
|
|
Shows K/D ratio and KAST percentage across recent matches. Higher is better.
|
|
</p>
|
|
</Card>
|
|
|
|
<!-- Map Performance Chart -->
|
|
<Card padding="lg">
|
|
<h3 class="mb-4 text-lg font-semibold text-base-content">
|
|
<div class="flex items-center gap-2">
|
|
<Target class="h-5 w-5 text-secondary" />
|
|
Map Win Rate
|
|
</div>
|
|
</h3>
|
|
<div class="h-64">
|
|
<BarChart data={mapPerformanceData} options={mapPerformanceOptions} />
|
|
</div>
|
|
<p class="mt-4 text-sm text-base-content/60">
|
|
Win rate percentage by map. Green = strong (≥60%), Blue = average (40-60%), Red = weak
|
|
(<40%).
|
|
</p>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Utility Effectiveness -->
|
|
<div>
|
|
<h2 class="mb-4 text-2xl font-bold text-base-content">Utility Effectiveness</h2>
|
|
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
|
<Card padding="lg">
|
|
<div class="mb-2 flex items-center gap-2">
|
|
<Crosshair class="h-5 w-5 text-info" />
|
|
<span class="text-sm font-medium text-base-content/70">Flash Assists</span>
|
|
</div>
|
|
<div class="text-3xl font-bold text-base-content">{utilityStats.flashAssists}</div>
|
|
<div class="mt-1 text-xs text-base-content/60">
|
|
{(utilityStats.flashAssists / utilityStats.totalMatches).toFixed(1)} per match
|
|
</div>
|
|
</Card>
|
|
|
|
<Card padding="lg">
|
|
<div class="mb-2 flex items-center gap-2">
|
|
<Target class="h-5 w-5 text-warning" />
|
|
<span class="text-sm font-medium text-base-content/70">Enemies Blinded</span>
|
|
</div>
|
|
<div class="text-3xl font-bold text-base-content">{utilityStats.enemiesBlinded}</div>
|
|
<div class="mt-1 text-xs text-base-content/60">
|
|
{(utilityStats.enemiesBlinded / utilityStats.totalMatches).toFixed(1)} per match
|
|
</div>
|
|
</Card>
|
|
|
|
<Card padding="lg">
|
|
<div class="mb-2 flex items-center gap-2">
|
|
<Trophy class="h-5 w-5 text-error" />
|
|
<span class="text-sm font-medium text-base-content/70">HE Damage</span>
|
|
</div>
|
|
<div class="text-3xl font-bold text-base-content">
|
|
{Math.round(utilityStats.heDamage)}
|
|
</div>
|
|
<div class="mt-1 text-xs text-base-content/60">
|
|
{Math.round(utilityStats.heDamage / utilityStats.totalMatches)} per match
|
|
</div>
|
|
</Card>
|
|
|
|
<Card padding="lg">
|
|
<div class="mb-2 flex items-center gap-2">
|
|
<Trophy class="h-5 w-5 text-terrorist" />
|
|
<span class="text-sm font-medium text-base-content/70">Flame Damage</span>
|
|
</div>
|
|
<div class="text-3xl font-bold text-base-content">
|
|
{Math.round(utilityStats.flameDamage)}
|
|
</div>
|
|
<div class="mt-1 text-xs text-base-content/60">
|
|
{Math.round(utilityStats.flameDamage / utilityStats.totalMatches)} per match
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|