commit inicial
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
<div class="system-card">
|
||||
<span class="system-label">Servidor</span>
|
||||
<form method="post" action="/admin/system/restart"
|
||||
onsubmit="return confirm('¿Reiniciar el servidor?')">
|
||||
<button type="submit" class="btn-sm btn-system">🔄 Reiniciar</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/system/shutdown"
|
||||
onsubmit="return confirm('¿Apagar el servidor? Deberás iniciarlo manualmente.')">
|
||||
<button type="submit" class="btn-sm btn-system btn-danger">⏹ Apagar</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.system-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .6rem;
|
||||
padding: .65rem 1rem;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid #2a2a2a;
|
||||
margin-top: .5rem;
|
||||
}
|
||||
.system-label {
|
||||
font-size: .75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
margin-right: .25rem;
|
||||
}
|
||||
.btn-system { font-size: .82rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Admin — Cantina</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<h1>🔐 Admin</h1>
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/admin/login" class="login-form">
|
||||
<label>Usuario</label>
|
||||
<input type="text" name="username" class="input" autofocus required>
|
||||
<label>Contraseña</label>
|
||||
<input type="password" name="password" class="input" required>
|
||||
<button type="submit" class="btn-primary">Ingresar</button>
|
||||
</form>
|
||||
<a href="/" class="back-link">← Volver al reproductor</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,197 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Mantenedor — Cantina{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="admin-container">
|
||||
<div class="admin-header">
|
||||
<h1>Mantenedor</h1>
|
||||
<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>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
{% if success %}
|
||||
<div class="alert alert-success">{{ success }}</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Formulario para agregar -->
|
||||
<div class="card">
|
||||
<h2>Agregar elemento</h2>
|
||||
<form method="post" action="/admin/playlists" class="add-form">
|
||||
<div class="form-row">
|
||||
<input
|
||||
type="text"
|
||||
name="spotify_url"
|
||||
class="input"
|
||||
placeholder="URL de Spotify (canción, álbum, artista o playlist)"
|
||||
required
|
||||
>
|
||||
<button type="submit" class="btn-primary">Agregar</button>
|
||||
</div>
|
||||
<small class="hint">
|
||||
Acepta cualquier URL de <strong>open.spotify.com</strong>,
|
||||
URI (<code>spotify:track:…</code>, <code>spotify:album:…</code>, etc.)
|
||||
o ID de 22 caracteres (se asume playlist).
|
||||
</small>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Lista -->
|
||||
<div class="card">
|
||||
<h2>Elementos configurados ({{ playlists | length }})</h2>
|
||||
{% if playlists %}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Nombre</th>
|
||||
<th>Tipo</th>
|
||||
<th>Descripción</th>
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for pl in playlists %}
|
||||
{% set type_info = type_labels.get(pl.spotify_type, ('🎵', pl.spotify_type)) %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if pl.emoji %}
|
||||
<div class="table-thumb-placeholder emoji-thumb">{{ pl.emoji }}</div>
|
||||
{% elif pl.image_url %}
|
||||
<img src="{{ pl.image_url }}" alt="" class="table-thumb">
|
||||
{% else %}
|
||||
<div class="table-thumb-placeholder">{{ type_info[0] }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="td-name">{{ pl.name }}</td>
|
||||
<td>
|
||||
<span class="type-badge type-{{ pl.spotify_type }}">
|
||||
{{ type_info[0] }} {{ type_info[1] }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="td-desc">{{ pl.description[:80] }}{% if pl.description | length > 80 %}…{% endif %}</td>
|
||||
<td class="td-actions">
|
||||
<button class="btn-sm" onclick="toggleEdit({{ pl.id }})">✏️ Editar</button>
|
||||
<form method="post" action="/admin/playlists/{{ pl.id }}/delete"
|
||||
onsubmit="return confirm('¿Eliminar {{ pl.name }}?')">
|
||||
<button type="submit" class="btn-sm btn-danger">Eliminar</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Fila de edición inline -->
|
||||
<tr id="edit-row-{{ pl.id }}" class="edit-row" style="display:none">
|
||||
<td colspan="5">
|
||||
<form method="post" action="/admin/playlists/{{ pl.id }}/edit" class="edit-form">
|
||||
<div class="edit-fields">
|
||||
<div class="edit-field">
|
||||
<label>Nombre</label>
|
||||
<input type="text" name="name" class="input"
|
||||
value="{{ pl.name }}" required maxlength="200">
|
||||
</div>
|
||||
<div class="edit-field edit-field-desc">
|
||||
<label>Descripción</label>
|
||||
<input type="text" name="description" class="input"
|
||||
value="{{ pl.description }}" maxlength="300">
|
||||
</div>
|
||||
<div class="edit-field edit-field-emoji">
|
||||
<label>Emoticono <span class="hint-inline">(reemplaza imagen)</span></label>
|
||||
<div class="emoji-input-wrap">
|
||||
<input type="text" name="emoji" id="emoji-{{ pl.id }}" class="input emoji-input"
|
||||
value="{{ pl.emoji }}" maxlength="8" placeholder="😀">
|
||||
{% if pl.emoji %}
|
||||
<button type="button" class="btn-sm emoji-clear"
|
||||
onclick="clearEmoji({{ pl.id }})">✕</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="emoji-picker">
|
||||
{% for e in ['🎵','🎶','🎸','🎹','🎺','🎻','🥁','🎤','🎧','🎼','🎷','🪗','🪘','🔥','⚡','🌟','✨','💫','🌈','🎉','🥳','🍕','🍺','🍹','🌙','☀️','🏖️','🌊','💃','🕺','🎭','🎬','🎮','🏆','❤️','🤘'] %}
|
||||
<button type="button" class="emoji-opt"
|
||||
onclick="pickEmoji({{ pl.id }}, '{{ e }}')">{{ e }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="edit-actions">
|
||||
<button type="submit" class="btn-primary btn-sm">Guardar</button>
|
||||
<button type="button" class="btn-sm" onclick="toggleEdit({{ pl.id }})">Cancelar</button>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="empty-msg">No hay elementos configurados todavía.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% include "admin/_system_buttons.html" %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleEdit(id) {
|
||||
const row = document.getElementById('edit-row-' + id);
|
||||
const visible = row.style.display !== 'none';
|
||||
row.style.display = visible ? 'none' : 'table-row';
|
||||
if (!visible) row.querySelector('input[name="name"]').focus();
|
||||
}
|
||||
function pickEmoji(id, emoji) {
|
||||
document.getElementById('emoji-' + id).value = emoji;
|
||||
}
|
||||
function clearEmoji(id) {
|
||||
document.getElementById('emoji-' + id).value = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.td-actions { display: flex; gap: .4rem; align-items: center; }
|
||||
|
||||
.edit-row td { background: var(--surface2); padding: .75rem 1rem; }
|
||||
.edit-form { display: flex; flex-direction: column; gap: .75rem; }
|
||||
.edit-fields { display: flex; gap: .6rem; flex-wrap: wrap; align-items: flex-start; }
|
||||
.edit-field { display: flex; flex-direction: column; gap: .25rem; min-width: 140px; }
|
||||
.edit-field-desc { flex: 2; }
|
||||
.edit-field-emoji { min-width: 180px; }
|
||||
.edit-field label { font-size: .75rem; color: var(--text-muted); }
|
||||
.hint-inline { font-weight: 400; opacity: .6; }
|
||||
.edit-actions { display: flex; gap: .4rem; align-items: center; }
|
||||
|
||||
.emoji-input-wrap { display: flex; gap: .35rem; align-items: center; }
|
||||
.emoji-input { width: 70px; font-size: 1.2rem; text-align: center; padding: .3rem .4rem; }
|
||||
.emoji-clear { padding: .3rem .5rem; line-height: 1; }
|
||||
|
||||
.emoji-picker { display: flex; flex-wrap: wrap; gap: .25rem; margin-top: .35rem; }
|
||||
.emoji-opt {
|
||||
background: var(--surface);
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
font-size: 1.15rem;
|
||||
width: 34px; height: 34px;
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background .12s, transform .1s;
|
||||
}
|
||||
.emoji-opt:hover { background: #333; transform: scale(1.15); }
|
||||
|
||||
.emoji-thumb { font-size: 1.6rem; }
|
||||
|
||||
.type-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .3rem;
|
||||
font-size: .75rem;
|
||||
padding: .2rem .55rem;
|
||||
border-radius: 20px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.type-playlist { background: rgba(29,185,84,.12); color: #1db954; border: 1px solid rgba(29,185,84,.3); }
|
||||
.type-album { background: rgba(168,85,247,.12); color: #a855f7; border: 1px solid rgba(168,85,247,.3); }
|
||||
.type-artist { background: rgba(59,130,246,.12); color: #3b82f6; border: 1px solid rgba(59,130,246,.3); }
|
||||
.type-track { background: rgba(245,158,11,.12); color: #f59e0b; border: 1px solid rgba(245,158,11,.3); }
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,207 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Estadísticas — Cantina{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="admin-container">
|
||||
|
||||
<!-- Encabezado -->
|
||||
<div class="admin-header">
|
||||
<h1>Estadísticas de reproducción</h1>
|
||||
</div>
|
||||
|
||||
<!-- Selector de fecha -->
|
||||
<div class="card date-nav">
|
||||
<a href="/stats/?day={{ prev_date }}" class="btn-sm">← Día anterior</a>
|
||||
|
||||
<form method="get" action="/stats/" class="date-form">
|
||||
<input type="date" name="day" value="{{ selected_date }}"
|
||||
max="{{ selected_date if is_today else '' }}"
|
||||
class="input date-input" onchange="this.form.submit()">
|
||||
</form>
|
||||
|
||||
{% if not is_today %}
|
||||
<a href="/stats/?day={{ next_date }}" class="btn-sm">Día siguiente →</a>
|
||||
{% else %}
|
||||
<span class="btn-sm" style="opacity:.4;cursor:default">Día siguiente →</span>
|
||||
{% endif %}
|
||||
|
||||
<span class="total-badge">{{ total_plays }} reproduccion{{ 'es' if total_plays != 1 else '' }}</span>
|
||||
</div>
|
||||
|
||||
{% if total_plays == 0 %}
|
||||
<div class="card">
|
||||
<p class="empty-msg" style="text-align:center;padding:2rem 0">
|
||||
Sin reproducciones registradas para este día.<br>
|
||||
<small>El historial se carga automáticamente desde Spotify cada 30 segundos.</small>
|
||||
</p>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
<div class="stats-grid">
|
||||
|
||||
<!-- Top Canciones -->
|
||||
<div class="card">
|
||||
<h2>🎵 Canciones más escuchadas</h2>
|
||||
<div class="chart">
|
||||
{% for item in top_tracks %}
|
||||
<div class="chart-row">
|
||||
<span class="rank">{{ loop.index }}</span>
|
||||
<div class="chart-info">
|
||||
<span class="chart-label">{{ item.label }}</span>
|
||||
<div class="bar-wrap">
|
||||
<div class="bar" style="width:{{ item.pct }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="chart-count">{{ item.count }}x</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty-msg">Sin datos</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Artistas -->
|
||||
<div class="card">
|
||||
<h2>🎤 Artistas más escuchados</h2>
|
||||
<div class="chart">
|
||||
{% for item in top_artists %}
|
||||
<div class="chart-row">
|
||||
<span class="rank">{{ loop.index }}</span>
|
||||
<div class="chart-info">
|
||||
<span class="chart-label">{{ item.label }}</span>
|
||||
<div class="bar-wrap">
|
||||
<div class="bar bar-artist" style="width:{{ item.pct }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="chart-count">{{ item.count }}x</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty-msg">Sin datos</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Géneros -->
|
||||
<div class="card" style="grid-column: 1 / -1">
|
||||
<h2>🎸 Géneros más escuchados</h2>
|
||||
{% if top_genres %}
|
||||
<div class="genre-bars">
|
||||
{% for item in top_genres %}
|
||||
<div class="genre-row">
|
||||
<span class="genre-name">{{ item.label }}</span>
|
||||
<div class="bar-wrap genre-bar-wrap">
|
||||
<div class="bar bar-genre" style="width:{{ item.pct }}%">
|
||||
<span class="bar-label-inside">{{ item.count }}x</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty-msg">Sin información de géneros (los géneros se obtienen desde la API de artistas de Spotify).</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.date-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.date-form { display: flex; }
|
||||
.date-input { width: auto; min-width: 160px; }
|
||||
.total-badge {
|
||||
margin-left: auto;
|
||||
background: rgba(29,185,84,.12);
|
||||
color: var(--green);
|
||||
border: 1px solid rgba(29,185,84,.3);
|
||||
border-radius: 20px;
|
||||
padding: .3rem .9rem;
|
||||
font-size: .82rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.stats-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.chart { display: flex; flex-direction: column; gap: .55rem; margin-top: .25rem; }
|
||||
|
||||
.chart-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .6rem;
|
||||
}
|
||||
.rank {
|
||||
width: 18px;
|
||||
text-align: right;
|
||||
font-size: .78rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chart-info { flex: 1; display: flex; flex-direction: column; gap: .2rem; overflow: hidden; }
|
||||
.chart-label {
|
||||
font-size: .85rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.bar-wrap {
|
||||
height: 6px;
|
||||
background: #2a2a2a;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bar {
|
||||
height: 100%;
|
||||
background: var(--green);
|
||||
border-radius: 3px;
|
||||
transition: width .5s ease;
|
||||
}
|
||||
.bar-artist { background: #a855f7; }
|
||||
.chart-count {
|
||||
font-size: .78rem;
|
||||
color: var(--text-muted);
|
||||
min-width: 28px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Géneros — barras horizontales grandes */
|
||||
.genre-bars { display: flex; flex-direction: column; gap: .55rem; margin-top: .5rem; }
|
||||
.genre-row { display: flex; align-items: center; gap: .75rem; }
|
||||
.genre-name {
|
||||
width: 160px;
|
||||
font-size: .85rem;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.genre-bar-wrap { flex: 1; height: 22px; background: #2a2a2a; border-radius: 4px; overflow: hidden; }
|
||||
.bar-genre {
|
||||
height: 100%;
|
||||
background: #f59e0b;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: width .5s ease;
|
||||
min-width: 36px;
|
||||
}
|
||||
.bar-label-inside { font-size: .72rem; font-weight: 600; color: #000; padding: 0 8px; white-space: nowrap; }
|
||||
</style>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,53 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ action | capitalize }} — Cantina</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
{% if action == "reiniciando" %}
|
||||
<meta http-equiv="refresh" content="4;url=/admin/playlists">
|
||||
{% endif %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="system-action-container">
|
||||
<div class="system-action-icon">
|
||||
{% if action == "reiniciando" %}🔄{% else %}⏹{% endif %}
|
||||
</div>
|
||||
<h1>{{ message }}</h1>
|
||||
{% if action == "reiniciando" %}
|
||||
<p class="system-action-sub">Redirigiendo en unos segundos…</p>
|
||||
<div class="spinner"></div>
|
||||
{% else %}
|
||||
<p class="system-action-sub">Puedes cerrar esta ventana.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.system-action-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
.system-action-icon { font-size: 3.5rem; }
|
||||
h1 { font-size: 1.4rem; font-weight: 600; }
|
||||
.system-action-sub { color: var(--text-muted); font-size: .9rem; }
|
||||
|
||||
.spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 3px solid #333;
|
||||
border-top-color: var(--green);
|
||||
border-radius: 50%;
|
||||
animation: spin .8s linear infinite;
|
||||
margin-top: .5rem;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,166 @@
|
||||
{% 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-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>
|
||||
|
||||
<!-- 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">
|
||||
<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>
|
||||
|
||||
<style>
|
||||
.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; }
|
||||
|
||||
.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: #333; 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 %}
|
||||
Reference in New Issue
Block a user