Dashboard real-time updates con SSE y tabla de rooms
- Implementación de Server-Sent Events (SSE) para actualizaciones en tiempo real - Nuevo componente RoomsTable para vista de águila con animaciones de tokens - Componente RoomCard extraído para reutilización - Modal para ver detalles de rooms - SystemMessageDisplay usando AnimatedNumber creativamente - Indicador de conexión SSE con fallback a polling - Colores dinámicos de texto basados en brillo del fondo - Backend: broadcast de actualizaciones del dashboard - Backend: mensajes del sistema (excepto cambios de ronda) visibles en dashboard - Configuración de Vite para acceso desde red local
This commit is contained in:
353
client/src/components/RoomCard.vue
Normal file
353
client/src/components/RoomCard.vue
Normal file
@@ -0,0 +1,353 @@
|
||||
<template>
|
||||
<div class="room-card">
|
||||
<div class="room-header">
|
||||
<span class="room-id">Room {{ room.roomId.slice(0, 8) }}</span>
|
||||
<span class="room-status" :class="`status-${room.metadata?.gameStatus || 'waiting'}`">
|
||||
{{ room.metadata?.gameStatus || 'waiting' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="room-details">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Players:</span>
|
||||
<span class="detail-value">{{ room.clients }}/{{ room.maxClients }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Round:</span>
|
||||
<span class="detail-value">{{ room.metadata?.currentRound || 1 }}/3</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Game Variant:</span>
|
||||
<span class="detail-value">{{ room.metadata?.currentVariant || 'G1' }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Created:</span>
|
||||
<span class="detail-value">{{ formatTime(room.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="room-actions">
|
||||
<button
|
||||
v-if="room.metadata?.gameStatus === 'playing'"
|
||||
@click="$emit('pause', room.roomId)"
|
||||
class="btn btn-action btn-pause"
|
||||
>
|
||||
⏸️ Pause
|
||||
</button>
|
||||
<button
|
||||
v-if="room.metadata?.gameStatus === 'paused'"
|
||||
@click="$emit('resume', room.roomId)"
|
||||
class="btn btn-action btn-resume"
|
||||
>
|
||||
▶️ Resume
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('restart', room.roomId)"
|
||||
class="btn btn-action btn-restart"
|
||||
>
|
||||
🔄 Restart
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('viewDetails', room.roomId)"
|
||||
class="btn btn-action btn-view"
|
||||
>
|
||||
📊 Details
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="roomDetails" class="room-stats">
|
||||
<h4>Room Statistics</h4>
|
||||
<div v-if="roomDetails.players" class="players-list">
|
||||
<div v-for="player in roomDetails.players"
|
||||
:key="player.sessionId"
|
||||
class="player-row">
|
||||
<div class="player-info">
|
||||
<span class="player-name">{{ player.name }}</span>
|
||||
<span class="player-role">{{ player.role }}</span>
|
||||
</div>
|
||||
<div class="player-tokens">
|
||||
<span class="token pavo">🦃 {{ player.pavoTokens || 0 }}</span>
|
||||
<span class="token elote">🌽 {{ player.eloteTokens || 0 }}</span>
|
||||
<span class="token shame">😳 {{ player.shameTokens || 0 }}</span>
|
||||
</div>
|
||||
<button
|
||||
@click="$emit('kickPlayer', room.roomId, player.sessionId)"
|
||||
class="btn btn-kick"
|
||||
>
|
||||
Kick
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-info">
|
||||
<div class="stat-item">
|
||||
<span>Time Remaining:</span>
|
||||
<span>{{ formatSeconds(roomDetails.timeRemaining) }}</span>
|
||||
</div>
|
||||
<div v-if="roomDetails.winner" class="stat-item">
|
||||
<span>Winner:</span>
|
||||
<span class="winner-name">{{ roomDetails.winner }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Room {
|
||||
roomId: string;
|
||||
clients: number;
|
||||
maxClients: number;
|
||||
createdAt: number;
|
||||
metadata?: {
|
||||
gameStatus?: string;
|
||||
currentRound?: number;
|
||||
currentVariant?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface RoomDetails {
|
||||
players?: Array<{
|
||||
sessionId: string;
|
||||
name: string;
|
||||
role: string;
|
||||
pavoTokens?: number;
|
||||
eloteTokens?: number;
|
||||
shameTokens?: number;
|
||||
}>;
|
||||
timeRemaining: number;
|
||||
winner?: string;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
room: Room;
|
||||
roomDetails?: RoomDetails;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
pause: [roomId: string];
|
||||
resume: [roomId: string];
|
||||
restart: [roomId: string];
|
||||
viewDetails: [roomId: string];
|
||||
kickPlayer: [roomId: string, playerId: string];
|
||||
}>();
|
||||
|
||||
function formatTime(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString();
|
||||
}
|
||||
|
||||
function formatSeconds(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.room-card {
|
||||
background: white;
|
||||
color: #333;
|
||||
border-radius: 15px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.room-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.room-id {
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.room-status {
|
||||
padding: 5px 15px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-waiting {
|
||||
background: #e8f5e9;
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.status-playing {
|
||||
background: #e3f2fd;
|
||||
color: #2196f3;
|
||||
}
|
||||
|
||||
.status-paused {
|
||||
background: #fff3e0;
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
.status-finished {
|
||||
background: #f3e5f5;
|
||||
color: #9c27b0;
|
||||
}
|
||||
|
||||
.room-details {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.room-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.btn-pause {
|
||||
background: #ff9800;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-resume {
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-restart {
|
||||
background: #2196f3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-view {
|
||||
background: #9c27b0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-kick {
|
||||
background: #f44336;
|
||||
color: white;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.room-stats {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.room-stats h4 {
|
||||
margin-bottom: 15px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.players-list {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.player-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.player-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.player-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.player-role {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.player-tokens {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.token {
|
||||
background: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.token.pavo {
|
||||
color: #d84315;
|
||||
}
|
||||
|
||||
.token.elote {
|
||||
color: #f57f17;
|
||||
}
|
||||
|
||||
.token.shame {
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.stats-info {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.winner-name {
|
||||
color: #4caf50;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
203
client/src/components/RoomModal.vue
Normal file
203
client/src/components/RoomModal.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<div v-if="isOpen" class="modal-overlay" @click="closeModal">
|
||||
<div class="modal-container" @click.stop>
|
||||
<div class="modal-header">
|
||||
<h3>Room Details</h3>
|
||||
<button @click="closeModal" class="btn-close">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-content">
|
||||
<RoomCard
|
||||
v-if="room"
|
||||
:room="room"
|
||||
:room-details="roomDetails"
|
||||
@pause="handlePause"
|
||||
@resume="handleResume"
|
||||
@restart="handleRestart"
|
||||
@view-details="handleViewDetails"
|
||||
@kick-player="handleKickPlayer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import RoomCard from './RoomCard.vue';
|
||||
|
||||
interface Room {
|
||||
roomId: string;
|
||||
clients: number;
|
||||
maxClients: number;
|
||||
createdAt: number;
|
||||
metadata?: {
|
||||
gameStatus?: string;
|
||||
currentRound?: number;
|
||||
currentVariant?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface RoomDetails {
|
||||
players?: Array<{
|
||||
sessionId: string;
|
||||
name: string;
|
||||
role: string;
|
||||
pavoTokens?: number;
|
||||
eloteTokens?: number;
|
||||
shameTokens?: number;
|
||||
}>;
|
||||
timeRemaining: number;
|
||||
winner?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
isOpen: boolean;
|
||||
room?: Room;
|
||||
roomDetails?: RoomDetails;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
pause: [roomId: string];
|
||||
resume: [roomId: string];
|
||||
restart: [roomId: string];
|
||||
viewDetails: [roomId: string];
|
||||
kickPlayer: [roomId: string, playerId: string];
|
||||
}>();
|
||||
|
||||
function closeModal() {
|
||||
emit('close');
|
||||
}
|
||||
|
||||
function handlePause(roomId: string) {
|
||||
emit('pause', roomId);
|
||||
}
|
||||
|
||||
function handleResume(roomId: string) {
|
||||
emit('resume', roomId);
|
||||
}
|
||||
|
||||
function handleRestart(roomId: string) {
|
||||
emit('restart', roomId);
|
||||
}
|
||||
|
||||
function handleViewDetails(roomId: string) {
|
||||
emit('viewDetails', roomId);
|
||||
}
|
||||
|
||||
function handleKickPlayer(roomId: string, playerId: string) {
|
||||
emit('kickPlayer', roomId, playerId);
|
||||
}
|
||||
|
||||
// Close modal on Escape key
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && props.isOpen) {
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
animation: modalSlideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes modalSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-30px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
background: #f8f9fa;
|
||||
border-radius: 16px 16px 0 0;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-close:hover {
|
||||
background: #e0e0e0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Override RoomCard styles for modal context */
|
||||
.modal-content :deep(.room-card) {
|
||||
box-shadow: none;
|
||||
border-radius: 0 0 16px 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.modal-overlay {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
max-height: 95vh;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
467
client/src/components/RoomsTable.vue
Normal file
467
client/src/components/RoomsTable.vue
Normal file
@@ -0,0 +1,467 @@
|
||||
<template>
|
||||
<div class="rooms-table-container">
|
||||
<div class="table-header">
|
||||
<h3>🎮 Game Rooms Overview</h3>
|
||||
<div class="table-controls">
|
||||
<button @click="$emit('refresh')" class="btn btn-refresh">
|
||||
🔄 Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="rooms.length === 0" class="no-rooms">
|
||||
No active game rooms
|
||||
</div>
|
||||
|
||||
<div v-else class="table-wrapper">
|
||||
<table class="rooms-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Room ID</th>
|
||||
<th>Status</th>
|
||||
<th>Players</th>
|
||||
<th>Round</th>
|
||||
<th>Variant</th>
|
||||
<th>P1 Tokens</th>
|
||||
<th>P2 Tokens</th>
|
||||
<th>System Message</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="room in rooms" :key="room.roomId" class="room-row">
|
||||
<td class="room-id">
|
||||
<code>{{ room.roomId.slice(0, 8) }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-badge" :class="`status-${room.metadata?.gameStatus || 'waiting'}`">
|
||||
{{ room.metadata?.gameStatus || 'waiting' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="players-count">
|
||||
{{ room.clients }}/{{ room.maxClients }}
|
||||
</td>
|
||||
<td class="round-info">
|
||||
{{ room.metadata?.currentRound || 1 }}/3
|
||||
</td>
|
||||
<td class="variant-info">
|
||||
<span class="variant-badge">{{ room.metadata?.currentVariant || 'G1' }}</span>
|
||||
</td>
|
||||
<td class="tokens-cell">
|
||||
<div v-if="getRoomDetails(room.roomId)?.players?.[0]" class="player-section">
|
||||
<div class="player-name-chip" :style="{
|
||||
backgroundColor: getPlayerColor(getRoomDetails(room.roomId).players[0], 0),
|
||||
color: getReadableTextColor(getPlayerColor(getRoomDetails(room.roomId).players[0], 0))
|
||||
}">
|
||||
{{ getRoomDetails(room.roomId).players[0].name }}
|
||||
</div>
|
||||
<div class="token-summary">
|
||||
<span class="token pavo">🦃 <AnimatedNumber :value="getRoomDetails(room.roomId).players[0].pavoTokens || 0" :duration-ms="800" /></span>
|
||||
<span class="token elote">🌽 <AnimatedNumber :value="getRoomDetails(room.roomId).players[0].eloteTokens || 0" :duration-ms="800" /></span>
|
||||
<span class="token shame">😳 <AnimatedNumber :value="getRoomDetails(room.roomId).players[0].shameTokens || 0" :duration-ms="800" /></span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-data">-</div>
|
||||
</td>
|
||||
<td class="tokens-cell">
|
||||
<div v-if="getRoomDetails(room.roomId)?.players?.[1]" class="player-section">
|
||||
<div class="player-name-chip" :style="{
|
||||
backgroundColor: getPlayerColor(getRoomDetails(room.roomId).players[1], 1),
|
||||
color: getReadableTextColor(getPlayerColor(getRoomDetails(room.roomId).players[1], 1))
|
||||
}">
|
||||
{{ getRoomDetails(room.roomId).players[1].name }}
|
||||
</div>
|
||||
<div class="token-summary">
|
||||
<span class="token pavo">🦃 <AnimatedNumber :value="getRoomDetails(room.roomId).players[1].pavoTokens || 0" :duration-ms="800" /></span>
|
||||
<span class="token elote">🌽 <AnimatedNumber :value="getRoomDetails(room.roomId).players[1].eloteTokens || 0" :duration-ms="800" /></span>
|
||||
<span class="token shame">😳 <AnimatedNumber :value="getRoomDetails(room.roomId).players[1].shameTokens || 0" :duration-ms="800" /></span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-data">-</div>
|
||||
</td>
|
||||
<td class="system-message-cell">
|
||||
<SystemMessageDisplay
|
||||
:message="getRoomDetails(room.roomId)?.recentSystemMessage"
|
||||
/>
|
||||
</td>
|
||||
<td class="created-time">
|
||||
{{ formatTime(room.createdAt) }}
|
||||
</td>
|
||||
<td class="actions-cell">
|
||||
<button
|
||||
@click="$emit('viewRoomModal', room.roomId)"
|
||||
class="btn btn-details"
|
||||
title="View detailed card"
|
||||
>
|
||||
📊 Details
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AnimatedNumber from '../views/games/AnimatedNumber.vue';
|
||||
import SystemMessageDisplay from './SystemMessageDisplay.vue';
|
||||
interface Room {
|
||||
roomId: string;
|
||||
clients: number;
|
||||
maxClients: number;
|
||||
createdAt: number;
|
||||
metadata?: {
|
||||
gameStatus?: string;
|
||||
currentRound?: number;
|
||||
currentVariant?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface RoomDetails {
|
||||
players?: Array<{
|
||||
sessionId: string;
|
||||
name: string;
|
||||
role: string;
|
||||
pavoTokens?: number;
|
||||
eloteTokens?: number;
|
||||
shameTokens?: number;
|
||||
}>;
|
||||
timeRemaining: number;
|
||||
winner?: string;
|
||||
recentSystemMessage?: {
|
||||
text: string;
|
||||
kind: string;
|
||||
timestamp: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
rooms: Room[];
|
||||
roomDetails: { [key: string]: RoomDetails };
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
refresh: [];
|
||||
viewRoomModal: [roomId: string];
|
||||
}>();
|
||||
|
||||
function getRoomDetails(roomId: string) {
|
||||
return props.roomDetails[roomId];
|
||||
}
|
||||
|
||||
function formatTime(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function getPlayerColor(player: any, playerIndex: number): string {
|
||||
// Use player's actual color if available
|
||||
if (player?.color) {
|
||||
return player.color;
|
||||
}
|
||||
|
||||
// Fallback colors for P1 and P2
|
||||
const fallbackColors = ['#667eea', '#f093fb'];
|
||||
return fallbackColors[playerIndex] || '#999';
|
||||
}
|
||||
|
||||
function getReadableTextColor(hex?: string): string {
|
||||
const c = (hex || '').trim();
|
||||
const m = c.match(/^#?([a-fA-F0-9]{6})$/);
|
||||
let r=102, g=126, b=234; // fallback to #667eea
|
||||
if (m) {
|
||||
const int = parseInt(m[1], 16);
|
||||
r = (int >> 16) & 255; g = (int >> 8) & 255; b = int & 255;
|
||||
}
|
||||
// Perceived brightness formula
|
||||
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
|
||||
return yiq >= 140 ? '#111111' : '#ffffff';
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rooms-table-container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.table-header h3 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.table-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-refresh {
|
||||
background: #f0f0f0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.btn-refresh:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
.btn-details {
|
||||
background: #9c27b0;
|
||||
color: white;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-details:hover {
|
||||
background: #7b1fa2;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.no-rooms {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.rooms-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.rooms-table th {
|
||||
background: #f8f9fa;
|
||||
padding: 12px 8px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rooms-table td {
|
||||
padding: 12px 8px;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
vertical-align: middle;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.room-row:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.room-id code {
|
||||
background: #e9ecef;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-waiting {
|
||||
background: #e8f5e9;
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.status-playing {
|
||||
background: #e3f2fd;
|
||||
color: #2196f3;
|
||||
}
|
||||
|
||||
.status-paused {
|
||||
background: #fff3e0;
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
.status-finished {
|
||||
background: #f3e5f5;
|
||||
color: #9c27b0;
|
||||
}
|
||||
|
||||
.players-count {
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.round-info {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.variant-badge {
|
||||
background: #e1f5fe;
|
||||
color: #0277bd;
|
||||
padding: 2px 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tokens-cell {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.player-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.player-name-chip {
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.token-summary {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.token {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: #f8f9fa;
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border: 1px solid #e9ecef;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.token :deep(.anim-number) {
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
height: auto;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.token :deep(.viewport) {
|
||||
height: auto;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.token :deep(.val) {
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.token.pavo {
|
||||
color: #d84315;
|
||||
}
|
||||
|
||||
.token.elote {
|
||||
color: #f57f17;
|
||||
}
|
||||
|
||||
.token.shame {
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.created-time {
|
||||
color: #555;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.system-message-cell {
|
||||
min-width: 160px;
|
||||
max-width: 200px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 1200px) {
|
||||
.rooms-table {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rooms-table th,
|
||||
.rooms-table td {
|
||||
padding: 8px 6px;
|
||||
}
|
||||
|
||||
.token-summary {
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.token {
|
||||
font-size: 10px;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
.token :deep(.anim-number) {
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.system-message-cell {
|
||||
min-width: 120px;
|
||||
max-width: 150px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
150
client/src/components/SystemMessageDisplay.vue
Normal file
150
client/src/components/SystemMessageDisplay.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div class="system-message-container">
|
||||
<AnimatedNumber
|
||||
:value="messageCounter"
|
||||
:duration-ms="600"
|
||||
@value-change="onMessageChange"
|
||||
/>
|
||||
<div v-if="currentMessage" :class="['system-message', `kind-${currentMessage.kind}`]">
|
||||
{{ currentMessage.text }}
|
||||
</div>
|
||||
<div v-else class="system-message no-message">
|
||||
Waiting for activity...
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import AnimatedNumber from '../views/games/AnimatedNumber.vue';
|
||||
|
||||
interface SystemMessage {
|
||||
text: string;
|
||||
kind: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
message?: SystemMessage | null;
|
||||
}>();
|
||||
|
||||
// Use a counter that increments when message changes to trigger animation
|
||||
const messageCounter = ref(0);
|
||||
const currentMessage = ref<SystemMessage | null>(null);
|
||||
|
||||
// Watch for message changes
|
||||
watch(() => props.message, (newMessage) => {
|
||||
if (newMessage && newMessage.timestamp !== currentMessage.value?.timestamp) {
|
||||
currentMessage.value = newMessage;
|
||||
messageCounter.value++; // This triggers the AnimatedNumber animation
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
function onMessageChange() {
|
||||
// This fires when the AnimatedNumber animation completes
|
||||
// Could add additional effects here
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.system-message-container {
|
||||
position: relative;
|
||||
min-height: 24px;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.system-message-container :deep(.anim-number) {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.system-message {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #495057;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
animation: messageSlideIn 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes messageSlideIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px) scale(0.95);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Different styles for different message kinds */
|
||||
.kind-p2_accept {
|
||||
background: linear-gradient(135deg, #d4edda 0%, #c3e6cb 100%);
|
||||
border-color: #28a745;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.kind-p2_reject {
|
||||
background: linear-gradient(135deg, #fff3cd 0%, #ffeaa7 100%);
|
||||
border-color: #ffc107;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.kind-p2_snatch {
|
||||
background: linear-gradient(135deg, #f8d7da 0%, #f5c6cb 100%);
|
||||
border-color: #dc3545;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.kind-p1_propose {
|
||||
background: linear-gradient(135deg, #cce5ff 0%, #b3d9ff 100%);
|
||||
border-color: #007bff;
|
||||
color: #004085;
|
||||
}
|
||||
|
||||
.kind-p1_no_offer {
|
||||
background: linear-gradient(135deg, #e2e3e5 0%, #d1ecf1 100%);
|
||||
border-color: #6c757d;
|
||||
color: #383d41;
|
||||
}
|
||||
|
||||
.kind-p1_shame {
|
||||
background: linear-gradient(135deg, #fff3cd 0%, #ffeaa7 100%);
|
||||
border-color: #ffc107;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.kind-p1_report {
|
||||
background: linear-gradient(135deg, #e7e3ff 0%, #d1c4e9 100%);
|
||||
border-color: #9c27b0;
|
||||
color: #4a148c;
|
||||
}
|
||||
|
||||
.kind-variant_change {
|
||||
background: linear-gradient(135deg, #e1f5fe 0%, #b3e5fc 100%);
|
||||
border-color: #03a9f4;
|
||||
color: #01579b;
|
||||
}
|
||||
|
||||
.kind-round_advance {
|
||||
background: linear-gradient(135deg, #e8f5e8 0%, #c8e6c9 100%);
|
||||
border-color: #4caf50;
|
||||
color: #1b5e20;
|
||||
}
|
||||
|
||||
.no-message {
|
||||
background: #f8f9fa;
|
||||
border-color: #dee2e6;
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user