mejoras de UI

This commit is contained in:
2025-08-27 22:56:28 -06:00
parent 38141800b2
commit 293f407820
3 changed files with 271 additions and 41 deletions

View File

@@ -1,6 +1,22 @@
<template>
<div class="panel glass">
<h2 class="panel-title">Eventos y comparación</h2>
<div class="panel-header">
<h2 class="panel-title">Eventos y comparación</h2>
<div v-if="filtersCollapsed && (activeFilters?.hasFilters || selectedPlayerUuid)" class="active-filters-summary">
<span class="filter-tag" v-if="activeFilters?.dataSource !== 'aggregated'">
{{ activeFilters.dataSource === 'active-rooms' ? '🔴 Tiempo Real' : '📁 Agregado' }}
</span>
<span class="filter-tag" v-if="activeFilters?.round !== 'all'">
Round {{ activeFilters.round }}
</span>
<span class="filter-tag" v-if="activeFilters?.game !== 'all'">
{{ activeFilters.game }}
</span>
<span class="filter-tag player-tag" v-if="selectedPlayerUuid && activeFilters?.selectedPlayer">
👤 {{ activeFilters.selectedPlayer }}
</span>
</div>
</div>
<div v-if="loading" class="placeholder">Cargando datos</div>
<!-- Regular bars view -->
@@ -101,6 +117,14 @@ interface Props {
playerBarGradient: string;
viewMode: 'count' | 'percent' | 'ratio';
loading?: boolean;
filtersCollapsed?: boolean;
activeFilters?: {
dataSource: string;
round: string;
game: string;
hasFilters: boolean;
selectedPlayer?: string;
};
}
const props = withDefaults(defineProps<Props>(), {
@@ -249,11 +273,42 @@ function friendlyEventName(eventType: string): string {
border-radius: 16px;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
flex-wrap: wrap;
gap: 8px;
}
.panel-title {
margin: 0 0 10px;
margin: 0;
color: #334155;
}
.active-filters-summary {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.filter-tag {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 700;
box-shadow: 0 2px 6px rgba(102,126,234,0.25);
white-space: nowrap;
}
.filter-tag.player-tag {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
box-shadow: 0 2px 6px rgba(16,185,129,0.25);
}
.placeholder {
color: #64748b;
padding: 12px;

View File

@@ -4,6 +4,8 @@ export interface DetailedEvent {
kind: string;
round?: number;
gameVariant?: string;
playerUuid?: string;
playerName?: string;
}
export type DataSource = 'aggregated' | 'active-rooms';

View File

@@ -3,57 +3,73 @@
<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>
<button class="btn toggle" @click="cycleViewMode" :disabled="loading">
{{ viewModeLabel }}
</button>
</div>
</div>
<DataSourceSelector v-model="eventFilters.dataSource.value" />
<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"
/>
<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>
<div 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
</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>
<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
</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>
<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>
</div>
</Transition>
<EventChart
:event-types="EVENTS"
@@ -64,6 +80,14 @@
:player-bar-gradient="playerBarGradient"
:view-mode="viewMode"
:loading="loading"
:filters-collapsed="filtersCollapsed"
:active-filters="{
dataSource: eventFilters.dataSource.value,
round: eventFilters.roundFilter.value.toString(),
game: eventFilters.gameFilter.value,
hasFilters: eventFilters.hasActiveFilters.value,
selectedPlayer: selectedUuid ? players.find(p => p.uuid === selectedUuid)?.name || 'Jugador' : undefined
}"
/>
</div>
</template>
@@ -80,6 +104,7 @@ interface RoomState { players?: any[]; systemMessages?: { kind: string }[] }
const loading = ref(false);
const eventFilters = useEventFilters();
const filtersCollapsed = ref(false);
// View mode cycling
type ViewMode = 'count' | 'percent' | 'ratio';
@@ -433,6 +458,78 @@ watch(() => playersFiltered.value.length, () => {
// 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 = ['Event', 'Count', 'Round', 'GameVariant', 'PlayerUuid', 'PlayerName', '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.kind,
'1',
event.round?.toString() || '',
event.gameVariant || '',
event.playerUuid || '',
event.playerName || '',
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([
eventType,
'1',
'', // Round info not available in aggregated player data
'', // GameVariant info not available in aggregated player data
uuid,
player?.name || '',
'player-aggregated'
]);
}
}
});
});
// 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 = `eventos_${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>
@@ -568,6 +665,70 @@ const allPlayersActions = ref<{ uuid: string; name: string; total: number }[]>([
}
/* 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;
@@ -585,6 +746,18 @@ const allPlayersActions = ref<{ uuid: string; name: string; total: number }[]>([
}
}
@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;