Merge main into parenscript-conversion
- Resolve conflicts in frontend-partials.lisp, asteroid.lass, and templates - Keep improved recently-played styling from parenscript-conversion branch - Incorporate user management updates from main
This commit is contained in:
commit
ed13589202
|
|
@ -196,6 +196,27 @@
|
||||||
("message" . "Playlist not found"))
|
("message" . "Playlist not found"))
|
||||||
:status 404)))))
|
:status 404)))))
|
||||||
|
|
||||||
|
;; Recently played tracks API endpoint
|
||||||
|
(define-api asteroid/recently-played () ()
|
||||||
|
"Get the last 3 played tracks with AllMusic links"
|
||||||
|
(with-error-handling
|
||||||
|
(let ((tracks (get-recently-played)))
|
||||||
|
(api-output `(("status" . "success")
|
||||||
|
("tracks" . ,(mapcar (lambda (track)
|
||||||
|
(let* ((title (getf track :title))
|
||||||
|
(timestamp (getf track :timestamp))
|
||||||
|
(unix-timestamp (universal-time-to-unix timestamp))
|
||||||
|
(parsed (parse-track-title title))
|
||||||
|
(artist (getf parsed :artist))
|
||||||
|
(song (getf parsed :song))
|
||||||
|
(search-url (generate-music-search-url artist song)))
|
||||||
|
`(("title" . ,title)
|
||||||
|
("artist" . ,artist)
|
||||||
|
("song" . ,song)
|
||||||
|
("timestamp" . ,unix-timestamp)
|
||||||
|
("search_url" . ,search-url))))
|
||||||
|
tracks)))))))
|
||||||
|
|
||||||
;; API endpoint to get all tracks (for web player)
|
;; API endpoint to get all tracks (for web player)
|
||||||
(define-api asteroid/tracks () ()
|
(define-api asteroid/tracks () ()
|
||||||
"Get all tracks for web player"
|
"Get all tracks for web player"
|
||||||
|
|
|
||||||
|
|
@ -157,3 +157,59 @@
|
||||||
|
|
||||||
(api-output `(("status" . "success")
|
(api-output `(("status" . "success")
|
||||||
("message" . ,(format nil "Password reset for user: ~a" username)))))))
|
("message" . ,(format nil "Password reset for user: ~a" username)))))))
|
||||||
|
|
||||||
|
(define-api asteroid/user/activate (user-id active) ()
|
||||||
|
"API endpoint for setting the active state of an user account"
|
||||||
|
(format t "Activation of user: #~a set to ~a~%" user-id active)
|
||||||
|
(require-role :admin)
|
||||||
|
(with-error-handling
|
||||||
|
(let ((user (when user-id
|
||||||
|
(find-user-by-id user-id)))
|
||||||
|
(active (if (stringp active)
|
||||||
|
(parse-integer active)
|
||||||
|
active)))
|
||||||
|
|
||||||
|
(unless user
|
||||||
|
(error 'not-found-error :message "User not found"))
|
||||||
|
|
||||||
|
;; Change user active state
|
||||||
|
(let ((result (if (= 0 active)
|
||||||
|
(deactivate-user user-id)
|
||||||
|
(activate-user user-id))))
|
||||||
|
(if result
|
||||||
|
(api-output `(("status" . "success")
|
||||||
|
("message" . ,(format nil "User '~a' ~a."
|
||||||
|
(dm:field user "username")
|
||||||
|
(if (= 0 active)
|
||||||
|
"deactivated"
|
||||||
|
"activated")))))
|
||||||
|
(api-output `(("status" . "error")
|
||||||
|
("message" . ,(format nil "Could not ~a user '~a'."
|
||||||
|
(if (= 0 active)
|
||||||
|
"deactivated"
|
||||||
|
"activated")
|
||||||
|
(dm:field user "username"))))))))))
|
||||||
|
|
||||||
|
(define-api asteroid/user/role (user-id role) ()
|
||||||
|
"API endpoint for setting the access role of an user account"
|
||||||
|
(format t "Role of user: #~a set to ~a~%" user-id role)
|
||||||
|
(require-role :admin)
|
||||||
|
(with-error-handling
|
||||||
|
(let ((user (when user-id
|
||||||
|
(find-user-by-id user-id)))
|
||||||
|
(user-role (intern (string-upcase role) :keyword)))
|
||||||
|
|
||||||
|
(unless user
|
||||||
|
(error 'not-found-error :message "User not found"))
|
||||||
|
|
||||||
|
;; Change user role
|
||||||
|
(let ((result (update-user-role user-id user-role)))
|
||||||
|
(if result
|
||||||
|
(api-output `(("status" . "success")
|
||||||
|
("message" . ,(format nil "User '~a' is now a ~a."
|
||||||
|
(dm:field user "username")
|
||||||
|
role))))
|
||||||
|
(api-output `(("status" . "error")
|
||||||
|
("message" . ,(format nil "Could not set user '~a' as ~a."
|
||||||
|
(dm:field user "username")
|
||||||
|
role)))))))))
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@
|
||||||
|
|
||||||
`((:listenurl . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
|
`((:listenurl . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
|
||||||
(:title . ,title)
|
(:title . ,title)
|
||||||
(:listeners . ,total-listeners)))))))
|
(:listeners . ,total-listeners))))))))
|
||||||
|
|
||||||
(define-api asteroid/partial/now-playing () ()
|
(define-api asteroid/partial/now-playing () ()
|
||||||
"Get Partial HTML with live status from Icecast server"
|
"Get Partial HTML with live status from Icecast server"
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,131 @@ body .now-playing{
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body .recently-played-panel{
|
||||||
|
background: #1a2332;
|
||||||
|
border: 1px solid #2a3441;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .recently-played-panel h3{
|
||||||
|
margin: 0 0 15px 0;
|
||||||
|
color: #00ff00;
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .recently-played-panel .recently-played-list{
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .recently-played-panel .recently-played-list .loading,
|
||||||
|
body .recently-played-panel .recently-played-list .no-tracks,
|
||||||
|
body .recently-played-panel .recently-played-list .error{
|
||||||
|
text-align: center;
|
||||||
|
color: #888;
|
||||||
|
padding: 20px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .recently-played-panel .recently-played-list .error{
|
||||||
|
color: #ff4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .recently-played-panel .recently-played-list.track-list{
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
max-height: none;
|
||||||
|
overflow-y: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .recently-played-panel .recently-played-list.track-item{
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-bottom: 1px solid #2a3441;
|
||||||
|
-moz-transition: background-color 0.2s;
|
||||||
|
-o-transition: background-color 0.2s;
|
||||||
|
-webkit-transition: background-color 0.2s;
|
||||||
|
-ms-transition: background-color 0.2s;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .recently-played-panel .recently-played-list.track-item LAST-CHILD{
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .recently-played-panel .recently-played-list.track-item HOVER{
|
||||||
|
background-color: #2a3441;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .recently-played-panel .recently-played-list.track-info{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
gap: 2px 20px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .recently-played-panel .recently-played-list.track-title{
|
||||||
|
color: #4488ff;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1em;
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .recently-played-panel .recently-played-list.track-artist{
|
||||||
|
color: #4488ff;
|
||||||
|
font-size: 0.9em;
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .recently-played-panel .recently-played-list.track-time{
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.85em;
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .recently-played-panel .recently-played-list.track-meta{
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 2;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
body .recently-played-list .allmusic-link{
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: #4488ff;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.85em;
|
||||||
|
letter-spacing: 0.08rem;
|
||||||
|
-moz-transition: all 0.2s;
|
||||||
|
-o-transition: all 0.2s;
|
||||||
|
-webkit-transition: all 0.2s;
|
||||||
|
-ms-transition: all 0.2s;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .recently-played-list .allmusic-link HOVER{
|
||||||
|
color: #00ff00;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .recently-played-list .allmusic-link svg{
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
body .back{
|
body .back{
|
||||||
color: #00ffff;
|
color: #00ffff;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
|
||||||
|
|
@ -210,7 +210,6 @@
|
||||||
:grid-column 2
|
:grid-column 2
|
||||||
:grid-row 1
|
:grid-row 1
|
||||||
:text-align right))
|
:text-align right))
|
||||||
|
|
||||||
(.back
|
(.back
|
||||||
:color "#00ffff"
|
:color "#00ffff"
|
||||||
:text-decoration none
|
:text-decoration none
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
// Recently Played Tracks functionality
|
||||||
|
|
||||||
|
async function updateRecentlyPlayed() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/asteroid/recently-played');
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
// Radiance wraps API responses in a data envelope
|
||||||
|
const data = result.data || result;
|
||||||
|
|
||||||
|
if (data.status === 'success' && data.tracks && data.tracks.length > 0) {
|
||||||
|
const listEl = document.getElementById('recently-played-list');
|
||||||
|
if (!listEl) return;
|
||||||
|
|
||||||
|
// Build HTML for tracks
|
||||||
|
let html = '<ul class="track-list">';
|
||||||
|
data.tracks.forEach((track, index) => {
|
||||||
|
const timeAgo = formatTimeAgo(track.timestamp);
|
||||||
|
html += `
|
||||||
|
<li class="track-item">
|
||||||
|
<div class="track-info">
|
||||||
|
<div class="track-title">${escapeHtml(track.song)}</div>
|
||||||
|
<div class="track-artist">${escapeHtml(track.artist)}</div>
|
||||||
|
<span class="track-time">${timeAgo}</span>
|
||||||
|
<div class="track-meta">
|
||||||
|
<a href="${track.search_url}" target="_blank" rel="noopener noreferrer" class="allmusic-link">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
|
||||||
|
<polyline points="15 3 21 3 21 9"></polyline>
|
||||||
|
<line x1="10" y1="14" x2="21" y2="3"></line>
|
||||||
|
</svg>
|
||||||
|
MusicBrainz
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
html += '</ul>';
|
||||||
|
listEl.innerHTML = html;
|
||||||
|
} else {
|
||||||
|
const listEl = document.getElementById('recently-played-list');
|
||||||
|
if (listEl) {
|
||||||
|
listEl.innerHTML = '<p class="no-tracks">No tracks played yet</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching recently played:', error);
|
||||||
|
const listEl = document.getElementById('recently-played-list');
|
||||||
|
if (listEl) {
|
||||||
|
listEl.innerHTML = '<p class="error">Error loading recently played tracks</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeAgo(timestamp) {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const diff = now - timestamp;
|
||||||
|
|
||||||
|
if (diff < 60) return 'Just now';
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
return `${Math.floor(diff / 86400)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const panel = document.getElementById('recently-played-panel');
|
||||||
|
if (panel) {
|
||||||
|
updateRecentlyPlayed();
|
||||||
|
// Update every 30 seconds
|
||||||
|
setInterval(updateRecentlyPlayed, 30000);
|
||||||
|
} else {
|
||||||
|
const list = document.getElementById('recently-played-list');
|
||||||
|
if (list) {
|
||||||
|
updateRecentlyPlayed();
|
||||||
|
setInterval(updateRecentlyPlayed, 30000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -90,20 +90,23 @@ function hideUsersTable() {
|
||||||
async function updateUserRole(userId, newRole) {
|
async function updateUserRole(userId, newRole) {
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
formData.append('user-id', userId);
|
||||||
formData.append('role', newRole);
|
formData.append('role', newRole);
|
||||||
|
|
||||||
const response = await fetch(`/api/asteroid/users/${userId}/role`, {
|
const response = await fetch('/api/asteroid/user/role', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
// Handle Radiance API data wrapping
|
||||||
|
const data = result.data || result;
|
||||||
|
|
||||||
if (result.status === 'success') {
|
if (data.status === 'success') {
|
||||||
loadUserStats();
|
loadUserStats();
|
||||||
alert('User role updated successfully');
|
alert(data.message);
|
||||||
} else {
|
} else {
|
||||||
alert('Error updating user role: ' + result.message);
|
alert('Error updating user role: ' + data.message);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating user role:', error);
|
console.error('Error updating user role:', error);
|
||||||
|
|
@ -117,18 +120,25 @@ async function deactivateUser(userId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/asteroid/users/${userId}/deactivate`, {
|
const formData = new FormData();
|
||||||
method: 'POST'
|
formData.append('user-id', userId);
|
||||||
|
formData.append('active', 0);
|
||||||
|
|
||||||
|
const response = await fetch('/api/asteroid/user/activate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
// Handle Radiance API data wrapping
|
||||||
|
const data = result.data || result;
|
||||||
|
|
||||||
if (result.status === 'success') {
|
if (data.status === 'success') {
|
||||||
loadUsers();
|
loadUsers();
|
||||||
loadUserStats();
|
loadUserStats();
|
||||||
alert('User deactivated successfully');
|
alert(data.message);
|
||||||
} else {
|
} else {
|
||||||
alert('Error deactivating user: ' + result.message);
|
alert('Error deactivating user: ' + data.message);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deactivating user:', error);
|
console.error('Error deactivating user:', error);
|
||||||
|
|
@ -137,19 +147,31 @@ async function deactivateUser(userId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function activateUser(userId) {
|
async function activateUser(userId) {
|
||||||
|
if (!confirm('Are you sure you want to activate this user?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/asteroid/users/${userId}/activate`, {
|
|
||||||
method: 'POST'
|
const formData = new FormData();
|
||||||
|
formData.append('user-id', userId);
|
||||||
|
formData.append('active', 1);
|
||||||
|
|
||||||
|
const response = await fetch('/api/asteroid/user/activate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
// Handle Radiance API data wrapping
|
||||||
|
const data = result.data || result;
|
||||||
|
|
||||||
if (result.status === 'success') {
|
if (data.status === 'success') {
|
||||||
loadUsers();
|
loadUsers();
|
||||||
loadUserStats();
|
loadUserStats();
|
||||||
alert('User activated successfully');
|
alert(data.message);
|
||||||
} else {
|
} else {
|
||||||
alert('Error activating user: ' + result.message);
|
alert('Error activating user: ' + data.message);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error activating user:', error);
|
console.error('Error activating user:', error);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
<div id="recently-played-panel" class="recently-played-panel">
|
||||||
|
<h3>Recently Played</h3>
|
||||||
|
<div id="recently-played-list" class="recently-played-list">
|
||||||
|
<p class="loading">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -40,7 +40,10 @@
|
||||||
(defun find-user-by-id (user-id)
|
(defun find-user-by-id (user-id)
|
||||||
"Find a user by ID"
|
"Find a user by ID"
|
||||||
(format t "Looking for user with ID: ~a (type: ~a)~%" user-id (type-of user-id))
|
(format t "Looking for user with ID: ~a (type: ~a)~%" user-id (type-of user-id))
|
||||||
(let ((user (dm:get-one "USERS" (db:query (:= '_id user-id)))))
|
(let* ((user-id (if (stringp user-id)
|
||||||
|
(parse-integer user-id)
|
||||||
|
user-id))
|
||||||
|
(user (dm:get-one "USERS" (db:query (:= '_id user-id)))))
|
||||||
(when user
|
(when user
|
||||||
(format t "Found user '~a' with id #~a~%"
|
(format t "Found user '~a' with id #~a~%"
|
||||||
(dm:field user "username")
|
(dm:field user "username")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue