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>
|
<!DOCTYPE html>
|
||||||
<html><head><title>Dashboard</title></head>
|
<html lang="en" data-theme="dark">
|
||||||
<body><h1>NtR Playlist Dashboard</h1>
|
<head>
|
||||||
<script>const WS_TOKEN = "{{WS_TOKEN}}";</script>
|
<meta charset="utf-8">
|
||||||
</body></html>
|
<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>
|
<!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>
|
<body>
|
||||||
<!--ERROR-->
|
<main>
|
||||||
<form method="post" action="/login">
|
<article>
|
||||||
<label>Username <input name="username"></label>
|
<header><h2>NtR Playlist</h2></header>
|
||||||
<label>Password <input name="password" type="password"></label>
|
<!--ERROR-->
|
||||||
<button type="submit">Login</button>
|
<form method="post" action="/login">
|
||||||
</form>
|
<label>Username
|
||||||
</body></html>
|
<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