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:
@@ -224,7 +224,6 @@ class NtrPlaylist(callbacks.Plugin):
|
|||||||
def doPrivmsg(self, irc, msg):
|
def doPrivmsg(self, irc, msg):
|
||||||
channel = msg.args[0] if msg.args else None
|
channel = msg.args[0] if msg.args else None
|
||||||
if not channel or not irc.isChannel(channel):
|
if not channel or not irc.isChannel(channel):
|
||||||
super().doPrivmsg(irc, msg)
|
|
||||||
return
|
return
|
||||||
text = msg.args[1] if len(msg.args) > 1 else ""
|
text = msg.args[1] if len(msg.args) > 1 else ""
|
||||||
match = _NUMBER_RE.match(text)
|
match = _NUMBER_RE.match(text)
|
||||||
@@ -237,7 +236,6 @@ class NtrPlaylist(callbacks.Plugin):
|
|||||||
except ApiError as exc:
|
except ApiError as exc:
|
||||||
LOGGER.warning("API error for !%s: %s", position, exc)
|
LOGGER.warning("API error for !%s: %s", position, exc)
|
||||||
irc.reply(exc.detail)
|
irc.reply(exc.detail)
|
||||||
super().doPrivmsg(irc, msg)
|
|
||||||
|
|
||||||
@wrap([optional("text")])
|
@wrap([optional("text")])
|
||||||
def song(self, irc, msg, args, text):
|
def song(self, irc, msg, args, text):
|
||||||
|
|||||||
@@ -18,12 +18,11 @@
|
|||||||
.status-dot.disconnected { background: #666; }
|
.status-dot.disconnected { background: #666; }
|
||||||
nav { display: flex; align-items: center; justify-content: space-between; padding: 1rem 0; }
|
nav { display: flex; align-items: center; justify-content: space-between; padding: 1rem 0; }
|
||||||
nav .left { display: flex; align-items: center; gap: 8px; }
|
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.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; }
|
.announce-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
table { width: 100%; }
|
table { width: 100%; }
|
||||||
td a { word-break: break-all; }
|
|
||||||
.toast {
|
.toast {
|
||||||
position: fixed; bottom: 20px; right: 20px; padding: 12px 20px;
|
position: fixed; bottom: 20px; right: 20px; padding: 12px 20px;
|
||||||
border-radius: 8px; background: #333; color: #fff;
|
border-radius: 8px; background: #333; color: #fff;
|
||||||
@@ -31,13 +30,43 @@
|
|||||||
}
|
}
|
||||||
.toast.show { display: block; }
|
.toast.show { display: block; }
|
||||||
.toast.error { background: #e53935; }
|
.toast.error { background: #e53935; }
|
||||||
details summary h3 { display: inline; cursor: pointer; }
|
|
||||||
.track-num { width: 40px; text-align: center; }
|
.track-num { width: 40px; text-align: center; }
|
||||||
.client-tag {
|
.client-tag {
|
||||||
display: inline-block; padding: 2px 8px; margin: 2px 4px;
|
display: inline-block; padding: 2px 8px; margin: 2px 4px;
|
||||||
border-radius: 4px; background: #2a2a2a; font-size: 0.8rem;
|
border-radius: 4px; background: #2a2a2a; font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
#client-detail { display: none; margin-top: 4px; }
|
#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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -52,14 +81,13 @@
|
|||||||
<a href="/logout" role="button" class="outline secondary">Logout</a>
|
<a href="/logout" role="button" class="outline secondary">Logout</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<section id="current-show">
|
<div class="tab-bar" id="tab-bar">
|
||||||
<h3>Loading current show...</h3>
|
<span class="tab active">Loading...</span>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
<details id="prev-show-details">
|
<section id="show-content">
|
||||||
<summary><h3>Previous Show</h3></summary>
|
<p>Loading shows...</p>
|
||||||
<section id="prev-show"><p>Loading...</p></section>
|
</section>
|
||||||
</details>
|
|
||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
</main>
|
</main>
|
||||||
@@ -67,6 +95,9 @@
|
|||||||
<script>
|
<script>
|
||||||
const WS_TOKEN = "{{WS_TOKEN}}";
|
const WS_TOKEN = "{{WS_TOKEN}}";
|
||||||
let subscriberCount = 0;
|
let subscriberCount = 0;
|
||||||
|
let allShows = [];
|
||||||
|
let showCache = {};
|
||||||
|
let activeShowId = null;
|
||||||
|
|
||||||
function showToast(msg, isError) {
|
function showToast(msg, isError) {
|
||||||
const t = document.getElementById("toast");
|
const t = document.getElementById("toast");
|
||||||
@@ -75,71 +106,129 @@
|
|||||||
setTimeout(() => { t.className = "toast"; }, 3000);
|
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) {
|
function esc(s) {
|
||||||
const d = document.createElement("div");
|
const d = document.createElement("div");
|
||||||
d.textContent = s || "";
|
d.textContent = s || "";
|
||||||
return d.innerHTML;
|
return d.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentShowData = null;
|
function renderTrackTable(tracks, showId) {
|
||||||
let prevShowData = null;
|
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 {
|
try {
|
||||||
const resp = await fetch("/playlist");
|
const resp = await fetch("/playlist");
|
||||||
if (!resp.ok) throw new Error("Failed to load playlist");
|
if (!resp.ok) throw new Error("Failed to load current playlist");
|
||||||
currentShowData = await resp.json();
|
const current = await resp.json();
|
||||||
const el = document.getElementById("current-show");
|
showCache[current.show_id] = current;
|
||||||
const ep = currentShowData.episode_number ? `Episode ${currentShowData.episode_number}` : "Current Show";
|
|
||||||
el.innerHTML = `<h3>${esc(ep)} <small>(${currentShowData.tracks.length} tracks)</small></h3>`
|
const exists = allShows.find(s => s.id === current.show_id);
|
||||||
+ renderTrackTable(currentShowData.tracks, currentShowData.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) {
|
} 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() {
|
function renderTabs(activeId) {
|
||||||
try {
|
activeShowId = activeId;
|
||||||
const resp = await fetch("/shows?limit=2");
|
const bar = document.getElementById("tab-bar");
|
||||||
if (!resp.ok) throw new Error("Failed to load shows");
|
bar.innerHTML = "";
|
||||||
const shows = await resp.json();
|
for (const s of allShows) {
|
||||||
if (shows.length < 2) {
|
const tab = document.createElement("button");
|
||||||
document.getElementById("prev-show").innerHTML = "<p>No previous show.</p>";
|
tab.className = "tab" + (s.id === activeId ? " active" : "");
|
||||||
return;
|
const label = s.episode_number ? `Ep ${s.episode_number}` : `Show ${s.id}`;
|
||||||
}
|
tab.textContent = label;
|
||||||
const prevId = shows[1].id;
|
tab.onclick = () => selectShow(s.id);
|
||||||
const resp2 = await fetch(`/shows/${prevId}`);
|
bar.appendChild(tab);
|
||||||
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 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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) {
|
async function announce(showId, position, btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = "...";
|
btn.textContent = "...";
|
||||||
@@ -226,8 +315,7 @@
|
|||||||
ws.onerror = () => ws.close();
|
ws.onerror = () => ws.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
loadCurrentShow();
|
loadAllShows();
|
||||||
loadPreviousShow();
|
|
||||||
connectWS();
|
connectWS();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user