tauri-app: display game history with stats
This commit is contained in:
14
tauri-app/src-tauri/Cargo.lock
generated
14
tauri-app/src-tauri/Cargo.lock
generated
@@ -715,10 +715,10 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
name = "directories"
|
||||
version = "5.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
|
||||
checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35"
|
||||
dependencies = [
|
||||
"dirs-sys 0.4.1",
|
||||
]
|
||||
@@ -3631,7 +3631,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"dirs 6.0.0",
|
||||
"dirs",
|
||||
"dunce",
|
||||
"embed_plist",
|
||||
"getrandom 0.3.4",
|
||||
@@ -3678,7 +3678,7 @@ name = "tauri-app"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"directories",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
@@ -3695,7 +3695,7 @@ checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cargo_toml",
|
||||
"dirs 6.0.0",
|
||||
"dirs",
|
||||
"glob",
|
||||
"heck 0.5.0",
|
||||
"json-patch",
|
||||
@@ -4219,7 +4219,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dirs 6.0.0",
|
||||
"dirs",
|
||||
"libappindicator",
|
||||
"muda",
|
||||
"objc2",
|
||||
@@ -5292,7 +5292,7 @@ dependencies = [
|
||||
"block2",
|
||||
"cookie",
|
||||
"crossbeam-channel",
|
||||
"dirs 6.0.0",
|
||||
"dirs",
|
||||
"dom_query",
|
||||
"dpi",
|
||||
"dunce",
|
||||
|
||||
@@ -24,5 +24,5 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
dirs = "5"
|
||||
directories = "5"
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@ use uuid::Uuid;
|
||||
/// A timestamped event in the timeline.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TimestampedEvent {
|
||||
/// Video timestamp (offset from recording start) in seconds.
|
||||
pub video_timestamp: i64,
|
||||
/// Game timestamp (in-game time) in seconds.
|
||||
pub game_timestamp: Option<i64>,
|
||||
/// Video timestamp (offset from recording start) as [seconds, nanos].
|
||||
pub video_timestamp: (i64, i32),
|
||||
/// Game timestamp (in-game time) as [seconds, nanos].
|
||||
pub game_timestamp: Option<(i64, i32)>,
|
||||
/// Real-world timestamp.
|
||||
pub timestamp: DateTime<Utc>,
|
||||
/// Event type name.
|
||||
@@ -19,7 +19,81 @@ pub struct TimestampedEvent {
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
impl TimestampedEvent {
|
||||
/// Get video timestamp in seconds.
|
||||
pub fn video_timestamp_secs(&self) -> i64 {
|
||||
self.video_timestamp.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Final game statistics for the player.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GameFinalStats {
|
||||
pub kills: u32,
|
||||
pub deaths: u32,
|
||||
pub assists: u32,
|
||||
pub creep_score: u32,
|
||||
pub gold_earned: u32,
|
||||
pub damage_dealt: u64,
|
||||
pub damage_taken: u64,
|
||||
pub vision_score: f64,
|
||||
pub game_duration: f64,
|
||||
}
|
||||
|
||||
/// Rune page configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RunePage {
|
||||
pub primary_style_id: u32,
|
||||
pub secondary_style_id: u32,
|
||||
pub selected_perks: Vec<u32>,
|
||||
#[serde(default)]
|
||||
pub stat_modifiers: Vec<u32>,
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub current: bool,
|
||||
}
|
||||
|
||||
/// Summoner spell information.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SummonerSpells {
|
||||
pub spell1_id: u32,
|
||||
pub spell2_id: u32,
|
||||
pub spell1_name: Option<String>,
|
||||
pub spell2_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Individual item information.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ItemInfo {
|
||||
pub item_id: u32,
|
||||
pub name: Option<String>,
|
||||
pub slot: u32,
|
||||
}
|
||||
|
||||
/// Item build at game end.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ItemBuild {
|
||||
pub items: Vec<ItemInfo>,
|
||||
pub trinket: Option<ItemInfo>,
|
||||
}
|
||||
|
||||
/// Player identity information.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlayerIdentityInfo {
|
||||
pub puuid: String,
|
||||
#[serde(default)]
|
||||
pub summoner_name: String,
|
||||
pub champion_name: Option<String>,
|
||||
pub team: Option<u32>,
|
||||
}
|
||||
|
||||
/// A timeline of events for a recording.
|
||||
/// This matches the full JSON structure from record-daemon.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Timeline {
|
||||
/// Recording ID.
|
||||
@@ -32,39 +106,68 @@ pub struct Timeline {
|
||||
pub duration_secs: i64,
|
||||
/// Events in the timeline.
|
||||
pub events: Vec<TimestampedEvent>,
|
||||
}
|
||||
|
||||
/// Game history item for display in the UI.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GameHistoryItem {
|
||||
/// Recording ID.
|
||||
pub id: String,
|
||||
/// Game start time.
|
||||
pub start_time: DateTime<Utc>,
|
||||
/// Game end time.
|
||||
pub end_time: Option<DateTime<Utc>>,
|
||||
/// Duration in seconds.
|
||||
pub duration_secs: i64,
|
||||
/// Formatted duration string (e.g., "32:15").
|
||||
pub duration_formatted: String,
|
||||
/// Number of events.
|
||||
pub event_count: usize,
|
||||
/// Champion played (if available).
|
||||
/// Champion played.
|
||||
#[serde(default)]
|
||||
pub champion: Option<String>,
|
||||
/// Game result (if available).
|
||||
pub result: Option<String>,
|
||||
/// Video file path.
|
||||
pub video_path: Option<String>,
|
||||
/// Skin name.
|
||||
#[serde(default)]
|
||||
pub skin_name: Option<String>,
|
||||
/// Queue type.
|
||||
#[serde(default)]
|
||||
pub queue_type: Option<String>,
|
||||
/// Queue ID.
|
||||
#[serde(default)]
|
||||
pub queue_id: Option<u32>,
|
||||
/// Game mode.
|
||||
#[serde(default)]
|
||||
pub game_mode: Option<String>,
|
||||
/// Map name.
|
||||
#[serde(default)]
|
||||
pub map_name: Option<String>,
|
||||
/// Summoner name.
|
||||
#[serde(default)]
|
||||
pub summoner_name: Option<String>,
|
||||
/// Player's PUUID.
|
||||
#[serde(default)]
|
||||
pub puuid: Option<String>,
|
||||
/// Team (100 = blue, 200 = red).
|
||||
#[serde(default)]
|
||||
pub team: Option<u32>,
|
||||
/// Whether the game was won.
|
||||
#[serde(default)]
|
||||
pub victory: Option<bool>,
|
||||
/// Final player stats.
|
||||
#[serde(default)]
|
||||
pub final_stats: Option<GameFinalStats>,
|
||||
/// Game ID.
|
||||
#[serde(default)]
|
||||
pub game_id: Option<u64>,
|
||||
/// Match ID.
|
||||
#[serde(default)]
|
||||
pub match_id: Option<String>,
|
||||
/// Rune page at game start.
|
||||
#[serde(default)]
|
||||
pub runes: Option<RunePage>,
|
||||
/// Summoner spells.
|
||||
#[serde(default)]
|
||||
pub summoner_spells: Option<SummonerSpells>,
|
||||
/// Final item build at game end.
|
||||
#[serde(default)]
|
||||
pub final_items: Option<ItemBuild>,
|
||||
/// All players in the game.
|
||||
#[serde(default)]
|
||||
pub all_players: Vec<PlayerIdentityInfo>,
|
||||
}
|
||||
|
||||
/// Get the default output directory for recordings.
|
||||
/// Uses the same directory structure as the record-daemon.
|
||||
fn get_default_output_dir() -> Option<PathBuf> {
|
||||
dirs::video_dir()
|
||||
.map(|p| p.join("LeagueRecorder"))
|
||||
.or_else(|| dirs::home_dir().map(|p| p.join("Videos").join("LeagueRecorder")))
|
||||
directories::ProjectDirs::from("com", "leaguerecorder", "record-daemon")
|
||||
.map(|dirs| dirs.data_dir().join("recordings"))
|
||||
}
|
||||
|
||||
/// Get the timeline storage directory.
|
||||
/// Uses the same directory structure as the record-daemon.
|
||||
fn get_timeline_dir() -> PathBuf {
|
||||
get_default_output_dir()
|
||||
.map(|p| p.join("timelines"))
|
||||
@@ -98,105 +201,13 @@ fn load_timelines() -> Vec<Timeline> {
|
||||
timelines
|
||||
}
|
||||
|
||||
/// Format duration as MM:SS or HH:MM:SS.
|
||||
fn format_duration(secs: i64) -> String {
|
||||
let hours = secs / 3600;
|
||||
let minutes = (secs % 3600) / 60;
|
||||
let seconds = secs % 60;
|
||||
|
||||
if hours > 0 {
|
||||
format!("{:02}:{:02}:{:02}", hours, minutes, seconds)
|
||||
} else {
|
||||
format!("{:02}:{:02}", minutes, seconds)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract game result from events.
|
||||
fn extract_game_result(timeline: &Timeline) -> Option<String> {
|
||||
for event in &timeline.events {
|
||||
if event.event_type == "GameEnd" {
|
||||
// Check description for win/loss
|
||||
if event.description.to_lowercase().contains("win") {
|
||||
return Some("Victory".to_string());
|
||||
} else if event.description.to_lowercase().contains("loss")
|
||||
|| event.description.to_lowercase().contains("defeat")
|
||||
{
|
||||
return Some("Defeat".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract champion from events.
|
||||
fn extract_champion(timeline: &Timeline) -> Option<String> {
|
||||
for event in &timeline.events {
|
||||
if event.event_type == "GameStart" {
|
||||
// Try to extract champion from description
|
||||
let desc = &event.description;
|
||||
if desc.contains("Playing as ") {
|
||||
return Some(desc.replace("Playing as ", "").trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract video file path from timeline.
|
||||
fn extract_video_path(timeline: &Timeline) -> Option<String> {
|
||||
// Look for video file in the recordings directory
|
||||
let output_dir = get_default_output_dir()?;
|
||||
let video_dir = output_dir.join("videos");
|
||||
|
||||
if video_dir.exists() {
|
||||
if let Ok(entries) = fs::read_dir(&video_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let file_stem = path.file_stem()?.to_string_lossy().to_string();
|
||||
if file_stem.contains(&timeline.recording_id.to_string()) {
|
||||
return Some(path.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for files named with the recording ID
|
||||
let recording_id = timeline.recording_id.to_string();
|
||||
if let Ok(entries) = fs::read_dir(&output_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_file() {
|
||||
let file_stem = path.file_stem()?.to_string_lossy().to_string();
|
||||
if file_stem.contains(&recording_id) {
|
||||
return Some(path.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Get game history - returns full timeline data for each game.
|
||||
#[tauri::command]
|
||||
fn get_game_history() -> Vec<GameHistoryItem> {
|
||||
let timelines = load_timelines();
|
||||
|
||||
timelines
|
||||
.into_iter()
|
||||
.map(|timeline| GameHistoryItem {
|
||||
id: timeline.recording_id.to_string(),
|
||||
start_time: timeline.start_time,
|
||||
end_time: timeline.end_time,
|
||||
duration_secs: timeline.duration_secs,
|
||||
duration_formatted: format_duration(timeline.duration_secs),
|
||||
event_count: timeline.events.len(),
|
||||
champion: extract_champion(&timeline),
|
||||
result: extract_game_result(&timeline),
|
||||
video_path: extract_video_path(&timeline),
|
||||
})
|
||||
.collect()
|
||||
fn get_game_history() -> Vec<Timeline> {
|
||||
load_timelines()
|
||||
}
|
||||
|
||||
/// Get a specific timeline by recording ID.
|
||||
#[tauri::command]
|
||||
fn get_timeline(recording_id: String) -> Option<Timeline> {
|
||||
let timeline_dir = get_timeline_dir();
|
||||
@@ -211,6 +222,7 @@ fn get_timeline(recording_id: String) -> Option<Timeline> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Get the recordings directory path.
|
||||
#[tauri::command]
|
||||
fn get_recordings_dir() -> String {
|
||||
get_default_output_dir()
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { GameHistoryItem } from "../types/timeline";
|
||||
import { formatRelativeTime } from "../types/timeline";
|
||||
import type { GameHistoryItem, GameResult, TimestampedEvent, ItemInfo } from "../types/timeline";
|
||||
import {
|
||||
getGameResult,
|
||||
formatDuration,
|
||||
formatKDA,
|
||||
calculateKDA,
|
||||
formatCSPerMin,
|
||||
formatGoldPerMin,
|
||||
formatNumber,
|
||||
getChampionImageUrl,
|
||||
getQueueDisplayName,
|
||||
formatRelativeTime,
|
||||
formatGameStartTime,
|
||||
getSummonerSpellUrl,
|
||||
getItemImageUrl,
|
||||
} from "../types/timeline";
|
||||
|
||||
// Helper to get video timestamp in seconds from tuple format
|
||||
function getVideoTimestampSecs(event: TimestampedEvent): number {
|
||||
return event.video_timestamp[0];
|
||||
}
|
||||
|
||||
const games = ref<GameHistoryItem[]>([]);
|
||||
const loading = ref(true);
|
||||
@@ -30,6 +49,24 @@ function closeDetail() {
|
||||
selectedGame.value = null;
|
||||
}
|
||||
|
||||
// Helper to get items array for display (6 slots + trinket)
|
||||
function getItemsArray(game: GameHistoryItem): (ItemInfo | null)[] {
|
||||
const result: (ItemInfo | null)[] = [null, null, null, null, null, null, null];
|
||||
if (game.final_items) {
|
||||
// Fill main items (slots 0-5)
|
||||
for (const item of game.final_items.items) {
|
||||
if (item.slot >= 0 && item.slot <= 5) {
|
||||
result[item.slot] = item;
|
||||
}
|
||||
}
|
||||
// Fill trinket (slot 6)
|
||||
if (game.final_items.trinket) {
|
||||
result[6] = game.final_items.trinket;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadGameHistory();
|
||||
});
|
||||
@@ -38,14 +75,17 @@ onMounted(() => {
|
||||
<template>
|
||||
<div class="game-history">
|
||||
<header class="header">
|
||||
<h1>League Recorder</h1>
|
||||
<p class="subtitle">Game History</p>
|
||||
<h1>Match History</h1>
|
||||
<button class="refresh-btn" @click="loadGameHistory" :disabled="loading">
|
||||
<span v-if="loading">⟳</span>
|
||||
<span v-else>↻</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading game history...</p>
|
||||
<p>Loading match history...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
@@ -58,38 +98,137 @@ onMounted(() => {
|
||||
<div v-else-if="games.length === 0" class="empty">
|
||||
<div class="empty-icon">🎮</div>
|
||||
<h2>No Games Recorded</h2>
|
||||
<p>Start playing to see your game history here.</p>
|
||||
<p>Start playing to see your match history here.</p>
|
||||
</div>
|
||||
|
||||
<!-- Game List -->
|
||||
<!-- Game List - Single Column -->
|
||||
<div v-else class="game-list">
|
||||
<div
|
||||
v-for="game in games"
|
||||
:key="game.id"
|
||||
:key="game.recording_id"
|
||||
class="game-card"
|
||||
:class="{ victory: game.result === 'Victory', defeat: game.result === 'Defeat' }"
|
||||
:class="{
|
||||
victory: getGameResult(game) === 'Victory',
|
||||
defeat: getGameResult(game) === 'Defeat',
|
||||
terminated: getGameResult(game) === 'Terminated'
|
||||
}"
|
||||
@click="selectGame(game)"
|
||||
>
|
||||
<div class="game-result-badge" v-if="game.result">
|
||||
{{ game.result }}
|
||||
<!-- Result Banner -->
|
||||
<div class="result-banner">
|
||||
<span class="result-text">{{ getGameResult(game) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="game-main">
|
||||
<div class="game-champion" v-if="game.champion">
|
||||
{{ game.champion }}
|
||||
<!-- Main Content -->
|
||||
<div class="game-content">
|
||||
<!-- Left: Champion Image -->
|
||||
<div class="champion-section">
|
||||
<div class="champion-image-wrapper">
|
||||
<img
|
||||
:src="getChampionImageUrl(game.champion)"
|
||||
:alt="game.champion || 'Unknown Champion'"
|
||||
class="champion-image"
|
||||
@error="($event.target as HTMLImageElement).src = getChampionImageUrl(null)"
|
||||
/>
|
||||
<div class="champion-level" v-if="game.final_stats">
|
||||
{{ Math.min(18, Math.floor(game.final_stats.game_duration / 60)) }}
|
||||
</div>
|
||||
<div class="game-champion unknown" v-else>
|
||||
Unknown Champion
|
||||
</div>
|
||||
|
||||
<!-- Summoner Spells -->
|
||||
<div class="summoner-spells">
|
||||
<div class="spell-slot">
|
||||
<img
|
||||
v-if="game.summoner_spells"
|
||||
:src="getSummonerSpellUrl(game.summoner_spells.spell1Id)"
|
||||
:alt="game.summoner_spells.spell1Name || 'Spell 1'"
|
||||
class="spell-image"
|
||||
/>
|
||||
<div v-else class="spell-placeholder"></div>
|
||||
</div>
|
||||
<div class="spell-slot">
|
||||
<img
|
||||
v-if="game.summoner_spells"
|
||||
:src="getSummonerSpellUrl(game.summoner_spells.spell2Id)"
|
||||
:alt="game.summoner_spells.spell2Name || 'Spell 2'"
|
||||
class="spell-image"
|
||||
/>
|
||||
<div v-else class="spell-placeholder"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center: Game Info -->
|
||||
<div class="game-info-section">
|
||||
<div class="game-info-row">
|
||||
<!-- Left: Queue Type & Time -->
|
||||
<div class="game-info-left">
|
||||
<div class="queue-type">
|
||||
{{ getQueueDisplayName(game.queue_type, game.queue_id) }}
|
||||
</div>
|
||||
<div class="game-duration">
|
||||
{{ game.duration_formatted }}
|
||||
{{ formatDuration(game.duration_secs) }}
|
||||
</div>
|
||||
<div class="game-time">
|
||||
{{ formatRelativeTime(game.start_time) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="game-meta">
|
||||
<span class="game-time">{{ formatRelativeTime(game.start_time) }}</span>
|
||||
<span class="game-events">{{ game.event_count }} events</span>
|
||||
<!-- Right: KDA Score -->
|
||||
<div class="kda-section" v-if="game.final_stats">
|
||||
<span class="kda-values">
|
||||
{{ formatKDA(game.final_stats) }}
|
||||
</span>
|
||||
<span class="kda-ratio" :class="{ perfect: game.final_stats.deaths === 0 }">
|
||||
{{ calculateKDA(game.final_stats) }} KDA
|
||||
</span>
|
||||
</div>
|
||||
<div class="kda-section" v-else>
|
||||
<span class="kda-values">0/0/0</span>
|
||||
<span class="kda-ratio">0.0 KDA</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Key Stats Column + Items -->
|
||||
<div class="stats-section" v-if="game.final_stats">
|
||||
<!-- Key Stats Column -->
|
||||
<div class="key-stats-column">
|
||||
<div class="key-stat">
|
||||
<span class="key-stat-value">{{ formatCSPerMin(game.final_stats) }}</span>
|
||||
<span class="key-stat-label">CS/m</span>
|
||||
</div>
|
||||
<div class="key-stat">
|
||||
<span class="key-stat-value">{{ formatGoldPerMin(game.final_stats) }}</span>
|
||||
<span class="key-stat-label">Gold/m</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Items Grid -->
|
||||
<div class="items-grid">
|
||||
<div class="item-slot" v-for="(item, idx) in getItemsArray(game).slice(0, 6)" :key="idx">
|
||||
<img
|
||||
v-if="item && item.itemId"
|
||||
:src="getItemImageUrl(item.itemId)"
|
||||
:alt="item.name || `Item ${item.itemId}`"
|
||||
class="item-image"
|
||||
/>
|
||||
<div v-else class="item-empty"></div>
|
||||
</div>
|
||||
<div class="item-slot trinket">
|
||||
<img
|
||||
v-if="getItemsArray(game)[6] && getItemsArray(game)[6].itemId"
|
||||
:src="getItemImageUrl(getItemsArray(game)[6].itemId)"
|
||||
:alt="getItemsArray(game)[6].name || 'Trinket'"
|
||||
class="item-image"
|
||||
/>
|
||||
<div v-else class="item-empty"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-section no-stats" v-else>
|
||||
<div class="no-stats-text">No stats</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,42 +239,124 @@ onMounted(() => {
|
||||
<button class="modal-close" @click="closeDetail">×</button>
|
||||
|
||||
<div class="modal-header">
|
||||
<h2>Game Details</h2>
|
||||
<div class="modal-result" :class="selectedGame.result?.toLowerCase()">
|
||||
{{ selectedGame.result || 'Unknown Result' }}
|
||||
<div class="modal-champion">
|
||||
<img
|
||||
:src="getChampionImageUrl(selectedGame.champion)"
|
||||
:alt="selectedGame.champion || 'Unknown Champion'"
|
||||
class="modal-champion-image"
|
||||
/>
|
||||
<div class="modal-champion-info">
|
||||
<h2>{{ selectedGame.champion || 'Unknown Champion' }}</h2>
|
||||
<div class="modal-queue">
|
||||
{{ getQueueDisplayName(selectedGame.queue_type, selectedGame.queue_id) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-result" :class="getGameResult(selectedGame).toLowerCase()">
|
||||
{{ getGameResult(selectedGame) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Champion:</span>
|
||||
<span class="detail-value">{{ selectedGame.champion || 'Unknown' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Game Info -->
|
||||
<div class="modal-section">
|
||||
<h3>Game Information</h3>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Duration:</span>
|
||||
<span class="detail-value">{{ selectedGame.duration_formatted }}</span>
|
||||
<span class="detail-value">{{ formatDuration(selectedGame.duration_secs) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Started:</span>
|
||||
<span class="detail-value">{{ new Date(selectedGame.start_time).toLocaleString() }}</span>
|
||||
<span class="detail-value">{{ formatGameStartTime(selectedGame.start_time) }}</span>
|
||||
</div>
|
||||
<div class="detail-row" v-if="selectedGame.game_mode">
|
||||
<span class="detail-label">Game Mode:</span>
|
||||
<span class="detail-value">{{ selectedGame.game_mode }}</span>
|
||||
</div>
|
||||
<div class="detail-row" v-if="selectedGame.map_name">
|
||||
<span class="detail-label">Map:</span>
|
||||
<span class="detail-value">{{ selectedGame.map_name }}</span>
|
||||
</div>
|
||||
<div class="detail-row" v-if="selectedGame.summoner_name">
|
||||
<span class="detail-label">Summoner:</span>
|
||||
<span class="detail-value">{{ selectedGame.summoner_name }}</span>
|
||||
</div>
|
||||
<div class="detail-row" v-if="selectedGame.team">
|
||||
<span class="detail-label">Team:</span>
|
||||
<span class="detail-value">{{ selectedGame.team === 100 ? 'Blue' : 'Red' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="modal-section" v-if="selectedGame.final_stats">
|
||||
<h3>Performance</h3>
|
||||
<div class="stats-highlight">
|
||||
<div class="stat-highlight-item">
|
||||
<span class="stat-highlight-value">{{ selectedGame.final_stats.kills }}</span>
|
||||
<span class="stat-highlight-label">Kills</span>
|
||||
</div>
|
||||
<div class="stat-highlight-item">
|
||||
<span class="stat-highlight-value">{{ selectedGame.final_stats.deaths }}</span>
|
||||
<span class="stat-highlight-label">Deaths</span>
|
||||
</div>
|
||||
<div class="stat-highlight-item">
|
||||
<span class="stat-highlight-value">{{ selectedGame.final_stats.assists }}</span>
|
||||
<span class="stat-highlight-label">Assists</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Events:</span>
|
||||
<span class="detail-value">{{ selectedGame.event_count }}</span>
|
||||
<span class="detail-label">KDA Ratio:</span>
|
||||
<span class="detail-value">{{ calculateKDA(selectedGame.final_stats) }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Creep Score:</span>
|
||||
<span class="detail-value">{{ selectedGame.final_stats.creep_score }} ({{ formatCSPerMin(selectedGame.final_stats) }}/min)</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Gold Earned:</span>
|
||||
<span class="detail-value">{{ formatNumber(selectedGame.final_stats.gold_earned) }} ({{ formatGoldPerMin(selectedGame.final_stats) }}/min)</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Damage Dealt:</span>
|
||||
<span class="detail-value">{{ formatNumber(selectedGame.final_stats.damage_dealt) }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Damage Taken:</span>
|
||||
<span class="detail-value">{{ formatNumber(selectedGame.final_stats.damage_taken) }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Vision Score:</span>
|
||||
<span class="detail-value">{{ selectedGame.final_stats.vision_score.toFixed(1) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-row" v-if="selectedGame.video_path">
|
||||
<span class="detail-label">Video:</span>
|
||||
<span class="detail-value video-path">{{ selectedGame.video_path }}</span>
|
||||
<!-- Events Timeline -->
|
||||
<div class="modal-section" v-if="selectedGame.events.length > 0">
|
||||
<h3>Events ({{ selectedGame.events.length }})</h3>
|
||||
<div class="events-list">
|
||||
<div
|
||||
v-for="(event, idx) in selectedGame.events.slice(0, 10)"
|
||||
:key="idx"
|
||||
class="event-item"
|
||||
>
|
||||
<span class="event-time">{{ formatDuration(getVideoTimestampSecs(event)) }}</span>
|
||||
<span class="event-type">{{ event.event_type }}</span>
|
||||
<span class="event-desc">{{ event.description }}</span>
|
||||
</div>
|
||||
<div v-if="selectedGame.events.length > 10" class="events-more">
|
||||
+{{ selectedGame.events.length - 10 }} more events
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn-secondary" @click="closeDetail">Close</button>
|
||||
<button class="btn-primary" v-if="selectedGame.video_path">
|
||||
<button class="btn-primary">
|
||||
Open Video
|
||||
</button>
|
||||
</div>
|
||||
@@ -147,28 +368,52 @@ onMounted(() => {
|
||||
<style scoped>
|
||||
.game-history {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
background: linear-gradient(180deg, #0a0a13 0%, #0f0f1a 100%);
|
||||
color: #fff;
|
||||
padding: 2rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.5rem 2rem;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.5rem;
|
||||
font-size: 1.5rem;
|
||||
margin: 0;
|
||||
background: linear-gradient(90deg, #00d4ff, #7b2cbf);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
font-weight: 600;
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #888;
|
||||
margin: 0.5rem 0 0 0;
|
||||
.refresh-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.refresh-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.refresh-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.loading {
|
||||
@@ -184,7 +429,7 @@ onMounted(() => {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #333;
|
||||
border-top-color: #00d4ff;
|
||||
border-top-color: #c8aa6e;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@@ -231,91 +476,296 @@ onMounted(() => {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Game List - Single Column */
|
||||
.game-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
max-width: 1200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Game Card - League Client Style */
|
||||
.game-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
transition: all 0.15s ease;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.game-card:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.game-card.victory {
|
||||
border-left: 4px solid #4ade80;
|
||||
/* Result Banner Colors */
|
||||
.game-card.victory .result-banner {
|
||||
background: linear-gradient(90deg, rgba(30, 144, 255, 0.3) 0%, rgba(30, 144, 255, 0.1) 100%);
|
||||
border-left: 4px solid #1e90ff;
|
||||
}
|
||||
|
||||
.game-card.defeat {
|
||||
border-left: 4px solid #f87171;
|
||||
.game-card.victory .result-text {
|
||||
color: #4da6ff;
|
||||
}
|
||||
|
||||
.game-result-badge {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
.game-card.defeat .result-banner {
|
||||
background: linear-gradient(90deg, rgba(255, 68, 68, 0.3) 0%, rgba(255, 68, 68, 0.1) 100%);
|
||||
border-left: 4px solid #ff4444;
|
||||
}
|
||||
|
||||
.game-card.victory .game-result-badge {
|
||||
background: rgba(74, 222, 128, 0.2);
|
||||
color: #4ade80;
|
||||
.game-card.defeat .result-text {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.game-card.defeat .game-result-badge {
|
||||
background: rgba(248, 113, 113, 0.2);
|
||||
color: #f87171;
|
||||
.game-card.terminated .result-banner {
|
||||
background: linear-gradient(90deg, rgba(156, 163, 175, 0.3) 0%, rgba(156, 163, 175, 0.1) 100%);
|
||||
border-left: 4px solid #6b7280;
|
||||
}
|
||||
|
||||
.game-main {
|
||||
.game-card.terminated .result-text {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.result-banner {
|
||||
padding: 0.35rem 0.75rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.game-champion {
|
||||
font-size: 1.25rem;
|
||||
.result-text {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Game Content */
|
||||
.game-content {
|
||||
display: flex;
|
||||
padding: 0.5rem 0.75rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Champion Section */
|
||||
.champion-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.champion-image-wrapper {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.champion-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.champion-level {
|
||||
position: absolute;
|
||||
bottom: 1px;
|
||||
right: 1px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #fff;
|
||||
font-size: 0.55rem;
|
||||
font-weight: 600;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.game-champion.unknown {
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
.summoner-spells {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.spell-slot {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.spell-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Game Info Section */
|
||||
.game-info-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.game-info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.game-info-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.queue-type {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #f0f0f0;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.game-duration {
|
||||
font-size: 1.1rem;
|
||||
font-family: monospace;
|
||||
color: #00d4ff;
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.game-meta {
|
||||
.game-time {
|
||||
font-size: 0.7rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.kda-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.85rem;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.kda-values {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #f0f0f0;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.kda-ratio {
|
||||
font-size: 0.7rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.kda-ratio.perfect {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
/* Stats Section */
|
||||
.stats-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stats-section.no-stats {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.no-stats-text {
|
||||
color: #666;
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Key Stats Column */
|
||||
.key-stats-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.key-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.key-stat-value {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #c8aa6e;
|
||||
}
|
||||
|
||||
.key-stat-label {
|
||||
font-size: 0.6rem;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Items Grid - 2 rows of 4 (6 items + trinket) */
|
||||
.items-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-rows: repeat(2, 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.item-slot {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 3px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.item-slot.trinket {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.item-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.spell-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.item-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.item-empty {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
@@ -323,7 +773,7 @@ onMounted(() => {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -332,11 +782,13 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #1a1a2e;
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
max-width: 500px;
|
||||
background: #0f0f1a;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
@@ -363,11 +815,32 @@ onMounted(() => {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
.modal-champion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.modal-champion-image {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.modal-champion-info h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-queue {
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.modal-result {
|
||||
@@ -378,23 +851,46 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.modal-result.victory {
|
||||
background: rgba(74, 222, 128, 0.2);
|
||||
color: #4ade80;
|
||||
background: rgba(30, 144, 255, 0.2);
|
||||
color: #4da6ff;
|
||||
}
|
||||
|
||||
.modal-result.defeat {
|
||||
background: rgba(248, 113, 113, 0.2);
|
||||
color: #f87171;
|
||||
background: rgba(255, 68, 68, 0.2);
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.modal-result.terminated {
|
||||
background: rgba(156, 163, 175, 0.2);
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-section h3 {
|
||||
font-size: 0.9rem;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 0;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
@@ -410,13 +906,70 @@ onMounted(() => {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.video-path {
|
||||
.stats-highlight {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stat-highlight-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-highlight-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
.stat-highlight-label {
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.events-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
color: #00d4ff;
|
||||
max-width: 250px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.event-time {
|
||||
color: #c8aa6e;
|
||||
font-family: monospace;
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.event-type {
|
||||
color: #888;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.event-desc {
|
||||
color: #f0f0f0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.events-more {
|
||||
text-align: center;
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
@@ -436,7 +989,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(90deg, #00d4ff, #7b2cbf);
|
||||
background: linear-gradient(135deg, #1e90ff 0%, #0066cc 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -453,4 +1006,21 @@ onMounted(() => {
|
||||
.btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 600px) {
|
||||
.game-content {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.game-info-section {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
|
||||
/**
|
||||
* A timestamped event in the timeline.
|
||||
* Video timestamp is serialized as [seconds, nanos] tuple from Rust.
|
||||
*/
|
||||
export interface TimestampedEvent {
|
||||
/** Video timestamp (offset from recording start) in seconds. */
|
||||
video_timestamp: number;
|
||||
/** Game timestamp (in-game time) in seconds. */
|
||||
game_timestamp: number | null;
|
||||
/** Video timestamp (offset from recording start) as [seconds, nanos]. */
|
||||
video_timestamp: [number, number];
|
||||
/** Game timestamp (in-game time) as [seconds, nanos]. */
|
||||
game_timestamp: [number, number] | null;
|
||||
/** Real-world timestamp (ISO 8601). */
|
||||
timestamp: string;
|
||||
/** Event type name. */
|
||||
@@ -18,44 +19,383 @@ export interface TimestampedEvent {
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Final game statistics for the player.
|
||||
*/
|
||||
export interface GameFinalStats {
|
||||
/** Kills. */
|
||||
kills: number;
|
||||
/** Deaths. */
|
||||
deaths: number;
|
||||
/** Assists. */
|
||||
assists: number;
|
||||
/** Creep score. */
|
||||
creep_score: number;
|
||||
/** Gold earned. */
|
||||
gold_earned: number;
|
||||
/** Damage dealt. */
|
||||
damage_dealt: number;
|
||||
/** Damage taken. */
|
||||
damage_taken: number;
|
||||
/** Vision score. */
|
||||
vision_score: number;
|
||||
/** Game duration in seconds. */
|
||||
game_duration: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rune page configuration.
|
||||
*/
|
||||
export interface RunePage {
|
||||
/** Primary rune style (keystone tree). */
|
||||
primary_style_id: number;
|
||||
/** Secondary rune style. */
|
||||
secondary_style_id: number;
|
||||
/** Selected rune perk IDs. */
|
||||
selected_perks: number[];
|
||||
/** Stat modifier IDs. */
|
||||
stat_modifiers: number[];
|
||||
/** Rune page name. */
|
||||
name: string | null;
|
||||
/** Whether this is the current page. */
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summoner spell information.
|
||||
*/
|
||||
export interface SummonerSpells {
|
||||
/** First summoner spell ID. */
|
||||
spell1Id: number;
|
||||
/** Second summoner spell ID. */
|
||||
spell2Id: number;
|
||||
/** First summoner spell name. */
|
||||
spell1Name: string | null;
|
||||
/** Second summoner spell name. */
|
||||
spell2Name: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual item information.
|
||||
*/
|
||||
export interface ItemInfo {
|
||||
/** Item ID. */
|
||||
itemId: number;
|
||||
/** Item name. */
|
||||
name: string | null;
|
||||
/** Slot index (0-5 for main items, 6 for trinket). */
|
||||
slot: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Item build at game end.
|
||||
*/
|
||||
export interface ItemBuild {
|
||||
/** Final items (up to 6 item slots). */
|
||||
items: ItemInfo[];
|
||||
/** Trinket item. */
|
||||
trinket: ItemInfo | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Player identity information for puuid to summoner name mapping.
|
||||
*/
|
||||
export interface PlayerIdentityInfo {
|
||||
/** Player's PUUID. */
|
||||
puuid: string;
|
||||
/** Player's summoner name. */
|
||||
summoner_name: string;
|
||||
/** Player's champion name. */
|
||||
champion_name: string | null;
|
||||
/** Player's team (100 or 200). */
|
||||
team: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Game result type.
|
||||
*/
|
||||
export type GameResult = 'Victory' | 'Defeat' | 'Terminated';
|
||||
|
||||
/**
|
||||
* A timeline of events for a recording.
|
||||
* This is the main data structure returned by the backend.
|
||||
*/
|
||||
export interface Timeline {
|
||||
/** Recording ID (UUID). */
|
||||
recording_id: string;
|
||||
/** Recording start time (ISO 8601). */
|
||||
start_time: string;
|
||||
/** Recording end time (ISO 8601). */
|
||||
end_time: string | null;
|
||||
/** Total duration in seconds. */
|
||||
duration_secs: number;
|
||||
/** Events in the timeline. */
|
||||
events: TimestampedEvent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Game history item for display in the UI.
|
||||
*/
|
||||
export interface GameHistoryItem {
|
||||
/** Recording ID (UUID). */
|
||||
id: string;
|
||||
/** Game start time (ISO 8601). */
|
||||
start_time: string;
|
||||
/** Game end time (ISO 8601). */
|
||||
end_time: string | null;
|
||||
/** Duration in seconds. */
|
||||
duration_secs: number;
|
||||
/** Formatted duration string (e.g., "32:15"). */
|
||||
duration_formatted: string;
|
||||
/** Number of events. */
|
||||
event_count: number;
|
||||
/** Champion played (if available). */
|
||||
/** Events in the timeline. */
|
||||
events: TimestampedEvent[];
|
||||
|
||||
// Player information
|
||||
/** Champion played. */
|
||||
champion: string | null;
|
||||
/** Game result (Victory/Defeat, if available). */
|
||||
result: string | null;
|
||||
/** Video file path. */
|
||||
video_path: string | null;
|
||||
/** Skin name. */
|
||||
skin_name: string | null;
|
||||
/** Player's summoner name. */
|
||||
summoner_name: string | null;
|
||||
/** Player's PUUID. */
|
||||
puuid: string | null;
|
||||
/** Team (100 = blue, 200 = red). */
|
||||
team: number | null;
|
||||
|
||||
// Game information
|
||||
/** Queue type (ranked, normal, aram, etc.). */
|
||||
queue_type: string | null;
|
||||
/** Queue ID. */
|
||||
queue_id: number | null;
|
||||
/** Game mode. */
|
||||
game_mode: string | null;
|
||||
/** Map name. */
|
||||
map_name: string | null;
|
||||
/** Game ID. */
|
||||
game_id: number | null;
|
||||
/** Match ID. */
|
||||
match_id: string | null;
|
||||
|
||||
// Result
|
||||
/** Whether the game was won. */
|
||||
victory: boolean | null;
|
||||
/** Final player stats. */
|
||||
final_stats: GameFinalStats | null;
|
||||
|
||||
// Player metadata (runes, summoner spells, items)
|
||||
/** Rune page at game start. */
|
||||
runes: RunePage | null;
|
||||
/** Summoner spells. */
|
||||
summoner_spells: SummonerSpells | null;
|
||||
/** Final item build at game end. */
|
||||
final_items: ItemBuild | null;
|
||||
|
||||
// All players in the game (puuid to summoner name mapping)
|
||||
/** All players in the game. */
|
||||
all_players: PlayerIdentityInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Game history item for display in the UI.
|
||||
* Alias for Timeline since the backend returns full timeline data.
|
||||
*/
|
||||
export type GameHistoryItem = Timeline;
|
||||
|
||||
/**
|
||||
* Get the game result as a display string.
|
||||
*/
|
||||
export function getGameResult(game: GameHistoryItem): GameResult {
|
||||
if (game.victory === true) return 'Victory';
|
||||
if (game.victory === false) return 'Defeat';
|
||||
return 'Terminated';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the result color class.
|
||||
*/
|
||||
export function getResultColor(result: GameResult): string {
|
||||
switch (result) {
|
||||
case 'Victory': return '#4ade80'; // green
|
||||
case 'Defeat': return '#f87171'; // red
|
||||
case 'Terminated': return '#9ca3af'; // gray
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the result background color (with alpha).
|
||||
*/
|
||||
export function getResultBgColor(result: GameResult): string {
|
||||
switch (result) {
|
||||
case 'Victory': return 'rgba(74, 222, 128, 0.15)';
|
||||
case 'Defeat': return 'rgba(248, 113, 113, 0.15)';
|
||||
case 'Terminated': return 'rgba(156, 163, 175, 0.15)';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in MM:SS format.
|
||||
*/
|
||||
export function formatDuration(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format KDA as "K/D/A" string.
|
||||
*/
|
||||
export function formatKDA(stats: GameFinalStats | null): string {
|
||||
if (!stats) return '0/0/0';
|
||||
return `${stats.kills}/${stats.deaths}/${stats.assists}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate KDA ratio.
|
||||
*/
|
||||
export function calculateKDA(stats: GameFinalStats | null): string {
|
||||
if (!stats) return '0.0';
|
||||
const kda = stats.deaths === 0
|
||||
? stats.kills + stats.assists
|
||||
: (stats.kills + stats.assists) / stats.deaths;
|
||||
return kda.toFixed(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format CS per minute.
|
||||
*/
|
||||
export function formatCSPerMin(stats: GameFinalStats | null): string {
|
||||
if (!stats || stats.game_duration === 0) return '0.0';
|
||||
const csPerMin = stats.creep_score / (stats.game_duration / 60);
|
||||
return csPerMin.toFixed(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format gold per minute.
|
||||
*/
|
||||
export function formatGoldPerMin(stats: GameFinalStats | null): string {
|
||||
if (!stats || stats.game_duration === 0) return '0';
|
||||
const goldPerMin = stats.gold_earned / (stats.game_duration / 60);
|
||||
return Math.round(goldPerMin).toLocaleString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format large numbers (e.g., damage, gold).
|
||||
*/
|
||||
export function formatNumber(num: number): string {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M';
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K';
|
||||
}
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get champion image URL from Data Dragon.
|
||||
*/
|
||||
export function getChampionImageUrl(championName: string | null, size: 'square' | 'loading' = 'square'): string {
|
||||
if (!championName) {
|
||||
// Return placeholder for unknown champion
|
||||
return 'https://ddragon.leagueoflegends.com/cdn/16.6.1/img/champion/Aatrox.png';
|
||||
}
|
||||
|
||||
// Data Dragon uses specific champion name formatting
|
||||
const formattedName = formatChampionName(championName);
|
||||
|
||||
if (size === 'loading') {
|
||||
return `https://ddragon.leagueoflegends.com/cdn/16.6.1/img/champion/${formattedName}_0.jpg`;
|
||||
}
|
||||
return `https://ddragon.leagueoflegends.com/cdn/16.6.1/img/champion/${formattedName}.png`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format champion name for Data Dragon URL.
|
||||
*/
|
||||
function formatChampionName(name: string): string {
|
||||
// Handle special cases
|
||||
const specialCases: Record<string, string> = {
|
||||
'Wukong': 'MonkeyKing',
|
||||
'Nunu & Willump': 'Nunu',
|
||||
'Renata Glasc': 'Renata',
|
||||
'K\'Tanthi Aatrox': 'Aatrox',
|
||||
'K\'Tanthi Varus': 'Varus',
|
||||
};
|
||||
|
||||
if (specialCases[name]) {
|
||||
return specialCases[name];
|
||||
}
|
||||
|
||||
// Remove spaces and special characters, capitalize first letter of each word
|
||||
return name
|
||||
.split(/[\s&]+/)
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join('')
|
||||
.replace(/[^a-zA-Z]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summoner spell image URL from Data Dragon.
|
||||
*/
|
||||
export function getSummonerSpellUrl(spellId: number): string {
|
||||
// Map common spell IDs to names
|
||||
// Reference: https://github.com/RiotGames/developer-relations/issues/478
|
||||
const spellNames: Record<number, string> = {
|
||||
1: 'SummonerBoost', // Cleanse
|
||||
3: 'SummonerExhaust', // Exhaust
|
||||
4: 'SummonerFlash', // Flash
|
||||
6: 'SummonerHaste', // Ghost (ID 6 is Ghost, not Heal!)
|
||||
7: 'SummonerHeal', // Heal
|
||||
11: 'SummonerSmite', // Smite
|
||||
12: 'SummonerTeleport', // Teleport
|
||||
13: 'SummonerClarity', // Clarity
|
||||
14: 'SummonerIgnite', // Ignite
|
||||
21: 'SummonerBarrier', // Barrier
|
||||
32: 'SummonerMark', // Mark (ARAM snowball)
|
||||
39: 'SummonerSnowURFSnowball_Mark', // Mark (URF)
|
||||
54: 'SummonerPod', // Porcelain
|
||||
55: 'SummonerPoroRecall', // Poro Recall
|
||||
56: 'SummonerPoroThrow', // Poro Throw
|
||||
};
|
||||
|
||||
const spellName = spellNames[spellId] || 'SummonerFlash';
|
||||
return `https://ddragon.leagueoflegends.com/cdn/16.6.1/img/spell/${spellName}.png`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get item image URL from Data Dragon.
|
||||
*/
|
||||
export function getItemImageUrl(itemId: number): string {
|
||||
if (itemId === 0 || itemId === null) {
|
||||
return ''; // Empty item slot
|
||||
}
|
||||
return `https://ddragon.leagueoflegends.com/cdn/16.6.1/img/item/${itemId}.png`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue type display name.
|
||||
*/
|
||||
export function getQueueDisplayName(queueType: string | null, queueId: number | null): string {
|
||||
if (queueType) {
|
||||
// Clean up queue type names
|
||||
if (queueType.includes('Practice Tool')) return 'Practice Tool';
|
||||
if (queueType.includes('Ranked Solo')) return 'Ranked Solo';
|
||||
if (queueType.includes('Ranked Flex')) return 'Ranked Flex';
|
||||
if (queueType.includes('ARAM')) return 'ARAM';
|
||||
if (queueType.includes('Normal')) return 'Normal';
|
||||
if (queueType.includes('Co-op')) return 'Co-op vs AI';
|
||||
return queueType;
|
||||
}
|
||||
|
||||
// Fallback to queue ID mapping
|
||||
const queueNames: Record<number, string> = {
|
||||
400: 'Normal Draft',
|
||||
420: 'Ranked Solo',
|
||||
430: 'Normal Blind',
|
||||
440: 'Ranked Flex',
|
||||
450: 'ARAM',
|
||||
700: 'Clash',
|
||||
800: 'Co-op vs AI',
|
||||
830: 'Co-op vs AI',
|
||||
840: 'Co-op vs AI',
|
||||
850: 'Co-op vs AI',
|
||||
900: 'URF',
|
||||
1020: 'One for All',
|
||||
1300: 'Nexus Blitz',
|
||||
1400: 'Ultimate Spellbook',
|
||||
1900: 'URF',
|
||||
2000: 'Tutorial',
|
||||
2010: 'Tutorial',
|
||||
2020: 'Tutorial',
|
||||
3140: 'Practice Tool',
|
||||
};
|
||||
|
||||
if (queueId && queueNames[queueId]) {
|
||||
return queueNames[queueId];
|
||||
}
|
||||
|
||||
return 'Custom Game';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,3 +492,31 @@ export function formatRelativeTime(isoString: string): string {
|
||||
return "Just now";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format game start time for display (e.g., "Today at 3:45 PM").
|
||||
*/
|
||||
export function formatGameStartTime(isoString: string): string {
|
||||
const date = new Date(isoString);
|
||||
const now = new Date();
|
||||
const isToday = date.toDateString() === now.toDateString();
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const isYesterday = date.toDateString() === yesterday.toDateString();
|
||||
|
||||
const timeStr = date.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
if (isToday) {
|
||||
return `Today at ${timeStr}`;
|
||||
} else if (isYesterday) {
|
||||
return `Yesterday at ${timeStr}`;
|
||||
} else {
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}) + ` at ${timeStr}`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user