b4cc8770a7
- 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>
236 lines
9.7 KiB
HTML
236 lines
9.7 KiB
HTML
{% 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 %}
|