diff --git a/.playback-state.lisp b/.playback-state.lisp new file mode 100644 index 0000000..5d78b71 --- /dev/null +++ b/.playback-state.lisp @@ -0,0 +1,4 @@ +(:TRACK-FILE + "/home/glenn/SourceCode/asteroid/music/library/Brian Eno/2022 - ForeverAndEverNoMore/08 I’m Hardly Me.flac" + :PLAYLIST "/home/glenn/SourceCode/asteroid/playlists/evening-descent.m3u" + :TIMESTAMP 3984767087) \ No newline at end of file diff --git a/cl-streamer b/cl-streamer new file mode 160000 index 0000000..b38f4d1 --- /dev/null +++ b/cl-streamer @@ -0,0 +1 @@ +Subproject commit b38f4d1f8cb0df919761281162f4debaad123e72 diff --git a/parenscript/admin.lisp b/parenscript/admin.lisp index 02086e4..778be97 100644 --- a/parenscript/admin.lisp +++ b/parenscript/admin.lisp @@ -533,16 +533,41 @@ (defun show-toast (message) (let ((toast (ps:chain document (create-element "div")))) (setf (ps:@ toast text-content) message) - (setf (ps:@ toast style css-text) - "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 toast class-list (add "toast-notification")) (ps:chain document body (append-child toast)) - (set-timeout (lambda () - (setf (ps:@ toast style opacity) "0") - (setf (ps:@ toast style transition) "opacity 0.3s") + (ps:chain toast class-list (add "toast-fading")) (set-timeout (lambda () (ps:chain toast (remove))) 300)) 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 (defun add-random-tracks () (when (= (ps:@ *tracks* length) 0) @@ -648,25 +673,25 @@ (unless (confirm (+ "Load playlist '" name "'? This will replace the current stream queue.")) (return)) - (ps:chain - (fetch (+ "/api/asteroid/stream/playlists/load?name=" (encode-u-r-i-component name)) - (ps:create :method "POST")) - (then (lambda (response) (ps:chain response (json)))) - (then (lambda (result) - (let ((data (or (ps:@ result data) result))) - (if (= (ps:@ data status) "success") - (progn - (show-toast (+ "✓ Loaded " (ps:@ data count) " tracks from " name)) - (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"))) - (when channel-name - (update-channel-selector-name channel-name)))) - (alert (+ "Error loading playlist: " (or (ps:@ data message) "Unknown error"))))))) - (catch (lambda (error) - (ps:chain console (error "Error loading playlist:" error)) - (alert "Error loading playlist")))))) + (with-button-feedback "load-playlist-btn" + (lambda () + (ps:chain + (fetch (+ "/api/asteroid/stream/playlists/load?name=" (encode-u-r-i-component name)) + (ps:create :method "POST")) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (= (ps:@ data status) "success") + (progn + (show-toast (+ "✓ Loaded " (ps:@ data count) " tracks from " name)) + (load-current-queue) + (let ((channel-name (aref data "channel-name"))) + (when channel-name + (update-channel-selector-name channel-name)))) + (alert (+ "Error loading playlist: " (or (ps:@ data message) "Unknown error"))))))) + (catch (lambda (error) + (ps:chain console (error "Error loading playlist:" error)) + (alert "Error loading playlist")))))))) ;; Load current queue contents (from stream-queue.m3u) (defun load-current-queue () @@ -737,17 +762,19 @@ ;; Save current queue to stream-queue.m3u (defun save-stream-queue () - (ps:chain - (fetch "/api/asteroid/stream/playlists/save" (ps:create :method "POST")) - (then (lambda (response) (ps:chain response (json)))) - (then (lambda (result) - (let ((data (or (ps:@ result data) result))) - (if (= (ps:@ data status) "success") - (show-toast "✓ Queue saved") - (alert (+ "Error saving queue: " (or (ps:@ data message) "Unknown error"))))))) - (catch (lambda (error) - (ps:chain console (error "Error saving queue:" error)) - (alert "Error saving queue"))))) + (with-button-feedback "save-queue-btn" + (lambda () + (ps:chain + (fetch "/api/asteroid/stream/playlists/save" (ps:create :method "POST")) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (= (ps:@ data status) "success") + (show-toast "✓ Queue saved") + (alert (+ "Error saving queue: " (or (ps:@ data message) "Unknown error"))))))) + (catch (lambda (error) + (ps:chain console (error "Error saving queue:" error)) + (alert "Error saving queue"))))))) ;; Save queue as new playlist (defun save-queue-as-new () @@ -757,40 +784,44 @@ (alert "Please enter a name for the new playlist") (return)) - (ps:chain - (fetch (+ "/api/asteroid/stream/playlists/save-as?name=" (encode-u-r-i-component name)) - (ps:create :method "POST")) - (then (lambda (response) (ps:chain response (json)))) - (then (lambda (result) - (let ((data (or (ps:@ result data) result))) - (if (= (ps:@ data status) "success") - (progn - (show-toast (+ "✓ Saved as " name)) - (setf (ps:@ input value) "") - (load-playlist-list)) - (alert (+ "Error saving playlist: " (or (ps:@ data message) "Unknown error"))))))) - (catch (lambda (error) - (ps:chain console (error "Error saving playlist:" error)) - (alert "Error saving playlist")))))) + (with-button-feedback "save-as-btn" + (lambda () + (ps:chain + (fetch (+ "/api/asteroid/stream/playlists/save-as?name=" (encode-u-r-i-component name)) + (ps:create :method "POST")) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (= (ps:@ data status) "success") + (progn + (show-toast (+ "✓ Saved as " name)) + (setf (ps:@ input value) "") + (load-playlist-list)) + (alert (+ "Error saving playlist: " (or (ps:@ data message) "Unknown error"))))))) + (catch (lambda (error) + (ps:chain console (error "Error saving playlist:" error)) + (alert "Error saving playlist")))))))) ;; Clear stream queue (updated to use new API) (defun clear-stream-queue () (unless (confirm "Clear the stream queue? Liquidsoap will fall back to random playback from the music library.") (return)) - (ps:chain - (fetch "/api/asteroid/stream/playlists/clear" (ps:create :method "POST")) - (then (lambda (response) (ps:chain response (json)))) - (then (lambda (result) - (let ((data (or (ps:@ result data) result))) - (if (= (ps:@ data status) "success") - (progn - (show-toast "✓ Queue cleared") - (load-current-queue)) - (alert (+ "Error clearing queue: " (or (ps:@ data message) "Unknown error"))))))) - (catch (lambda (error) - (ps:chain console (error "Error clearing queue:" error)) - (alert "Error clearing queue"))))) + (with-button-feedback "clear-queue-btn" + (lambda () + (ps:chain + (fetch "/api/asteroid/stream/playlists/clear" (ps:create :method "POST")) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (= (ps:@ data status) "success") + (progn + (show-toast "✓ Queue cleared") + (load-current-queue)) + (alert (+ "Error clearing queue: " (or (ps:@ data message) "Unknown error"))))))) + (catch (lambda (error) + (ps:chain console (error "Error clearing queue:" error)) + (alert "Error clearing queue"))))))) ;; ======================================== ;; Liquidsoap Control Functions @@ -798,92 +829,99 @@ ;; Refresh Liquidsoap status (defun refresh-liquidsoap-status () - (ps:chain - (fetch "/api/asteroid/liquidsoap/status") - (then (lambda (response) (ps:chain response (json)))) - (then (lambda (result) - (let ((data (or (ps:@ result data) result))) - (when (= (ps:@ data status) "success") - (let ((uptime-el (ps:chain document (get-element-by-id "ls-uptime"))) - (remaining-el (ps:chain document (get-element-by-id "ls-remaining"))) - (metadata-el (ps:chain document (get-element-by-id "ls-metadata")))) - (when uptime-el - (setf (ps:@ uptime-el text-content) (or (ps:@ data uptime) "--"))) - (when remaining-el - (setf (ps:@ remaining-el text-content) (or (ps:@ data remaining) "--"))) - (when metadata-el - (setf (ps:@ metadata-el text-content) (or (ps:@ data metadata) "--")))))))) - (catch (lambda (error) - (ps:chain console (error "Error fetching Liquidsoap status:" error)))))) + (with-button-feedback "ls-refresh-status" + (lambda () + (ps:chain + (fetch "/api/asteroid/liquidsoap/status") + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (when (= (ps:@ data status) "success") + (let ((uptime-el (ps:chain document (get-element-by-id "ls-uptime"))) + (remaining-el (ps:chain document (get-element-by-id "ls-remaining"))) + (metadata-el (ps:chain document (get-element-by-id "ls-metadata")))) + (when uptime-el + (setf (ps:@ uptime-el text-content) (or (ps:@ data uptime) "--"))) + (when remaining-el + (setf (ps:@ remaining-el text-content) (or (ps:@ data remaining) "--"))) + (when metadata-el + (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 (defun liquidsoap-skip () - (ps:chain - (fetch "/api/asteroid/liquidsoap/skip" (ps:create :method "POST")) - (then (lambda (response) (ps:chain response (json)))) - (then (lambda (result) - (let ((data (or (ps:@ result data) result))) - (if (= (ps:@ data status) "success") - (progn - (show-toast "⏭️ Track skipped") - (set-timeout refresh-liquidsoap-status 1000)) - (alert (+ "Error skipping track: " (or (ps:@ data message) "Unknown error"))))))) - (catch (lambda (error) - (ps:chain console (error "Error skipping track:" error)) - (alert "Error skipping track"))))) + (with-button-feedback "ls-skip" + (lambda () + (ps:chain + (fetch "/api/asteroid/liquidsoap/skip" (ps:create :method "POST")) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (= (ps:@ data status) "success") + (progn + (show-toast "⏭️ Track skipped") + (set-timeout refresh-liquidsoap-status 1000)) + (alert (+ "Error skipping track: " (or (ps:@ data message) "Unknown error"))))))) + (catch (lambda (error) + (ps:chain console (error "Error skipping track:" error)) + (alert "Error skipping track"))))))) ;; Reload playlist (defun liquidsoap-reload () - (ps:chain - (fetch "/api/asteroid/liquidsoap/reload" (ps:create :method "POST")) - (then (lambda (response) (ps:chain response (json)))) - (then (lambda (result) - (let ((data (or (ps:@ result data) result))) - (if (= (ps:@ data status) "success") - (show-toast "📂 Playlist reloaded") - (alert (+ "Error reloading playlist: " (or (ps:@ data message) "Unknown error"))))))) - (catch (lambda (error) - (ps:chain console (error "Error reloading playlist:" error)) - (alert "Error reloading playlist"))))) + (with-button-feedback "ls-reload" + (lambda () + (ps:chain + (fetch "/api/asteroid/liquidsoap/reload" (ps:create :method "POST")) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (= (ps:@ data status) "success") + (show-toast "📂 Playlist reloaded") + (alert (+ "Error reloading playlist: " (or (ps:@ data message) "Unknown error"))))))) + (catch (lambda (error) + (ps:chain console (error "Error reloading playlist:" error)) + (alert "Error reloading playlist"))))))) ;; Restart Liquidsoap container (defun liquidsoap-restart () (unless (confirm "Restart Liquidsoap container? This will cause a brief interruption to the stream.") (return)) - (show-toast "🔄 Restarting Liquidsoap...") - (ps:chain - (fetch "/api/asteroid/liquidsoap/restart" (ps:create :method "POST")) - (then (lambda (response) (ps:chain response (json)))) - (then (lambda (result) - (let ((data (or (ps:@ result data) result))) - (if (= (ps:@ data status) "success") - (progn - (show-toast "✓ Liquidsoap restarting") - ;; Refresh status after a delay to let container restart - (set-timeout refresh-liquidsoap-status 5000)) - (alert (+ "Error restarting Liquidsoap: " (or (ps:@ data message) "Unknown error"))))))) - (catch (lambda (error) - (ps:chain console (error "Error restarting Liquidsoap:" error)) - (alert "Error restarting Liquidsoap"))))) + (with-button-feedback "ls-restart" + (lambda () + (ps:chain + (fetch "/api/asteroid/liquidsoap/restart" (ps:create :method "POST")) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (= (ps:@ data status) "success") + (progn + (show-toast "✓ Liquidsoap restarting") + (set-timeout refresh-liquidsoap-status 5000)) + (alert (+ "Error restarting Liquidsoap: " (or (ps:@ data message) "Unknown error"))))))) + (catch (lambda (error) + (ps:chain console (error "Error restarting Liquidsoap:" error)) + (alert "Error restarting Liquidsoap"))))))) ;; Restart Icecast container (defun icecast-restart () (unless (confirm "Restart Icecast container? This will disconnect all listeners temporarily.") (return)) - (show-toast "🔄 Restarting Icecast...") - (ps:chain - (fetch "/api/asteroid/icecast/restart" (ps:create :method "POST")) - (then (lambda (response) (ps:chain response (json)))) - (then (lambda (result) - (let ((data (or (ps:@ result data) result))) - (if (= (ps:@ data status) "success") - (show-toast "✓ Icecast restarting - listeners will reconnect automatically") - (alert (+ "Error restarting Icecast: " (or (ps:@ data message) "Unknown error"))))))) - (catch (lambda (error) - (ps:chain console (error "Error restarting Icecast:" error)) - (alert "Error restarting Icecast"))))) + (with-button-feedback "icecast-restart" + (lambda () + (ps:chain + (fetch "/api/asteroid/icecast/restart" (ps:create :method "POST")) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (= (ps:@ data status) "success") + (show-toast "✓ Icecast restarting - listeners will reconnect automatically") + (alert (+ "Error restarting Icecast: " (or (ps:@ data message) "Unknown error"))))))) + (catch (lambda (error) + (ps:chain console (error "Error restarting Icecast:" error)) + (alert "Error restarting Icecast"))))))) ;; ======================================== ;; Listener Statistics @@ -1135,10 +1173,12 @@ ;; Refresh scheduler status (defun refresh-scheduler-status () - (ps:chain - (fetch "/api/asteroid/scheduler/status") - (then (lambda (response) (ps:chain response (json)))) - (then (lambda (result) + (with-button-feedback "scheduler-refresh" + (lambda () + (ps:chain + (fetch "/api/asteroid/scheduler/status") + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) (let ((data (or (ps:@ result data) result))) (when (= (ps:@ data status) "success") ;; Update server time @@ -1194,8 +1234,8 @@ "" "")))))) (setf (ps:@ table-body inner-h-t-m-l) html)))))))) - (catch (lambda (error) - (ps:chain console (error "Error loading scheduler status:" error)))))) + (catch (lambda (error) + (ps:chain console (error "Error loading scheduler status:" error)))))))) ;; Add or update schedule entry (defun add-schedule-entry () @@ -1245,52 +1285,58 @@ ;; Enable scheduler (defun enable-scheduler () - (ps:chain - (fetch "/api/asteroid/scheduler/enable" (ps:create :method "POST")) - (then (lambda (response) (ps:chain response (json)))) - (then (lambda (result) - (let ((data (or (ps:@ result data) result))) - (if (= (ps:@ data status) "success") - (progn - (show-toast "✓ Scheduler enabled") - (refresh-scheduler-status)) - (alert (+ "Error: " (or (ps:@ data message) "Unknown error"))))))) - (catch (lambda (error) - (ps:chain console (error "Error enabling scheduler:" error)) - (alert "Error enabling scheduler"))))) + (with-button-feedback "scheduler-enable" + (lambda () + (ps:chain + (fetch "/api/asteroid/scheduler/enable" (ps:create :method "POST")) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (= (ps:@ data status) "success") + (progn + (show-toast "✓ Scheduler enabled") + (refresh-scheduler-status)) + (alert (+ "Error: " (or (ps:@ data message) "Unknown error"))))))) + (catch (lambda (error) + (ps:chain console (error "Error enabling scheduler:" error)) + (alert "Error enabling scheduler"))))))) ;; Disable scheduler (defun disable-scheduler () - (ps:chain - (fetch "/api/asteroid/scheduler/disable" (ps:create :method "POST")) - (then (lambda (response) (ps:chain response (json)))) - (then (lambda (result) - (let ((data (or (ps:@ result data) result))) - (if (= (ps:@ data status) "success") - (progn - (show-toast "⏸️ Scheduler disabled") - (refresh-scheduler-status)) - (alert (+ "Error: " (or (ps:@ data message) "Unknown error"))))))) - (catch (lambda (error) - (ps:chain console (error "Error disabling scheduler:" error)) - (alert "Error disabling scheduler"))))) + (with-button-feedback "scheduler-disable" + (lambda () + (ps:chain + (fetch "/api/asteroid/scheduler/disable" (ps:create :method "POST")) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (= (ps:@ data status) "success") + (progn + (show-toast "⏸️ Scheduler disabled") + (refresh-scheduler-status)) + (alert (+ "Error: " (or (ps:@ data message) "Unknown error"))))))) + (catch (lambda (error) + (ps:chain console (error "Error disabling scheduler:" error)) + (alert "Error disabling scheduler"))))))) ;; Load current scheduled playlist (defun load-current-scheduled-playlist () - (ps:chain - (fetch "/api/asteroid/scheduler/load-current" (ps:create :method "POST")) - (then (lambda (response) (ps:chain response (json)))) - (then (lambda (result) - (let ((data (or (ps:@ result data) result))) - (if (= (ps:@ data status) "success") - (progn - (show-toast (+ "✓ Loaded " (ps:@ data playlist))) - (refresh-scheduler-status) - (load-current-queue)) - (alert (+ "Error: " (or (ps:@ data message) "Unknown error"))))))) - (catch (lambda (error) - (ps:chain console (error "Error loading scheduled playlist:" error)) - (alert "Error loading scheduled playlist"))))) + (with-button-feedback "scheduler-load-current" + (lambda () + (ps:chain + (fetch "/api/asteroid/scheduler/load-current" (ps:create :method "POST")) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (= (ps:@ data status) "success") + (progn + (show-toast (+ "✓ Loaded " (ps:@ data playlist))) + (refresh-scheduler-status) + (load-current-queue)) + (alert (+ "Error: " (or (ps:@ data message) "Unknown error"))))))) + (catch (lambda (error) + (ps:chain console (error "Error loading scheduled playlist:" error)) + (alert "Error loading scheduled playlist"))))))) ;; ======================================== ;; Track Requests Management diff --git a/static/asteroid.css b/static/asteroid.css index 621a5e6..a40418a 100644 --- a/static/asteroid.css +++ b/static/asteroid.css @@ -2352,4 +2352,87 @@ body.popout-body .status-mini{ .status-rejected{ 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; + } } \ No newline at end of file diff --git a/static/asteroid.lass b/static/asteroid.lass index e817f48..d6703e4 100644 --- a/static/asteroid.lass +++ b/static/asteroid.lass @@ -1907,4 +1907,50 @@ (.status-rejected :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