feat: styled login and dashboard pages with Pico CSS dark theme

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-12 07:21:39 -04:00
parent 92136f0508
commit a7849e6cd9
2 changed files with 244 additions and 12 deletions

View File

@@ -1,5 +1,216 @@
<!DOCTYPE html>
<html><head><title>Dashboard</title></head>
<body><h1>NtR Playlist Dashboard</h1>
<script>const WS_TOKEN = "{{WS_TOKEN}}";</script>
</body></html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NtR Playlist Dashboard</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<style>
.status-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 6px;
vertical-align: middle;
}
.status-dot.connected { background: #4caf50; }
.status-dot.disconnected { background: #666; }
nav { display: flex; align-items: center; justify-content: space-between; padding: 1rem 0; }
nav .left { display: flex; align-items: center; gap: 8px; }
.announce-btn { padding: 4px 14px; font-size: 0.85rem; cursor: pointer; }
.announce-btn.success { background: #4caf50; border-color: #4caf50; pointer-events: none; }
.announce-btn.warning { opacity: 0.5; cursor: not-allowed; }
.announce-btn:disabled { opacity: 0.5; cursor: not-allowed; }
table { width: 100%; }
td a { word-break: break-all; }
.toast {
position: fixed; bottom: 20px; right: 20px; padding: 12px 20px;
border-radius: 8px; background: #333; color: #fff;
display: none; z-index: 100; max-width: 350px;
}
.toast.show { display: block; }
.toast.error { background: #e53935; }
details summary h3 { display: inline; cursor: pointer; }
.track-num { width: 40px; text-align: center; }
</style>
</head>
<body>
<main class="container">
<nav>
<div class="left">
<span class="status-dot disconnected" id="status-dot"></span>
<strong>NtR Playlist Dashboard</strong>
<small id="sub-count">(connecting...)</small>
</div>
<a href="/logout" role="button" class="outline secondary">Logout</a>
</nav>
<section id="current-show">
<h3>Loading current show...</h3>
</section>
<details id="prev-show-details">
<summary><h3>Previous Show</h3></summary>
<section id="prev-show"><p>Loading...</p></section>
</details>
<div class="toast" id="toast"></div>
</main>
<script>
const WS_TOKEN = "{{WS_TOKEN}}";
let subscriberCount = 0;
function showToast(msg, isError) {
const t = document.getElementById("toast");
t.textContent = msg;
t.className = "toast show" + (isError ? " error" : "");
setTimeout(() => { t.className = "toast"; }, 3000);
}
function renderTrackTable(tracks, showId) {
if (!tracks || tracks.length === 0) return "<p>No tracks yet.</p>";
let html = '<table role="grid"><thead><tr>';
html += '<th class="track-num">#</th><th>Title</th><th>Artist</th><th>Link</th><th></th>';
html += '</tr></thead><tbody>';
for (const t of tracks) {
const disabled = subscriberCount === 0 ? 'disabled title="No bots connected"' : "";
html += `<tr>
<td class="track-num">${t.position}</td>
<td>${esc(t.title)}</td>
<td>${esc(t.artist)}</td>
<td><a href="${esc(t.permalink_url)}" target="_blank" rel="noopener">link</a></td>
<td><button class="announce-btn" ${disabled}
onclick="announce(${showId}, ${t.position}, this)">Announce</button></td>
</tr>`;
}
html += '</tbody></table>';
return html;
}
function esc(s) {
const d = document.createElement("div");
d.textContent = s || "";
return d.innerHTML;
}
let currentShowData = null;
let prevShowData = null;
async function loadCurrentShow() {
try {
const resp = await fetch("/playlist");
if (!resp.ok) throw new Error("Failed to load playlist");
currentShowData = await resp.json();
const el = document.getElementById("current-show");
const ep = currentShowData.episode_number ? `Episode ${currentShowData.episode_number}` : "Current Show";
el.innerHTML = `<h3>${esc(ep)} <small>(${currentShowData.tracks.length} tracks)</small></h3>`
+ renderTrackTable(currentShowData.tracks, currentShowData.show_id);
} catch (e) {
document.getElementById("current-show").innerHTML = "<p>Failed to load current show.</p>";
}
}
async function loadPreviousShow() {
try {
const resp = await fetch("/shows?limit=2");
if (!resp.ok) throw new Error("Failed to load shows");
const shows = await resp.json();
if (shows.length < 2) {
document.getElementById("prev-show").innerHTML = "<p>No previous show.</p>";
return;
}
const prevId = shows[1].id;
const resp2 = await fetch(`/shows/${prevId}`);
if (!resp2.ok) throw new Error("Failed to load previous show");
prevShowData = await resp2.json();
const el = document.getElementById("prev-show");
const ep = prevShowData.episode_number ? `Episode ${prevShowData.episode_number}` : "Previous Show";
el.innerHTML = `<h4>${esc(ep)} <small>(${prevShowData.tracks.length} tracks)</small></h4>`
+ renderTrackTable(prevShowData.tracks, prevShowData.show_id);
} catch (e) {
document.getElementById("prev-show").innerHTML = "<p>Failed to load previous show.</p>";
}
}
async function announce(showId, position, btn) {
btn.disabled = true;
btn.textContent = "...";
try {
const resp = await fetch("/admin/announce", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({show_id: showId, position: position}),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.detail || "Announce failed");
}
btn.textContent = "\u2713";
btn.classList.add("success");
setTimeout(() => {
btn.textContent = "Announce";
btn.classList.remove("success");
btn.disabled = false;
}, 2000);
} catch (e) {
showToast(e.message, true);
btn.textContent = "Announce";
btn.disabled = false;
}
}
function updateStatus(count) {
subscriberCount = count;
const dot = document.getElementById("status-dot");
const sub = document.getElementById("sub-count");
if (count > 0) {
dot.className = "status-dot connected";
sub.textContent = `(${count} bot${count > 1 ? "s" : ""} connected)`;
} else {
dot.className = "status-dot disconnected";
sub.textContent = "(no bots connected)";
}
document.querySelectorAll(".announce-btn").forEach(btn => {
if (count === 0) {
btn.disabled = true;
btn.title = "No bots connected";
} else {
btn.disabled = false;
btn.title = "";
}
});
}
let wsBackoff = 1000;
function connectWS() {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${proto}//${location.host}/ws/announce`);
ws.onopen = () => {
ws.send(JSON.stringify({type: "subscribe", token: WS_TOKEN}));
wsBackoff = 1000;
};
ws.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
if (data.type === "status") {
updateStatus(data.subscribers);
}
} catch {}
};
ws.onclose = () => {
updateStatus(0);
document.getElementById("sub-count").textContent = "(reconnecting...)";
setTimeout(connectWS, wsBackoff);
wsBackoff = Math.min(wsBackoff * 2, 60000);
};
ws.onerror = () => ws.close();
}
loadCurrentShow();
loadPreviousShow();
connectWS();
</script>
</body>
</html>

View File

@@ -1,10 +1,31 @@
<!DOCTYPE html>
<html><head><title>Login</title></head>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NtR Login</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<style>
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
main { max-width: 400px; width: 100%; }
.error { color: var(--pico-color-red-500, #e53935); margin-bottom: 1rem; }
</style>
</head>
<body>
<!--ERROR-->
<form method="post" action="/login">
<label>Username <input name="username"></label>
<label>Password <input name="password" type="password"></label>
<button type="submit">Login</button>
</form>
</body></html>
<main>
<article>
<header><h2>NtR Playlist</h2></header>
<!--ERROR-->
<form method="post" action="/login">
<label>Username
<input name="username" autocomplete="username" required>
</label>
<label>Password
<input name="password" type="password" autocomplete="current-password" required>
</label>
<button type="submit">Log in</button>
</form>
</article>
</main>
</body>
</html>