1140 lines
39 KiB
Vue
1140 lines
39 KiB
Vue
<template>
|
||
<div class="leaderboard light">
|
||
<div class="header glass light">
|
||
<h1>📈 Leaderboard</h1>
|
||
<div class="actions">
|
||
<button class="btn-collapse" @click="filtersCollapsed = !filtersCollapsed" :title="filtersCollapsed ? 'Mostrar filtros' : 'Ocultar filtros'">
|
||
<span class="collapse-icon" :class="{ rotated: filtersCollapsed }">▼</span>
|
||
<span class="collapse-text">{{ filtersCollapsed ? 'Mostrar filtros' : 'Ocultar filtros' }}</span>
|
||
</button>
|
||
<button class="btn" @click="refreshAll" :disabled="loading">{{ loading ? 'Actualizando…' : 'Actualizar' }}</button>
|
||
<button class="btn" @click="downloadCSV" :disabled="loading" title="Descargar datos actuales como CSV">
|
||
📊 CSV
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="filters-section" :class="{ collapsed: filtersCollapsed }">
|
||
|
||
<Transition name="filters-slide">
|
||
<div v-if="!filtersCollapsed" class="filters-content">
|
||
<DataSourceSelector v-model="eventFilters.dataSource.value" />
|
||
|
||
<EventFilters
|
||
:round-filter="eventFilters.roundFilter.value"
|
||
:game-filter="eventFilters.gameFilter.value"
|
||
:has-active-filters="eventFilters.hasActiveFilters.value"
|
||
:filter-summary="eventFilters.filterSummary.value"
|
||
@update:round-filter="eventFilters.roundFilter.value = $event"
|
||
@update:game-filter="eventFilters.gameFilter.value = $event"
|
||
@reset-filters="eventFilters.resetFilters"
|
||
/>
|
||
</div>
|
||
</Transition>
|
||
</div>
|
||
|
||
<Transition name="filters-slide">
|
||
<div v-if="!filtersCollapsed" class="controls glass light">
|
||
<div class="legend">
|
||
<span class="key global"></span> Global
|
||
<span class="sep">·</span>
|
||
<span class="key player" v-if="selectedUuid"></span> Jugador
|
||
<span class="sep" v-if="selectedRoomId">·</span>
|
||
<span class="key room" v-if="selectedRoomId"></span> Sala
|
||
</div>
|
||
<div class="player-chips">
|
||
<div class="search-controls">
|
||
<input class="search" v-model="search" placeholder="Buscar jugador…" />
|
||
<div class="pagination compact" v-if="pageCount > 1">
|
||
<button class="pg-btn compact" @click="prevPage" :disabled="page <= 1">‹</button>
|
||
<span class="pg-ind">{{ page }}/{{ pageCount }}</span>
|
||
<button class="pg-btn compact" @click="nextPage" :disabled="page >= pageCount">›</button>
|
||
</div>
|
||
</div>
|
||
<div class="chips">
|
||
<button
|
||
v-for="p in playersPage"
|
||
:key="p.uuid"
|
||
class="chip"
|
||
:class="{ active: p.uuid === selectedUuid }"
|
||
@click="selectPlayer(p.uuid)"
|
||
:title="p.uuid"
|
||
:style="{ '--primary': p.color || '#667eea' } as any"
|
||
>
|
||
<span class="avatar">{{ initials(p.name) }}</span>
|
||
<span class="label">{{ p.name || 'Jugador' }}</span>
|
||
</button>
|
||
<button v-if="selectedUuid" class="chip clear" @click="clearPlayer">Quitar selección</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Room Filter Section -->
|
||
<div class="room-chips">
|
||
<div class="search-controls">
|
||
<input class="search" v-model="roomSearch" placeholder="Buscar sala…" />
|
||
<div class="pagination compact" v-if="roomPageCount > 1">
|
||
<button class="pg-btn compact" @click="prevRoomPage" :disabled="roomPage <= 1">‹</button>
|
||
<span class="pg-ind">{{ roomPage }}/{{ roomPageCount }}</span>
|
||
<button class="pg-btn compact" @click="nextRoomPage" :disabled="roomPage >= roomPageCount">›</button>
|
||
</div>
|
||
</div>
|
||
<div class="chips">
|
||
<button
|
||
v-for="r in roomsPage"
|
||
:key="r.roomId"
|
||
class="chip room-chip"
|
||
:class="{ active: r.roomId === selectedRoomId }"
|
||
@click="selectRoom(r.roomId)"
|
||
:title="`Sala: ${r.roomId} (${r.playerCount || 0} jugadores)`"
|
||
>
|
||
<span class="avatar">🏠</span>
|
||
<span class="label">{{ r.name }}</span>
|
||
<span class="count" v-if="r.playerCount">{{ r.playerCount }}</span>
|
||
</button>
|
||
<button v-if="selectedRoomId" class="chip clear" @click="clearRoom">Quitar selección</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Transition>
|
||
|
||
<EventChart
|
||
:event-types="ALL_CHART_TYPES"
|
||
:event-styles="EVENT_STYLES"
|
||
:global-event-counts="combinedGlobalCounts"
|
||
:player-event-counts="combinedPlayerCounts"
|
||
:selected-player-uuid="selectedUuid"
|
||
:player-bar-gradient="playerBarGradient"
|
||
view-mode="ratio"
|
||
:loading="loading"
|
||
:filters-collapsed="filtersCollapsed"
|
||
:active-filters="activeFilters"
|
||
:group-totals="{
|
||
offers: offersTotal,
|
||
responses: responsesTotal,
|
||
force: forceTotal,
|
||
shame: shameTotal,
|
||
report: reportTotal,
|
||
averageScore: averageScoreTotal,
|
||
totalPlayers: totalPlayersCount
|
||
}"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||
import EventChart from '../components/EventChart.vue';
|
||
import EventFilters from '../components/EventFilters.vue';
|
||
import DataSourceSelector from '../components/DataSourceSelector.vue';
|
||
import { useEventFilters } from '../composables/useEventFilters';
|
||
|
||
interface RoomInfo { roomId: string; metadata?: any; }
|
||
interface RoomState { players?: any[]; systemMessages?: { kind: string }[] }
|
||
|
||
const loading = ref(false);
|
||
const eventFilters = useEventFilters();
|
||
const filtersCollapsed = ref(false);
|
||
|
||
|
||
const EVENTS = [
|
||
'p1_propose', 'p1_no_offer',
|
||
'p2_snatch', 'p2_accept', 'p2_force', 'p2_no_force', 'p2_reject',
|
||
'p1_shame', 'p1_no_shame', 'p1_report', 'p1_no_report'
|
||
];
|
||
|
||
// New metric types for additional charts
|
||
const METRICS = [
|
||
'players_seated', 'score_p1', 'score_p2', 'players_with_shame', 'players_without_shame'
|
||
];
|
||
|
||
const ALL_CHART_TYPES = [...EVENTS, ...METRICS];
|
||
|
||
// Event and metric styles matching OfferActions and OfferControls components
|
||
const EVENT_STYLES: Record<string, { icon: string; color: string; gradient: string }> = {
|
||
// Event types
|
||
'p1_propose': { icon: '✨', color: '#667eea', gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
|
||
'p1_no_offer': { icon: '❌', color: '#6b7280', gradient: 'linear-gradient(135deg, #94a3b8 0%, #64748b 100%)' },
|
||
'p2_accept': { icon: '✓', color: '#10b981', gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)' },
|
||
'p2_reject': { icon: '✕', color: '#f59e0b', gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)' },
|
||
'p2_snatch': { icon: '👹', color: '#ef4444', gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)' },
|
||
'p2_force': { icon: '⚡', color: '#667eea', gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
|
||
'p2_no_force': { icon: '🚫', color: '#6b7280', gradient: 'linear-gradient(135deg, #94a3b8 0%, #64748b 100%)' },
|
||
'p1_shame': { icon: '😶', color: '#fbbf24', gradient: 'linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%)' },
|
||
'p1_no_shame': { icon: '🙂', color: '#6b7280', gradient: 'linear-gradient(135deg, #94a3b8 0%, #64748b 100%)' },
|
||
'p1_report': { icon: '⚖️', color: '#8b5cf6', gradient: 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)' },
|
||
'p1_no_report': { icon: '🤝', color: '#6b7280', gradient: 'linear-gradient(135deg, #94a3b8 0%, #64748b 100%)' },
|
||
|
||
// Metric types
|
||
'players_seated': { icon: '👥', color: '#06b6d4', gradient: 'linear-gradient(135deg, #06b6d4 0%, #0891b2 100%)' },
|
||
'score_p1': { icon: '🦃', color: '#16a34a', gradient: 'linear-gradient(135deg, #16a34a 0%, #15803d 100%)' },
|
||
'score_p2': { icon: '🌽', color: '#d97706', gradient: 'linear-gradient(135deg, #d97706 0%, #b45309 100%)' },
|
||
'players_with_shame': { icon: '😶', color: '#dc2626', gradient: 'linear-gradient(135deg, #dc2626 0%, #b91c1c 100%)' },
|
||
'players_without_shame': { icon: '👥', color: '#06b6d4', gradient: 'linear-gradient(135deg, #06b6d4 0%, #0891b2 100%)' }
|
||
};
|
||
|
||
|
||
// Additional metrics computation
|
||
const additionalMetrics = ref<Record<string, number>>({
|
||
players_seated: 0,
|
||
score_p1: 0,
|
||
score_p2: 0,
|
||
players_with_shame: 0,
|
||
players_without_shame: 0
|
||
});
|
||
|
||
const selectedPlayerMetrics = ref<Record<string, number>>({
|
||
players_seated: 0,
|
||
score_p1: 0,
|
||
score_p2: 0,
|
||
players_with_shame: 0,
|
||
players_without_shame: 0
|
||
});
|
||
|
||
// Store room score history from players
|
||
const allPlayersWithScores = ref<any[]>([]);
|
||
|
||
// Function to compute additional metrics from players' score history
|
||
function computeMetricsFromScores() {
|
||
if (!allPlayersWithScores.value.length) return;
|
||
|
||
let totalP1Scores = 0;
|
||
let totalP2Scores = 0;
|
||
let p1Count = 0;
|
||
let p2Count = 0;
|
||
let playersWithShame = 0;
|
||
let totalPlayersWithNames = 0;
|
||
|
||
// Get score data from players with room score history
|
||
allPlayersWithScores.value.forEach((player: any) => {
|
||
if (player.name) {
|
||
totalPlayersWithNames++;
|
||
|
||
// Extract scores from roomScoreHistory if available
|
||
if (player.roomScoreHistory) {
|
||
player.roomScoreHistory.forEach((roomScore: any) => {
|
||
roomScore.scores.forEach((score: any) => {
|
||
if (score.role === 'P1') {
|
||
totalP1Scores += score.score;
|
||
p1Count++;
|
||
} else if (score.role === 'P2') {
|
||
totalP2Scores += score.score;
|
||
p2Count++;
|
||
}
|
||
});
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
const avgP1Score = p1Count > 0 ? totalP1Scores / p1Count : 0;
|
||
const avgP2Score = p2Count > 0 ? totalP2Scores / p2Count : 0;
|
||
|
||
additionalMetrics.value = {
|
||
players_seated: totalPlayersWithNames,
|
||
score_p1: Math.round(avgP1Score * 10) / 10, // Round to 1 decimal
|
||
score_p2: Math.round(avgP2Score * 10) / 10,
|
||
players_with_shame: playersWithShame,
|
||
players_without_shame: totalPlayersWithNames - playersWithShame
|
||
};
|
||
}
|
||
|
||
// Function to compute metrics for a selected player
|
||
function computeSelectedPlayerMetrics(uuid: string) {
|
||
const playerData = allPlayersWithScores.value.find(p => p.uuid === uuid);
|
||
if (!playerData?.roomScoreHistory) {
|
||
selectedPlayerMetrics.value = {
|
||
players_seated: 1,
|
||
score_p1: 0,
|
||
score_p2: 0,
|
||
players_with_shame: 0,
|
||
players_without_shame: 0
|
||
};
|
||
return;
|
||
}
|
||
|
||
let totalP1Scores = 0;
|
||
let totalP2Scores = 0;
|
||
let p1Count = 0;
|
||
let p2Count = 0;
|
||
|
||
playerData.roomScoreHistory.forEach((roomScore: any) => {
|
||
roomScore.scores.forEach((score: any) => {
|
||
if (score.role === 'P1') {
|
||
totalP1Scores += score.score;
|
||
p1Count++;
|
||
} else if (score.role === 'P2') {
|
||
totalP2Scores += score.score;
|
||
p2Count++;
|
||
}
|
||
});
|
||
});
|
||
|
||
selectedPlayerMetrics.value = {
|
||
players_seated: 1,
|
||
score_p1: p1Count > 0 ? Math.round((totalP1Scores / p1Count) * 10) / 10 : 0,
|
||
score_p2: p2Count > 0 ? Math.round((totalP2Scores / p2Count) * 10) / 10 : 0,
|
||
players_with_shame: 0, // Would need shame data
|
||
players_without_shame: 0
|
||
};
|
||
}
|
||
|
||
// Combined counts for charts (events + metrics)
|
||
const combinedGlobalCounts = computed(() => ({
|
||
...eventFilters.globalEventCounts.value,
|
||
...additionalMetrics.value
|
||
}));
|
||
|
||
const combinedPlayerCounts = computed(() => ({
|
||
...playerEventCounts.value,
|
||
...selectedPlayerMetrics.value
|
||
}));
|
||
|
||
|
||
// Chart group totals
|
||
const offersTotal = computed(() => {
|
||
const propose = eventFilters.globalEventCounts.value.p1_propose || 0;
|
||
const noOffer = eventFilters.globalEventCounts.value.p1_no_offer || 0;
|
||
return propose + noOffer;
|
||
});
|
||
|
||
const responsesTotal = computed(() => {
|
||
const accept = eventFilters.globalEventCounts.value.p2_accept || 0;
|
||
const reject = eventFilters.globalEventCounts.value.p2_reject || 0;
|
||
const snatch = eventFilters.globalEventCounts.value.p2_snatch || 0;
|
||
return accept + reject + snatch;
|
||
});
|
||
|
||
const forceTotal = computed(() => {
|
||
const force = eventFilters.globalEventCounts.value.p2_force || 0;
|
||
const noForce = eventFilters.globalEventCounts.value.p2_no_force || 0;
|
||
return force + noForce;
|
||
});
|
||
|
||
const shameTotal = computed(() => {
|
||
const shame = eventFilters.globalEventCounts.value.p1_shame || 0;
|
||
const noShame = eventFilters.globalEventCounts.value.p1_no_shame || 0;
|
||
return shame + noShame;
|
||
});
|
||
|
||
const reportTotal = computed(() => {
|
||
const report = eventFilters.globalEventCounts.value.p1_report || 0;
|
||
const noReport = eventFilters.globalEventCounts.value.p1_no_report || 0;
|
||
return report + noReport;
|
||
});
|
||
|
||
const averageScoreTotal = computed(() => {
|
||
if (!allPlayersWithScores.value.length) return 0;
|
||
|
||
let totalScores = 0;
|
||
let totalScoreCount = 0;
|
||
|
||
// Sum all individual scores from all players regardless of role
|
||
allPlayersWithScores.value.forEach((player: any) => {
|
||
if (player.roomScoreHistory) {
|
||
player.roomScoreHistory.forEach((roomScore: any) => {
|
||
roomScore.scores.forEach((score: any) => {
|
||
totalScores += score.score;
|
||
totalScoreCount++;
|
||
});
|
||
});
|
||
}
|
||
});
|
||
|
||
return totalScoreCount > 0 ? Math.round((totalScores / totalScoreCount) * 10) / 10 : 0;
|
||
});
|
||
|
||
const totalPlayersCount = computed(() => {
|
||
return additionalMetrics.value.players_seated || 0;
|
||
});
|
||
|
||
// Active filters object
|
||
const activeFilters = computed(() => ({
|
||
dataSource: eventFilters.dataSource.value,
|
||
round: eventFilters.roundFilter.value.toString(),
|
||
game: eventFilters.gameFilter.value,
|
||
hasFilters: eventFilters.hasActiveFilters.value || !!selectedRoomId.value,
|
||
selectedPlayer: selectedUuid.value ? players.value.find(p => p.uuid === selectedUuid.value)?.name || 'Jugador' : undefined,
|
||
selectedRoom: selectedRoomId.value ? availableRooms.value.find(r => r.roomId === selectedRoomId.value)?.name || 'Sala' : undefined
|
||
}));
|
||
|
||
// Watch for changes in filters and data source
|
||
watch([eventFilters.dataSource, eventFilters.roundFilter, eventFilters.gameFilter, eventFilters.roomFilter], () => {
|
||
eventFilters.applyFilters(EVENTS);
|
||
});
|
||
|
||
// selectedUuid watch will be added after selectedUuid is declared
|
||
|
||
const rooms = ref<RoomInfo[]>([]);
|
||
const players = ref<{ uuid: string; name: string; color?: string }[]>([]);
|
||
const availableRooms = ref<{ roomId: string; name: string; playerCount?: number }[]>([]);
|
||
const search = ref('');
|
||
const roomSearch = ref('');
|
||
const playersFiltered = computed(() => {
|
||
const q = (search.value || '').toLowerCase();
|
||
if (!q) return players.value;
|
||
return players.value.filter(p => (p.name || '').toLowerCase().includes(q) || (p.uuid || '').toLowerCase().includes(q));
|
||
});
|
||
|
||
const roomsFiltered = computed(() => {
|
||
const q = (roomSearch.value || '').toLowerCase();
|
||
if (!q) return availableRooms.value;
|
||
return availableRooms.value.filter(r =>
|
||
(r.roomId || '').toLowerCase().includes(q) ||
|
||
(r.name || '').toLowerCase().includes(q)
|
||
);
|
||
});
|
||
|
||
// Reset page when search changes
|
||
watch(search, () => {
|
||
page.value = 1;
|
||
});
|
||
watch(roomSearch, () => {
|
||
roomPage.value = 1;
|
||
});
|
||
const page = ref(1);
|
||
const roomPage = ref(1);
|
||
const containerWidth = ref(1200); // Default width, will be updated dynamically
|
||
const dynamicPageSize = computed(() => {
|
||
// Estimate space needed per chip and controls
|
||
const chipWidth = 140; // Average chip width including gap
|
||
const clearBtnWidth = selectedUuid.value ? 150 : 0; // "Quitar selección" button
|
||
const searchWidth = 240; // Search input
|
||
const paginationWidth = pageCount.value > 1 ? 100 : 0; // Compact pagination
|
||
const margin = 40; // Container margins
|
||
|
||
const availableWidth = containerWidth.value - searchWidth - paginationWidth - clearBtnWidth - margin;
|
||
const maxChips = Math.max(3, Math.floor(availableWidth / chipWidth)); // Minimum 3 chips
|
||
|
||
return Math.min(maxChips, 15); // Maximum 15 chips per page
|
||
});
|
||
|
||
const roomDynamicPageSize = computed(() => {
|
||
// Similar calculation for rooms
|
||
const chipWidth = 160; // Room chips might be slightly wider
|
||
const clearBtnWidth = selectedRoomId.value ? 150 : 0;
|
||
const searchWidth = 240;
|
||
const paginationWidth = roomPageCount.value > 1 ? 100 : 0;
|
||
const margin = 40;
|
||
|
||
const availableWidth = containerWidth.value - searchWidth - paginationWidth - clearBtnWidth - margin;
|
||
const maxChips = Math.max(3, Math.floor(availableWidth / chipWidth));
|
||
|
||
return Math.min(maxChips, 10); // Maximum 10 room chips per page
|
||
});
|
||
|
||
const pageCount = computed(() => Math.max(1, Math.ceil((playersFiltered.value.length || 0) / dynamicPageSize.value)));
|
||
const roomPageCount = computed(() => Math.max(1, Math.ceil((roomsFiltered.value.length || 0) / roomDynamicPageSize.value)));
|
||
|
||
const playersPage = computed(() => {
|
||
const start = (page.value - 1) * dynamicPageSize.value;
|
||
return playersFiltered.value.slice(start, start + dynamicPageSize.value);
|
||
});
|
||
|
||
const roomsPage = computed(() => {
|
||
const start = (roomPage.value - 1) * roomDynamicPageSize.value;
|
||
return roomsFiltered.value.slice(start, start + roomDynamicPageSize.value);
|
||
});
|
||
|
||
const selectedUuid = ref('');
|
||
const selectedRoomId = ref('');
|
||
const playerLoading = ref(false);
|
||
const playerEventCounts = ref<Record<string, number>>((Object.fromEntries(EVENTS.map(k => [k, 0])) as Record<string, number>));
|
||
const playersActionsByUuid = ref<Record<string, Record<string, number>>>({});
|
||
|
||
// Watch for selected player changes to update their metrics
|
||
watch(selectedUuid, (newUuid) => {
|
||
if (newUuid) {
|
||
computeSelectedPlayerMetrics(newUuid);
|
||
}
|
||
});
|
||
|
||
function initials(name: string): string {
|
||
const n = (name || '').trim();
|
||
if (!n) return '🙂';
|
||
const parts = n.split(/\s+/).slice(0, 2);
|
||
const chars = parts.map(p => p[0]?.toUpperCase()).join('');
|
||
return chars || '🙂';
|
||
}
|
||
|
||
|
||
function selectPlayer(uuid: string) {
|
||
if (selectedUuid.value === uuid) return;
|
||
selectedUuid.value = uuid;
|
||
loadPlayerHistory();
|
||
}
|
||
|
||
function clearPlayer() {
|
||
selectedUuid.value = '';
|
||
}
|
||
|
||
function selectRoom(roomId: string) {
|
||
if (selectedRoomId.value === roomId) return;
|
||
selectedRoomId.value = roomId;
|
||
eventFilters.roomFilter.value = roomId;
|
||
eventFilters.applyFilters(EVENTS);
|
||
}
|
||
|
||
function clearRoom() {
|
||
selectedRoomId.value = '';
|
||
eventFilters.roomFilter.value = 'all';
|
||
eventFilters.applyFilters(EVENTS);
|
||
}
|
||
|
||
function prevPage() { page.value = Math.max(1, page.value - 1); }
|
||
function nextPage() { page.value = Math.min(pageCount.value, page.value + 1); }
|
||
function prevRoomPage() { roomPage.value = Math.max(1, roomPage.value - 1); }
|
||
function nextRoomPage() { roomPage.value = Math.min(roomPageCount.value, roomPage.value + 1); }
|
||
|
||
// Ensure page doesn't exceed pageCount when players change
|
||
watch(pageCount, (newCount) => {
|
||
if (page.value > newCount) {
|
||
page.value = Math.max(1, newCount);
|
||
}
|
||
});
|
||
|
||
watch(roomPageCount, (newCount) => {
|
||
if (roomPage.value > newCount) {
|
||
roomPage.value = Math.max(1, newCount);
|
||
}
|
||
});
|
||
|
||
// Dynamic per-player overlay bar gradient and label color
|
||
const playerBarGradient = computed(() => {
|
||
const p = players.value.find(x => x.uuid === selectedUuid.value);
|
||
const c = p?.color || '#667eea';
|
||
return `linear-gradient(90deg, ${c}, ${c})`;
|
||
});
|
||
|
||
|
||
|
||
const apiBase = (import.meta as any).env?.VITE_API_URL || `${window.location.protocol}//${window.location.host}/api`;
|
||
const esRooms = ref<EventSource|null>(null);
|
||
const esUuids = ref<EventSource|null>(null);
|
||
const esActions = ref<EventSource|null>(null);
|
||
|
||
function closeStreams() {
|
||
try { esRooms.value?.close(); } catch {}
|
||
try { esUuids.value?.close(); } catch {}
|
||
try { esActions.value?.close(); } catch {}
|
||
esRooms.value = null;
|
||
esUuids.value = null;
|
||
esActions.value = null;
|
||
}
|
||
|
||
function setupStreams() {
|
||
loading.value = true;
|
||
// Rooms stream
|
||
closeStreams();
|
||
esRooms.value = new EventSource(`${apiBase}/dashboard-stream`);
|
||
esRooms.value.onmessage = (e) => {
|
||
try {
|
||
const data = JSON.parse((e as MessageEvent).data || '{}');
|
||
const details = data?.roomDetails || {};
|
||
|
||
// Room details are for active rooms, we'll get room data from player history instead
|
||
|
||
// Collect detailed events for active rooms
|
||
const detailedEvents: Array<{ kind: string; round?: number; gameVariant?: string; roomId?: string }> = [];
|
||
const counts: Record<string, number> = Object.fromEntries(EVENTS.map(k => [k, 0])) as any;
|
||
|
||
Object.entries(details).forEach(([roomId, d]: [string, any]) => {
|
||
(Array.isArray(d?.systemMessages) ? d.systemMessages : []).forEach((m: any) => {
|
||
const k = (m?.kind || '').toString();
|
||
if (EVENTS.includes(k)) {
|
||
counts[k] = (counts[k] || 0) + 1;
|
||
detailedEvents.push({
|
||
kind: k,
|
||
round: m?.round,
|
||
gameVariant: m?.gameVariant || m?.variant,
|
||
roomId: m?.roomId || roomId // Use message roomId if available, otherwise use room key
|
||
});
|
||
}
|
||
});
|
||
});
|
||
|
||
eventFilters.updateActiveRoomsData(detailedEvents, counts);
|
||
|
||
// Compute additional metrics from active rooms (for current UI compatibility)
|
||
// computeMetrics(details); // Removed since we now use score history
|
||
|
||
// Don't extract rooms from active data - we'll get them from aggregated player history
|
||
|
||
// Apply filters and update display
|
||
if (eventFilters.dataSource.value === 'active-rooms') {
|
||
eventFilters.applyFilters(EVENTS);
|
||
}
|
||
// Build players list from room details (keep color if provided)
|
||
const playerMap = new Map<string, { name: string; color?: string }>();
|
||
Object.values(details).forEach((d: any) => {
|
||
(d?.players || []).forEach((p: any) => {
|
||
const uuid = (p?.uuid || p?.sessionId || '').toString();
|
||
if (uuid && !playerMap.has(uuid)) playerMap.set(uuid, { name: (p?.name || 'player'), color: p?.color });
|
||
});
|
||
});
|
||
const merged = new Map<string, { name: string; color?: string }>();
|
||
players.value.forEach(p => merged.set(p.uuid, { name: p.name, color: p.color }));
|
||
playerMap.forEach((obj, uuid) => merged.set(uuid, { name: obj.name, color: obj.color || merged.get(uuid)?.color }));
|
||
players.value = Array.from(merged.entries()).map(([uuid, obj]) => ({ uuid, name: obj.name, color: obj.color })).sort((a,b)=>a.name.localeCompare(b.name));
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
esRooms.value.onerror = () => {};
|
||
|
||
// UUIDs stream
|
||
esUuids.value = new EventSource(`${apiBase}/uuids-stream`);
|
||
esUuids.value.onmessage = (e) => {
|
||
try {
|
||
const data = JSON.parse((e as MessageEvent).data || '{}');
|
||
const list = Array.isArray(data?.uuids) ? data.uuids : [];
|
||
const existing = new Map<string, { name: string; color?: string }>();
|
||
players.value.forEach(p => existing.set(p.uuid, { name: p.name, color: p.color }));
|
||
list.forEach((u: any) => {
|
||
const uuid = (u?.uuid || '').toString();
|
||
if (!uuid) return;
|
||
const prev = existing.get(uuid);
|
||
const name = (u?.name || prev?.name || 'player').toString();
|
||
existing.set(uuid, { name, color: prev?.color });
|
||
});
|
||
players.value = Array.from(existing.entries()).map(([uuid, obj]) => ({ uuid, name: obj.name, color: obj.color })).sort((a,b)=>a.name.localeCompare(b.name));
|
||
} catch {}
|
||
};
|
||
esUuids.value.onerror = () => {};
|
||
|
||
// Per-player actions stream
|
||
esActions.value = new EventSource(`${apiBase}/players-actions-stream`);
|
||
esActions.value.onmessage = (e) => {
|
||
try {
|
||
const data = JSON.parse((e as MessageEvent).data || '{}');
|
||
const list = Array.isArray(data?.players) ? data.players : [];
|
||
allPlayersActions.value = list.map((p: any) => ({ uuid: String(p.uuid||''), name: String(p.name||''), total: Number(p.total||0) }));
|
||
|
||
// Store complete player data with room score history
|
||
allPlayersWithScores.value = list;
|
||
|
||
|
||
// Collect all detailed events from all players
|
||
const allDetailedEvents: Array<{ kind: string; round?: number; gameVariant?: string; roomId?: string }> = [];
|
||
const aggregatedCounts: Record<string, number> = Object.fromEntries(EVENTS.map(k => [k, 0])) as any;
|
||
|
||
// Update detailed counts map
|
||
const byUuid: Record<string, Record<string, number>> = {};
|
||
list.forEach((p: any) => {
|
||
const uuid = String(p?.uuid || '');
|
||
if (!uuid) return;
|
||
const src = p?.counts || {};
|
||
const normalized: Record<string, number> = Object.fromEntries(EVENTS.map(k => [k, 0])) as any;
|
||
|
||
// Add detailed history events
|
||
if (Array.isArray(p?.detailedHistory)) {
|
||
allDetailedEvents.push(...p.detailedHistory);
|
||
}
|
||
|
||
EVENTS.forEach(k => {
|
||
const count = Number(src[k] || 0);
|
||
normalized[k] = count;
|
||
aggregatedCounts[k] = (aggregatedCounts[k] || 0) + count;
|
||
});
|
||
byUuid[uuid] = normalized;
|
||
});
|
||
|
||
playersActionsByUuid.value = byUuid;
|
||
eventFilters.updateAggregatedData(allDetailedEvents, aggregatedCounts);
|
||
|
||
// Extract unique room IDs from aggregated events - this is our primary source for rooms
|
||
const roomIds = new Set<string>();
|
||
allDetailedEvents.forEach(event => {
|
||
if (event.roomId && event.roomId.trim()) {
|
||
roomIds.add(event.roomId);
|
||
}
|
||
});
|
||
|
||
// Build available rooms list from aggregated events
|
||
availableRooms.value = Array.from(roomIds).map(roomId => ({
|
||
roomId,
|
||
name: `Sala ${roomId.slice(0, 8)}`,
|
||
playerCount: allDetailedEvents.filter(e => e.roomId === roomId).length
|
||
}));
|
||
|
||
// Compute metrics from score history
|
||
computeMetricsFromScores();
|
||
|
||
// Apply filters and update display if viewing aggregated data
|
||
if (eventFilters.dataSource.value === 'aggregated') {
|
||
eventFilters.applyFilters(EVENTS);
|
||
}
|
||
// If a player is selected, update playerEventCounts live
|
||
if (selectedUuid.value) {
|
||
const counts = byUuid[selectedUuid.value];
|
||
if (counts) playerEventCounts.value = counts as any;
|
||
}
|
||
// Merge names into players list; preserve colors from previous streams
|
||
const existing = new Map<string, { name: string; color?: string }>();
|
||
players.value.forEach(p => existing.set(p.uuid, { name: p.name, color: p.color }));
|
||
list.forEach((u: any) => {
|
||
const uuid = (u?.uuid || '').toString();
|
||
if (!uuid) return;
|
||
const prev = existing.get(uuid);
|
||
const name = (u?.name || prev?.name || 'player').toString();
|
||
existing.set(uuid, { name, color: prev?.color });
|
||
});
|
||
players.value = Array.from(existing.entries()).map(([uuid, obj]) => ({ uuid, name: obj.name, color: obj.color })).sort((a,b)=>a.name.localeCompare(b.name));
|
||
} catch {}
|
||
};
|
||
esActions.value.onerror = () => {};
|
||
}
|
||
|
||
async function fetchRooms(): Promise<RoomInfo[]> {
|
||
const res = await fetch(`${apiBase}/rooms`);
|
||
return await res.json();
|
||
}
|
||
|
||
async function fetchRoomStats(roomId: string): Promise<RoomState> {
|
||
const res = await fetch(`${apiBase}/rooms/${roomId}/stats`);
|
||
return await res.json();
|
||
}
|
||
|
||
async function refreshAll() {
|
||
loading.value = true;
|
||
try {
|
||
const list = await fetchRooms();
|
||
rooms.value = (list || []).filter((r: any) => r?.name === 'game');
|
||
// reset counts
|
||
eventFilters.globalEventCounts.value = Object.fromEntries(EVENTS.map(k => [k, 0])) as Record<string, number>;
|
||
const playerMap = new Map<string, { name: string; color?: string }>();
|
||
|
||
for (const r of rooms.value) {
|
||
try {
|
||
const s = await fetchRoomStats(r.roomId);
|
||
const msgs = (s?.systemMessages || []) as Array<{ kind: string }>;
|
||
msgs.forEach(m => {
|
||
const k = (m?.kind || '').toString();
|
||
if (EVENTS.includes(k)) {
|
||
eventFilters.globalEventCounts.value[k] = (eventFilters.globalEventCounts.value[k] || 0) + 1;
|
||
}
|
||
});
|
||
// collect players for filter
|
||
(s?.players || []).forEach((p: any) => {
|
||
const uuid = (p?.uuid || p?.sessionId || '').toString();
|
||
if (uuid && !playerMap.has(uuid)) playerMap.set(uuid, { name: (p?.name || 'player'), color: p?.color });
|
||
});
|
||
} catch {}
|
||
}
|
||
players.value = Array.from(playerMap.entries()).map(([uuid, obj]) => ({ uuid, name: obj.name, color: obj.color }));
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function loadPlayerHistory() {
|
||
playerLoading.value = true;
|
||
const next: Record<string, number> = Object.fromEntries(EVENTS.map(k => [k, 0])) as any;
|
||
const counts = playersActionsByUuid.value[selectedUuid.value || ''];
|
||
if (counts) {
|
||
EVENTS.forEach(k => { next[k] = Number(counts[k] || 0); });
|
||
}
|
||
playerEventCounts.value = next as any;
|
||
playerLoading.value = false;
|
||
}
|
||
|
||
// Update container width on resize
|
||
function updateContainerWidth() {
|
||
containerWidth.value = window.innerWidth;
|
||
}
|
||
|
||
onMounted(() => {
|
||
setupStreams();
|
||
// Initialize with aggregated data as default
|
||
eventFilters.globalEventCounts.value = { ...eventFilters.globalEventCountsAggregated.value };
|
||
|
||
// Set initial container width and add resize listener
|
||
updateContainerWidth();
|
||
window.addEventListener('resize', updateContainerWidth);
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
closeStreams();
|
||
window.removeEventListener('resize', updateContainerWidth);
|
||
});
|
||
|
||
// Reset to first page when search changes or players list length shrinks below current page
|
||
watch(() => search.value, () => { page.value = 1; });
|
||
watch(() => playersFiltered.value.length, () => {
|
||
if (page.value > pageCount.value) page.value = pageCount.value;
|
||
});
|
||
|
||
// Removed totals table and sorting; keep actions stream for per-player counts only
|
||
const allPlayersActions = ref<{ uuid: string; name: string; total: number }[]>([]);
|
||
|
||
|
||
function downloadCSV() {
|
||
const currentEvents = eventFilters.currentSourceEvents.value;
|
||
|
||
// Create CSV headers
|
||
const headers = ['Type', 'Event', 'Count', 'Round', 'GameVariant', 'PlayerUuid', 'PlayerName', 'RoomId', 'DataSource'];
|
||
|
||
// Create CSV rows - one row per individual event occurrence
|
||
const rows: string[][] = [];
|
||
|
||
// Add detailed individual event rows if we have detailed data
|
||
currentEvents.forEach(event => {
|
||
if (EVENTS.includes(event.kind)) {
|
||
rows.push([
|
||
'event',
|
||
event.kind,
|
||
'1',
|
||
event.round?.toString() || '',
|
||
event.gameVariant || '',
|
||
event.playerUuid || '',
|
||
event.playerName || '',
|
||
event.roomId || '',
|
||
eventFilters.dataSource.value
|
||
]);
|
||
}
|
||
});
|
||
|
||
// Also add per-player aggregated data from playersActionsByUuid
|
||
Object.entries(playersActionsByUuid.value).forEach(([uuid, counts]) => {
|
||
const player = players.value.find(p => p.uuid === uuid);
|
||
EVENTS.forEach(eventType => {
|
||
const count = counts[eventType] || 0;
|
||
if (count > 0) {
|
||
// Add one row per occurrence for proper import compatibility
|
||
for (let i = 0; i < count; i++) {
|
||
rows.push([
|
||
'event',
|
||
eventType,
|
||
'1',
|
||
'', // Round info not available in aggregated player data
|
||
'', // GameVariant info not available in aggregated player data
|
||
uuid,
|
||
player?.name || '',
|
||
'', // Room info not available in aggregated player data
|
||
'player-aggregated'
|
||
]);
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
// Add metric data
|
||
const currentMetrics = eventFilters.dataSource.value === 'aggregated'
|
||
? additionalMetrics.value
|
||
: additionalMetrics.value; // Same source for now
|
||
|
||
METRICS.forEach(metricType => {
|
||
const count = currentMetrics[metricType] || 0;
|
||
if (count > 0) {
|
||
rows.push([
|
||
'metric',
|
||
metricType,
|
||
count.toString(),
|
||
'',
|
||
'',
|
||
'',
|
||
'',
|
||
'',
|
||
eventFilters.dataSource.value
|
||
]);
|
||
}
|
||
});
|
||
|
||
// Convert to CSV string
|
||
const csvContent = [
|
||
headers.join(','),
|
||
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
|
||
].join('\n');
|
||
|
||
// Create and download file
|
||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||
const link = document.createElement('a');
|
||
const url = URL.createObjectURL(blob);
|
||
link.setAttribute('href', url);
|
||
|
||
// Generate filename with current filters and timestamp
|
||
const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-');
|
||
const dataSourceLabel = eventFilters.dataSource.value === 'aggregated' ? 'agregados' : 'activos';
|
||
const filterLabel = eventFilters.hasActiveFilters.value ? `_${eventFilters.filterSummary.value.replace(/\s+/g, '_')}` : '';
|
||
const filename = `datos_${dataSourceLabel}${filterLabel}_${timestamp}.csv`;
|
||
|
||
link.setAttribute('download', filename);
|
||
link.style.visibility = 'hidden';
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* Light theme aligned with PlayerStats/GameEndModal */
|
||
.leaderboard.light { min-height: 100vh; padding: 20px; background: linear-gradient(135deg, #eef2ff 0%, #f8fafc 100%); color:#0f172a; display:flex; flex-direction:column; }
|
||
|
||
.glass.light { background: rgba(255, 255, 255, 0.78); border: 1px solid rgba(229, 231, 235, 0.9); box-shadow: 0 18px 50px rgba(0,0,0,0.12), inset 0 1px 0 rgba(255,255,255,0.6); backdrop-filter: blur(18px) saturate(120%); -webkit-backdrop-filter: blur(18px) saturate(120%); border-radius: 16px; }
|
||
|
||
.header { display:flex; align-items:center; justify-content:space-between; padding: 12px 14px; margin-bottom: 14px; }
|
||
.header h1 { margin: 0; }
|
||
.actions { display:flex; gap: 8px; }
|
||
.actions .btn { background:#667eea; color:#fff; border:none; border-radius:10px; padding:8px 12px; font-weight:800; cursor:pointer; }
|
||
.actions .btn.toggle { background:#eef2ff; color:#3949ab; border:1px solid #c7d2fe; }
|
||
.actions .btn.toggle.active { background:#3949ab; color:#fff; border-color:#2e3f9a; }
|
||
|
||
.controls { display:grid; grid-template-columns: 1fr; gap: 10px; padding: 10px 12px; margin-bottom: 14px; }
|
||
.legend { font-size: 13px; color:#334155; display:flex; align-items:center; gap:10px; }
|
||
.key { width: 12px; height: 12px; border-radius: 999px; display:inline-block; }
|
||
.key.global { background: linear-gradient(90deg, #34d399, #10b981); box-shadow: 0 0 8px rgba(16,185,129,0.35); }
|
||
.key.player { background: linear-gradient(90deg, #a78bfa, #6366f1); box-shadow: 0 0 8px rgba(99,102,241,0.35); }
|
||
.key.room { background: linear-gradient(90deg, #f59e0b, #d97706); box-shadow: 0 0 8px rgba(245,158,11,0.35); }
|
||
.sep { opacity: 0.6; }
|
||
|
||
.player-chips, .room-chips {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.room-chips {
|
||
margin-top: 16px;
|
||
padding-top: 16px;
|
||
border-top: 1px solid rgba(203, 213, 225, 0.5);
|
||
}
|
||
|
||
.search-controls {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.search {
|
||
padding: 8px 10px;
|
||
border: 1px solid #cbd5e1;
|
||
background: #fff;
|
||
color: #0f172a;
|
||
border-radius: 10px;
|
||
min-width: 240px;
|
||
outline: none;
|
||
flex: 1;
|
||
max-width: 300px;
|
||
}
|
||
|
||
.search::placeholder {
|
||
color: #64748b;
|
||
}
|
||
|
||
.chips {
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
}
|
||
.chip { display:flex; align-items:center; gap:8px; background: color-mix(in srgb, var(--primary) 6%, white); border:1px solid color-mix(in srgb, var(--primary) 24%, #e5e7eb); padding:8px 12px; border-radius: 999px; color:#111827; cursor:pointer; transition: transform .18s ease, background .18s ease, box-shadow .18s ease; }
|
||
.chip:hover { transform: translateY(-1px); background: color-mix(in srgb, var(--primary) 10%, white); box-shadow: 0 6px 18px rgba(102,126,234,0.18); }
|
||
.chip.active { background: color-mix(in srgb, var(--primary) 18%, white); border-color: color-mix(in srgb, var(--primary) 45%, #c7d2fe); box-shadow: 0 6px 22px rgba(99,102,241,0.22); }
|
||
.chip.clear { background:#fff; border-style:dashed; color:#334155; }
|
||
.chip.room-chip { --primary: #f59e0b; }
|
||
.chip.room-chip .count {
|
||
background: color-mix(in srgb, var(--primary) 20%, white);
|
||
color: color-mix(in srgb, var(--primary) 80%, #111);
|
||
font-size: 11px;
|
||
font-weight: 800;
|
||
padding: 2px 6px;
|
||
border-radius: 10px;
|
||
margin-left: 4px;
|
||
}
|
||
.avatar { width: 24px; height: 24px; border-radius: 50%; background: color-mix(in srgb, var(--primary) 25%, #eef2ff); display:grid; place-items:center; font-weight:900; color: color-mix(in srgb, var(--primary) 80%, #111); }
|
||
|
||
|
||
|
||
/* Pagination styles */
|
||
.pagination {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px 10px;
|
||
background: rgba(255,255,255,0.82);
|
||
border: 1px solid rgba(229,231,235,0.9);
|
||
border-radius: 999px;
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.08), inset 0 1px 0 rgba(255,255,255,0.6);
|
||
backdrop-filter: blur(10px) saturate(120%);
|
||
-webkit-backdrop-filter: blur(10px) saturate(120%);
|
||
}
|
||
|
||
.pagination.compact {
|
||
gap: 6px;
|
||
padding: 4px 8px;
|
||
}
|
||
|
||
.pg-btn {
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: 50%;
|
||
border: 1px solid #cbd5e1;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
font-weight: 900;
|
||
font-size: 16px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.2s ease;
|
||
box-shadow: 0 4px 12px rgba(102,126,234,0.25);
|
||
}
|
||
|
||
.pg-btn.compact {
|
||
width: 24px;
|
||
height: 24px;
|
||
font-size: 14px;
|
||
font-weight: 800;
|
||
box-shadow: 0 2px 8px rgba(102,126,234,0.2);
|
||
}
|
||
|
||
.pg-btn:hover:not(:disabled) {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 6px 16px rgba(102,126,234,0.35);
|
||
}
|
||
|
||
.pg-btn.compact:hover:not(:disabled) {
|
||
box-shadow: 0 4px 12px rgba(102,126,234,0.3);
|
||
}
|
||
|
||
.pg-btn:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
background: #e5e7eb;
|
||
color: #94a3b8;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.pg-ind {
|
||
font-weight: 600;
|
||
color: #334155;
|
||
font-size: 12px;
|
||
min-width: 35px;
|
||
text-align: center;
|
||
}
|
||
|
||
|
||
/* Filters section styles */
|
||
.filters-section {
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.btn-collapse {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 8px 12px;
|
||
border: none;
|
||
border-radius: 10px;
|
||
background: #eef2ff;
|
||
color: #3949ab;
|
||
border: 1px solid #c7d2fe;
|
||
font-weight: 800;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.btn-collapse:hover {
|
||
background: rgba(255,255,255,0.9);
|
||
border-color: #cbd5e1;
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||
}
|
||
|
||
.collapse-icon {
|
||
transition: transform 0.3s ease;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.collapse-icon.rotated {
|
||
transform: rotate(-90deg);
|
||
}
|
||
|
||
.filters-content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0;
|
||
}
|
||
|
||
/* Transition for filters */
|
||
.filters-slide-enter-active,
|
||
.filters-slide-leave-active {
|
||
transition: all 0.3s ease;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.filters-slide-enter-from,
|
||
.filters-slide-leave-to {
|
||
opacity: 0;
|
||
max-height: 0;
|
||
transform: translateY(-20px);
|
||
}
|
||
|
||
.filters-slide-enter-to,
|
||
.filters-slide-leave-from {
|
||
opacity: 1;
|
||
max-height: 200px;
|
||
transform: translateY(0);
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.search-controls {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
gap: 8px;
|
||
}
|
||
|
||
.search {
|
||
min-width: auto;
|
||
max-width: none;
|
||
}
|
||
|
||
.pagination.compact {
|
||
align-self: center;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
.btn-collapse .collapse-text {
|
||
display: none;
|
||
}
|
||
|
||
.btn-collapse {
|
||
padding: 8px;
|
||
min-width: 40px;
|
||
justify-content: center;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 480px) {
|
||
.player-chips {
|
||
gap: 8px;
|
||
}
|
||
|
||
.chips {
|
||
gap: 8px;
|
||
justify-content: center;
|
||
}
|
||
|
||
.chip {
|
||
padding: 6px 10px;
|
||
}
|
||
|
||
.pg-btn.compact {
|
||
width: 20px;
|
||
height: 20px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.pg-ind {
|
||
font-size: 11px;
|
||
min-width: 30px;
|
||
}
|
||
}
|
||
|
||
</style>
|