/ 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*)