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"))
|
||||
: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)
|
||||
(define-api asteroid/tracks () ()
|
||||
"Get all tracks for web player"
|
||||
|
|
|
|||
|
|
@ -157,3 +157,59 @@
|
|||
|
||||
(api-output `(("status" . "success")
|
||||
("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*))
|
||||
(:title . ,title)
|
||||
(:listeners . ,total-listeners)))))))
|
||||
(:listeners . ,total-listeners))))))))
|
||||
|
||||
(define-api asteroid/partial/now-playing () ()
|
||||
"Get Partial HTML with live status from Icecast server"
|
||||
|
|
|
|||
|
|
@ -143,6 +143,131 @@ body .now-playing{
|
|||
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{
|
||||
color: #00ffff;
|
||||
text-decoration: none;
|
||||
|
|
|
|||
|
|
@ -210,7 +210,6 @@
|
|||
:grid-column 2
|
||||
:grid-row 1
|
||||
:text-align right))
|
||||
|
||||
(.back
|
||||
:color "#00ffff"
|
||||
: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) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('user-id', userId);
|
||||
formData.append('role', newRole);
|
||||
|
||||
const response = await fetch(`/api/asteroid/users/${userId}/role`, {
|
||||
const response = await fetch('/api/asteroid/user/role', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
// Handle Radiance API data wrapping
|
||||
const data = result.data || result;
|
||||
|
||||
if (result.status === 'success') {
|
||||
if (data.status === 'success') {
|
||||
loadUserStats();
|
||||
alert('User role updated successfully');
|
||||
alert(data.message);
|
||||
} else {
|
||||
alert('Error updating user role: ' + result.message);
|
||||
alert('Error updating user role: ' + data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating user role:', error);
|
||||
|
|
@ -117,18 +120,25 @@ async function deactivateUser(userId) {
|
|||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/asteroid/users/${userId}/deactivate`, {
|
||||
method: 'POST'
|
||||
const formData = new FormData();
|
||||
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();
|
||||
// Handle Radiance API data wrapping
|
||||
const data = result.data || result;
|
||||
|
||||
if (result.status === 'success') {
|
||||
if (data.status === 'success') {
|
||||
loadUsers();
|
||||
loadUserStats();
|
||||
alert('User deactivated successfully');
|
||||
alert(data.message);
|
||||
} else {
|
||||
alert('Error deactivating user: ' + result.message);
|
||||
alert('Error deactivating user: ' + data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deactivating user:', error);
|
||||
|
|
@ -137,19 +147,31 @@ async function deactivateUser(userId) {
|
|||
}
|
||||
|
||||
async function activateUser(userId) {
|
||||
if (!confirm('Are you sure you want to activate this user?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
// Handle Radiance API data wrapping
|
||||
const data = result.data || result;
|
||||
|
||||
if (result.status === 'success') {
|
||||
if (data.status === 'success') {
|
||||
loadUsers();
|
||||
loadUserStats();
|
||||
alert('User activated successfully');
|
||||
alert(data.message);
|
||||
} else {
|
||||
alert('Error activating user: ' + result.message);
|
||||
alert('Error activating user: ' + data.message);
|
||||
}
|
||||
} catch (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)
|
||||
"Find a user by 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
|
||||
(format t "Found user '~a' with id #~a~%"
|
||||
(dm:field user "username")
|
||||
|
|
|
|||
Loading…
Reference in New Issue