feat: mejoras al dashboard y selector de UUID

- Dashboard: agregar opciones de 50 pestañas deterministas (lotes 1-4)
- Dashboard: botón para cerrar salas individuales y expulsar jugadores
- Dashboard: selector de variante G por sala individual en tabla
- Nuevo selector moderno de UUID en página principal
- Mostrar nombres de jugadores en selector de UUID
- Búsqueda por UUID o nombre de jugador
- Redireccionar /missing-uuid a selector principal
- Endpoints para obtener UUIDs con nombres y cerrar/cambiar variante de salas
This commit is contained in:
2025-08-15 22:56:07 -06:00
parent f214174bab
commit ace9f8ee50
5 changed files with 581 additions and 12 deletions

View File

@@ -96,6 +96,27 @@
> >
📊 Details 📊 Details
</button> </button>
<div class="variant-selector-container">
<select
:value="room.metadata?.currentVariant || 'G1'"
@change="$emit('changeVariant', room.roomId, ($event.target as HTMLSelectElement).value)"
class="variant-select"
title="Cambiar variante"
>
<option value="G1">G1</option>
<option value="G2">G2</option>
<option value="G3">G3</option>
<option value="G4">G4</option>
<option value="G5">G5</option>
</select>
</div>
<button
@click="$emit('closeRoom', room.roomId)"
class="btn btn-close"
title="Expulsar jugadores y cerrar sala"
>
🚪 Cerrar
</button>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -145,6 +166,8 @@ const props = defineProps<{
defineEmits<{ defineEmits<{
refresh: []; refresh: [];
viewRoomModal: [roomId: string]; viewRoomModal: [roomId: string];
closeRoom: [roomId: string];
changeVariant: [roomId: string, variant: string];
}>(); }>();
function getRoomDetails(roomId: string) { function getRoomDetails(roomId: string) {
@@ -243,6 +266,47 @@ function getReadableTextColor(hex?: string): string {
transform: translateY(-1px); transform: translateY(-1px);
} }
.btn-close {
background: #f44336;
color: white;
padding: 6px 12px;
font-size: 12px;
margin-left: 4px;
}
.btn-close:hover {
background: #d32f2f;
transform: translateY(-1px);
}
.variant-selector-container {
display: inline-block;
margin: 0 4px;
}
.variant-select {
padding: 4px 8px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
font-size: 12px;
font-weight: 600;
color: #333;
cursor: pointer;
transition: all 0.2s;
}
.variant-select:hover {
border-color: #9c27b0;
background: #f8f9fa;
}
.variant-select:focus {
outline: none;
border-color: #9c27b0;
box-shadow: 0 0 0 2px rgba(156, 39, 176, 0.2);
}
.no-rooms { .no-rooms {
text-align: center; text-align: center;
padding: 40px; padding: 40px;

View File

@@ -3,6 +3,7 @@ import Lobby from '../views/Lobby.vue';
import Game from '../views/Game.vue'; import Game from '../views/Game.vue';
import Dashboard from '../views/Dashboard.vue'; import Dashboard from '../views/Dashboard.vue';
import DemoGame from '../views/DemoGame.vue'; import DemoGame from '../views/DemoGame.vue';
import UuidSelector from '../views/UuidSelector.vue';
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
@@ -29,14 +30,13 @@ const router = createRouter({
}, },
{ {
path: '/', path: '/',
redirect: '/missing-uuid' name: 'UuidSelector',
component: UuidSelector
}, },
{ {
// simple fallback for users hitting root without UUID // Redirect old missing-uuid path to the new selector
path: '/missing-uuid', path: '/missing-uuid',
component: { redirect: '/'
template: `<div style="padding:20px;font-family:sans-serif"><h2>Falta UUID</h2><p>Abre el juego escaneando tu código QR: snatchgame.nucleoriofrio.com/{uuid}</p></div>`
}
} }
] ]
}); });

View File

@@ -37,6 +37,10 @@
<option :value="2">2</option> <option :value="2">2</option>
<option :value="6">6</option> <option :value="6">6</option>
<option :value="10">10</option> <option :value="10">10</option>
<option value="50-1">50 (Lote 1: UUIDs 1-50)</option>
<option value="50-2">50 (Lote 2: UUIDs 51-100)</option>
<option value="50-3">50 (Lote 3: UUIDs 101-150)</option>
<option value="50-4">50 (Lote 4: UUIDs 151-200)</option>
</select> </select>
<template v-if="openCount === 1"> <template v-if="openCount === 1">
<label>UUID:</label> <label>UUID:</label>
@@ -154,6 +158,8 @@
:room-details="roomDetails" :room-details="roomDetails"
@refresh="fetchData" @refresh="fetchData"
@view-room-modal="openRoomModal" @view-room-modal="openRoomModal"
@close-room="closeRoom"
@change-variant="changeRoomVariant"
/> />
<!-- Cards View --> <!-- Cards View -->
@@ -238,7 +244,7 @@ const isLoadingGlobal = ref(false);
// Open tabs UI state // Open tabs UI state
const allowedUuids = ref<string[]>([]); const allowedUuids = ref<string[]>([]);
const openCount = ref<1|2|6|10>(1); const openCount = ref<1|2|6|10|'50-1'|'50-2'|'50-3'|'50-4'>(1);
const selectedUuid = ref(''); const selectedUuid = ref('');
const gameRooms = computed(() => rooms.value.filter(r => r.name === 'game')); const gameRooms = computed(() => rooms.value.filter(r => r.name === 'game'));
@@ -248,14 +254,17 @@ const totalPlayers = computed(() => rooms.value.reduce((sum, room) => sum + room
onMounted(async () => { onMounted(async () => {
// Try SSE first, fallback to polling if it fails // Try SSE first, fallback to polling if it fails
initSSE(); initSSE();
// Load allowed UUIDs // Load allowed UUIDs from API
try { try {
const list = await colyseusService.fetchAllowedUuids(); const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3000/api'}/admin/uuids`);
allowedUuids.value = list || []; const data = await response.json();
allowedUuids.value = data.uuids || [];
if (!selectedUuid.value && allowedUuids.value.length > 0) { if (!selectedUuid.value && allowedUuids.value.length > 0) {
selectedUuid.value = allowedUuids.value[0]; selectedUuid.value = allowedUuids.value[0];
} }
} catch {} } catch {
console.error('Failed to load UUIDs from API');
}
}); });
onUnmounted(() => { onUnmounted(() => {
@@ -329,6 +338,43 @@ async function kickPlayer(roomId: string, playerId: string) {
} }
} }
async function closeRoom(roomId: string) {
if (!confirm('¿Estás seguro de que quieres expulsar a todos los jugadores y cerrar esta sala?')) return;
try {
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3000/api'}/rooms/${roomId}/close`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) throw new Error('Failed to close room');
console.log(`Room ${roomId} closed successfully`);
await fetchData();
} catch (error) {
console.error('Failed to close room:', error);
alert('Failed to close room. Check console for details.');
}
}
async function changeRoomVariant(roomId: string, variant: string) {
try {
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3000/api'}/rooms/${roomId}/variant`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ variant })
});
if (!response.ok) throw new Error('Failed to change room variant');
console.log(`Room ${roomId} variant changed to ${variant} successfully`);
await fetchData();
} catch (error) {
console.error('Failed to change room variant:', error);
alert('Failed to change room variant. Check console for details.');
}
}
function refreshData() { function refreshData() {
fetchData(); fetchData();
@@ -351,10 +397,24 @@ function openTabs() {
return; return;
} }
// Randomly select N distinct UUIDs
const N = openCount.value as number;
const pool = [...allowedUuids.value]; const pool = [...allowedUuids.value];
if (pool.length === 0) return; if (pool.length === 0) return;
// Handle deterministic 50-UUID batches
if (typeof openCount.value === 'string' && openCount.value.startsWith('50-')) {
const batchNumber = parseInt(openCount.value.split('-')[1]);
const startIndex = (batchNumber - 1) * 50;
const endIndex = startIndex + 50;
const batchUuids = pool.slice(startIndex, Math.min(endIndex, pool.length));
batchUuids.forEach(u => openOne(u));
return;
}
// For numeric counts, randomly select N distinct UUIDs
const N = openCount.value as number;
// For other counts, use distinct UUIDs
for (let i = pool.length - 1; i > 0; i--) { for (let i = pool.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1)); const j = Math.floor(Math.random() * (i + 1));
[pool[i], pool[j]] = [pool[j], pool[i]]; [pool[i], pool[j]] = [pool[j], pool[i]];

View File

@@ -0,0 +1,381 @@
<template>
<div class="uuid-selector-container">
<div class="selector-card">
<div class="header">
<h1 class="title">🎮 Snatch Game</h1>
<p class="subtitle">Selecciona tu UUID para continuar</p>
</div>
<div class="search-container">
<input
v-model="searchQuery"
type="text"
placeholder="Buscar UUID o nombre..."
class="search-input"
@input="filterUuids"
/>
<span class="search-icon">🔍</span>
</div>
<div class="uuids-grid" v-if="!loading">
<div
v-for="(uuidInfo, index) in filteredUuids"
:key="uuidInfo.uuid"
class="uuid-card"
:class="{ 'has-name': uuidInfo.hasName }"
@click="selectUuid(uuidInfo.uuid)"
:style="{ animationDelay: `${index * 0.02}s` }"
>
<div class="uuid-number">{{ getUuidIndex(uuidInfo.uuid) }}</div>
<div v-if="uuidInfo.name" class="player-name">{{ uuidInfo.name }}</div>
<div class="uuid-text">{{ formatUuid(uuidInfo.uuid) }}</div>
</div>
</div>
<div v-else class="loading">
<div class="spinner"></div>
<p>Cargando UUIDs...</p>
</div>
<div class="quick-actions">
<button @click="selectRandom" class="btn-random">
🎲 Seleccionar Aleatorio
</button>
<button @click="goToDashboard" class="btn-dashboard">
🎛 Dashboard Admin
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
interface UuidInfo {
uuid: string;
name: string | null;
hasName: boolean;
}
const router = useRouter();
const loading = ref(true);
const allUuids = ref<UuidInfo[]>([]);
const searchQuery = ref('');
const filteredUuids = ref<UuidInfo[]>([]);
onMounted(async () => {
try {
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3000/api'}/admin/uuids-with-names`);
const data = await response.json();
allUuids.value = data.uuids || [];
filteredUuids.value = allUuids.value; // Show all UUIDs
} catch (error) {
console.error('Failed to load UUIDs:', error);
allUuids.value = [];
} finally {
loading.value = false;
}
});
function filterUuids() {
const query = searchQuery.value.toLowerCase();
if (!query) {
filteredUuids.value = allUuids.value; // Show all when no search
return;
}
filteredUuids.value = allUuids.value.filter(uuidInfo =>
uuidInfo.uuid.toLowerCase().includes(query) ||
(uuidInfo.name && uuidInfo.name.toLowerCase().includes(query)) ||
getUuidIndex(uuidInfo.uuid).toString().includes(query)
);
}
function getUuidIndex(uuid: string): number {
return allUuids.value.findIndex(u => u.uuid === uuid) + 1;
}
function formatUuid(uuid: string): string {
// Show first 8 chars for better readability
return uuid.substring(0, 8) + '...';
}
function selectUuid(uuid: string) {
router.push(`/${uuid}`);
}
function selectRandom() {
if (allUuids.value.length > 0) {
const randomUuidInfo = allUuids.value[Math.floor(Math.random() * allUuids.value.length)];
selectUuid(randomUuidInfo.uuid);
}
}
function goToDashboard() {
router.push('/dashboard');
}
</script>
<style scoped>
.uuid-selector-container {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.selector-card {
background: white;
border-radius: 20px;
padding: 40px;
max-width: 1200px;
width: 100%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
animation: slideUp 0.5s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.header {
text-align: center;
margin-bottom: 30px;
}
.title {
font-size: 3rem;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
font-weight: bold;
}
.subtitle {
color: #666;
font-size: 1.2rem;
margin-top: 10px;
}
.search-container {
position: relative;
margin-bottom: 30px;
}
.search-input {
width: 100%;
padding: 15px 50px 15px 20px;
font-size: 16px;
border: 2px solid #e0e0e0;
border-radius: 12px;
transition: all 0.3s;
outline: none;
}
.search-input:focus {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.search-icon {
position: absolute;
right: 20px;
top: 50%;
transform: translateY(-50%);
font-size: 20px;
opacity: 0.6;
}
.uuids-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 30px;
max-height: 500px;
overflow-y: auto;
padding: 10px;
}
.uuids-grid::-webkit-scrollbar {
width: 8px;
}
.uuids-grid::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 10px;
}
.uuids-grid::-webkit-scrollbar-thumb {
background: #888;
border-radius: 10px;
}
.uuids-grid::-webkit-scrollbar-thumb:hover {
background: #555;
}
.uuid-card {
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
padding: 20px;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s;
animation: fadeIn 0.5s ease-out backwards;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
.uuid-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.15);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.uuid-card:hover .uuid-number,
.uuid-card:hover .uuid-text,
.uuid-card:hover .player-name {
color: white;
}
.uuid-card.has-name {
background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
border: 2px solid rgba(102, 126, 234, 0.3);
}
.uuid-card.has-name:hover {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-color: transparent;
}
.uuid-number {
font-size: 24px;
font-weight: bold;
color: #667eea;
transition: color 0.3s;
}
.player-name {
font-size: 16px;
font-weight: 600;
color: #333;
transition: color 0.3s;
padding: 4px 8px;
background: rgba(255, 255, 255, 0.5);
border-radius: 8px;
margin: 4px 0;
}
.uuid-text {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 12px;
color: #666;
transition: color 0.3s;
}
.loading {
text-align: center;
padding: 60px;
}
.spinner {
width: 50px;
height: 50px;
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.quick-actions {
display: flex;
gap: 15px;
justify-content: center;
}
.btn-random,
.btn-dashboard {
padding: 12px 30px;
border: none;
border-radius: 10px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.btn-random {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
}
.btn-random:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(240, 147, 251, 0.4);
}
.btn-dashboard {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
}
.btn-dashboard:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(79, 172, 254, 0.4);
}
/* Responsive */
@media (max-width: 768px) {
.selector-card {
padding: 20px;
}
.title {
font-size: 2rem;
}
.uuids-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
.quick-actions {
flex-direction: column;
}
.btn-random,
.btn-dashboard {
width: 100%;
}
}
</style>

View File

@@ -114,6 +114,48 @@ adminRouter.post("/rooms/:roomId/kick/:playerId", async (req: Request, res: Resp
} }
}); });
adminRouter.post("/rooms/:roomId/close", async (req: Request, res: Response) => {
try {
const { roomId } = req.params;
const rooms = await matchMaker.query({ roomId });
if (rooms.length === 0) {
return res.status(404).json({ error: "Room not found" });
}
await matchMaker.remoteRoomCall(roomId, "executeAdminCommand", ["sendToLobby"]);
res.json({ success: true, message: `Room ${roomId} closed and players sent to lobby` });
} catch (error) {
console.error(`[AdminAPI] Error closing room ${req.params.roomId}:`, error);
res.status(500).json({ error: "Failed to close room" });
}
});
adminRouter.post("/rooms/:roomId/variant", async (req: Request, res: Response) => {
try {
const { roomId } = req.params;
const { variant } = req.body;
if (!variant || !['G1', 'G2', 'G3', 'G4', 'G5'].includes(variant)) {
return res.status(400).json({ error: "Invalid variant. Must be one of: G1, G2, G3, G4, G5" });
}
const rooms = await matchMaker.query({ roomId });
if (rooms.length === 0) {
return res.status(404).json({ error: "Room not found" });
}
await matchMaker.remoteRoomCall(roomId, "executeAdminCommand", ["setVariant", variant]);
res.json({ success: true, message: `Room ${roomId} variant changed to ${variant}` });
} catch (error) {
console.error(`[AdminAPI] Error changing variant for room ${req.params.roomId}:`, error);
res.status(500).json({ error: "Failed to change room variant" });
}
});
adminRouter.get("/stats", async (req: Request, res: Response) => { adminRouter.get("/stats", async (req: Request, res: Response) => {
try { try {
const stats = await matchMaker.stats.fetchAll(); const stats = await matchMaker.stats.fetchAll();
@@ -461,6 +503,28 @@ adminRouter.get("/admin/uuids", async (req: Request, res: Response) => {
} }
}); });
// UUID with names endpoint
adminRouter.get("/admin/uuids-with-names", async (req: Request, res: Response) => {
try {
const uuids = listAllowedUuids();
const nameManager = NameManager.getInstance();
const uuidsWithInfo = uuids.map(uuid => {
const name = nameManager.getPlayerName(uuid);
return {
uuid,
name: name || null,
hasName: !!name
};
});
res.json({ uuids: uuidsWithInfo });
} catch (error) {
console.error("[AdminAPI] Error fetching UUIDs with names:", error);
res.status(500).json({ error: "Failed to fetch UUIDs with names" });
}
});
// SSE endpoint for real-time dashboard updates // SSE endpoint for real-time dashboard updates
adminRouter.get("/dashboard-stream", (req: Request, res: Response) => { adminRouter.get("/dashboard-stream", (req: Request, res: Response) => {
// Set SSE headers // Set SSE headers