Files
spotify-cantina/app/templates/admin/voting.html
T
deivid b4cc8770a7 reproduce ganador en dispositivo seleccionado si lo permite
- play_winner acepta device_id del formulario y verifica que el
  dispositivo exista y no sea restringido antes de reproducir
- corrige construcción del URI según spotify_type del ganador
  (antes siempre usaba spotify:playlist: independiente del tipo)
- errores se muestran en la página admin en lugar de HTTPException cruda
- template sincroniza device_id activo al campo oculto del formulario
- dispositivos restringidos se marcan en el selector del admin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 19:29:29 -04:00

236 lines
9.7 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}Votación — Admin{% endblock %}
{% block content %}
<div class="admin-container">
<div class="admin-header">
<h1>Control de Votación</h1>
<div style="display:flex;gap:.5rem">
<a href="/admin/playlists" class="btn-sm">Playlists</a>
<a href="/auth/logout" class="btn-sm btn-danger"
onclick="return confirm('¿Desconectar la cuenta de Spotify?')">⏏ Cuenta Spotify</a>
<a href="/admin/logout" class="btn-sm btn-danger">Cerrar sesión</a>
</div>
</div>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
{% if success %}
<div class="alert alert-success">{{ success }}</div>
{% endif %}
<!-- Configuración de rango horario -->
<div class="card">
<h2>Configurar votación</h2>
<form method="post" action="/admin/voting" class="voting-form">
<div class="time-row">
<div class="time-field">
<label>Hora de inicio</label>
<input type="time" name="start_time" class="input"
value="{{ config.start_time }}" required>
</div>
<div class="time-sep"></div>
<div class="time-field">
<label>Hora de fin</label>
<input type="time" name="end_time" class="input"
value="{{ config.end_time }}" required>
</div>
</div>
<label class="toggle-label">
<input type="checkbox" name="is_active"
{% if config.is_active %}checked{% endif %}>
<span class="toggle-text">Votación activa</span>
<span class="toggle-hint">
{% if config.is_active %}
Los usuarios pueden votar dentro del rango horario
{% else %}
La votación está desactivada
{% endif %}
</span>
</label>
<button type="submit" class="btn-primary">Guardar configuración</button>
</form>
<div class="status-row">
<div class="status-badge {% if config.is_active %}status-active{% else %}status-inactive{% endif %}">
{% if config.is_active %}
🟢 Activa · Ventana: {{ config.start_time }} {{ config.end_time }}
{% else %}
🔴 Inactiva
{% endif %}
</div>
<div class="server-time">🕐 Hora del servidor: <strong>{{ server_time }}</strong></div>
</div>
</div>
<!-- Selector de dispositivo -->
<div class="card">
<h2>Dispositivo de reproducción</h2>
<div class="device-row">
<select id="device-select" class="input" onchange="setDevice(this.value)" style="flex:1">
<option value="">Cargando dispositivos...</option>
</select>
<button class="btn-sm" onclick="loadDevices()">↻ Actualizar</button>
</div>
<div id="device-msg" style="font-size:.8rem;color:var(--text-muted);margin-top:.4rem"></div>
</div>
<!-- Resultados -->
<div class="card">
<div class="results-header">
<h2>Resultados ({{ total_votes }} votos)</h2>
<div style="display:flex;gap:.5rem;align-items:center">
{% if results and results[0].votes > 0 %}
<form method="post" action="/admin/voting/play-winner">
<input type="hidden" id="play-winner-device-id" name="device_id" value="">
<button type="submit" class="btn-primary btn-sm">
▶ Reproducir ganador
</button>
</form>
{% endif %}
<form method="post" action="/admin/voting/reset"
onsubmit="return confirm('¿Reiniciar todos los votos?')">
<button type="submit" class="btn-sm btn-danger">Reiniciar votos</button>
</form>
</div>
</div>
{% if results %}
<div class="results-list">
{% for item in results %}
{% set pct = (item.votes / total_votes * 100) | round(1) if total_votes > 0 else 0 %}
<div class="result-row {% if loop.first and item.votes > 0 %}result-winner{% endif %}">
<div class="result-rank">{{ loop.index }}</div>
{% if item.playlist.image_url %}
<img src="{{ item.playlist.image_url }}" alt="" class="table-thumb">
{% else %}
<div class="table-thumb-placeholder">🎵</div>
{% endif %}
<div class="result-info">
<div class="result-name">
{{ item.playlist.name }}
{% if loop.first and item.votes > 0 %}
<span class="winner-badge">🏆 Ganador</span>
{% endif %}
</div>
<div class="result-bar-wrap">
<div class="result-bar" style="width: {{ pct }}%"></div>
</div>
</div>
<div class="result-count">
<strong>{{ item.votes }}</strong>
<span class="pct-label">{{ pct }}%</span>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="empty-msg">No hay playlists configuradas.</p>
{% endif %}
</div>
</div>
<script>
function _syncWinnerDevice(deviceId) {
const input = document.getElementById('play-winner-device-id');
if (input) input.value = deviceId || '';
}
async function loadDevices() {
const sel = document.getElementById('device-select');
const msg = document.getElementById('device-msg');
sel.innerHTML = '<option value="">Cargando...</option>';
msg.textContent = '';
try {
const res = await fetch('/player/devices');
const devices = await res.json();
if (!devices.length) {
sel.innerHTML = '<option value="">Sin dispositivos activos</option>';
_syncWinnerDevice('');
return;
}
sel.innerHTML = devices.map(d =>
`<option value="${d.id}"${d.is_active ? ' selected' : ''}>${d.name} (${d.type})${d.is_restricted ? ' — sin control remoto' : ''}</option>`
).join('');
const active = devices.find(d => d.is_active);
_syncWinnerDevice(active ? active.id : devices[0].id);
} catch (_) {
sel.innerHTML = '<option value="">Error al cargar dispositivos</option>';
_syncWinnerDevice('');
}
}
async function setDevice(deviceId) {
if (!deviceId) return;
const msg = document.getElementById('device-msg');
_syncWinnerDevice(deviceId);
try {
await fetch('/player/device', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_id: deviceId }),
});
msg.textContent = 'Dispositivo actualizado.';
setTimeout(() => msg.textContent = '', 2500);
} catch (_) {
msg.textContent = 'Error al cambiar dispositivo.';
}
}
loadDevices();
</script>
<style>
.device-row { display: flex; align-items: center; gap: .5rem; }
.voting-form { display: flex; flex-direction: column; gap: 1rem; }
.time-row { display: flex; align-items: flex-end; gap: 1rem; }
.time-field { display: flex; flex-direction: column; gap: .3rem; flex: 1; }
.time-field label { font-size: .8rem; color: var(--text-muted); }
.time-sep { padding-bottom: .6rem; color: var(--text-muted); }
.toggle-label { display: flex; align-items: center; gap: .6rem; cursor: pointer; }
.toggle-label input[type=checkbox] { width: 16px; height: 16px; accent-color: var(--green); }
.toggle-text { font-weight: 600; font-size: .9rem; }
.toggle-hint { font-size: .8rem; color: var(--text-muted); }
.status-badge {
display: inline-flex;
padding: .4rem .8rem;
border-radius: 20px;
font-size: .82rem;
font-weight: 500;
align-self: flex-start;
}
.status-active { background: rgba(29,185,84,.15); color: var(--green); border: 1px solid rgba(29,185,84,.3); }
.status-inactive { background: rgba(136,136,136,.1); color: var(--text-muted); border: 1px solid #333; }
.status-row { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
.server-time { font-size: .8rem; color: var(--text-muted); }
.results-header { display: flex; justify-content: space-between; align-items: center; }
.results-list { display: flex; flex-direction: column; gap: .5rem; margin-top: .25rem; }
.result-row {
display: flex;
align-items: center;
gap: .75rem;
padding: .6rem;
border-radius: 8px;
background: var(--surface2);
}
.result-winner { border: 1px solid rgba(29,185,84,.4); background: rgba(29,185,84,.06); }
.result-rank { width: 20px; text-align: center; font-size: .82rem; color: var(--text-muted); font-weight: 600; }
.result-info { flex: 1; display: flex; flex-direction: column; gap: .3rem; }
.result-name { font-size: .9rem; font-weight: 500; display: flex; align-items: center; gap: .5rem; }
.winner-badge { background: rgba(255,215,0,.15); color: gold; font-size: .75rem; padding: 1px 6px; border-radius: 10px; border: 1px solid rgba(255,215,0,.3); }
.result-bar-wrap { height: 6px; background: var(--surface3); border-radius: 3px; overflow: hidden; }
.result-bar { height: 100%; background: var(--green); border-radius: 3px; transition: width .4s; }
.result-count { display: flex; flex-direction: column; align-items: flex-end; min-width: 50px; }
.result-count strong { font-size: 1.1rem; }
.pct-label { font-size: .75rem; color: var(--text-muted); }
</style>
{% endblock %}