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]]
|
[[package]]
|
||||||
name = "dirs"
|
name = "directories"
|
||||||
version = "5.0.1"
|
version = "5.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
|
checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs-sys 0.4.1",
|
"dirs-sys 0.4.1",
|
||||||
]
|
]
|
||||||
@@ -3631,7 +3631,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
"cookie",
|
"cookie",
|
||||||
"dirs 6.0.0",
|
"dirs",
|
||||||
"dunce",
|
"dunce",
|
||||||
"embed_plist",
|
"embed_plist",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
@@ -3678,7 +3678,7 @@ name = "tauri-app"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs 5.0.1",
|
"directories",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
@@ -3695,7 +3695,7 @@ checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"cargo_toml",
|
"cargo_toml",
|
||||||
"dirs 6.0.0",
|
"dirs",
|
||||||
"glob",
|
"glob",
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"json-patch",
|
"json-patch",
|
||||||
@@ -4219,7 +4219,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c"
|
checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dirs 6.0.0",
|
"dirs",
|
||||||
"libappindicator",
|
"libappindicator",
|
||||||
"muda",
|
"muda",
|
||||||
"objc2",
|
"objc2",
|
||||||
@@ -5292,7 +5292,7 @@ dependencies = [
|
|||||||
"block2",
|
"block2",
|
||||||
"cookie",
|
"cookie",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dirs 6.0.0",
|
"dirs",
|
||||||
"dom_query",
|
"dom_query",
|
||||||
"dpi",
|
"dpi",
|
||||||
"dunce",
|
"dunce",
|
||||||
|
|||||||
@@ -24,5 +24,5 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
dirs = "5"
|
directories = "5"
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ use uuid::Uuid;
|
|||||||
/// A timestamped event in the timeline.
|
/// A timestamped event in the timeline.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct TimestampedEvent {
|
pub struct TimestampedEvent {
|
||||||
/// Video timestamp (offset from recording start) in seconds.
|
/// Video timestamp (offset from recording start) as [seconds, nanos].
|
||||||
pub video_timestamp: i64,
|
pub video_timestamp: (i64, i32),
|
||||||
/// Game timestamp (in-game time) in seconds.
|
/// Game timestamp (in-game time) as [seconds, nanos].
|
||||||
pub game_timestamp: Option<i64>,
|
pub game_timestamp: Option<(i64, i32)>,
|
||||||
/// Real-world timestamp.
|
/// Real-world timestamp.
|
||||||
pub timestamp: DateTime<Utc>,
|
pub timestamp: DateTime<Utc>,
|
||||||
/// Event type name.
|
/// Event type name.
|
||||||
@@ -19,7 +19,81 @@ pub struct TimestampedEvent {
|
|||||||
pub description: String,
|
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.
|
/// A timeline of events for a recording.
|
||||||
|
/// This matches the full JSON structure from record-daemon.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Timeline {
|
pub struct Timeline {
|
||||||
/// Recording ID.
|
/// Recording ID.
|
||||||
@@ -32,39 +106,68 @@ pub struct Timeline {
|
|||||||
pub duration_secs: i64,
|
pub duration_secs: i64,
|
||||||
/// Events in the timeline.
|
/// Events in the timeline.
|
||||||
pub events: Vec<TimestampedEvent>,
|
pub events: Vec<TimestampedEvent>,
|
||||||
}
|
/// Champion played.
|
||||||
|
#[serde(default)]
|
||||||
/// 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).
|
|
||||||
pub champion: Option<String>,
|
pub champion: Option<String>,
|
||||||
/// Game result (if available).
|
/// Skin name.
|
||||||
pub result: Option<String>,
|
#[serde(default)]
|
||||||
/// Video file path.
|
pub skin_name: Option<String>,
|
||||||
pub video_path: 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.
|
/// Get the default output directory for recordings.
|
||||||
|
/// Uses the same directory structure as the record-daemon.
|
||||||
fn get_default_output_dir() -> Option<PathBuf> {
|
fn get_default_output_dir() -> Option<PathBuf> {
|
||||||
dirs::video_dir()
|
directories::ProjectDirs::from("com", "leaguerecorder", "record-daemon")
|
||||||
.map(|p| p.join("LeagueRecorder"))
|
.map(|dirs| dirs.data_dir().join("recordings"))
|
||||||
.or_else(|| dirs::home_dir().map(|p| p.join("Videos").join("LeagueRecorder")))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the timeline storage directory.
|
/// Get the timeline storage directory.
|
||||||
|
/// Uses the same directory structure as the record-daemon.
|
||||||
fn get_timeline_dir() -> PathBuf {
|
fn get_timeline_dir() -> PathBuf {
|
||||||
get_default_output_dir()
|
get_default_output_dir()
|
||||||
.map(|p| p.join("timelines"))
|
.map(|p| p.join("timelines"))
|
||||||
@@ -98,105 +201,13 @@ fn load_timelines() -> Vec<Timeline> {
|
|||||||
timelines
|
timelines
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format duration as MM:SS or HH:MM:SS.
|
/// Get game history - returns full timeline data for each game.
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn get_game_history() -> Vec<GameHistoryItem> {
|
fn get_game_history() -> Vec<Timeline> {
|
||||||
let timelines = load_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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get a specific timeline by recording ID.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn get_timeline(recording_id: String) -> Option<Timeline> {
|
fn get_timeline(recording_id: String) -> Option<Timeline> {
|
||||||
let timeline_dir = get_timeline_dir();
|
let timeline_dir = get_timeline_dir();
|
||||||
@@ -211,6 +222,7 @@ fn get_timeline(recording_id: String) -> Option<Timeline> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the recordings directory path.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn get_recordings_dir() -> String {
|
fn get_recordings_dir() -> String {
|
||||||
get_default_output_dir()
|
get_default_output_dir()
|
||||||
|
|||||||
@@ -1,8 +1,27 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from "vue";
|
import { ref, onMounted } from "vue";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import type { GameHistoryItem } from "../types/timeline";
|
import type { GameHistoryItem, GameResult, TimestampedEvent, ItemInfo } from "../types/timeline";
|
||||||
import { formatRelativeTime } 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 games = ref<GameHistoryItem[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
@@ -30,6 +49,24 @@ function closeDetail() {
|
|||||||
selectedGame.value = null;
|
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(() => {
|
onMounted(() => {
|
||||||
loadGameHistory();
|
loadGameHistory();
|
||||||
});
|
});
|
||||||
@@ -38,14 +75,17 @@ onMounted(() => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="game-history">
|
<div class="game-history">
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<h1>League Recorder</h1>
|
<h1>Match History</h1>
|
||||||
<p class="subtitle">Game History</p>
|
<button class="refresh-btn" @click="loadGameHistory" :disabled="loading">
|
||||||
|
<span v-if="loading">⟳</span>
|
||||||
|
<span v-else>↻</span>
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div v-if="loading" class="loading">
|
<div v-if="loading" class="loading">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
<p>Loading game history...</p>
|
<p>Loading match history...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error State -->
|
<!-- Error State -->
|
||||||
@@ -58,38 +98,137 @@ onMounted(() => {
|
|||||||
<div v-else-if="games.length === 0" class="empty">
|
<div v-else-if="games.length === 0" class="empty">
|
||||||
<div class="empty-icon">🎮</div>
|
<div class="empty-icon">🎮</div>
|
||||||
<h2>No Games Recorded</h2>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Game List -->
|
<!-- Game List - Single Column -->
|
||||||
<div v-else class="game-list">
|
<div v-else class="game-list">
|
||||||
<div
|
<div
|
||||||
v-for="game in games"
|
v-for="game in games"
|
||||||
:key="game.id"
|
:key="game.recording_id"
|
||||||
class="game-card"
|
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)"
|
@click="selectGame(game)"
|
||||||
>
|
>
|
||||||
<div class="game-result-badge" v-if="game.result">
|
<!-- Result Banner -->
|
||||||
{{ game.result }}
|
<div class="result-banner">
|
||||||
|
<span class="result-text">{{ getGameResult(game) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="game-main">
|
<!-- Main Content -->
|
||||||
<div class="game-champion" v-if="game.champion">
|
<div class="game-content">
|
||||||
{{ game.champion }}
|
<!-- 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>
|
||||||
<div class="game-champion unknown" v-else>
|
|
||||||
Unknown Champion
|
|
||||||
</div>
|
</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">
|
<div class="game-duration">
|
||||||
{{ game.duration_formatted }}
|
{{ formatDuration(game.duration_secs) }}
|
||||||
|
</div>
|
||||||
|
<div class="game-time">
|
||||||
|
{{ formatRelativeTime(game.start_time) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="game-meta">
|
<!-- Right: KDA Score -->
|
||||||
<span class="game-time">{{ formatRelativeTime(game.start_time) }}</span>
|
<div class="kda-section" v-if="game.final_stats">
|
||||||
<span class="game-events">{{ game.event_count }} events</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,42 +239,124 @@ onMounted(() => {
|
|||||||
<button class="modal-close" @click="closeDetail">×</button>
|
<button class="modal-close" @click="closeDetail">×</button>
|
||||||
|
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2>Game Details</h2>
|
<div class="modal-champion">
|
||||||
<div class="modal-result" :class="selectedGame.result?.toLowerCase()">
|
<img
|
||||||
{{ selectedGame.result || 'Unknown Result' }}
|
: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>
|
</div>
|
||||||
|
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="detail-row">
|
<!-- Game Info -->
|
||||||
<span class="detail-label">Champion:</span>
|
<div class="modal-section">
|
||||||
<span class="detail-value">{{ selectedGame.champion || 'Unknown' }}</span>
|
<h3>Game Information</h3>
|
||||||
</div>
|
<div class="detail-grid">
|
||||||
|
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<span class="detail-label">Duration:</span>
|
<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>
|
||||||
|
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<span class="detail-label">Started:</span>
|
<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>
|
</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">
|
<div class="detail-row">
|
||||||
<span class="detail-label">Events:</span>
|
<span class="detail-label">KDA Ratio:</span>
|
||||||
<span class="detail-value">{{ selectedGame.event_count }}</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>
|
||||||
|
|
||||||
<div class="detail-row" v-if="selectedGame.video_path">
|
<!-- Events Timeline -->
|
||||||
<span class="detail-label">Video:</span>
|
<div class="modal-section" v-if="selectedGame.events.length > 0">
|
||||||
<span class="detail-value video-path">{{ selectedGame.video_path }}</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn-secondary" @click="closeDetail">Close</button>
|
<button class="btn-secondary" @click="closeDetail">Close</button>
|
||||||
<button class="btn-primary" v-if="selectedGame.video_path">
|
<button class="btn-primary">
|
||||||
Open Video
|
Open Video
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,28 +368,52 @@ onMounted(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.game-history {
|
.game-history {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
background: linear-gradient(180deg, #0a0a13 0%, #0f0f1a 100%);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 2rem;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
text-align: center;
|
display: flex;
|
||||||
margin-bottom: 2rem;
|
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 {
|
.header h1 {
|
||||||
font-size: 2.5rem;
|
font-size: 1.5rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: linear-gradient(90deg, #00d4ff, #7b2cbf);
|
font-weight: 600;
|
||||||
-webkit-background-clip: text;
|
color: #f0f0f0;
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.refresh-btn {
|
||||||
color: #888;
|
background: rgba(255, 255, 255, 0.1);
|
||||||
margin: 0.5rem 0 0 0;
|
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 {
|
.loading {
|
||||||
@@ -184,7 +429,7 @@ onMounted(() => {
|
|||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
border: 3px solid #333;
|
border: 3px solid #333;
|
||||||
border-top-color: #00d4ff;
|
border-top-color: #c8aa6e;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
@@ -231,91 +476,296 @@ onMounted(() => {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Game List - Single Column */
|
||||||
.game-list {
|
.game-list {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
flex-direction: column;
|
||||||
gap: 1rem;
|
max-width: 900px;
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
padding: 1rem;
|
||||||
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Game Card - League Client Style */
|
||||||
.game-card {
|
.game-card {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
display: flex;
|
||||||
border-radius: 12px;
|
flex-direction: column;
|
||||||
padding: 1.25rem;
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.15s ease;
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-card:hover {
|
.game-card:hover {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-card.victory {
|
/* Result Banner Colors */
|
||||||
border-left: 4px solid #4ade80;
|
.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 {
|
.game-card.victory .result-text {
|
||||||
border-left: 4px solid #f87171;
|
color: #4da6ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-result-badge {
|
.game-card.defeat .result-banner {
|
||||||
position: absolute;
|
background: linear-gradient(90deg, rgba(255, 68, 68, 0.3) 0%, rgba(255, 68, 68, 0.1) 100%);
|
||||||
top: 0.75rem;
|
border-left: 4px solid #ff4444;
|
||||||
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.victory .game-result-badge {
|
.game-card.defeat .result-text {
|
||||||
background: rgba(74, 222, 128, 0.2);
|
color: #ff6b6b;
|
||||||
color: #4ade80;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-card.defeat .game-result-badge {
|
.game-card.terminated .result-banner {
|
||||||
background: rgba(248, 113, 113, 0.2);
|
background: linear-gradient(90deg, rgba(156, 163, 175, 0.3) 0%, rgba(156, 163, 175, 0.1) 100%);
|
||||||
color: #f87171;
|
border-left: 4px solid #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-main {
|
.game-card.terminated .result-text {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-banner {
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-champion {
|
.result-text {
|
||||||
font-size: 1.25rem;
|
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;
|
font-weight: 600;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-champion.unknown {
|
.summoner-spells {
|
||||||
color: #888;
|
display: flex;
|
||||||
font-style: italic;
|
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 {
|
.game-duration {
|
||||||
font-size: 1.1rem;
|
font-size: 0.75rem;
|
||||||
font-family: monospace;
|
color: #888;
|
||||||
color: #00d4ff;
|
margin-bottom: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-meta {
|
.game-time {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kda-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
flex-direction: column;
|
||||||
font-size: 0.85rem;
|
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;
|
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 Styles */
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -323,7 +773,7 @@ onMounted(() => {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: rgba(0, 0, 0, 0.7);
|
background: rgba(0, 0, 0, 0.8);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -332,11 +782,13 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
background: #1a1a2e;
|
background: #0f0f1a;
|
||||||
border-radius: 16px;
|
border-radius: 12px;
|
||||||
padding: 2rem;
|
padding: 1.5rem;
|
||||||
max-width: 500px;
|
max-width: 600px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
position: relative;
|
position: relative;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
@@ -363,11 +815,32 @@ onMounted(() => {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 1.5rem;
|
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;
|
margin: 0;
|
||||||
font-size: 1.5rem;
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-queue {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-result {
|
.modal-result {
|
||||||
@@ -378,23 +851,46 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-result.victory {
|
.modal-result.victory {
|
||||||
background: rgba(74, 222, 128, 0.2);
|
background: rgba(30, 144, 255, 0.2);
|
||||||
color: #4ade80;
|
color: #4da6ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-result.defeat {
|
.modal-result.defeat {
|
||||||
background: rgba(248, 113, 113, 0.2);
|
background: rgba(255, 68, 68, 0.2);
|
||||||
color: #f87171;
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-result.terminated {
|
||||||
|
background: rgba(156, 163, 175, 0.2);
|
||||||
|
color: #9ca3af;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body {
|
.modal-body {
|
||||||
margin-bottom: 1.5rem;
|
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 {
|
.detail-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0.75rem 0;
|
padding: 0.5rem 0;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,13 +906,70 @@ onMounted(() => {
|
|||||||
font-weight: 500;
|
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;
|
font-size: 0.85rem;
|
||||||
color: #00d4ff;
|
}
|
||||||
max-width: 250px;
|
|
||||||
overflow: hidden;
|
.event-time {
|
||||||
text-overflow: ellipsis;
|
color: #c8aa6e;
|
||||||
white-space: nowrap;
|
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 {
|
.modal-actions {
|
||||||
@@ -436,7 +989,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: linear-gradient(90deg, #00d4ff, #7b2cbf);
|
background: linear-gradient(135deg, #1e90ff 0%, #0066cc 100%);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,4 +1006,21 @@ onMounted(() => {
|
|||||||
.btn-secondary:hover {
|
.btn-secondary:hover {
|
||||||
background: rgba(255, 255, 255, 0.15);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -4,12 +4,13 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A timestamped event in the timeline.
|
* A timestamped event in the timeline.
|
||||||
|
* Video timestamp is serialized as [seconds, nanos] tuple from Rust.
|
||||||
*/
|
*/
|
||||||
export interface TimestampedEvent {
|
export interface TimestampedEvent {
|
||||||
/** Video timestamp (offset from recording start) in seconds. */
|
/** Video timestamp (offset from recording start) as [seconds, nanos]. */
|
||||||
video_timestamp: number;
|
video_timestamp: [number, number];
|
||||||
/** Game timestamp (in-game time) in seconds. */
|
/** Game timestamp (in-game time) as [seconds, nanos]. */
|
||||||
game_timestamp: number | null;
|
game_timestamp: [number, number] | null;
|
||||||
/** Real-world timestamp (ISO 8601). */
|
/** Real-world timestamp (ISO 8601). */
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
/** Event type name. */
|
/** Event type name. */
|
||||||
@@ -18,44 +19,383 @@ export interface TimestampedEvent {
|
|||||||
description: string;
|
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.
|
* A timeline of events for a recording.
|
||||||
|
* This is the main data structure returned by the backend.
|
||||||
*/
|
*/
|
||||||
export interface Timeline {
|
export interface Timeline {
|
||||||
/** Recording ID (UUID). */
|
/** Recording ID (UUID). */
|
||||||
recording_id: string;
|
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). */
|
/** Game start time (ISO 8601). */
|
||||||
start_time: string;
|
start_time: string;
|
||||||
/** Game end time (ISO 8601). */
|
/** Game end time (ISO 8601). */
|
||||||
end_time: string | null;
|
end_time: string | null;
|
||||||
/** Duration in seconds. */
|
/** Duration in seconds. */
|
||||||
duration_secs: number;
|
duration_secs: number;
|
||||||
/** Formatted duration string (e.g., "32:15"). */
|
/** Events in the timeline. */
|
||||||
duration_formatted: string;
|
events: TimestampedEvent[];
|
||||||
/** Number of events. */
|
|
||||||
event_count: number;
|
// Player information
|
||||||
/** Champion played (if available). */
|
/** Champion played. */
|
||||||
champion: string | null;
|
champion: string | null;
|
||||||
/** Game result (Victory/Defeat, if available). */
|
/** Skin name. */
|
||||||
result: string | null;
|
skin_name: string | null;
|
||||||
/** Video file path. */
|
/** Player's summoner name. */
|
||||||
video_path: string | null;
|
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";
|
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