feat: styled login and dashboard pages with Pico CSS dark theme
Made-with: Cursor
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user