feat: add visual feedback to admin control buttons

- Add with-button-feedback helper that shows loading/success/error states
- Button pulses while request is in-flight, flashes green on success, red on error
- Button is disabled during request to prevent double-clicks
- Migrate show-toast from inline styles to CSS class (toast-notification)
- Add LASS styles: btn-loading, btn-success-flash, btn-error-flash, keyframe animations
- Wrap all admin controls: liquidsoap (refresh/skip/reload/restart), icecast restart,
  queue (save/save-as/clear), playlist (load), scheduler (refresh/enable/disable/load)
- No inline CSS or JS added - all styles in asteroid.lass, all logic in ParenScript
This commit is contained in:
Glenn Thompson 2026-04-13 09:59:40 +01:00 committed by Brian O'Reilly
parent b3790bcb25
commit ed5ede437b
5 changed files with 357 additions and 177 deletions

4
.playback-state.lisp Normal file
View File

@ -0,0 +1,4 @@
(:TRACK-FILE
"/home/glenn/SourceCode/asteroid/music/library/Brian Eno/2022 - ForeverAndEverNoMore/08 Im Hardly Me.flac"
:PLAYLIST "/home/glenn/SourceCode/asteroid/playlists/evening-descent.m3u"
:TIMESTAMP 3984767087)

1
cl-streamer Submodule

@ -0,0 +1 @@
Subproject commit b38f4d1f8cb0df919761281162f4debaad123e72

View File

@ -533,16 +533,41 @@
(defun show-toast (message) (defun show-toast (message)
(let ((toast (ps:chain document (create-element "div")))) (let ((toast (ps:chain document (create-element "div"))))
(setf (ps:@ toast text-content) message) (setf (ps:@ toast text-content) message)
(setf (ps:@ toast style css-text) (ps:chain toast class-list (add "toast-notification"))
"position: fixed; bottom: 20px; right: 20px; background: #00ff00; color: #000; padding: 12px 20px; border-radius: 4px; font-weight: bold; z-index: 10000; animation: slideIn 0.3s ease-out;")
(ps:chain document body (append-child toast)) (ps:chain document body (append-child toast))
(set-timeout (lambda () (set-timeout (lambda ()
(setf (ps:@ toast style opacity) "0") (ps:chain toast class-list (add "toast-fading"))
(setf (ps:@ toast style transition) "opacity 0.3s")
(set-timeout (lambda () (ps:chain toast (remove))) 300)) (set-timeout (lambda () (ps:chain toast (remove))) 300))
2000))) 2000)))
;; Button feedback helper - shows loading state during async operations
;; btn-id: the DOM id of the button
;; promise-fn: a function that returns a promise (the actual async work)
(defun with-button-feedback (btn-id promise-fn)
(let ((btn (ps:chain document (get-element-by-id btn-id))))
(if btn
(progn
(ps:chain btn class-list (add "btn-loading"))
(setf (ps:@ btn disabled) t)
(ps:chain
(promise-fn)
(then (lambda (result)
(ps:chain btn class-list (remove "btn-loading"))
(ps:chain btn class-list (add "btn-success-flash"))
(set-timeout (lambda ()
(ps:chain btn class-list (remove "btn-success-flash"))
(setf (ps:@ btn disabled) nil))
800)
result))
(catch (lambda (error)
(ps:chain btn class-list (remove "btn-loading"))
(ps:chain btn class-list (add "btn-error-flash"))
(set-timeout (lambda ()
(ps:chain btn class-list (remove "btn-error-flash"))
(setf (ps:@ btn disabled) nil))
800)))))
(promise-fn))))
;; Add random tracks to queue ;; Add random tracks to queue
(defun add-random-tracks () (defun add-random-tracks ()
(when (= (ps:@ *tracks* length) 0) (when (= (ps:@ *tracks* length) 0)
@ -648,6 +673,8 @@
(unless (confirm (+ "Load playlist '" name "'? This will replace the current stream queue.")) (unless (confirm (+ "Load playlist '" name "'? This will replace the current stream queue."))
(return)) (return))
(with-button-feedback "load-playlist-btn"
(lambda ()
(ps:chain (ps:chain
(fetch (+ "/api/asteroid/stream/playlists/load?name=" (encode-u-r-i-component name)) (fetch (+ "/api/asteroid/stream/playlists/load?name=" (encode-u-r-i-component name))
(ps:create :method "POST")) (ps:create :method "POST"))
@ -658,15 +685,13 @@
(progn (progn
(show-toast (+ "✓ Loaded " (ps:@ data count) " tracks from " name)) (show-toast (+ "✓ Loaded " (ps:@ data count) " tracks from " name))
(load-current-queue) (load-current-queue)
;; Update channel name in all channel selectors
;; Use bracket notation because API returns "channel-name" with hyphen
(let ((channel-name (aref data "channel-name"))) (let ((channel-name (aref data "channel-name")))
(when channel-name (when channel-name
(update-channel-selector-name channel-name)))) (update-channel-selector-name channel-name))))
(alert (+ "Error loading playlist: " (or (ps:@ data message) "Unknown error"))))))) (alert (+ "Error loading playlist: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error) (catch (lambda (error)
(ps:chain console (error "Error loading playlist:" error)) (ps:chain console (error "Error loading playlist:" error))
(alert "Error loading playlist")))))) (alert "Error loading playlist"))))))))
;; Load current queue contents (from stream-queue.m3u) ;; Load current queue contents (from stream-queue.m3u)
(defun load-current-queue () (defun load-current-queue ()
@ -737,6 +762,8 @@
;; Save current queue to stream-queue.m3u ;; Save current queue to stream-queue.m3u
(defun save-stream-queue () (defun save-stream-queue ()
(with-button-feedback "save-queue-btn"
(lambda ()
(ps:chain (ps:chain
(fetch "/api/asteroid/stream/playlists/save" (ps:create :method "POST")) (fetch "/api/asteroid/stream/playlists/save" (ps:create :method "POST"))
(then (lambda (response) (ps:chain response (json)))) (then (lambda (response) (ps:chain response (json))))
@ -747,7 +774,7 @@
(alert (+ "Error saving queue: " (or (ps:@ data message) "Unknown error"))))))) (alert (+ "Error saving queue: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error) (catch (lambda (error)
(ps:chain console (error "Error saving queue:" error)) (ps:chain console (error "Error saving queue:" error))
(alert "Error saving queue"))))) (alert "Error saving queue")))))))
;; Save queue as new playlist ;; Save queue as new playlist
(defun save-queue-as-new () (defun save-queue-as-new ()
@ -757,6 +784,8 @@
(alert "Please enter a name for the new playlist") (alert "Please enter a name for the new playlist")
(return)) (return))
(with-button-feedback "save-as-btn"
(lambda ()
(ps:chain (ps:chain
(fetch (+ "/api/asteroid/stream/playlists/save-as?name=" (encode-u-r-i-component name)) (fetch (+ "/api/asteroid/stream/playlists/save-as?name=" (encode-u-r-i-component name))
(ps:create :method "POST")) (ps:create :method "POST"))
@ -771,13 +800,15 @@
(alert (+ "Error saving playlist: " (or (ps:@ data message) "Unknown error"))))))) (alert (+ "Error saving playlist: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error) (catch (lambda (error)
(ps:chain console (error "Error saving playlist:" error)) (ps:chain console (error "Error saving playlist:" error))
(alert "Error saving playlist")))))) (alert "Error saving playlist"))))))))
;; Clear stream queue (updated to use new API) ;; Clear stream queue (updated to use new API)
(defun clear-stream-queue () (defun clear-stream-queue ()
(unless (confirm "Clear the stream queue? Liquidsoap will fall back to random playback from the music library.") (unless (confirm "Clear the stream queue? Liquidsoap will fall back to random playback from the music library.")
(return)) (return))
(with-button-feedback "clear-queue-btn"
(lambda ()
(ps:chain (ps:chain
(fetch "/api/asteroid/stream/playlists/clear" (ps:create :method "POST")) (fetch "/api/asteroid/stream/playlists/clear" (ps:create :method "POST"))
(then (lambda (response) (ps:chain response (json)))) (then (lambda (response) (ps:chain response (json))))
@ -790,7 +821,7 @@
(alert (+ "Error clearing queue: " (or (ps:@ data message) "Unknown error"))))))) (alert (+ "Error clearing queue: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error) (catch (lambda (error)
(ps:chain console (error "Error clearing queue:" error)) (ps:chain console (error "Error clearing queue:" error))
(alert "Error clearing queue"))))) (alert "Error clearing queue")))))))
;; ======================================== ;; ========================================
;; Liquidsoap Control Functions ;; Liquidsoap Control Functions
@ -798,6 +829,8 @@
;; Refresh Liquidsoap status ;; Refresh Liquidsoap status
(defun refresh-liquidsoap-status () (defun refresh-liquidsoap-status ()
(with-button-feedback "ls-refresh-status"
(lambda ()
(ps:chain (ps:chain
(fetch "/api/asteroid/liquidsoap/status") (fetch "/api/asteroid/liquidsoap/status")
(then (lambda (response) (ps:chain response (json)))) (then (lambda (response) (ps:chain response (json))))
@ -814,10 +847,12 @@
(when metadata-el (when metadata-el
(setf (ps:@ metadata-el text-content) (or (ps:@ data metadata) "--")))))))) (setf (ps:@ metadata-el text-content) (or (ps:@ data metadata) "--"))))))))
(catch (lambda (error) (catch (lambda (error)
(ps:chain console (error "Error fetching Liquidsoap status:" error)))))) (ps:chain console (error "Error fetching Liquidsoap status:" error))))))))
;; Skip current track ;; Skip current track
(defun liquidsoap-skip () (defun liquidsoap-skip ()
(with-button-feedback "ls-skip"
(lambda ()
(ps:chain (ps:chain
(fetch "/api/asteroid/liquidsoap/skip" (ps:create :method "POST")) (fetch "/api/asteroid/liquidsoap/skip" (ps:create :method "POST"))
(then (lambda (response) (ps:chain response (json)))) (then (lambda (response) (ps:chain response (json))))
@ -830,10 +865,12 @@
(alert (+ "Error skipping track: " (or (ps:@ data message) "Unknown error"))))))) (alert (+ "Error skipping track: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error) (catch (lambda (error)
(ps:chain console (error "Error skipping track:" error)) (ps:chain console (error "Error skipping track:" error))
(alert "Error skipping track"))))) (alert "Error skipping track")))))))
;; Reload playlist ;; Reload playlist
(defun liquidsoap-reload () (defun liquidsoap-reload ()
(with-button-feedback "ls-reload"
(lambda ()
(ps:chain (ps:chain
(fetch "/api/asteroid/liquidsoap/reload" (ps:create :method "POST")) (fetch "/api/asteroid/liquidsoap/reload" (ps:create :method "POST"))
(then (lambda (response) (ps:chain response (json)))) (then (lambda (response) (ps:chain response (json))))
@ -844,14 +881,15 @@
(alert (+ "Error reloading playlist: " (or (ps:@ data message) "Unknown error"))))))) (alert (+ "Error reloading playlist: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error) (catch (lambda (error)
(ps:chain console (error "Error reloading playlist:" error)) (ps:chain console (error "Error reloading playlist:" error))
(alert "Error reloading playlist"))))) (alert "Error reloading playlist")))))))
;; Restart Liquidsoap container ;; Restart Liquidsoap container
(defun liquidsoap-restart () (defun liquidsoap-restart ()
(unless (confirm "Restart Liquidsoap container? This will cause a brief interruption to the stream.") (unless (confirm "Restart Liquidsoap container? This will cause a brief interruption to the stream.")
(return)) (return))
(show-toast "🔄 Restarting Liquidsoap...") (with-button-feedback "ls-restart"
(lambda ()
(ps:chain (ps:chain
(fetch "/api/asteroid/liquidsoap/restart" (ps:create :method "POST")) (fetch "/api/asteroid/liquidsoap/restart" (ps:create :method "POST"))
(then (lambda (response) (ps:chain response (json)))) (then (lambda (response) (ps:chain response (json))))
@ -860,19 +898,19 @@
(if (= (ps:@ data status) "success") (if (= (ps:@ data status) "success")
(progn (progn
(show-toast "✓ Liquidsoap restarting") (show-toast "✓ Liquidsoap restarting")
;; Refresh status after a delay to let container restart
(set-timeout refresh-liquidsoap-status 5000)) (set-timeout refresh-liquidsoap-status 5000))
(alert (+ "Error restarting Liquidsoap: " (or (ps:@ data message) "Unknown error"))))))) (alert (+ "Error restarting Liquidsoap: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error) (catch (lambda (error)
(ps:chain console (error "Error restarting Liquidsoap:" error)) (ps:chain console (error "Error restarting Liquidsoap:" error))
(alert "Error restarting Liquidsoap"))))) (alert "Error restarting Liquidsoap")))))))
;; Restart Icecast container ;; Restart Icecast container
(defun icecast-restart () (defun icecast-restart ()
(unless (confirm "Restart Icecast container? This will disconnect all listeners temporarily.") (unless (confirm "Restart Icecast container? This will disconnect all listeners temporarily.")
(return)) (return))
(show-toast "🔄 Restarting Icecast...") (with-button-feedback "icecast-restart"
(lambda ()
(ps:chain (ps:chain
(fetch "/api/asteroid/icecast/restart" (ps:create :method "POST")) (fetch "/api/asteroid/icecast/restart" (ps:create :method "POST"))
(then (lambda (response) (ps:chain response (json)))) (then (lambda (response) (ps:chain response (json))))
@ -883,7 +921,7 @@
(alert (+ "Error restarting Icecast: " (or (ps:@ data message) "Unknown error"))))))) (alert (+ "Error restarting Icecast: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error) (catch (lambda (error)
(ps:chain console (error "Error restarting Icecast:" error)) (ps:chain console (error "Error restarting Icecast:" error))
(alert "Error restarting Icecast"))))) (alert "Error restarting Icecast")))))))
;; ======================================== ;; ========================================
;; Listener Statistics ;; Listener Statistics
@ -1135,6 +1173,8 @@
;; Refresh scheduler status ;; Refresh scheduler status
(defun refresh-scheduler-status () (defun refresh-scheduler-status ()
(with-button-feedback "scheduler-refresh"
(lambda ()
(ps:chain (ps:chain
(fetch "/api/asteroid/scheduler/status") (fetch "/api/asteroid/scheduler/status")
(then (lambda (response) (ps:chain response (json)))) (then (lambda (response) (ps:chain response (json))))
@ -1195,7 +1235,7 @@
"</tr>")))))) "</tr>"))))))
(setf (ps:@ table-body inner-h-t-m-l) html)))))))) (setf (ps:@ table-body inner-h-t-m-l) html))))))))
(catch (lambda (error) (catch (lambda (error)
(ps:chain console (error "Error loading scheduler status:" error)))))) (ps:chain console (error "Error loading scheduler status:" error))))))))
;; Add or update schedule entry ;; Add or update schedule entry
(defun add-schedule-entry () (defun add-schedule-entry ()
@ -1245,6 +1285,8 @@
;; Enable scheduler ;; Enable scheduler
(defun enable-scheduler () (defun enable-scheduler ()
(with-button-feedback "scheduler-enable"
(lambda ()
(ps:chain (ps:chain
(fetch "/api/asteroid/scheduler/enable" (ps:create :method "POST")) (fetch "/api/asteroid/scheduler/enable" (ps:create :method "POST"))
(then (lambda (response) (ps:chain response (json)))) (then (lambda (response) (ps:chain response (json))))
@ -1257,10 +1299,12 @@
(alert (+ "Error: " (or (ps:@ data message) "Unknown error"))))))) (alert (+ "Error: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error) (catch (lambda (error)
(ps:chain console (error "Error enabling scheduler:" error)) (ps:chain console (error "Error enabling scheduler:" error))
(alert "Error enabling scheduler"))))) (alert "Error enabling scheduler")))))))
;; Disable scheduler ;; Disable scheduler
(defun disable-scheduler () (defun disable-scheduler ()
(with-button-feedback "scheduler-disable"
(lambda ()
(ps:chain (ps:chain
(fetch "/api/asteroid/scheduler/disable" (ps:create :method "POST")) (fetch "/api/asteroid/scheduler/disable" (ps:create :method "POST"))
(then (lambda (response) (ps:chain response (json)))) (then (lambda (response) (ps:chain response (json))))
@ -1273,10 +1317,12 @@
(alert (+ "Error: " (or (ps:@ data message) "Unknown error"))))))) (alert (+ "Error: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error) (catch (lambda (error)
(ps:chain console (error "Error disabling scheduler:" error)) (ps:chain console (error "Error disabling scheduler:" error))
(alert "Error disabling scheduler"))))) (alert "Error disabling scheduler")))))))
;; Load current scheduled playlist ;; Load current scheduled playlist
(defun load-current-scheduled-playlist () (defun load-current-scheduled-playlist ()
(with-button-feedback "scheduler-load-current"
(lambda ()
(ps:chain (ps:chain
(fetch "/api/asteroid/scheduler/load-current" (ps:create :method "POST")) (fetch "/api/asteroid/scheduler/load-current" (ps:create :method "POST"))
(then (lambda (response) (ps:chain response (json)))) (then (lambda (response) (ps:chain response (json))))
@ -1290,7 +1336,7 @@
(alert (+ "Error: " (or (ps:@ data message) "Unknown error"))))))) (alert (+ "Error: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error) (catch (lambda (error)
(ps:chain console (error "Error loading scheduled playlist:" error)) (ps:chain console (error "Error loading scheduled playlist:" error))
(alert "Error loading scheduled playlist"))))) (alert "Error loading scheduled playlist")))))))
;; ======================================== ;; ========================================
;; Track Requests Management ;; Track Requests Management

View File

@ -2353,3 +2353,86 @@ body.popout-body .status-mini{
.status-rejected{ .status-rejected{
border-left: 3px solid #cc0000; border-left: 3px solid #cc0000;
} }
.btn.btn-loading{
opacity: 0.7;
cursor: wait;
position: relative;
pointer-events: none;
animation: btn-pulse 1s ease-in-out infinite;
}
.btn.btn-success-flash{
background: #00cc00 !important;
border-color: #00ff00 !important;
color: #000 !important;
-moz-transition: all 0.3s ease;
-o-transition: all 0.3s ease;
-webkit-transition: all 0.3s ease;
-ms-transition: all 0.3s ease;
transition: all 0.3s ease;
}
.btn.btn-error-flash{
background: #cc0000 !important;
border-color: #ff0000 !important;
color: #fff !important;
-moz-transition: all 0.3s ease;
-o-transition: all 0.3s ease;
-webkit-transition: all 0.3s ease;
-ms-transition: all 0.3s ease;
transition: all 0.3s ease;
}
@keyframes btn-pulse{
0%{
opacity: 0.7;
}
50%{
opacity: 0.4;
}
100%{
opacity: 0.7;
}
}
.toast-notification{
position: fixed;
bottom: 20px;
right: 20px;
background: #00ff00;
color: #000;
padding: 12px 20px;
border-radius: 4px;
font-weight: bold;
z-index: 10000;
animation: toast-slide-in 0.3s ease-out;
-moz-transition: opacity 0.3s;
-o-transition: opacity 0.3s;
-webkit-transition: opacity 0.3s;
-ms-transition: opacity 0.3s;
transition: opacity 0.3s;
}
.toast-notification.toast-fading{
opacity: 0;
}
@keyframes toast-slide-in{
0%{
-moz-transform: translateX(100%);
-o-transform: translateX(100%);
-webkit-transform: translateX(100%);
-ms-transform: translateX(100%);
transform: translateX(100%);
opacity: 0;
}
100%{
-moz-transform: translateX(0);
-o-transform: translateX(0);
-webkit-transform: translateX(0);
-ms-transform: translateX(0);
transform: translateX(0);
opacity: 1;
}
}

View File

@ -1907,4 +1907,50 @@
(.status-rejected (.status-rejected
:border-left "3px solid #cc0000") :border-left "3px solid #cc0000")
;; Button feedback states for admin controls
((:and .btn .btn-loading)
:opacity "0.7"
:cursor "wait"
:position "relative"
:pointer-events "none"
:animation "btn-pulse 1s ease-in-out infinite")
((:and .btn .btn-success-flash)
:background "#00cc00 !important"
:border-color "#00ff00 !important"
:color "#000 !important"
:transition "all 0.3s ease")
((:and .btn .btn-error-flash)
:background "#cc0000 !important"
:border-color "#ff0000 !important"
:color "#fff !important"
:transition "all 0.3s ease")
(:keyframes btn-pulse
("0%" :opacity "0.7")
("50%" :opacity "0.4")
("100%" :opacity "0.7"))
;; Toast notification
(.toast-notification
:position "fixed"
:bottom "20px"
:right "20px"
:background "#00ff00"
:color "#000"
:padding "12px 20px"
:border-radius "4px"
:font-weight "bold"
:z-index "10000"
:animation "toast-slide-in 0.3s ease-out"
:transition "opacity 0.3s")
((:and .toast-notification .toast-fading)
:opacity "0")
(:keyframes toast-slide-in
("0%" :transform "translateX(100%)" :opacity "0")
("100%" :transform "translateX(0)" :opacity "1"))
) ;; End of let block ) ;; End of let block