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:
Glenn Thompson 2025-11-22 10:50:42 +03:00
commit ed13589202
9 changed files with 335 additions and 17 deletions

View File

@ -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"

View File

@ -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)))))))))

View File

@ -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"

View File

@ -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;

View File

@ -210,7 +210,6 @@
:grid-column 2
:grid-row 1
:text-align right))
(.back
:color "#00ffff"
:text-decoration none

View File

@ -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);
}
}
});

View File

@ -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);

View File

@ -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>

View File

@ -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")