Files
csgowtf/src/routes/player/[id]/+page.svelte
vikingowl 7e101ba274 feat: Add Steam profile link to player pages
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>
2025-11-12 20:13:31 +01:00

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
(&lt;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>