mejoras de UI
This commit is contained in:
@@ -1,6 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="panel glass">
|
<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>
|
<div v-if="loading" class="placeholder">Cargando datos…</div>
|
||||||
|
|
||||||
<!-- Regular bars view -->
|
<!-- Regular bars view -->
|
||||||
@@ -101,6 +117,14 @@ interface Props {
|
|||||||
playerBarGradient: string;
|
playerBarGradient: string;
|
||||||
viewMode: 'count' | 'percent' | 'ratio';
|
viewMode: 'count' | 'percent' | 'ratio';
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
filtersCollapsed?: boolean;
|
||||||
|
activeFilters?: {
|
||||||
|
dataSource: string;
|
||||||
|
round: string;
|
||||||
|
game: string;
|
||||||
|
hasFilters: boolean;
|
||||||
|
selectedPlayer?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -249,11 +273,42 @@ function friendlyEventName(eventType: string): string {
|
|||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.panel-title {
|
.panel-title {
|
||||||
margin: 0 0 10px;
|
margin: 0;
|
||||||
color: #334155;
|
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 {
|
.placeholder {
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export interface DetailedEvent {
|
|||||||
kind: string;
|
kind: string;
|
||||||
round?: number;
|
round?: number;
|
||||||
gameVariant?: string;
|
gameVariant?: string;
|
||||||
|
playerUuid?: string;
|
||||||
|
playerName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DataSource = 'aggregated' | 'active-rooms';
|
export type DataSource = 'aggregated' | 'active-rooms';
|
||||||
|
|||||||
@@ -3,57 +3,73 @@
|
|||||||
<div class="header glass light">
|
<div class="header glass light">
|
||||||
<h1>📈 Leaderboard</h1>
|
<h1>📈 Leaderboard</h1>
|
||||||
<div class="actions">
|
<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="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">
|
<button class="btn toggle" @click="cycleViewMode" :disabled="loading">
|
||||||
{{ viewModeLabel }}
|
{{ viewModeLabel }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<EventFilters
|
||||||
:round-filter="eventFilters.roundFilter.value"
|
:round-filter="eventFilters.roundFilter.value"
|
||||||
:game-filter="eventFilters.gameFilter.value"
|
:game-filter="eventFilters.gameFilter.value"
|
||||||
:has-active-filters="eventFilters.hasActiveFilters.value"
|
:has-active-filters="eventFilters.hasActiveFilters.value"
|
||||||
:filter-summary="eventFilters.filterSummary.value"
|
:filter-summary="eventFilters.filterSummary.value"
|
||||||
@update:round-filter="eventFilters.roundFilter.value = $event"
|
@update:round-filter="eventFilters.roundFilter.value = $event"
|
||||||
@update:game-filter="eventFilters.gameFilter.value = $event"
|
@update:game-filter="eventFilters.gameFilter.value = $event"
|
||||||
@reset-filters="eventFilters.resetFilters"
|
@reset-filters="eventFilters.resetFilters"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="controls glass light">
|
<Transition name="filters-slide">
|
||||||
<div class="legend">
|
<div v-if="!filtersCollapsed" class="controls glass light">
|
||||||
<span class="key global"></span> Global
|
<div class="legend">
|
||||||
<span class="sep">·</span>
|
<span class="key global"></span> Global
|
||||||
<span class="key player" v-if="selectedUuid"></span> Jugador
|
<span class="sep">·</span>
|
||||||
</div>
|
<span class="key player" v-if="selectedUuid"></span> Jugador
|
||||||
<div class="player-chips">
|
</div>
|
||||||
<div class="search-controls">
|
<div class="player-chips">
|
||||||
<input class="search" v-model="search" placeholder="Buscar jugador…" />
|
<div class="search-controls">
|
||||||
<div class="pagination compact" v-if="pageCount > 1">
|
<input class="search" v-model="search" placeholder="Buscar jugador…" />
|
||||||
<button class="pg-btn compact" @click="prevPage" :disabled="page <= 1">‹</button>
|
<div class="pagination compact" v-if="pageCount > 1">
|
||||||
<span class="pg-ind">{{ page }}/{{ pageCount }}</span>
|
<button class="pg-btn compact" @click="prevPage" :disabled="page <= 1">‹</button>
|
||||||
<button class="pg-btn compact" @click="nextPage" :disabled="page >= pageCount">›</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>
|
</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>
|
||||||
</div>
|
</Transition>
|
||||||
|
|
||||||
<EventChart
|
<EventChart
|
||||||
:event-types="EVENTS"
|
:event-types="EVENTS"
|
||||||
@@ -64,6 +80,14 @@
|
|||||||
:player-bar-gradient="playerBarGradient"
|
:player-bar-gradient="playerBarGradient"
|
||||||
:view-mode="viewMode"
|
:view-mode="viewMode"
|
||||||
:loading="loading"
|
: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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -80,6 +104,7 @@ interface RoomState { players?: any[]; systemMessages?: { kind: string }[] }
|
|||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const eventFilters = useEventFilters();
|
const eventFilters = useEventFilters();
|
||||||
|
const filtersCollapsed = ref(false);
|
||||||
|
|
||||||
// View mode cycling
|
// View mode cycling
|
||||||
type ViewMode = 'count' | 'percent' | 'ratio';
|
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
|
// Removed totals table and sorting; keep actions stream for per-player counts only
|
||||||
const allPlayersActions = ref<{ uuid: string; name: string; total: number }[]>([]);
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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) {
|
@media (max-width: 768px) {
|
||||||
.search-controls {
|
.search-controls {
|
||||||
flex-direction: column;
|
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) {
|
@media (max-width: 480px) {
|
||||||
.player-chips {
|
.player-chips {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
Reference in New Issue
Block a user