feat: tabbed show interface and copy-to-clipboard button

Replace the link column with a Copy button that copies
"Title by Artist - URL" to clipboard. Replace the current/previous
show layout with a horizontal scrollable tab bar showing all shows
from the database, most recent first. Tabs lazy-load and cache
show data on click.

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-12 08:15:22 -04:00
parent f244749293
commit 911dd3d5dd
2 changed files with 150 additions and 64 deletions

View File

@@ -224,7 +224,6 @@ class NtrPlaylist(callbacks.Plugin):
def doPrivmsg(self, irc, msg):
channel = msg.args[0] if msg.args else None
if not channel or not irc.isChannel(channel):
super().doPrivmsg(irc, msg)
return
text = msg.args[1] if len(msg.args) > 1 else ""
match = _NUMBER_RE.match(text)
@@ -237,7 +236,6 @@ class NtrPlaylist(callbacks.Plugin):
except ApiError as exc:
LOGGER.warning("API error for !%s: %s", position, exc)
irc.reply(exc.detail)
super().doPrivmsg(irc, msg)
@wrap([optional("text")])
def song(self, irc, msg, args, text):

View File

@@ -18,12 +18,11 @@
.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; }
.btn-sm { 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; }
.copy-btn.copied { background: #666; border-color: #666; pointer-events: none; }
.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;
@@ -31,13 +30,43 @@
}
.toast.show { display: block; }
.toast.error { background: #e53935; }
details summary h3 { display: inline; cursor: pointer; }
.track-num { width: 40px; text-align: center; }
.client-tag {
display: inline-block; padding: 2px 8px; margin: 2px 4px;
border-radius: 4px; background: #2a2a2a; font-size: 0.8rem;
}
#client-detail { display: none; margin-top: 4px; }
.tab-bar {
display: flex;
gap: 0;
overflow-x: auto;
border-bottom: 2px solid #333;
margin-bottom: 1rem;
scrollbar-width: thin;
}
.tab-bar::-webkit-scrollbar { height: 4px; }
.tab-bar::-webkit-scrollbar-thumb { background: #555; border-radius: 2px; }
.tab {
padding: 8px 16px;
cursor: pointer;
white-space: nowrap;
border-bottom: 3px solid transparent;
color: #999;
font-size: 0.9rem;
transition: color 0.15s, border-color 0.15s;
flex-shrink: 0;
background: none;
border-top: none;
border-left: none;
border-right: none;
}
.tab:hover { color: #ddd; }
.tab.active {
color: #fff;
border-bottom-color: #4caf50;
font-weight: 600;
}
.btn-group { display: flex; gap: 4px; }
</style>
</head>
<body>
@@ -52,14 +81,13 @@
<a href="/logout" role="button" class="outline secondary">Logout</a>
</nav>
<section id="current-show">
<h3>Loading current show...</h3>
</section>
<div class="tab-bar" id="tab-bar">
<span class="tab active">Loading...</span>
</div>
<details id="prev-show-details">
<summary><h3>Previous Show</h3></summary>
<section id="prev-show"><p>Loading...</p></section>
</details>
<section id="show-content">
<p>Loading shows...</p>
</section>
<div class="toast" id="toast"></div>
</main>
@@ -67,6 +95,9 @@
<script>
const WS_TOKEN = "{{WS_TOKEN}}";
let subscriberCount = 0;
let allShows = [];
let showCache = {};
let activeShowId = null;
function showToast(msg, isError) {
const t = document.getElementById("toast");
@@ -75,70 +106,128 @@
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;
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></th>';
html += '</tr></thead><tbody>';
for (const t of tracks) {
const disabled = subscriberCount === 0 ? 'disabled title="No bots connected"' : "";
const copyText = `${t.title} by ${t.artist} - ${t.permalink_url}`;
html += `<tr>
<td class="track-num">${t.position}</td>
<td>${esc(t.title)}</td>
<td>${esc(t.artist)}</td>
<td>
<div class="btn-group">
<button class="btn-sm copy-btn outline"
onclick="copyTrack(this, '${esc(copyText).replace(/'/g, "\\'")}')">Copy</button>
<button class="btn-sm announce-btn" ${disabled}
onclick="announce(${showId}, ${t.position}, this)">Announce</button>
</div>
</td>
</tr>`;
}
html += '</tbody></table>';
return html;
}
async function loadAllShows() {
try {
const resp = await fetch("/shows?limit=200");
if (!resp.ok) throw new Error("Failed to load shows");
allShows = await resp.json();
} catch (e) {
allShows = [];
}
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);
if (!resp.ok) throw new Error("Failed to load current playlist");
const current = await resp.json();
showCache[current.show_id] = current;
const exists = allShows.find(s => s.id === current.show_id);
if (!exists) {
allShows.unshift({
id: current.show_id,
episode_number: current.episode_number,
week_start: current.week_start,
week_end: current.week_end,
});
}
renderTabs(current.show_id);
renderShow(current);
} catch (e) {
document.getElementById("current-show").innerHTML = "<p>Failed to load current show.</p>";
document.getElementById("show-content").innerHTML = "<p>Failed to load shows.</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>";
function renderTabs(activeId) {
activeShowId = activeId;
const bar = document.getElementById("tab-bar");
bar.innerHTML = "";
for (const s of allShows) {
const tab = document.createElement("button");
tab.className = "tab" + (s.id === activeId ? " active" : "");
const label = s.episode_number ? `Ep ${s.episode_number}` : `Show ${s.id}`;
tab.textContent = label;
tab.onclick = () => selectShow(s.id);
bar.appendChild(tab);
}
}
async function selectShow(showId) {
activeShowId = showId;
document.querySelectorAll(".tab").forEach((tab, i) => {
tab.classList.toggle("active", allShows[i].id === showId);
});
if (showCache[showId]) {
renderShow(showCache[showId]);
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);
document.getElementById("show-content").innerHTML = "<p>Loading...</p>";
try {
const resp = await fetch(`/shows/${showId}`);
if (!resp.ok) throw new Error("Failed to load show");
const data = await resp.json();
showCache[showId] = data;
if (activeShowId === showId) renderShow(data);
} catch (e) {
document.getElementById("prev-show").innerHTML = "<p>Failed to load previous show.</p>";
if (activeShowId === showId) {
document.getElementById("show-content").innerHTML = "<p>Failed to load show.</p>";
}
}
}
function renderShow(data) {
const el = document.getElementById("show-content");
const ep = data.episode_number ? `Episode ${data.episode_number}` : "Show";
el.innerHTML = `<h3>${esc(ep)} <small>(${data.tracks.length} tracks)</small></h3>`
+ renderTrackTable(data.tracks, data.show_id);
}
function copyTrack(btn, text) {
navigator.clipboard.writeText(text).then(() => {
btn.textContent = "\u2713";
btn.classList.add("copied");
setTimeout(() => {
btn.textContent = "Copy";
btn.classList.remove("copied");
}, 1500);
}).catch(() => {
showToast("Copy failed", true);
});
}
async function announce(showId, position, btn) {
btn.disabled = true;
@@ -226,8 +315,7 @@
ws.onerror = () => ws.close();
}
loadCurrentShow();
loadPreviousShow();
loadAllShows();
connectWS();
</script>
</body>