Compare commits

...

2 Commits

Author SHA1 Message Date
glenneth 349fa31d8f Fix last-login timezone handling
- Add cl-time-to-unix helper to convert CL universal time to Unix epoch
- Store last-login as UTC time for correct timezone conversion
- Handle Unix epoch in JavaScript (detect seconds vs milliseconds)
2025-12-19 18:00:30 -05:00
glenneth bc7da82d84 Fix playlist display polling and add footer links
- Add /api/asteroid/channel-name endpoint for live channel name updates
- Update front-page.js and stream-player.js to poll server for channel name
  instead of relying on localStorage (fixes issue where listeners don't see
  playlist changes until page refresh)
- Add footer with Internet Radio directory link and craftering webring links
- Fix last-login display bug: use proper ISO 8601 timestamp format and
  parse as Date string instead of Unix epoch
2025-12-19 18:00:30 -05:00
9 changed files with 127 additions and 20 deletions

View File

@ -53,6 +53,14 @@
(setf (session:field "user-id") nil) (setf (session:field "user-id") nil)
(radiance:redirect "/")) (radiance:redirect "/"))
;; Helper to convert Common Lisp universal time to Unix epoch
(defun cl-time-to-unix (cl-time)
"Convert Common Lisp universal time to Unix epoch.
CL epoch is 1900-01-01, Unix epoch is 1970-01-01.
Difference is 2208988800 seconds."
(when cl-time
(- cl-time 2208988800)))
;; API: Get all users (admin only) ;; API: Get all users (admin only)
(define-api asteroid/users () () (define-api asteroid/users () ()
"API endpoint to get all users" "API endpoint to get all users"
@ -66,8 +74,8 @@
("email" . ,(dm:field user "email")) ("email" . ,(dm:field user "email"))
("role" . ,(dm:field user "role")) ("role" . ,(dm:field user "role"))
("active" . ,(= (dm:field user "active") 1)) ("active" . ,(= (dm:field user "active") 1))
("created-date" . ,(dm:field user "created-date")) ("created-date" . ,(cl-time-to-unix (dm:field user "created-date")))
("last-login" . ,(dm:field user "last-login")))) ("last-login" . ,(cl-time-to-unix (dm:field user "last-login")))))
users))))) users)))))
(error (e) (error (e)
(api-output `(("status" . "error") (api-output `(("status" . "error")

View File

@ -96,3 +96,9 @@
(progn (progn
(setf (header "Content-Type") "text/plain") (setf (header "Content-Type") "text/plain")
"Stream Offline"))))) "Stream Offline")))))
(define-api asteroid/channel-name () ()
"Get the current curated channel name for live updates.
Returns JSON with the channel name from the current playlist's PHASE header."
(with-error-handling
(api-output `(("channel_name" . ,(get-curated-channel-name))))))

View File

@ -595,7 +595,36 @@
;; Update now playing every 5 seconds ;; Update now playing every 5 seconds
(set-interval update-now-playing 5000) (set-interval update-now-playing 5000)
;; Listen for messages from popout window ;; Poll server for channel name changes (works across all listeners)
(let ((last-channel-name nil))
(set-interval
(lambda ()
(ps:chain
(fetch "/api/asteroid/channel-name")
(then (lambda (response)
(if (ps:@ response ok)
(ps:chain response (json))
nil)))
(then (lambda (data)
(when data
(let ((current-channel-name (or (ps:@ data data channel_name)
(ps:@ data channel_name))))
(when (and current-channel-name
(not (= current-channel-name last-channel-name)))
(setf last-channel-name current-channel-name)
;; Update localStorage for cross-window sync
(ps:chain local-storage (set-item "curated-channel-name" current-channel-name))
;; Update channel selector in current document
(let ((channel-selector (ps:chain document (get-element-by-id "stream-channel"))))
(when channel-selector
(let ((curated-option (ps:chain channel-selector (query-selector "option[value='curated']"))))
(when curated-option
(setf (ps:@ curated-option text-content) (+ "🎧 " current-channel-name)))))))))))
(catch (lambda (error)
(ps:chain console (log "Could not fetch channel name:" error))))))
10000)) ;; Poll every 10 seconds
;; Listen for messages from popout window
(ps:chain window (ps:chain window
(add-event-listener (add-event-listener
"message" "message"

View File

@ -556,20 +556,33 @@
;; Update quality selector state based on channel ;; Update quality selector state based on channel
(update-quality-selector-state) (update-quality-selector-state)
;; Check for channel name changes from localStorage periodically ;; Poll server for channel name changes (works across all listeners)
(let ((last-channel-name (ps:chain local-storage (get-item "curated-channel-name")))) (let ((last-channel-name nil))
(set-interval (set-interval
(lambda () (lambda ()
(let ((current-channel-name (ps:chain local-storage (get-item "curated-channel-name")))) (ps:chain
(when (and current-channel-name (fetch "/api/asteroid/channel-name")
(not (= current-channel-name last-channel-name))) (then (lambda (response)
(setf last-channel-name current-channel-name) (if (ps:@ response ok)
(let ((channel-selector (ps:chain document (get-element-by-id "stream-channel")))) (ps:chain response (json))
(when channel-selector nil)))
(let ((curated-option (ps:chain channel-selector (query-selector "option[value='curated']")))) (then (lambda (data)
(when curated-option (when data
(setf (ps:@ curated-option text-content) (+ "🎧 " current-channel-name))))))))) (let ((current-channel-name (or (ps:@ data data channel_name)
2000)) (ps:@ data channel_name))))
(when (and current-channel-name
(not (= current-channel-name last-channel-name)))
(setf last-channel-name current-channel-name)
;; Update localStorage for popout player sync
(ps:chain local-storage (set-item "curated-channel-name" current-channel-name))
(let ((channel-selector (ps:chain document (get-element-by-id "stream-channel"))))
(when channel-selector
(let ((curated-option (ps:chain channel-selector (query-selector "option[value='curated']"))))
(when curated-option
(setf (ps:@ curated-option text-content) (+ "🎧 " current-channel-name)))))))))))
(catch (lambda (error)
(ps:chain console (log "Could not fetch channel name:" error))))))
10000)) ;; Poll every 10 seconds
;; Start now playing updates ;; Start now playing updates
(set-timeout update-mini-now-playing 1000) (set-timeout update-mini-now-playing 1000)

View File

@ -57,9 +57,13 @@
"</select>" "</select>"
"</td>" "</td>"
"<td>" (if (ps:@ user active) "✅ Active" "❌ Inactive") "</td>" "<td>" (if (ps:@ user active) "✅ Active" "❌ Inactive") "</td>"
"<td>" (if (ps:getprop user "last-login") "<td>" (let ((login-val (ps:getprop user "last-login")))
(ps:chain (ps:new (-date (* (ps:getprop user "last-login") 1000))) (to-locale-string)) (if login-val
"Never") "</td>" (let ((date-val (if (> login-val 9999999999)
login-val ; Already milliseconds
(* login-val 1000)))) ; Convert seconds to ms
(ps:chain (ps:new (-date date-val)) (to-locale-string)))
"Never")) "</td>"
"<td class=\"user-actions\">" "<td class=\"user-actions\">"
(if (ps:@ user active) (if (ps:@ user active)
(+ "<button class=\"btn btn-danger\" onclick=\"deactivateUser('" (ps:@ user id) "')\">Deactivate</button>") (+ "<button class=\"btn btn-danger\" onclick=\"deactivateUser('" (ps:@ user id) "')\">Deactivate</button>")

View File

@ -1250,4 +1250,28 @@
(0% :opacity 1) (0% :opacity 1)
(50% :opacity 0.3) (50% :opacity 0.3)
(100% :opacity 1)) (100% :opacity 1))
;; Site footer - subtle, unobtrusive
(.site-footer
:text-align "center"
:padding "20px 0"
:margin-top "30px"
:border-top "1px solid #333"
:font-size "0.85em"
:color "#666"
:display "flex"
:justify-content "center"
:gap "30px"
:flex-wrap "wrap"
(a
:color "#888"
:text-decoration "none"
:transition "color 0.2s ease")
((:and a :hover)
:color "#00ff00")
(.craftering
(a :margin "0 5px")))
) ;; End of let block ) ;; End of let block

View File

@ -93,6 +93,15 @@
</div> </div>
</div> </div>
</main> </main>
<footer class="site-footer">
<span>Listed on <a href="http://www.internet-radio.com/stations/ambient/" target="_blank" rel="noopener">Internet Radio</a></span>
<span class="craftering">
<a href="https://craftering.systemcrafters.net/@asteroid/previous">←</a>
<a href="https://craftering.systemcrafters.net/">craftering</a>
<a href="https://craftering.systemcrafters.net/@asteroid/next">→</a>
</span>
</footer>
</div> </div>
</body> </body>
</html> </html>

View File

@ -126,6 +126,15 @@
</div> </div>
</div> </div>
</main> </main>
<footer class="site-footer">
<span>Listed on <a href="http://www.internet-radio.com/stations/ambient/" target="_blank" rel="noopener">Internet Radio</a></span>
<span class="craftering">
<a href="https://craftering.systemcrafters.net/@asteroid/previous">←</a>
<a href="https://craftering.systemcrafters.net/">craftering</a>
<a href="https://craftering.systemcrafters.net/@asteroid/next">→</a>
</span>
</footer>
</div> </div>
</body> </body>
</html> </html>

View File

@ -70,9 +70,14 @@
(when (and (= 1 user-active) (when (and (= 1 user-active)
(verify-password password user-password)) (verify-password password user-password))
;; Update last login using data-model (database agnostic) ;; Update last login using data-model (database agnostic)
;; Use ISO 8601 format in UTC that PostgreSQL TIMESTAMP can parse
(handler-case (handler-case
(progn (progn
(setf (dm:field user "last-login") (format nil "~a" (local-time:now))) (setf (dm:field user "last-login")
(local-time:format-timestring nil (local-time:now)
:format '(:year "-" (:month 2) "-" (:day 2) " "
(:hour 2) ":" (:min 2) ":" (:sec 2))
:timezone local-time:+utc-zone+))
(dm:save user)) (dm:save user))
(error (e) (error (e)
(format t "Warning: Could not update last-login: ~a~%" e))) (format t "Warning: Could not update last-login: ~a~%" e)))