/ parenscript / profile.lisp
profile.lisp
1 ;;;; profile.lisp - ParenScript version of profile.js 2 ;;;; User profile page with listening stats and history 3 4 (in-package #:asteroid) 5 6 (defparameter *profile-js* 7 (ps:ps* 8 '(progn 9 10 ;; Global state 11 (defvar *current-user* nil) 12 (defvar *listening-data* nil) 13 14 ;; Utility functions 15 (defun update-element (data-text value) 16 (let ((element (ps:chain document (query-selector (+ "[data-text=\"" data-text "\"]"))))) 17 (when (and element (not (= value undefined)) (not (= value null))) 18 (setf (ps:@ element text-content) value)))) 19 20 (defun format-role (role) 21 (let ((role-map (ps:create 22 "admin" "👑 Admin" 23 "dj" "🎧 DJ" 24 "listener" "🎵 Listener"))) 25 (or (ps:getprop role-map role) role))) 26 27 (defun format-date (date-string) 28 (let ((date (ps:new (-date date-string)))) 29 (ps:chain date (to-locale-date-string "en-US" 30 (ps:create :year "numeric" 31 :month "long" 32 :day "numeric"))))) 33 34 (defun format-relative-time (date-string) 35 (let* ((date (ps:new (-date date-string))) 36 (now (ps:new (-date))) 37 (diff-ms (- now date)) 38 (diff-days (ps:chain -math (floor (/ diff-ms (* 1000 60 60 24))))) 39 (diff-hours (ps:chain -math (floor (/ diff-ms (* 1000 60 60))))) 40 (diff-minutes (ps:chain -math (floor (/ diff-ms (* 1000 60)))))) 41 (cond 42 ((> diff-days 0) 43 (+ diff-days " day" (if (> diff-days 1) "s" "") " ago")) 44 ((> diff-hours 0) 45 (+ diff-hours " hour" (if (> diff-hours 1) "s" "") " ago")) 46 ((> diff-minutes 0) 47 (+ diff-minutes " minute" (if (> diff-minutes 1) "s" "") " ago")) 48 (t "Just now")))) 49 50 (defun format-duration (seconds) 51 (let ((hours (ps:chain -math (floor (/ seconds 3600)))) 52 (minutes (ps:chain -math (floor (/ (rem seconds 3600) 60))))) 53 (if (> hours 0) 54 (+ hours "h " minutes "m") 55 (+ minutes "m")))) 56 57 (defun show-message (message &optional (type "info")) 58 (let ((toast (ps:chain document (create-element "div"))) 59 (colors (ps:create 60 "info" "#007bff" 61 "success" "#28a745" 62 "error" "#dc3545" 63 "warning" "#ffc107"))) 64 (setf (ps:@ toast class-name) (+ "toast toast-" type)) 65 (setf (ps:@ toast text-content) message) 66 (setf (ps:@ toast style css-text) 67 "position: fixed; top: 20px; right: 20px; padding: 12px 20px; border-radius: 4px; color: white; font-weight: bold; z-index: 1000; opacity: 0; transition: opacity 0.3s ease;") 68 (setf (ps:@ toast style background-color) (or (ps:getprop colors type) (ps:getprop colors "info"))) 69 70 (ps:chain document body (append-child toast)) 71 72 (set-timeout (lambda () (setf (ps:@ toast style opacity) "1")) 100) 73 (set-timeout (lambda () 74 (setf (ps:@ toast style opacity) "0") 75 (set-timeout (lambda () (ps:chain document body (remove-child toast))) 300)) 76 3000))) 77 78 (defun show-error (message) 79 (show-message message "error")) 80 81 ;; Profile data loading 82 (defun update-profile-display (user) 83 (update-element "username" (or (ps:@ user username) "Unknown User")) 84 (update-element "user-role" (format-role (or (ps:@ user role) "listener"))) 85 (update-element "join-date" (format-date (or (ps:@ user created_at) (ps:new (-date))))) 86 (update-element "last-active" (format-relative-time (or (ps:@ user last_active) (ps:new (-date))))) 87 88 (let ((admin-link (ps:chain document (query-selector "[data-show-if-admin]")))) 89 (when admin-link 90 (setf (ps:@ admin-link style display) 91 (if (= (ps:@ user role) "admin") "inline" "none"))))) 92 93 (defun load-listening-stats () 94 (ps:chain 95 (fetch "/api/asteroid/user/listening-stats") 96 (then (lambda (response) (ps:chain response (json)))) 97 (then (lambda (result) 98 (let ((data (or (ps:@ result data) result))) 99 (when (= (ps:@ data status) "success") 100 (let ((stats (ps:@ data stats))) 101 (update-element "total-listen-time" (format-duration (or (ps:@ stats total_listen_time) 0))) 102 (update-element "tracks-played" (or (ps:@ stats tracks_played) 0)) 103 (update-element "session-count" (or (ps:@ stats session_count) 0)) 104 (update-element "favorite-genre" (or (ps:@ stats favorite_genre) "Unknown"))))))) 105 (catch (lambda (error) 106 (ps:chain console (error "Error loading listening stats:" error)) 107 (update-element "total-listen-time" "0h 0m") 108 (update-element "tracks-played" "0") 109 (update-element "session-count" "0") 110 (update-element "favorite-genre" "Unknown"))))) 111 112 (defun load-recent-tracks () 113 (ps:chain 114 (fetch "/api/asteroid/user/recent-tracks?limit=3") 115 (then (lambda (response) (ps:chain response (json)))) 116 (then (lambda (result) 117 (let ((data (or (ps:@ result data) result))) 118 (if (and (= (ps:@ data status) "success") 119 (ps:@ data tracks) 120 (> (ps:@ data tracks length) 0)) 121 (ps:chain data tracks 122 (for-each (lambda (track index) 123 (let ((track-num (+ index 1))) 124 (update-element (+ "recent-track-" track-num "-title") 125 (or (ps:@ track title) "Unknown Track")) 126 (update-element (+ "recent-track-" track-num "-artist") 127 (or (ps:@ track artist) "Unknown Artist")) 128 (update-element (+ "recent-track-" track-num "-duration") 129 (format-duration (or (ps:@ track duration) 0))) 130 (update-element (+ "recent-track-" track-num "-played-at") 131 (format-relative-time (ps:@ track played_at))))))) 132 (loop for i from 1 to 3 133 do (let* ((track-item-selector (+ "[data-text=\"recent-track-" i "-title\"]")) 134 (track-item-el (ps:chain document (query-selector track-item-selector))) 135 (track-item (when track-item-el (ps:chain track-item-el (closest ".track-item"))))) 136 (when (and track-item 137 (or (not (ps:@ data tracks)) 138 (not (ps:getprop (ps:@ data tracks) (- i 1))))) 139 (setf (ps:@ track-item style display) "none")))))))) 140 (catch (lambda (error) 141 (ps:chain console (error "Error loading recent tracks:" error)))))) 142 143 (defun load-top-artists () 144 (ps:chain 145 (fetch "/api/asteroid/user/top-artists?limit=5") 146 (then (lambda (response) (ps:chain response (json)))) 147 (then (lambda (result) 148 (let ((data (or (ps:@ result data) result))) 149 (if (and (= (ps:@ data status) "success") 150 (ps:@ data artists) 151 (> (ps:@ data artists length) 0)) 152 (ps:chain data artists 153 (for-each (lambda (artist index) 154 (let ((artist-num (+ index 1))) 155 (update-element (+ "top-artist-" artist-num) 156 (or (ps:@ artist name) "Unknown Artist")) 157 (update-element (+ "top-artist-" artist-num "-plays") 158 (+ (or (ps:@ artist play_count) 0) " plays")))))) 159 (loop for i from 1 to 5 160 do (let* ((artist-item-selector (+ "[data-text=\"top-artist-" i "\"]")) 161 (artist-item-el (ps:chain document (query-selector artist-item-selector))) 162 (artist-item (when artist-item-el (ps:chain artist-item-el (closest ".artist-item"))))) 163 (when (and artist-item 164 (or (not (ps:@ data artists)) 165 (not (ps:getprop (ps:@ data artists) (- i 1))))) 166 (setf (ps:@ artist-item style display) "none")))))))) 167 (catch (lambda (error) 168 (ps:chain console (error "Error loading top artists:" error)))))) 169 170 (defun load-profile-data () 171 (ps:chain console (log "Loading profile data...")) 172 173 (ps:chain 174 (fetch "/api/asteroid/user/profile") 175 (then (lambda (response) (ps:chain response (json)))) 176 (then (lambda (result) 177 (let ((data (or (ps:@ result data) result))) 178 (if (= (ps:@ data status) "success") 179 (progn 180 (setf *current-user* (ps:@ data user)) 181 (update-profile-display (ps:@ data user))) 182 (progn 183 (ps:chain console (error "Failed to load profile:" (ps:@ data message))) 184 (show-error "Failed to load profile data")))))) 185 (catch (lambda (error) 186 (ps:chain console (error "Error loading profile:" error)) 187 (show-error "Error loading profile data")))) 188 189 (load-listening-stats) 190 (load-recent-tracks) 191 (load-top-artists)) 192 193 ;; Action functions 194 (defun load-more-recent-tracks () 195 (ps:chain console (log "Loading more recent tracks...")) 196 (show-message "Loading more tracks..." "info")) 197 198 (defun edit-profile () 199 (ps:chain console (log "Edit profile clicked")) 200 (show-message "Profile editing coming soon!" "info")) 201 202 (defun export-listening-data () 203 (ps:chain console (log "Exporting listening data...")) 204 (show-message "Preparing data export..." "info") 205 206 (ps:chain 207 (fetch "/api/asteroid/user/export-data" (ps:create :method "POST")) 208 (then (lambda (response) (ps:chain response (blob)))) 209 (then (lambda (blob) 210 (let* ((url (ps:chain window -u-r-l (create-object-u-r-l blob))) 211 (a (ps:chain document (create-element "a")))) 212 (setf (ps:@ a style display) "none") 213 (setf (ps:@ a href) url) 214 (setf (ps:@ a download) (+ "asteroid-listening-data-" 215 (or (ps:@ *current-user* username) "user") 216 ".json")) 217 (ps:chain document body (append-child a)) 218 (ps:chain a (click)) 219 (ps:chain window -u-r-l (revoke-object-u-r-l url)) 220 (show-message "Data exported successfully!" "success")))) 221 (catch (lambda (error) 222 (ps:chain console (error "Error exporting data:" error)) 223 (show-message "Failed to export data" "error"))))) 224 225 (defun clear-listening-history () 226 (when (not (confirm "Are you sure you want to clear your listening history? This action cannot be undone.")) 227 (return)) 228 229 (ps:chain console (log "Clearing listening history...")) 230 (show-message "Clearing listening history..." "info") 231 232 (ps:chain 233 (fetch "/api/asteroid/user/clear-history" (ps:create :method "POST")) 234 (then (lambda (response) (ps:chain response (json)))) 235 (then (lambda (data) 236 (if (= (ps:@ data status) "success") 237 (progn 238 (show-message "Listening history cleared successfully!" "success") 239 (set-timeout (lambda () (ps:chain location (reload))) 1500)) 240 (show-message (+ "Failed to clear history: " (ps:@ data message)) "error")))) 241 (catch (lambda (error) 242 (ps:chain console (error "Error clearing history:" error)) 243 (show-message "Failed to clear history" "error"))))) 244 245 ;; Password change 246 (defun change-password (event) 247 (ps:chain event (prevent-default)) 248 249 (let ((current-password (ps:@ (ps:chain document (get-element-by-id "current-password")) value)) 250 (new-password (ps:@ (ps:chain document (get-element-by-id "new-password")) value)) 251 (confirm-password (ps:@ (ps:chain document (get-element-by-id "confirm-password")) value)) 252 (message-div (ps:chain document (get-element-by-id "password-message")))) 253 254 ;; Client-side validation 255 (cond 256 ((< (ps:@ new-password length) 8) 257 (setf (ps:@ message-div text-content) "New password must be at least 8 characters") 258 (setf (ps:@ message-div class-name) "message error") 259 (return false)) 260 ((not (= new-password confirm-password)) 261 (setf (ps:@ message-div text-content) "New passwords do not match") 262 (setf (ps:@ message-div class-name) "message error") 263 (return false))) 264 265 ;; Send request to API 266 (let ((form-data (ps:new (-form-data)))) 267 (ps:chain form-data (append "current-password" current-password)) 268 (ps:chain form-data (append "new-password" new-password)) 269 270 (ps:chain 271 (fetch "/api/asteroid/user/change-password" 272 (ps:create :method "POST" :body form-data)) 273 (then (lambda (response) (ps:chain response (json)))) 274 (then (lambda (data) 275 (if (or (= (ps:@ data status) "success") 276 (and (ps:@ data data) (= (ps:@ data data status) "success"))) 277 (progn 278 (setf (ps:@ message-div text-content) "Password changed successfully!") 279 (setf (ps:@ message-div class-name) "message success") 280 (ps:chain (ps:chain document (get-element-by-id "change-password-form")) (reset))) 281 (progn 282 (setf (ps:@ message-div text-content) 283 (or (ps:@ data message) 284 (ps:@ data data message) 285 "Failed to change password")) 286 (setf (ps:@ message-div class-name) "message error"))))) 287 (catch (lambda (error) 288 (ps:chain console (error "Error changing password:" error)) 289 (setf (ps:@ message-div text-content) "Error changing password") 290 (setf (ps:@ message-div class-name) "message error"))))) 291 292 false)) 293 294 ;; Initialize on page load 295 (ps:chain window 296 (add-event-listener 297 "DOMContentLoaded" 298 load-profile-data)))) 299 "Compiled JavaScript for profile page - generated at load time") 300 301 (defun generate-profile-js () 302 "Return the pre-compiled JavaScript for profile page" 303 *profile-js*)