Compare commits

..

3 Commits

Author SHA1 Message Date
deivid c6d66e66c6 agrega botón reproducir en panel de playlists admin
Cada fila del mantenedor ahora tiene un botón "▶ Reproducir" que fuerza
la reproducción inmediata en Spotify, sin necesidad de ir al reproductor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:59:23 -04:00
deivid 808ddd889d agrega botón Admin en navbar con detección de sesión
- Nuevo endpoint GET /admin/status devuelve si el usuario está logueado como admin
- Navbar muestra botón "Admin" que lleva a /admin/login si no hay sesión,
  o "⚙ Admin" → /admin/playlists si ya está autenticado

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:59:16 -04:00
deivid 94cda7293f mejora vista de votación: modal, alineación y auto-reproducción del ganador
- Tarjetas de votación ahora abren el modal de canciones al hacer clic en la imagen/nombre
- Botón "Votar" siempre alineado al fondo independiente del alto de cada tarjeta
- Muestra descripción de la playlist en modo votación
- Emoji de playlist escala proporcionalmente usando container queries (55cqi)
- Al cerrar la votación, reproduce automáticamente la playlist con más votos

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:59:06 -04:00
5 changed files with 71 additions and 7 deletions
+5
View File
@@ -84,6 +84,11 @@ def _require_admin(request: Request):
# ── Login ──────────────────────────────────────────────────────────────────────
@router.get("/status")
def admin_status(request: Request):
return {"logged_in": bool(request.session.get("admin_logged_in"))}
@router.get("/login", response_class=HTMLResponse)
def login_page(request: Request):
return templates.TemplateResponse("admin/login.html", {"request": request, "error": None})
+27
View File
@@ -74,6 +74,8 @@
</td>
<td class="td-desc">{{ pl.description[:80] }}{% if pl.description | length > 80 %}…{% endif %}</td>
<td class="td-actions">
<button class="btn-sm btn-play-now" id="play-btn-{{ pl.id }}"
onclick="playNow('{{ pl.spotify_id }}', '{{ pl.spotify_type }}', {{ pl.id }})">▶ Reproducir</button>
<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 }}?')">
@@ -133,6 +135,29 @@
</div>
<script>
async function playNow(spotifyId, spotifyType, plId) {
const btn = document.getElementById('play-btn-' + plId);
btn.disabled = true;
btn.textContent = '⏳';
const body = spotifyType === 'track'
? { uris: [`spotify:track:${spotifyId}`] }
: { context_uri: `spotify:${spotifyType}:${spotifyId}` };
try {
const res = await fetch('/player/play', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
btn.textContent = res.ok ? '✓ Reproduciendo' : '✗ Error';
} catch (_) {
btn.textContent = '✗ Error';
}
setTimeout(() => { btn.disabled = false; btn.textContent = '▶ Reproducir'; }, 2500);
}
function toggleEdit(id) {
const row = document.getElementById('edit-row-' + id);
const visible = row.style.display !== 'none';
@@ -149,6 +174,8 @@ function clearEmoji(id) {
<style>
.td-actions { display: flex; gap: .4rem; align-items: center; }
.btn-play-now { color: var(--green); border-color: rgba(29,185,84,.4); }
.btn-play-now:hover { background: rgba(29,185,84,.12); }
.edit-row td { background: var(--surface2); padding: .75rem 1rem; }
.edit-form { display: flex; flex-direction: column; gap: .75rem; }
+10 -2
View File
@@ -11,11 +11,19 @@
<div class="brand">🎵 Cantina</div>
<div class="nav-links">
<a href="/">Reproductor</a>
<a href="/admin/playlists">Playlists</a>
<a href="/admin/voting">Votación</a>
<a href="/stats/">Estadísticas</a>
<a id="admin-nav-btn" href="/admin/login" class="btn-sm">Admin</a>
</div>
</nav>
<script>
fetch('/admin/status').then(r => r.json()).then(data => {
const btn = document.getElementById('admin-nav-btn');
if (data.logged_in) {
btn.textContent = '⚙ Admin';
btn.href = '/admin/playlists';
}
}).catch(() => {});
</script>
<main>
{% block content %}{% endblock %}
</main>
+27 -4
View File
@@ -243,9 +243,18 @@ async function fetchVotingStatus() {
const res = await fetch('/voting/status');
const data = await res.json();
const wasOpen = votingOpen;
votingOpen = data.is_open;
myVotedPlaylistId = data.my_last_vote_playlist_id;
if (wasOpen && !votingOpen && data.playlists?.length) {
const totalVotes = data.playlists.reduce((s, p) => s + p.votes, 0);
if (totalVotes > 0) {
const winner = data.playlists.reduce((best, p) => p.votes > best.votes ? p : best);
playItem(winner.spotify_id, winner.spotify_type);
}
}
// Solo sincronizar cooldown desde servidor si no hay timer local activo
if (cooldownRemaining <= 0 && data.cooldown_remaining > 0) {
startCooldownTick(data.cooldown_remaining);
@@ -291,9 +300,12 @@ function renderPlaylists(playlists, isVoting, myVote) {
const btnLabel = cooldownRemaining > 0 ? `${cooldownRemaining}s` : '👍 Votar';
const btnDisabled = cooldownRemaining > 0 ? 'disabled' : '';
return `
<div class="playlist-card clickable voting-card ${isVoted ? 'voted' : ''}">
${img}
<div class="playlist-card-name">${pl.name}</div>
<div class="playlist-card voting-card ${isVoted ? 'voted' : ''}">
<div class="voting-card-top clickable" onclick='openTracksModal(${JSON.stringify(pl)})'>
${img}
<div class="playlist-card-name">${pl.name}</div>
${pl.description ? `<div class="playlist-card-desc">${pl.description}</div>` : ''}
</div>
<div class="vote-bar-wrap"><div class="vote-bar" style="width:${pct}%"></div></div>
<div class="vote-info">
<span class="vote-count">${pl.votes} votos</span>
@@ -416,7 +428,18 @@ setInterval(fetchVotingStatus, 5000);
gap: .5rem;
}
.voting-card { cursor: pointer; position: relative; }
.voting-card { position: relative; }
.voting-card-top { cursor: pointer; flex: 1; width: 100%; display: flex; flex-direction: column; align-items: center; gap: 0.4rem; }
.voting-card-top:hover .playlist-card-name { color: var(--green); }
.playlist-card-desc {
font-size: 0.7rem;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
text-align: center;
}
.voting-card.voted { border: 2px solid var(--green); }
.vote-bar-wrap {
+2 -1
View File
@@ -197,8 +197,9 @@ a:hover { text-decoration: underline; }
align-items: center;
justify-content: center;
font-size: 2rem;
container-type: inline-size;
}
.playlist-thumb-emoji { font-size: 2.4rem; }
.playlist-thumb-emoji { font-size: 55cqi; }
.playlist-card-name {
font-size: 0.78rem;