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
parent b3790bcb25
commit 40c23a6a17
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,25 +673,25 @@
(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))
(ps:chain (with-button-feedback "load-playlist-btn"
(fetch (+ "/api/asteroid/stream/playlists/load?name=" (encode-u-r-i-component name)) (lambda ()
(ps:create :method "POST")) (ps:chain
(then (lambda (response) (ps:chain response (json)))) (fetch (+ "/api/asteroid/stream/playlists/load?name=" (encode-u-r-i-component name))
(then (lambda (result) (ps:create :method "POST"))
(let ((data (or (ps:@ result data) result))) (then (lambda (response) (ps:chain response (json))))
(if (= (ps:@ data status) "success") (then (lambda (result)
(progn (let ((data (or (ps:@ result data) result)))
(show-toast (+ "✓ Loaded " (ps:@ data count) " tracks from " name)) (if (= (ps:@ data status) "success")
(load-current-queue) (progn
;; Update channel name in all channel selectors (show-toast (+ "✓ Loaded " (ps:@ data count) " tracks from " name))
;; Use bracket notation because API returns "channel-name" with hyphen (load-current-queue)
(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,17 +762,19 @@
;; Save current queue to stream-queue.m3u ;; Save current queue to stream-queue.m3u
(defun save-stream-queue () (defun save-stream-queue ()
(ps:chain (with-button-feedback "save-queue-btn"
(fetch "/api/asteroid/stream/playlists/save" (ps:create :method "POST")) (lambda ()
(then (lambda (response) (ps:chain response (json)))) (ps:chain
(then (lambda (result) (fetch "/api/asteroid/stream/playlists/save" (ps:create :method "POST"))
(let ((data (or (ps:@ result data) result))) (then (lambda (response) (ps:chain response (json))))
(if (= (ps:@ data status) "success") (then (lambda (result)
(show-toast "✓ Queue saved") (let ((data (or (ps:@ result data) result)))
(alert (+ "Error saving queue: " (or (ps:@ data message) "Unknown error"))))))) (if (= (ps:@ data status) "success")
(catch (lambda (error) (show-toast "✓ Queue saved")
(ps:chain console (error "Error saving queue:" error)) (alert (+ "Error saving queue: " (or (ps:@ data message) "Unknown error")))))))
(alert "Error saving queue"))))) (catch (lambda (error)
(ps:chain console (error "Error saving queue:" error))
(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,40 +784,44 @@
(alert "Please enter a name for the new playlist") (alert "Please enter a name for the new playlist")
(return)) (return))
(ps:chain (with-button-feedback "save-as-btn"
(fetch (+ "/api/asteroid/stream/playlists/save-as?name=" (encode-u-r-i-component name)) (lambda ()
(ps:create :method "POST")) (ps:chain
(then (lambda (response) (ps:chain response (json)))) (fetch (+ "/api/asteroid/stream/playlists/save-as?name=" (encode-u-r-i-component name))
(then (lambda (result) (ps:create :method "POST"))
(let ((data (or (ps:@ result data) result))) (then (lambda (response) (ps:chain response (json))))
(if (= (ps:@ data status) "success") (then (lambda (result)
(progn (let ((data (or (ps:@ result data) result)))
(show-toast (+ "✓ Saved as " name)) (if (= (ps:@ data status) "success")
(setf (ps:@ input value) "") (progn
(load-playlist-list)) (show-toast (+ "✓ Saved as " name))
(alert (+ "Error saving playlist: " (or (ps:@ data message) "Unknown error"))))))) (setf (ps:@ input value) "")
(catch (lambda (error) (load-playlist-list))
(ps:chain console (error "Error saving playlist:" error)) (alert (+ "Error saving playlist: " (or (ps:@ data message) "Unknown error")))))))
(alert "Error saving playlist")))))) (catch (lambda (error)
(ps:chain console (error "Error saving playlist:" error))
(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))
(ps:chain (with-button-feedback "clear-queue-btn"
(fetch "/api/asteroid/stream/playlists/clear" (ps:create :method "POST")) (lambda ()
(then (lambda (response) (ps:chain response (json)))) (ps:chain
(then (lambda (result) (fetch "/api/asteroid/stream/playlists/clear" (ps:create :method "POST"))
(let ((data (or (ps:@ result data) result))) (then (lambda (response) (ps:chain response (json))))
(if (= (ps:@ data status) "success") (then (lambda (result)
(progn (let ((data (or (ps:@ result data) result)))
(show-toast "✓ Queue cleared") (if (= (ps:@ data status) "success")
(load-current-queue)) (progn
(alert (+ "Error clearing queue: " (or (ps:@ data message) "Unknown error"))))))) (show-toast "✓ Queue cleared")
(catch (lambda (error) (load-current-queue))
(ps:chain console (error "Error clearing queue:" error)) (alert (+ "Error clearing queue: " (or (ps:@ data message) "Unknown error")))))))
(alert "Error clearing queue"))))) (catch (lambda (error)
(ps:chain console (error "Error clearing queue:" error))
(alert "Error clearing queue")))))))
;; ======================================== ;; ========================================
;; Liquidsoap Control Functions ;; Liquidsoap Control Functions
@ -798,92 +829,99 @@
;; Refresh Liquidsoap status ;; Refresh Liquidsoap status
(defun refresh-liquidsoap-status () (defun refresh-liquidsoap-status ()
(ps:chain (with-button-feedback "ls-refresh-status"
(fetch "/api/asteroid/liquidsoap/status") (lambda ()
(then (lambda (response) (ps:chain response (json)))) (ps:chain
(then (lambda (result) (fetch "/api/asteroid/liquidsoap/status")
(let ((data (or (ps:@ result data) result))) (then (lambda (response) (ps:chain response (json))))
(when (= (ps:@ data status) "success") (then (lambda (result)
(let ((uptime-el (ps:chain document (get-element-by-id "ls-uptime"))) (let ((data (or (ps:@ result data) result)))
(remaining-el (ps:chain document (get-element-by-id "ls-remaining"))) (when (= (ps:@ data status) "success")
(metadata-el (ps:chain document (get-element-by-id "ls-metadata")))) (let ((uptime-el (ps:chain document (get-element-by-id "ls-uptime")))
(when uptime-el (remaining-el (ps:chain document (get-element-by-id "ls-remaining")))
(setf (ps:@ uptime-el text-content) (or (ps:@ data uptime) "--"))) (metadata-el (ps:chain document (get-element-by-id "ls-metadata"))))
(when remaining-el (when uptime-el
(setf (ps:@ remaining-el text-content) (or (ps:@ data remaining) "--"))) (setf (ps:@ uptime-el text-content) (or (ps:@ data uptime) "--")))
(when metadata-el (when remaining-el
(setf (ps:@ metadata-el text-content) (or (ps:@ data metadata) "--")))))))) (setf (ps:@ remaining-el text-content) (or (ps:@ data remaining) "--")))
(catch (lambda (error) (when metadata-el
(ps:chain console (error "Error fetching Liquidsoap status:" error)))))) (setf (ps:@ metadata-el text-content) (or (ps:@ data metadata) "--"))))))))
(catch (lambda (error)
(ps:chain console (error "Error fetching Liquidsoap status:" error))))))))
;; Skip current track ;; Skip current track
(defun liquidsoap-skip () (defun liquidsoap-skip ()
(ps:chain (with-button-feedback "ls-skip"
(fetch "/api/asteroid/liquidsoap/skip" (ps:create :method "POST")) (lambda ()
(then (lambda (response) (ps:chain response (json)))) (ps:chain
(then (lambda (result) (fetch "/api/asteroid/liquidsoap/skip" (ps:create :method "POST"))
(let ((data (or (ps:@ result data) result))) (then (lambda (response) (ps:chain response (json))))
(if (= (ps:@ data status) "success") (then (lambda (result)
(progn (let ((data (or (ps:@ result data) result)))
(show-toast "⏭️ Track skipped") (if (= (ps:@ data status) "success")
(set-timeout refresh-liquidsoap-status 1000)) (progn
(alert (+ "Error skipping track: " (or (ps:@ data message) "Unknown error"))))))) (show-toast "⏭️ Track skipped")
(catch (lambda (error) (set-timeout refresh-liquidsoap-status 1000))
(ps:chain console (error "Error skipping track:" error)) (alert (+ "Error skipping track: " (or (ps:@ data message) "Unknown error")))))))
(alert "Error skipping track"))))) (catch (lambda (error)
(ps:chain console (error "Error skipping track:" error))
(alert "Error skipping track")))))))
;; Reload playlist ;; Reload playlist
(defun liquidsoap-reload () (defun liquidsoap-reload ()
(ps:chain (with-button-feedback "ls-reload"
(fetch "/api/asteroid/liquidsoap/reload" (ps:create :method "POST")) (lambda ()
(then (lambda (response) (ps:chain response (json)))) (ps:chain
(then (lambda (result) (fetch "/api/asteroid/liquidsoap/reload" (ps:create :method "POST"))
(let ((data (or (ps:@ result data) result))) (then (lambda (response) (ps:chain response (json))))
(if (= (ps:@ data status) "success") (then (lambda (result)
(show-toast "📂 Playlist reloaded") (let ((data (or (ps:@ result data) result)))
(alert (+ "Error reloading playlist: " (or (ps:@ data message) "Unknown error"))))))) (if (= (ps:@ data status) "success")
(catch (lambda (error) (show-toast "📂 Playlist reloaded")
(ps:chain console (error "Error reloading playlist:" error)) (alert (+ "Error reloading playlist: " (or (ps:@ data message) "Unknown error")))))))
(alert "Error reloading playlist"))))) (catch (lambda (error)
(ps:chain console (error "Error reloading playlist:" error))
(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"
(ps:chain (lambda ()
(fetch "/api/asteroid/liquidsoap/restart" (ps:create :method "POST")) (ps:chain
(then (lambda (response) (ps:chain response (json)))) (fetch "/api/asteroid/liquidsoap/restart" (ps:create :method "POST"))
(then (lambda (result) (then (lambda (response) (ps:chain response (json))))
(let ((data (or (ps:@ result data) result))) (then (lambda (result)
(if (= (ps:@ data status) "success") (let ((data (or (ps:@ result data) result)))
(progn (if (= (ps:@ data status) "success")
(show-toast "✓ Liquidsoap restarting") (progn
;; Refresh status after a delay to let container restart (show-toast "✓ Liquidsoap restarting")
(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"
(ps:chain (lambda ()
(fetch "/api/asteroid/icecast/restart" (ps:create :method "POST")) (ps:chain
(then (lambda (response) (ps:chain response (json)))) (fetch "/api/asteroid/icecast/restart" (ps:create :method "POST"))
(then (lambda (result) (then (lambda (response) (ps:chain response (json))))
(let ((data (or (ps:@ result data) result))) (then (lambda (result)
(if (= (ps:@ data status) "success") (let ((data (or (ps:@ result data) result)))
(show-toast "✓ Icecast restarting - listeners will reconnect automatically") (if (= (ps:@ data status) "success")
(alert (+ "Error restarting Icecast: " (or (ps:@ data message) "Unknown error"))))))) (show-toast "✓ Icecast restarting - listeners will reconnect automatically")
(catch (lambda (error) (alert (+ "Error restarting Icecast: " (or (ps:@ data message) "Unknown error")))))))
(ps:chain console (error "Error restarting Icecast:" error)) (catch (lambda (error)
(alert "Error restarting Icecast"))))) (ps:chain console (error "Error restarting Icecast:" error))
(alert "Error restarting Icecast")))))))
;; ======================================== ;; ========================================
;; Listener Statistics ;; Listener Statistics
@ -1135,10 +1173,12 @@
;; Refresh scheduler status ;; Refresh scheduler status
(defun refresh-scheduler-status () (defun refresh-scheduler-status ()
(ps:chain (with-button-feedback "scheduler-refresh"
(fetch "/api/asteroid/scheduler/status") (lambda ()
(then (lambda (response) (ps:chain response (json)))) (ps:chain
(then (lambda (result) (fetch "/api/asteroid/scheduler/status")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result))) (let ((data (or (ps:@ result data) result)))
(when (= (ps:@ data status) "success") (when (= (ps:@ data status) "success")
;; Update server time ;; Update server time
@ -1194,8 +1234,8 @@
"<td><button class=\"btn btn-danger btn-sm\" onclick=\"removeScheduleEntry(" hour ")\">🗑️</button></td>" "<td><button class=\"btn btn-danger btn-sm\" onclick=\"removeScheduleEntry(" hour ")\">🗑️</button></td>"
"</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,52 +1285,58 @@
;; Enable scheduler ;; Enable scheduler
(defun enable-scheduler () (defun enable-scheduler ()
(ps:chain (with-button-feedback "scheduler-enable"
(fetch "/api/asteroid/scheduler/enable" (ps:create :method "POST")) (lambda ()
(then (lambda (response) (ps:chain response (json)))) (ps:chain
(then (lambda (result) (fetch "/api/asteroid/scheduler/enable" (ps:create :method "POST"))
(let ((data (or (ps:@ result data) result))) (then (lambda (response) (ps:chain response (json))))
(if (= (ps:@ data status) "success") (then (lambda (result)
(progn (let ((data (or (ps:@ result data) result)))
(show-toast "✓ Scheduler enabled") (if (= (ps:@ data status) "success")
(refresh-scheduler-status)) (progn
(alert (+ "Error: " (or (ps:@ data message) "Unknown error"))))))) (show-toast "✓ Scheduler enabled")
(catch (lambda (error) (refresh-scheduler-status))
(ps:chain console (error "Error enabling scheduler:" error)) (alert (+ "Error: " (or (ps:@ data message) "Unknown error")))))))
(alert "Error enabling scheduler"))))) (catch (lambda (error)
(ps:chain console (error "Error enabling scheduler:" error))
(alert "Error enabling scheduler")))))))
;; Disable scheduler ;; Disable scheduler
(defun disable-scheduler () (defun disable-scheduler ()
(ps:chain (with-button-feedback "scheduler-disable"
(fetch "/api/asteroid/scheduler/disable" (ps:create :method "POST")) (lambda ()
(then (lambda (response) (ps:chain response (json)))) (ps:chain
(then (lambda (result) (fetch "/api/asteroid/scheduler/disable" (ps:create :method "POST"))
(let ((data (or (ps:@ result data) result))) (then (lambda (response) (ps:chain response (json))))
(if (= (ps:@ data status) "success") (then (lambda (result)
(progn (let ((data (or (ps:@ result data) result)))
(show-toast "⏸️ Scheduler disabled") (if (= (ps:@ data status) "success")
(refresh-scheduler-status)) (progn
(alert (+ "Error: " (or (ps:@ data message) "Unknown error"))))))) (show-toast "⏸️ Scheduler disabled")
(catch (lambda (error) (refresh-scheduler-status))
(ps:chain console (error "Error disabling scheduler:" error)) (alert (+ "Error: " (or (ps:@ data message) "Unknown error")))))))
(alert "Error disabling scheduler"))))) (catch (lambda (error)
(ps:chain console (error "Error disabling scheduler:" error))
(alert "Error disabling scheduler")))))))
;; Load current scheduled playlist ;; Load current scheduled playlist
(defun load-current-scheduled-playlist () (defun load-current-scheduled-playlist ()
(ps:chain (with-button-feedback "scheduler-load-current"
(fetch "/api/asteroid/scheduler/load-current" (ps:create :method "POST")) (lambda ()
(then (lambda (response) (ps:chain response (json)))) (ps:chain
(then (lambda (result) (fetch "/api/asteroid/scheduler/load-current" (ps:create :method "POST"))
(let ((data (or (ps:@ result data) result))) (then (lambda (response) (ps:chain response (json))))
(if (= (ps:@ data status) "success") (then (lambda (result)
(progn (let ((data (or (ps:@ result data) result)))
(show-toast (+ "✓ Loaded " (ps:@ data playlist))) (if (= (ps:@ data status) "success")
(refresh-scheduler-status) (progn
(load-current-queue)) (show-toast (+ "✓ Loaded " (ps:@ data playlist)))
(alert (+ "Error: " (or (ps:@ data message) "Unknown error"))))))) (refresh-scheduler-status)
(catch (lambda (error) (load-current-queue))
(ps:chain console (error "Error loading scheduled playlist:" error)) (alert (+ "Error: " (or (ps:@ data message) "Unknown error")))))))
(alert "Error loading scheduled playlist"))))) (catch (lambda (error)
(ps:chain console (error "Error loading scheduled playlist:" error))
(alert "Error loading scheduled playlist")))))))
;; ======================================== ;; ========================================
;; Track Requests Management ;; Track Requests Management

View File

@ -2352,4 +2352,87 @@ 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