Improved dashboard and music queue

This commit is contained in:
Nate Anderson
2025-06-21 11:47:06 -06:00
parent 0745a4eb75
commit 71f535be27
16 changed files with 1386 additions and 664 deletions
+131
View File
@@ -0,0 +1,131 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MumBullet Dashboard</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header>
<h1>MumBullet Dashboard</h1>
<nav>
<button id="queueTab" class="tab-button active">Queue</button>
<button id="usersTab" class="tab-button">Users</button>
<button id="cacheTab" class="tab-button">Cache</button>
<button id="logoutButton">Logout</button>
</nav>
</header>
<main>
<section id="queueSection" class="tab-content active">
<h2>Music Queue</h2>
<div class="card">
<div class="card-header">
<h3>Now Playing</h3>
</div>
<div class="card-content" id="nowPlaying">
<p>Nothing playing</p>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>Queue</h3>
<button id="clearQueueButton" class="danger-button">Clear Queue</button>
</div>
<div class="card-content">
<ul id="queueList" class="queue-list">
<li class="empty-message">Queue is empty</li>
</ul>
</div>
</div>
</section>
<section id="usersSection" class="tab-content">
<h2>User Management</h2>
<div class="card">
<div class="card-header">
<h3>Users</h3>
<button id="addUserButton">Add User</button>
</div>
<div class="card-content">
<table id="usersTable" class="data-table">
<thead>
<tr>
<th>Username</th>
<th>Permission Level</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr class="empty-message">
<td colspan="3">No users found</td>
</tr>
</tbody>
</table>
</div>
</div>
<div id="addUserModal" class="modal">
<div class="modal-content">
<h3>Add User</h3>
<form id="addUserForm">
<div class="form-group">
<label for="newUsername">Username</label>
<input type="text" id="newUsername" name="username" required>
</div>
<div class="form-group">
<label for="newPermissionLevel">Permission Level</label>
<select id="newPermissionLevel" name="permissionLevel" required>
<option value="0">None</option>
<option value="1">View Only</option>
<option value="2">Read/Write</option>
<option value="3">Admin</option>
</select>
</div>
<div class="form-actions">
<button type="button" id="cancelAddUser">Cancel</button>
<button type="submit">Add User</button>
</div>
</form>
</div>
</div>
</section>
<section id="cacheSection" class="tab-content">
<h2>Cache Management</h2>
<div class="card">
<div class="card-header">
<h3>Cache Statistics</h3>
<button id="clearCacheButton" class="danger-button">Clear Cache</button>
</div>
<div class="card-content">
<div id="cacheStats" class="stats-grid">
<div class="stat-item">
<span class="stat-label">Songs</span>
<span class="stat-value" id="cacheSongCount">0</span>
</div>
<div class="stat-item">
<span class="stat-label">Size</span>
<span class="stat-value" id="cacheSize">0 MB</span>
</div>
<div class="stat-item">
<span class="stat-label">Max Size</span>
<span class="stat-value" id="cacheMaxSize">0 MB</span>
</div>
<div class="stat-item">
<span class="stat-label">Usage</span>
<div class="progress-bar">
<div id="cacheUsage" class="progress-value" style="width: 0%"></div>
</div>
</div>
</div>
</div>
</div>
</section>
</main>
<script src="/script.js"></script>
</body>
</html>
+85
View File
@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - MumBullet Dashboard</title>
<link rel="stylesheet" href="/style.css">
</head>
<body class="login-page">
<div class="login-container">
<h1>MumBullet Dashboard</h1>
<div class="card">
<div class="card-header">
<h2>Login</h2>
</div>
<div class="card-content">
<!--ERROR-->
<form id="loginForm" action="/api/login" method="post">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<div id="loginError" class="error" style="display: none;">
Invalid username or password.
</div>
<div class="form-actions">
<button type="submit">Login</button>
</div>
</form>
<script>
// Check if there's an error parameter in the URL
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('error') && urlParams.get('error') === 'invalid') {
document.getElementById('loginError').style.display = 'block';
}
// Handle form submission with fetch API instead of traditional form submission
document.getElementById('loginForm').addEventListener('submit', async function(e) {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: `username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`,
// Important for cookies to work
credentials: 'include'
});
if (response.redirected) {
// Follow the redirect manually
window.location.href = response.url;
return;
}
if (response.ok) {
// Successful login, redirect to dashboard
window.location.href = '/';
} else {
// Show error message
document.getElementById('loginError').style.display = 'block';
console.error('Login failed:', await response.text());
}
} catch (error) {
console.error('Login error:', error);
document.getElementById('loginError').textContent = 'An error occurred. Please try again.';
document.getElementById('loginError').style.display = 'block';
}
});
</script>
</div>
</div>
</div>
</body>
</html>
+287
View File
@@ -0,0 +1,287 @@
// DOM Elements
const queueTab = document.getElementById('queueTab');
const usersTab = document.getElementById('usersTab');
const cacheTab = document.getElementById('cacheTab');
const logoutButton = document.getElementById('logoutButton');
const queueSection = document.getElementById('queueSection');
const usersSection = document.getElementById('usersSection');
const cacheSection = document.getElementById('cacheSection');
const nowPlaying = document.getElementById('nowPlaying');
const queueList = document.getElementById('queueList');
const clearQueueButton = document.getElementById('clearQueueButton');
const usersTable = document.getElementById('usersTable');
const addUserButton = document.getElementById('addUserButton');
const addUserModal = document.getElementById('addUserModal');
const addUserForm = document.getElementById('addUserForm');
const cancelAddUser = document.getElementById('cancelAddUser');
const cacheSongCount = document.getElementById('cacheSongCount');
const cacheSize = document.getElementById('cacheSize');
const cacheMaxSize = document.getElementById('cacheMaxSize');
const cacheUsage = document.getElementById('cacheUsage');
const clearCacheButton = document.getElementById('clearCacheButton');
// Tab switching
function switchTab(tab, section) {
// Remove active class from all tabs and sections
[queueTab, usersTab, cacheTab].forEach(t => t.classList.remove('active'));
[queueSection, usersSection, cacheSection].forEach(s => s.classList.remove('active'));
// Add active class to selected tab and section
tab.classList.add('active');
section.classList.add('active');
}
queueTab.addEventListener('click', () => switchTab(queueTab, queueSection));
usersTab.addEventListener('click', () => switchTab(usersTab, usersSection));
cacheTab.addEventListener('click', () => switchTab(cacheTab, cacheSection));
// Logout
logoutButton.addEventListener('click', async () => {
try {
await fetch('/api/logout', {
method: 'POST',
credentials: 'include', // Important for cookies to work
});
window.location.href = '/login';
} catch (error) {
console.error('Logout failed:', error);
}
});
// Queue management
function formatDuration(seconds) {
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${minutes}:${secs.toString().padStart(2, '0')}`;
}
async function loadQueue() {
try {
const response = await fetch('/api/queue', {
credentials: 'include', // Important for cookies to work
});
const responseData = await response.json();
// Update now playing
if (responseData.current_song) {
nowPlaying.innerHTML = `
<div class="song-info">
<h4>${responseData.current_song.title}</h4>
<p>${formatDuration(responseData.current_song.duration)}</p>
</div>
<div class="song-status">
<span class="badge">${responseData.state}</span>
</div>
`;
} else {
nowPlaying.innerHTML = '<p>Nothing playing</p>';
}
// Update queue
if (responseData.queue && responseData.queue.length > 0) {
queueList.innerHTML = responseData.queue.map((songItem, idx) => {
if (songItem.is_current) {
return `
<li class="now-playing">
<div class="song-info">
<strong>${songItem.title}</strong>
<span>${formatDuration(songItem.duration)}</span>
</div>
<div class="song-status">
<span class="badge">Now Playing</span>
</div>
</li>
`;
} else {
return `
<li>
<div class="song-info">
<strong>${idx + 1}. ${songItem.title}</strong>
<span>${formatDuration(songItem.duration)}</span>
</div>
</li>
`;
}
}).join('');
} else {
queueList.innerHTML = '<li class="empty-message">Queue is empty</li>';
}
} catch (error) {
console.error('Failed to load queue:', error);
queueList.innerHTML = '<li class="empty-message">Failed to load queue</li>';
}
}
clearQueueButton.addEventListener('click', async () => {
if (!confirm('Are you sure you want to clear the queue?')) return;
try {
await fetch('/api/queue', {
method: 'DELETE',
credentials: 'include', // Important for cookies to work
});
loadQueue();
} catch (error) {
console.error('Failed to clear queue:', error);
alert('Failed to clear queue');
}
});
// User management
async function loadUsers() {
try {
const response = await fetch('/api/users', {
credentials: 'include', // Important for cookies to work
});
const responseData = await response.json();
if (responseData.users && responseData.users.length > 0) {
const tbody = usersTable.querySelector('tbody');
tbody.innerHTML = responseData.users.map(userItem => `
<tr>
<td>${userItem.username}</td>
<td>
<select class="permission-select" data-username="${userItem.username}">
<option value="0" ${userItem.permission_level == 0 ? 'selected' : ''}>None</option>
<option value="1" ${userItem.permission_level == 1 ? 'selected' : ''}>View Only</option>
<option value="2" ${userItem.permission_level == 2 ? 'selected' : ''}>Read/Write</option>
<option value="3" ${userItem.permission_level == 3 ? 'selected' : ''}>Admin</option>
</select>
</td>
<td>
<button class="save-permission" data-username="${userItem.username}">Save</button>
</td>
</tr>
`).join('');
// Add event listeners to save buttons
document.querySelectorAll('.save-permission').forEach(button => {
button.addEventListener('click', async () => {
const username = button.dataset.username;
const select = document.querySelector(`.permission-select[data-username="${username}"]`);
const permissionLevel = parseInt(select.value);
try {
await fetch(`/api/users/${username}/permissions`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({ permission_level: permissionLevel }),
credentials: 'include', // Important for cookies to work
});
alert(`Permission updated for ${username}`);
} catch (error) {
console.error('Failed to update permission:', error);
alert('Failed to update permission');
}
});
});
} else {
usersTable.querySelector('tbody').innerHTML = '<tr class="empty-message"><td colspan="3">No users found</td></tr>';
}
} catch (error) {
console.error('Failed to load users:', error);
usersTable.querySelector('tbody').innerHTML = '<tr class="empty-message"><td colspan="3">Failed to load users</td></tr>';
}
}
// Add user modal
addUserButton.addEventListener('click', () => {
addUserModal.classList.add('active');
});
cancelAddUser.addEventListener('click', () => {
addUserModal.classList.remove('active');
addUserForm.reset();
});
addUserForm.addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('newUsername').value;
const permissionLevel = parseInt(document.getElementById('newPermissionLevel').value);
try {
await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, permission_level: permissionLevel }),
credentials: 'include', // Important for cookies to work
});
addUserModal.classList.remove('active');
addUserForm.reset();
loadUsers();
} catch (error) {
console.error('Failed to add user:', error);
alert('Failed to add user');
}
});
// Cache management
async function loadCacheStats() {
try {
const response = await fetch('/api/cache', {
credentials: 'include', // Important for cookies to work
headers: {
'Accept': 'application/json'
}
});
const stats = await response.json();
cacheSongCount.textContent = stats.songCount;
cacheSize.textContent = `${stats.totalSizeMb} MB`;
cacheMaxSize.textContent = `${stats.maxSizeMb} MB`;
const usagePercent = stats.maxSizeMb > 0
? (stats.totalSizeMb / stats.maxSizeMb) * 100
: 0;
cacheUsage.style.width = `${Math.min(usagePercent, 100)}%`;
// Change color based on usage
if (usagePercent > 90) {
cacheUsage.style.backgroundColor = 'var(--danger-color)';
} else if (usagePercent > 70) {
cacheUsage.style.backgroundColor = 'var(--accent-color)';
} else {
cacheUsage.style.backgroundColor = 'var(--primary-color)';
}
} catch (error) {
console.error('Failed to load cache stats:', error);
}
}
clearCacheButton.addEventListener('click', async () => {
if (!confirm('Are you sure you want to clear the cache? This will delete all downloaded audio files.')) return;
try {
await fetch('/api/cache', {
method: 'DELETE',
credentials: 'include', // Important for cookies to work
});
loadCacheStats();
} catch (error) {
console.error('Failed to clear cache:', error);
alert('Failed to clear cache');
}
});
// Initial load
document.addEventListener('DOMContentLoaded', () => {
loadQueue();
loadUsers();
loadCacheStats();
// Refresh data periodically
setInterval(loadQueue, 10000); // Every 10 seconds
setInterval(loadCacheStats, 30000); // Every 30 seconds
});
+319
View File
@@ -0,0 +1,319 @@
/* Base styles */
:root {
--primary-color: #4a6da7;
--primary-dark: #345a96;
--secondary-color: #f5f5f5;
--accent-color: #ff9800;
--danger-color: #f44336;
--text-color: #333333;
--text-light: #ffffff;
--border-color: #dddddd;
--card-bg: #ffffff;
--bg-color: #f0f2f5;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
line-height: 1.6;
color: var(--text-color);
background-color: var(--bg-color);
}
a {
color: var(--primary-color);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
button {
cursor: pointer;
padding: 8px 16px;
background-color: var(--primary-color);
color: var(--text-light);
border: none;
border-radius: 4px;
font-size: 14px;
transition: background-color 0.2s;
}
button:hover {
background-color: var(--primary-dark);
}
.danger-button {
background-color: var(--danger-color);
}
.danger-button:hover {
background-color: #d32f2f;
}
/* Layout */
header {
background-color: var(--primary-color);
color: var(--text-light);
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
header h1 {
font-size: 1.5rem;
margin: 0;
}
nav {
display: flex;
gap: 10px;
}
main {
max-width: 1200px;
margin: 0 auto;
padding: 1.5rem;
}
/* Cards */
.card {
background-color: var(--card-bg);
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 1.5rem;
overflow: hidden;
}
.card-header {
padding: 1rem;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header h3 {
margin: 0;
font-size: 1.2rem;
}
.card-content {
padding: 1rem;
}
/* Tabs */
.tab-button {
background: transparent;
color: rgba(255, 255, 255, 0.7);
border: none;
padding: 8px 16px;
border-radius: 4px;
}
.tab-button.active {
background: rgba(255, 255, 255, 0.2);
color: white;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* Queue */
.queue-list {
list-style: none;
}
.queue-list li {
padding: 10px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.queue-list li:last-child {
border-bottom: none;
}
.queue-list .now-playing {
background-color: rgba(74, 109, 167, 0.1);
border-left: 4px solid var(--primary-color);
}
/* Tables */
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th, .data-table td {
padding: 10px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.data-table th {
background-color: var(--secondary-color);
font-weight: 600;
}
/* Forms */
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
.form-group input, .form-group select {
width: 100%;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 16px;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
}
/* Modal */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal.active {
display: flex;
}
.modal-content {
background-color: var(--card-bg);
border-radius: 8px;
width: 90%;
max-width: 500px;
padding: 20px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.modal-content h3 {
margin-bottom: 15px;
}
/* Stats */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
}
.stat-item {
display: flex;
flex-direction: column;
}
.stat-label {
font-size: 14px;
color: #666;
}
.stat-value {
font-size: 24px;
font-weight: 600;
}
/* Progress bar */
.progress-bar {
width: 100%;
height: 10px;
background-color: #e0e0e0;
border-radius: 5px;
overflow: hidden;
margin-top: 10px;
}
.progress-value {
height: 100%;
background-color: var(--primary-color);
}
/* Login page */
.login-page {
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.login-container {
width: 90%;
max-width: 400px;
}
.login-container h1 {
text-align: center;
margin-bottom: 20px;
color: var(--primary-color);
}
.error {
background-color: rgba(244, 67, 54, 0.1);
color: var(--danger-color);
padding: 10px;
border-radius: 4px;
margin-bottom: 15px;
border-left: 4px solid var(--danger-color);
}
/* Empty states */
.empty-message {
text-align: center;
color: #999;
padding: 20px 0;
}
/* Responsive */
@media (max-width: 768px) {
header {
flex-direction: column;
align-items: flex-start;
}
nav {
margin-top: 10px;
width: 100%;
overflow-x: auto;
}
.stats-grid {
grid-template-columns: 1fr 1fr;
}
}