;;;; profile.lisp - ParenScript version of profile.js ;;;; User profile page with listening stats and history (in-package #:asteroid) (defparameter *profile-js* (ps:ps* '(progn ;; Global state (defvar *current-user* nil) (defvar *listening-data* nil) ;; Utility functions (defun update-element (data-text value) (let ((element (ps:chain document (query-selector (+ "[data-text=\"" data-text "\"]"))))) (when (and element (not (= value undefined)) (not (= value null))) (setf (ps:@ element text-content) value)))) (defun format-role (role) (let ((role-map (ps:create "admin" "👑 Admin" "dj" "🎧 DJ" "listener" "🎵 Listener"))) (or (ps:getprop role-map role) role))) (defun format-date (date-string) (let ((date (ps:new (-date date-string)))) (ps:chain date (to-locale-date-string "en-US" (ps:create :year "numeric" :month "long" :day "numeric"))))) (defun format-relative-time (date-string) (when (not date-string) (return-from format-relative-time "Unknown")) ;; Convert PostgreSQL timestamp format to ISO format ;; "2025-12-21 09:22:58.215986" -> "2025-12-21T09:22:58.215986Z" (let* ((iso-string (if (and (ps:@ date-string replace) (ps:chain date-string (includes " "))) (+ (ps:chain date-string (replace " " "T")) "Z") date-string)) (date (ps:new (-date iso-string))) (now (ps:new (-date)))) ;; Check if date is valid (when (ps:chain -number (is-na-n (ps:chain date (get-time)))) (return-from format-relative-time "Recently")) (let* ((diff-ms (- now date)) (diff-days (ps:chain -math (floor (/ diff-ms (* 1000 60 60 24))))) (diff-hours (ps:chain -math (floor (/ diff-ms (* 1000 60 60))))) (diff-minutes (ps:chain -math (floor (/ diff-ms (* 1000 60)))))) (cond ((> diff-days 0) (+ diff-days " day" (if (> diff-days 1) "s" "") " ago")) ((> diff-hours 0) (+ diff-hours " hour" (if (> diff-hours 1) "s" "") " ago")) ((> diff-minutes 0) (+ diff-minutes " minute" (if (> diff-minutes 1) "s" "") " ago")) (t "Just now"))))) (defun format-duration (seconds) (let ((hours (ps:chain -math (floor (/ seconds 3600)))) (minutes (ps:chain -math (floor (/ (rem seconds 3600) 60))))) (if (> hours 0) (+ hours "h " minutes "m") (+ minutes "m")))) (defun show-message (message &optional (type "info")) (let ((toast (ps:chain document (create-element "div"))) (colors (ps:create "info" "#007bff" "success" "#28a745" "error" "#dc3545" "warning" "#ffc107"))) (setf (ps:@ toast class-name) (+ "toast toast-" type)) (setf (ps:@ toast text-content) message) (setf (ps:@ toast style css-text) "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;") (setf (ps:@ toast style background-color) (or (ps:getprop colors type) (ps:getprop colors "info"))) (ps:chain document body (append-child toast)) (set-timeout (lambda () (setf (ps:@ toast style opacity) "1")) 100) (set-timeout (lambda () (setf (ps:@ toast style opacity) "0") (set-timeout (lambda () (ps:chain document body (remove-child toast))) 300)) 3000))) (defun show-error (message) (show-message message "error")) ;; Profile data loading (defun update-profile-display (user) (update-element "username" (or (ps:@ user username) "Unknown User")) (update-element "user-role" (format-role (or (ps:@ user role) "listener"))) (update-element "join-date" (format-date (or (ps:@ user created_at) (ps:new (-date))))) (update-element "last-active" (format-relative-time (or (ps:@ user last_active) (ps:new (-date))))) (let ((admin-link (ps:chain document (query-selector "[data-show-if-admin]")))) (when admin-link (setf (ps:@ admin-link style display) (if (= (ps:@ user role) "admin") "inline" "none"))))) (defun load-listening-stats () (ps:chain (fetch "/api/asteroid/user/listening-stats") (then (lambda (response) (ps:chain response (json)))) (then (lambda (result) (let ((data (or (ps:@ result data) result))) (when (= (ps:@ data status) "success") (let ((stats (ps:@ data stats))) (update-element "total-listen-time" (format-duration (or (ps:@ stats total_listen_time) 0))) (update-element "tracks-played" (or (ps:@ stats tracks_played) 0)) (update-element "session-count" (or (ps:@ stats session_count) 0)) (update-element "favorite-genre" (or (ps:@ stats favorite_genre) "Unknown"))))))) (catch (lambda (error) (ps:chain console (error "Error loading listening stats:" error)) (update-element "total-listen-time" "0h 0m") (update-element "tracks-played" "0") (update-element "session-count" "0") (update-element "favorite-genre" "Unknown"))))) (defun load-top-artists () (ps:chain (fetch "/api/asteroid/user/top-artists?limit=5") (then (lambda (response) (ps:chain response (json)))) (then (lambda (result) (let ((data (or (ps:@ result data) result))) (if (and (= (ps:@ data status) "success") (ps:@ data artists) (> (ps:@ data artists length) 0)) (ps:chain data artists (for-each (lambda (artist index) (let ((artist-num (+ index 1))) (update-element (+ "top-artist-" artist-num) (or (ps:@ artist name) "Unknown Artist")) (update-element (+ "top-artist-" artist-num "-plays") (+ (or (ps:@ artist play_count) 0) " plays")))))) (loop for i from 1 to 5 do (let* ((artist-item-selector (+ "[data-text=\"top-artist-" i "\"]")) (artist-item-el (ps:chain document (query-selector artist-item-selector))) (artist-item (when artist-item-el (ps:chain artist-item-el (closest ".artist-item"))))) (when (and artist-item (or (not (ps:@ data artists)) (not (ps:getprop (ps:@ data artists) (- i 1))))) (setf (ps:@ artist-item style display) "none")))))))) (catch (lambda (error) (ps:chain console (error "Error loading top artists:" error)))))) (defvar *favorites-offset* 0) (defun load-favorites () (ps:chain (fetch "/api/asteroid/user/favorites") (then (lambda (response) (ps:chain response (json)))) (then (lambda (result) (let ((data (or (ps:@ result data) result)) (container (ps:chain document (get-element-by-id "favorites-list")))) (when container (if (and (= (ps:@ data status) "success") (ps:@ data favorites) (> (ps:@ data favorites length) 0)) (progn (setf (ps:@ container inner-h-t-m-l) "") (ps:chain data favorites (for-each (lambda (fav) (let ((item (ps:chain document (create-element "div")))) (setf (ps:@ item class-name) "track-item favorite-item") (setf (ps:@ item inner-h-t-m-l) (+ "
" "" (or (ps:@ fav title) "Unknown") "" "" (or (ps:@ fav artist) "") "" "
" "
" "" (render-stars (or (ps:@ fav rating) 1)) "" "" "
")) (ps:chain container (append-child item))))))) (setf (ps:@ container inner-h-t-m-l) "

No favorites yet. Like tracks while listening!

")))))) (catch (lambda (error) (ps:chain console (error "Error loading favorites:" error)) (let ((container (ps:chain document (get-element-by-id "favorites-list")))) (when container (setf (ps:@ container inner-h-t-m-l) "

Failed to load favorites

"))))))) (defun render-stars (rating) (let ((stars "")) (dotimes (i 5) (setf stars (+ stars (if (< i rating) "★" "☆")))) stars)) (defun remove-favorite (title) (ps:chain (fetch (+ "/api/asteroid/user/favorites/remove?title=" (encode-u-r-i-component title)) (ps:create :method "POST")) (then (lambda (response) (if (ps:@ response ok) (ps:chain response (json)) (throw (ps:new (-error "Request failed")))))) (then (lambda (data) ;; API returns {"status": 200, "data": {"status": "success"}} (let ((inner-status (or (ps:@ data data status) (ps:@ data status)))) (if (or (= inner-status "success") (= (ps:@ data status) 200)) (progn (show-message "Removed from favorites" "success") (load-favorites)) (show-message "Failed to remove favorite" "error"))))) (catch (lambda (error) (ps:chain console (error "Error removing favorite:" error)) (show-message "Error removing favorite" "error"))))) (defun load-more-favorites () (show-message "Loading more favorites..." "info")) (defun load-avatar () (ps:chain (fetch "/api/asteroid/user/avatar") (then (lambda (response) (ps:chain response (json)))) (then (lambda (result) (let ((data (or (ps:@ result data) result))) (when (and (= (ps:@ data status) "success") (ps:@ data avatar_path)) (let ((img (ps:chain document (get-element-by-id "user-avatar")))) (when img (setf (ps:@ img src) (ps:@ data avatar_path)))))))) (catch (lambda (error) (ps:chain console (log "No avatar set or error loading:" error)))))) (defun upload-avatar (input) (let ((file (ps:getprop (ps:@ input files) 0))) (when file (let ((form-data (ps:new (-form-data)))) (ps:chain form-data (append "avatar" file)) (ps:chain form-data (append "filename" (ps:@ file name))) (show-message "Uploading avatar..." "info") (ps:chain (fetch "/api/asteroid/user/avatar/upload" (ps:create :method "POST" :body form-data)) (then (lambda (response) (ps:chain response (json)))) (then (lambda (result) (let ((data (or (ps:@ result data) result))) (if (= (ps:@ data status) "success") (progn (let ((img (ps:chain document (get-element-by-id "user-avatar")))) (when img (setf (ps:@ img src) (+ (ps:@ data avatar_path) "?" (ps:chain -date (now)))))) (show-message "Avatar updated!" "success")) (show-message "Failed to upload avatar" "error"))))) (catch (lambda (error) (ps:chain console (error "Error uploading avatar:" error)) (show-message "Error uploading avatar" "error")))))))) (defun load-activity-chart () (ps:chain (fetch "/api/asteroid/user/activity?days=30") (then (lambda (response) (ps:chain response (json)))) (then (lambda (result) (let ((data (or (ps:@ result data) result)) (container (ps:chain document (get-element-by-id "activity-chart"))) (total-el (ps:chain document (get-element-by-id "activity-total")))) (when container (if (and (= (ps:@ data status) "success") (ps:@ data activity) (> (ps:@ data activity length) 0)) (let ((activity (ps:@ data activity)) (max-count 1) (total 0)) ;; Find max for scaling (ps:chain activity (for-each (lambda (day) (let ((count (or (ps:@ day track_count) 0))) (setf total (+ total count)) (when (> count max-count) (setf max-count count)))))) ;; Build chart HTML (let ((html "
")) (ps:chain activity (for-each (lambda (day) (let* ((count (or (ps:@ day track_count) 0)) (height (ps:chain -math (round (* (/ count max-count) 100)))) (date-raw (ps:@ day day)) (date-str (if (and date-raw (ps:@ date-raw to-string)) (ps:chain date-raw (to-string)) (+ "" date-raw))) (date-parts (if (and date-str (ps:@ date-str split)) (ps:chain date-str (split "-")) (array))) (day-label (if (> (ps:@ date-parts length) 2) (ps:getprop date-parts 2) ""))) (setf html (+ html "
" "
" "" day-label "" "
")))))) (setf html (+ html "
")) (setf (ps:@ container inner-h-t-m-l) html)) ;; Update total (when total-el (setf (ps:@ total-el text-content) (+ "Total: " total " tracks in the last 30 days")))) ;; No data (setf (ps:@ container inner-h-t-m-l) "

No listening activity yet. Start listening to build your history!

")))))) (catch (lambda (error) (ps:chain console (error "Error loading activity:" error)) (let ((container (ps:chain document (get-element-by-id "activity-chart")))) (when container (setf (ps:@ container inner-h-t-m-l) "

Failed to load activity data

"))))))) (defun load-profile-data () (ps:chain console (log "Loading profile data...")) (ps:chain (fetch "/api/asteroid/user/profile") (then (lambda (response) (ps:chain response (json)))) (then (lambda (result) (let ((data (or (ps:@ result data) result))) (if (= (ps:@ data status) "success") (progn (setf *current-user* (ps:@ data user)) (update-profile-display (ps:@ data user))) (progn (ps:chain console (error "Failed to load profile:" (ps:@ data message))) (show-error "Failed to load profile data")))))) (catch (lambda (error) (ps:chain console (error "Error loading profile:" error)) (show-error "Error loading profile data")))) (load-listening-stats) (load-favorites) (load-top-artists) (load-activity-chart) (load-avatar) (load-my-requests)) ;; Load user's track requests (defun load-my-requests () (ps:chain (fetch "/api/asteroid/requests/my") (then (lambda (response) (ps:chain response (json)))) (then (lambda (result) (let ((data (or (ps:@ result data) result)) (container (ps:chain document (get-element-by-id "my-requests-list")))) (when container (if (and (= (ps:@ data status) "success") (ps:@ data requests) (> (ps:@ data requests length) 0)) (let ((html "")) (ps:chain (ps:@ data requests) (for-each (lambda (req) (let ((status-class (cond ((= (ps:@ req status) "pending") "status-pending") ((= (ps:@ req status) "approved") "status-approved") ((= (ps:@ req status) "rejected") "status-rejected") ((= (ps:@ req status) "played") "status-played") (t ""))) (status-icon (cond ((= (ps:@ req status) "pending") "⏳") ((= (ps:@ req status) "approved") "✓") ((= (ps:@ req status) "rejected") "✗") ((= (ps:@ req status) "played") "🎵") (t "?")))) (setf html (+ html "
" "
" (ps:@ req title) "
" "
" "" status-icon " " (ps:@ req status) "" "
" "
")))))) (setf (ps:@ container inner-h-t-m-l) html)) (setf (ps:@ container inner-h-t-m-l) "

You haven't made any requests yet.

")))))) (catch (lambda (error) (ps:chain console (error "Error loading requests:" error)))))) ;; Action functions (defun edit-profile () (ps:chain console (log "Edit profile clicked")) (show-message "Profile editing coming soon!" "info")) (defun export-listening-data () (ps:chain console (log "Exporting listening data...")) (show-message "Preparing data export..." "info") (ps:chain (fetch "/api/asteroid/user/export-data" (ps:create :method "POST")) (then (lambda (response) (ps:chain response (blob)))) (then (lambda (blob) (let* ((url (ps:chain window -u-r-l (create-object-u-r-l blob))) (a (ps:chain document (create-element "a")))) (setf (ps:@ a style display) "none") (setf (ps:@ a href) url) (setf (ps:@ a download) (+ "asteroid-listening-data-" (or (ps:@ *current-user* username) "user") ".json")) (ps:chain document body (append-child a)) (ps:chain a (click)) (ps:chain window -u-r-l (revoke-object-u-r-l url)) (show-message "Data exported successfully!" "success")))) (catch (lambda (error) (ps:chain console (error "Error exporting data:" error)) (show-message "Failed to export data" "error"))))) (defun clear-listening-history () (when (not (confirm "Are you sure you want to clear your listening history? This action cannot be undone.")) (return)) (ps:chain console (log "Clearing listening history...")) (show-message "Clearing listening history..." "info") (ps:chain (fetch "/api/asteroid/user/clear-history" (ps:create :method "POST")) (then (lambda (response) (ps:chain response (json)))) (then (lambda (data) (if (= (ps:@ data status) "success") (progn (show-message "Listening history cleared successfully!" "success") (set-timeout (lambda () (ps:chain location (reload))) 1500)) (show-message (+ "Failed to clear history: " (ps:@ data message)) "error")))) (catch (lambda (error) (ps:chain console (error "Error clearing history:" error)) (show-message "Failed to clear history" "error"))))) ;; Password change (defun change-password (event) (ps:chain event (prevent-default)) (let ((current-password (ps:@ (ps:chain document (get-element-by-id "current-password")) value)) (new-password (ps:@ (ps:chain document (get-element-by-id "new-password")) value)) (confirm-password (ps:@ (ps:chain document (get-element-by-id "confirm-password")) value)) (message-div (ps:chain document (get-element-by-id "password-message")))) ;; Client-side validation (cond ((< (ps:@ new-password length) 8) (setf (ps:@ message-div text-content) "New password must be at least 8 characters") (setf (ps:@ message-div class-name) "message error") (return false)) ((not (= new-password confirm-password)) (setf (ps:@ message-div text-content) "New passwords do not match") (setf (ps:@ message-div class-name) "message error") (return false))) ;; Send request to API (let ((form-data (ps:new (-form-data)))) (ps:chain form-data (append "current-password" current-password)) (ps:chain form-data (append "new-password" new-password)) (ps:chain (fetch "/api/asteroid/user/change-password" (ps:create :method "POST" :body form-data)) (then (lambda (response) (ps:chain response (json)))) (then (lambda (data) (if (or (= (ps:@ data status) "success") (and (ps:@ data data) (= (ps:@ data data status) "success"))) (progn (setf (ps:@ message-div text-content) "Password changed successfully!") (setf (ps:@ message-div class-name) "message success") (ps:chain (ps:chain document (get-element-by-id "change-password-form")) (reset))) (progn (setf (ps:@ message-div text-content) (or (ps:@ data message) (ps:@ data data message) "Failed to change password")) (setf (ps:@ message-div class-name) "message error"))))) (catch (lambda (error) (ps:chain console (error "Error changing password:" error)) (setf (ps:@ message-div text-content) "Error changing password") (setf (ps:@ message-div class-name) "message error"))))) false)) ;; ======================================== ;; User Playlists functionality ;; ======================================== (defvar *library-page* 1) (defvar *library-search* "") (defvar *library-artist* "") (defvar *library-total* 0) (defvar *current-playlist-tracks* (array)) (defvar *user-playlists* (array)) ;; Load user's playlists (defun load-my-playlists () (ps:chain (fetch "/api/asteroid/user/playlists") (then (lambda (response) (ps:chain response (json)))) (then (lambda (result) (let ((data (or (ps:@ result data) result)) (container (ps:chain document (get-element-by-id "my-playlists-list")))) (when container (if (and (= (ps:@ data status) "success") (ps:@ data playlists) (> (ps:@ data playlists length) 0)) (progn (setf *user-playlists* (ps:@ data playlists)) (let ((html "")) (ps:chain (ps:@ data playlists) (for-each (lambda (pl) (let ((playlist-id (or (ps:@ pl id) (aref pl "id"))) (status-class (cond ((= (ps:@ pl status) "draft") "status-draft") ((= (ps:@ pl status) "submitted") "status-pending") ((= (ps:@ pl status) "approved") "status-approved") ((= (ps:@ pl status) "rejected") "status-rejected") (t ""))) (status-icon (cond ((= (ps:@ pl status) "draft") "📝") ((= (ps:@ pl status) "submitted") "⏳") ((= (ps:@ pl status) "approved") "✓") ((= (ps:@ pl status) "rejected") "✗") (t "?")))) (ps:chain console (log "Playlist:" pl "ID:" playlist-id)) (setf html (+ html "
" "
" "" (or (ps:@ pl name) (aref pl "name")) "" "" (or (ps:@ pl track-count) (aref pl "track-count") 0) " tracks" "
" "
" "" status-icon " " (or (ps:@ pl status) (aref pl "status")) "" (if (= (or (ps:@ pl status) (aref pl "status")) "draft") (+ "") "") "
" "
")))))) (setf (ps:@ container inner-h-t-m-l) html))) (setf (ps:@ container inner-h-t-m-l) "

No playlists yet. Create one to get started!

")))))) (catch (lambda (error) (ps:chain console (error "Error loading playlists:" error)))))) ;; Modal functions (defun show-create-playlist-modal () (let ((modal (ps:chain document (get-element-by-id "create-playlist-modal")))) (when modal (setf (ps:@ modal style display) "flex")))) (defun hide-create-playlist-modal () (let ((modal (ps:chain document (get-element-by-id "create-playlist-modal")))) (when modal (setf (ps:@ modal style display) "none") (ps:chain (ps:chain document (get-element-by-id "create-playlist-form")) (reset))))) (defun show-library-browser () (let ((modal (ps:chain document (get-element-by-id "library-browser-modal")))) (when modal (setf (ps:@ modal style display) "flex") (load-library-tracks) (update-playlist-select)))) (defun hide-library-browser () (let ((modal (ps:chain document (get-element-by-id "library-browser-modal")))) (when modal (setf (ps:@ modal style display) "none")))) (defun show-library-browser-for-playlist () (show-library-browser)) (defun show-edit-playlist-modal () (let ((modal (ps:chain document (get-element-by-id "edit-playlist-modal")))) (when modal (setf (ps:@ modal style display) "flex")))) (defun hide-edit-playlist-modal () (let ((modal (ps:chain document (get-element-by-id "edit-playlist-modal")))) (when modal (setf (ps:@ modal style display) "none")))) ;; Create playlist (defun create-playlist (event) (ps:chain event (prevent-default)) (let ((name (ps:@ (ps:chain document (get-element-by-id "playlist-name")) value)) (description (ps:@ (ps:chain document (get-element-by-id "playlist-description")) value)) (message-div (ps:chain document (get-element-by-id "create-playlist-message")))) (ps:chain (fetch (+ "/api/asteroid/user/playlists/create?name=" (encode-u-r-i-component name) "&description=" (encode-u-r-i-component description)) (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-message "Playlist created!" "success") (hide-create-playlist-modal) (load-my-playlists) ;; Open the new playlist for editing (when (ps:@ data playlist id) (edit-playlist (ps:@ data playlist id)))) (progn (setf (ps:@ message-div text-content) (or (ps:@ data message) "Failed to create playlist")) (setf (ps:@ message-div class-name) "message error")))))) (catch (lambda (error) (ps:chain console (error "Error creating playlist:" error)) (setf (ps:@ message-div text-content) "Error creating playlist") (setf (ps:@ message-div class-name) "message error"))))) false) ;; Edit playlist (defun edit-playlist (playlist-id) (ps:chain console (log "edit-playlist called with id:" playlist-id)) (ps:chain (fetch (+ "/api/asteroid/user/playlists/get?id=" playlist-id)) (then (lambda (response) (ps:chain response (json)))) (then (lambda (result) (ps:chain console (log "edit-playlist response:" result)) (let ((data (or (ps:@ result data) result))) (if (= (ps:@ data status) "success") (let* ((pl (ps:@ data playlist)) (pl-id (or (ps:@ pl id) (aref pl "id"))) (pl-name (or (ps:@ pl name) (aref pl "name"))) (pl-desc (or (ps:@ pl description) (aref pl "description") "")) (pl-tracks (or (ps:@ pl tracks) (aref pl "tracks") (array)))) (ps:chain console (log "Playlist id:" pl-id "name:" pl-name)) (setf (ps:@ (ps:chain document (get-element-by-id "current-edit-playlist-id")) value) pl-id) (setf (ps:@ (ps:chain document (get-element-by-id "edit-playlist-name")) value) pl-name) (setf (ps:@ (ps:chain document (get-element-by-id "edit-playlist-description")) value) pl-desc) (setf (ps:@ (ps:chain document (get-element-by-id "edit-playlist-title")) text-content) (+ "Edit: " pl-name)) (setf *current-playlist-tracks* pl-tracks) (render-playlist-tracks) (show-edit-playlist-modal)) (show-message "Failed to load playlist" "error"))))) (catch (lambda (error) (ps:chain console (error "Error loading playlist:" error)) (show-message "Error loading playlist" "error"))))) (defun render-playlist-tracks () (let ((container (ps:chain document (get-element-by-id "playlist-tracks-list")))) (when container (if (> (ps:@ *current-playlist-tracks* length) 0) (let ((html "")) (ps:chain *current-playlist-tracks* (for-each (lambda (track index) (setf html (+ html "
" "" (+ index 1) "." "" (ps:@ track title) "" "" (ps:@ track artist) "" "
" "" "" "" "
" "
"))))) (setf (ps:@ container inner-h-t-m-l) html)) (setf (ps:@ container inner-h-t-m-l) "

No tracks yet. Browse the library to add tracks!

"))))) (defun move-track-in-playlist (index direction) (let ((new-index (+ index direction))) (when (and (>= new-index 0) (< new-index (ps:@ *current-playlist-tracks* length))) (let ((track (ps:chain *current-playlist-tracks* (splice index 1)))) (ps:chain *current-playlist-tracks* (splice new-index 0 (ps:getprop track 0))) (render-playlist-tracks) (save-playlist-tracks))))) (defun remove-track-from-playlist (index) (ps:chain *current-playlist-tracks* (splice index 1)) (render-playlist-tracks) (save-playlist-tracks)) (defun add-track-to-playlist (track-id title artist album) (ps:chain console (log "addTrackToPlaylist called with track-id:" track-id "title:" title)) (let ((playlist-id (ps:@ (ps:chain document (get-element-by-id "current-edit-playlist-id")) value))) (when (not playlist-id) ;; No playlist open, use the select dropdown (let ((select (ps:chain document (get-element-by-id "add-to-playlist-select")))) (when select (setf playlist-id (ps:@ select value))))) (when (not playlist-id) (show-message "Please select a playlist first" "warning") (return)) ;; Add to current tracks array (ps:chain console (log "Adding track with id:" track-id "to playlist:" playlist-id)) ;; Create object and set id property explicitly (let ((track-obj (ps:create))) (setf (ps:@ track-obj id) track-id) (setf (ps:@ track-obj title) title) (setf (ps:@ track-obj artist) artist) (setf (ps:@ track-obj album) album) (ps:chain *current-playlist-tracks* (push track-obj))) (ps:chain console (log "Current tracks:" *current-playlist-tracks*)) (render-playlist-tracks) (save-playlist-tracks) (show-message (+ "Added: " title) "success"))) (defun save-playlist-tracks () (let ((playlist-id (ps:@ (ps:chain document (get-element-by-id "current-edit-playlist-id")) value))) (when playlist-id ;; Access id property directly - use 'trk' not 't' (t is boolean true in Lisp/ParenScript) (let ((track-ids (ps:chain *current-playlist-tracks* (map (lambda (trk) (ps:@ trk id)))))) (ps:chain console (log "Saving track-ids:" track-ids)) (ps:chain (fetch (+ "/api/asteroid/user/playlists/update?id=" playlist-id "&tracks=" (encode-u-r-i-component (ps:chain -j-s-o-n (stringify track-ids)))) (ps:create :method "POST")) (then (lambda (response) (ps:chain response (json)))) (catch (lambda (error) (ps:chain console (error "Error saving playlist:" error))))))))) (defun save-playlist-metadata () (let ((playlist-id (ps:@ (ps:chain document (get-element-by-id "current-edit-playlist-id")) value)) (name (ps:@ (ps:chain document (get-element-by-id "edit-playlist-name")) value)) (description (ps:@ (ps:chain document (get-element-by-id "edit-playlist-description")) value))) (ps:chain (fetch (+ "/api/asteroid/user/playlists/update?id=" playlist-id "&name=" (encode-u-r-i-component name) "&description=" (encode-u-r-i-component description)) (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-message "Playlist saved!" "success") (setf (ps:@ (ps:chain document (get-element-by-id "edit-playlist-title")) text-content) (+ "Edit: " name)) (load-my-playlists)) (show-message "Failed to save playlist" "error"))))) (catch (lambda (error) (ps:chain console (error "Error saving playlist:" error)) (show-message "Error saving playlist" "error")))))) (defun submit-playlist-for-review () (let ((playlist-id (ps:@ (ps:chain document (get-element-by-id "current-edit-playlist-id")) value))) (when (not (confirm "Submit this playlist for admin review? You won't be able to edit it after submission.")) (return)) (ps:chain (fetch (+ "/api/asteroid/user/playlists/submit?id=" playlist-id) (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-message "Playlist submitted for review!" "success") (hide-edit-playlist-modal) (load-my-playlists)) (show-message (or (ps:@ data message) "Failed to submit playlist") "error"))))) (catch (lambda (error) (ps:chain console (error "Error submitting playlist:" error)) (show-message "Error submitting playlist" "error")))))) (defun delete-current-playlist () (let ((playlist-id (ps:@ (ps:chain document (get-element-by-id "current-edit-playlist-id")) value))) (when (not (confirm "Delete this playlist? This cannot be undone.")) (return)) (ps:chain (fetch (+ "/api/asteroid/user/playlists/delete?id=" playlist-id) (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-message "Playlist deleted" "success") (hide-edit-playlist-modal) (load-my-playlists)) (show-message "Failed to delete playlist" "error"))))) (catch (lambda (error) (ps:chain console (error "Error deleting playlist:" error)) (show-message "Error deleting playlist" "error")))))) ;; Library browsing (defun load-library-tracks () (let ((url (+ "/api/asteroid/library/browse?page=" *library-page*))) (when (and *library-search* (> (ps:@ *library-search* length) 0)) (setf url (+ url "&search=" (encode-u-r-i-component *library-search*)))) (when (and *library-artist* (> (ps:@ *library-artist* length) 0)) (setf url (+ url "&artist=" (encode-u-r-i-component *library-artist*)))) (ps:chain (fetch url) (then (lambda (response) (ps:chain response (json)))) (then (lambda (result) (let ((data (or (ps:@ result data) result)) (container (ps:chain document (get-element-by-id "library-tracks"))) (artist-select (ps:chain document (get-element-by-id "library-artist-filter")))) (when container (setf *library-total* (or (ps:@ data total) 0)) (if (and (= (ps:@ data status) "success") (ps:@ data tracks) (> (ps:@ data tracks length) 0)) (let ((html "")) (ps:chain (ps:@ data tracks) (for-each (lambda (track) (setf html (+ html "
" "
" "" (ps:@ track title) "" "" (ps:@ track artist) "" "" (ps:@ track album) "" "
" "" "
"))))) (setf (ps:@ container inner-h-t-m-l) html)) (setf (ps:@ container inner-h-t-m-l) "

No tracks found

"))) ;; Update artist filter (when (and artist-select (ps:@ data artists)) (let ((current-val (ps:@ artist-select value))) (setf (ps:@ artist-select inner-h-t-m-l) "") (ps:chain (ps:@ data artists) (for-each (lambda (artist) (let ((opt (ps:chain document (create-element "option")))) (setf (ps:@ opt value) artist) (setf (ps:@ opt text-content) artist) (ps:chain artist-select (append-child opt)))))) (setf (ps:@ artist-select value) current-val))) ;; Update pagination (update-library-pagination)))) (catch (lambda (error) (ps:chain console (error "Error loading library:" error))))))) (defun update-library-pagination () (let ((page-info (ps:chain document (get-element-by-id "library-page-info"))) (prev-btn (ps:chain document (get-element-by-id "lib-prev-btn"))) (next-btn (ps:chain document (get-element-by-id "lib-next-btn"))) (total-pages (ps:chain -math (ceil (/ *library-total* 50))))) (when page-info (setf (ps:@ page-info text-content) (+ "Page " *library-page* " of " total-pages))) (when prev-btn (setf (ps:@ prev-btn disabled) (<= *library-page* 1))) (when next-btn (setf (ps:@ next-btn disabled) (>= *library-page* total-pages))))) (defun prev-library-page () (when (> *library-page* 1) (setf *library-page* (- *library-page* 1)) (load-library-tracks))) (defun next-library-page () (setf *library-page* (+ *library-page* 1)) (load-library-tracks)) (defvar *search-timeout* nil) (defun search-library () (when *search-timeout* (clear-timeout *search-timeout*)) (setf *search-timeout* (set-timeout (lambda () (setf *library-search* (ps:@ (ps:chain document (get-element-by-id "library-search")) value)) (setf *library-page* 1) (load-library-tracks)) 300))) (defun filter-by-artist () (setf *library-artist* (ps:@ (ps:chain document (get-element-by-id "library-artist-filter")) value)) (setf *library-page* 1) (load-library-tracks)) (defun update-playlist-select () (let ((select (ps:chain document (get-element-by-id "add-to-playlist-select")))) (when select (setf (ps:@ select inner-h-t-m-l) "") (ps:chain *user-playlists* (for-each (lambda (pl) (when (= (ps:@ pl status) "draft") (let ((opt (ps:chain document (create-element "option")))) (setf (ps:@ opt value) (ps:@ pl id)) (setf (ps:@ opt text-content) (ps:@ pl name)) (ps:chain select (append-child opt)))))))))) ;; Initialize on page load (ps:chain window (add-event-listener "DOMContentLoaded" (lambda () (load-profile-data) (load-my-playlists)))))) "Compiled JavaScript for profile page - generated at load time") (defun generate-profile-js () "Return the pre-compiled JavaScript for profile page" *profile-js*)