Merge remote-tracking branch 'upstream/main'
This commit is contained in:
commit
19c984b238
296
asteroid.lisp
296
asteroid.lisp
|
|
@ -189,24 +189,29 @@
|
|||
;; API endpoint to get all tracks (for web player)
|
||||
(define-page api-tracks #@"/api/tracks" ()
|
||||
"Get all tracks for web player"
|
||||
(require-authentication)
|
||||
(setf (radiance:header "Content-Type") "application/json")
|
||||
(handler-case
|
||||
(let ((tracks (db:select "tracks" (db:query :all))))
|
||||
(cl-json:encode-json-to-string
|
||||
`(("status" . "success")
|
||||
("tracks" . ,(mapcar (lambda (track)
|
||||
`(("id" . ,(gethash "_id" track))
|
||||
("title" . ,(gethash "title" track))
|
||||
("artist" . ,(gethash "artist" track))
|
||||
("album" . ,(gethash "album" track))
|
||||
("duration" . ,(gethash "duration" track))
|
||||
("format" . ,(gethash "format" track))))
|
||||
tracks)))))
|
||||
(error (e)
|
||||
(cl-json:encode-json-to-string
|
||||
`(("status" . "error")
|
||||
("message" . ,(format nil "Error retrieving tracks: ~a" e)))))))
|
||||
(let ((auth-result (require-authentication)))
|
||||
(if (eq auth-result t)
|
||||
;; Authenticated - return track data
|
||||
(progn
|
||||
(setf (radiance:header "Content-Type") "application/json")
|
||||
(handler-case
|
||||
(let ((tracks (db:select "tracks" (db:query :all))))
|
||||
(cl-json:encode-json-to-string
|
||||
`(("status" . "success")
|
||||
("tracks" . ,(mapcar (lambda (track)
|
||||
`(("id" . ,(gethash "_id" track))
|
||||
("title" . ,(gethash "title" track))
|
||||
("artist" . ,(gethash "artist" track))
|
||||
("album" . ,(gethash "album" track))
|
||||
("duration" . ,(gethash "duration" track))
|
||||
("format" . ,(gethash "format" track))))
|
||||
tracks)))))
|
||||
(error (e)
|
||||
(cl-json:encode-json-to-string
|
||||
`(("status" . "error")
|
||||
("message" . ,(format nil "Error retrieving tracks: ~a" e)))))))
|
||||
;; Auth failed - return the value from api-output
|
||||
auth-result)))
|
||||
|
||||
;; API endpoint to get track by ID (for streaming)
|
||||
(define-page api-get-track-by-id #@"/api/tracks/(.*)" (:uri-groups (track-id))
|
||||
|
|
@ -384,6 +389,77 @@
|
|||
`(("status" . "success")
|
||||
("player" . ,(get-player-status)))))
|
||||
|
||||
;; Profile API Routes - TEMPORARILY COMMENTED OUT
|
||||
#|
|
||||
(define-page api-user-profile #@"/api/user/profile" ()
|
||||
"Get current user profile information"
|
||||
(require-authentication)
|
||||
(setf (radiance:header "Content-Type") "application/json")
|
||||
(let ((current-user (auth:current-user)))
|
||||
(cl-json:encode-json-to-string
|
||||
`(("status" . "success")
|
||||
("user" . (("username" . ,(gethash "username" current-user))
|
||||
("role" . ,(gethash "role" current-user))
|
||||
("created_at" . ,(gethash "created_at" current-user))
|
||||
("last_active" . ,(get-universal-time))))))))
|
||||
|
||||
(define-page api-user-listening-stats #@"/api/user/listening-stats" ()
|
||||
"Get user listening statistics"
|
||||
(require-authentication)
|
||||
(setf (radiance:header "Content-Type") "application/json")
|
||||
;; TODO: Implement actual listening statistics from database
|
||||
;; For now, return mock data
|
||||
(cl-json:encode-json-to-string
|
||||
`(("status" . "success")
|
||||
("stats" . (("total_listen_time" . 0)
|
||||
("tracks_played" . 0)
|
||||
("session_count" . 0)
|
||||
("favorite_genre" . "Unknown"))))))
|
||||
|
||||
(define-page api-user-recent-tracks #@"/api/user/recent-tracks" ()
|
||||
"Get user's recently played tracks"
|
||||
(require-authentication)
|
||||
(setf (radiance:header "Content-Type") "application/json")
|
||||
;; TODO: Implement actual recent tracks from database
|
||||
;; For now, return empty array
|
||||
(cl-json:encode-json-to-string
|
||||
`(("status" . "success")
|
||||
("tracks" . #()))))
|
||||
|
||||
(define-page api-user-top-artists #@"/api/user/top-artists" ()
|
||||
"Get user's top artists"
|
||||
(require-authentication)
|
||||
(setf (radiance:header "Content-Type") "application/json")
|
||||
;; TODO: Implement actual top artists from database
|
||||
;; For now, return empty array
|
||||
(cl-json:encode-json-to-string
|
||||
`(("status" . "success")
|
||||
("artists" . #()))))
|
||||
|
||||
(define-page api-user-export-data #@"/api/user/export-data" ()
|
||||
"Export user listening data"
|
||||
(require-authentication)
|
||||
(setf (radiance:header "Content-Type") "application/json")
|
||||
(setf (radiance:header "Content-Disposition") "attachment; filename=listening-data.json")
|
||||
;; TODO: Implement actual data export
|
||||
(cl-json:encode-json-to-string
|
||||
`(("user" . ,(gethash "username" (auth:current-user)))
|
||||
("export_date" . ,(get-universal-time))
|
||||
("listening_history" . #())
|
||||
("statistics" . (("total_listen_time" . 0)
|
||||
("tracks_played" . 0)
|
||||
("session_count" . 0))))))
|
||||
|
||||
(define-page api-user-clear-history #@"/api/user/clear-history" ()
|
||||
"Clear user listening history"
|
||||
(require-authentication)
|
||||
(setf (radiance:header "Content-Type") "application/json")
|
||||
;; TODO: Implement actual history clearing
|
||||
(cl-json:encode-json-to-string
|
||||
`(("status" . "success")
|
||||
("message" . "Listening history cleared successfully"))))
|
||||
|#
|
||||
|
||||
;; Front page
|
||||
(define-page front-page #@"/" ()
|
||||
"Main front page"
|
||||
|
|
@ -459,6 +535,190 @@
|
|||
(plump:parse (alexandria:read-file-into-string template-path))
|
||||
:title "🎵 ASTEROID RADIO - User Management")))
|
||||
|
||||
;; User Profile page (requires authentication)
|
||||
(define-page user-profile #@"/profile" ()
|
||||
"User profile page"
|
||||
(require-authentication)
|
||||
(let ((template-path (merge-pathnames "template/profile.chtml"
|
||||
(asdf:system-source-directory :asteroid))))
|
||||
(clip:process-to-string
|
||||
(plump:parse (alexandria:read-file-into-string template-path))
|
||||
:title "🎧 admin - Profile | Asteroid Radio"
|
||||
:username "admin"
|
||||
:user-role "admin"
|
||||
:join-date "Unknown"
|
||||
:last-active "Unknown"
|
||||
:total-listen-time "0h 0m"
|
||||
:tracks-played "0"
|
||||
:session-count "0"
|
||||
:favorite-genre "Unknown"
|
||||
:recent-track-1-title ""
|
||||
:recent-track-1-artist ""
|
||||
:recent-track-1-duration ""
|
||||
:recent-track-1-played-at ""
|
||||
:recent-track-2-title ""
|
||||
:recent-track-2-artist ""
|
||||
:recent-track-2-duration ""
|
||||
:recent-track-2-played-at ""
|
||||
:recent-track-3-title ""
|
||||
:recent-track-3-artist ""
|
||||
:recent-track-3-duration ""
|
||||
:recent-track-3-played-at ""
|
||||
:top-artist-1 ""
|
||||
:top-artist-1-plays ""
|
||||
:top-artist-2 ""
|
||||
:top-artist-2-plays ""
|
||||
:top-artist-3 ""
|
||||
:top-artist-3-plays ""
|
||||
:top-artist-4 ""
|
||||
:top-artist-4-plays ""
|
||||
:top-artist-5 ""
|
||||
:top-artist-5-plays "")))
|
||||
|
||||
;; Helper functions for profile page - TEMPORARILY COMMENTED OUT
|
||||
#|
|
||||
(defun format-timestamp (stream timestamp &key format)
|
||||
"Format a timestamp for display"
|
||||
(declare (ignore stream format))
|
||||
(if timestamp
|
||||
(multiple-value-bind (second minute hour date month year)
|
||||
(decode-universal-time timestamp)
|
||||
(format nil "~a ~d, ~d"
|
||||
(nth (1- month) '("January" "February" "March" "April" "May" "June"
|
||||
"July" "August" "September" "October" "November" "December"))
|
||||
date year))
|
||||
"Unknown"))
|
||||
|
||||
(defun format-relative-time (timestamp)
|
||||
"Format a timestamp as relative time (e.g., '2 hours ago')"
|
||||
(if timestamp
|
||||
(let* ((now (get-universal-time))
|
||||
(diff (- now timestamp))
|
||||
(minutes (floor diff 60))
|
||||
(hours (floor minutes 60))
|
||||
(days (floor hours 24)))
|
||||
(cond
|
||||
((< diff 60) "Just now")
|
||||
((< minutes 60) (format nil "~d minute~p ago" minutes minutes))
|
||||
((< hours 24) (format nil "~d hour~p ago" hours hours))
|
||||
(t (format nil "~d day~p ago" days days))))
|
||||
"Unknown"))
|
||||
|
||||
;; User Profile page (requires authentication)
|
||||
(define-page user-profile #@"/profile" ()
|
||||
"User profile page with listening statistics and track data"
|
||||
(require-authentication)
|
||||
(let* ((current-user (auth:current-user))
|
||||
(username (gethash "username" current-user))
|
||||
(template-path (merge-pathnames "template/profile.chtml"
|
||||
(asdf:system-source-directory :asteroid))))
|
||||
(clip:process-to-string
|
||||
(plump:parse (alexandria:read-file-into-string template-path))
|
||||
:title (format nil "🎧 ~a - Profile | Asteroid Radio" username)
|
||||
:username (or username "Unknown User")
|
||||
:user-role "listener"
|
||||
:join-date "Unknown"
|
||||
:last-active "Unknown"
|
||||
:total-listen-time "0h 0m"
|
||||
:tracks-played "0"
|
||||
:session-count "0"
|
||||
:favorite-genre "Unknown"
|
||||
:recent-track-1-title ""
|
||||
:recent-track-1-artist ""
|
||||
:recent-track-1-duration ""
|
||||
:recent-track-1-played-at ""
|
||||
:recent-track-2-title ""
|
||||
:recent-track-2-artist ""
|
||||
:recent-track-2-duration ""
|
||||
:recent-track-2-played-at ""
|
||||
:recent-track-3-title ""
|
||||
:recent-track-3-artist ""
|
||||
:recent-track-3-duration ""
|
||||
:recent-track-3-played-at ""
|
||||
:top-artist-1 ""
|
||||
:top-artist-1-plays ""
|
||||
:top-artist-2 ""
|
||||
:top-artist-2-plays ""
|
||||
:top-artist-3 ""
|
||||
:top-artist-3-plays ""
|
||||
:top-artist-4 ""
|
||||
:top-artist-4-plays ""
|
||||
:top-artist-5 ""
|
||||
:top-artist-5-plays "")))
|
||||
|#
|
||||
|
||||
;; Auth status API endpoint
|
||||
(define-page api-auth-status #@"/api/auth-status" ()
|
||||
"Check if user is logged in and their role"
|
||||
(setf (radiance:header "Content-Type") "application/json")
|
||||
(handler-case
|
||||
(let* ((user-id (session:field "user-id"))
|
||||
(user (when user-id (find-user-by-id user-id))))
|
||||
(cl-json:encode-json-to-string
|
||||
`(("loggedIn" . ,(if user t nil))
|
||||
("isAdmin" . ,(if (and user (user-has-role-p user :admin)) t nil))
|
||||
("username" . ,(if user
|
||||
(let ((username (gethash "username" user)))
|
||||
(if (listp username) (first username) username))
|
||||
nil)))))
|
||||
(error (e)
|
||||
(cl-json:encode-json-to-string
|
||||
`(("loggedIn" . nil)
|
||||
("isAdmin" . nil)
|
||||
("error" . ,(format nil "~a" e)))))))
|
||||
|
||||
;; Register page (GET)
|
||||
(define-page register #@"/register" ()
|
||||
"User registration page"
|
||||
(let ((username (radiance:post-var "username"))
|
||||
(email (radiance:post-var "email"))
|
||||
(password (radiance:post-var "password"))
|
||||
(confirm-password (radiance:post-var "confirm-password")))
|
||||
(if (and username password)
|
||||
;; Handle registration form submission
|
||||
(cond
|
||||
;; Validate passwords match
|
||||
((not (string= password confirm-password))
|
||||
(render-template-with-plist "register"
|
||||
:title "Asteroid Radio - Register"
|
||||
:display-error "display: block;"
|
||||
:display-success "display: none;"
|
||||
:error-message "Passwords do not match"
|
||||
:success-message ""))
|
||||
|
||||
;; Check if username already exists
|
||||
((find-user-by-username username)
|
||||
(render-template-with-plist "register"
|
||||
:title "Asteroid Radio - Register"
|
||||
:display-error "display: block;"
|
||||
:display-success "display: none;"
|
||||
:error-message "Username already exists"
|
||||
:success-message ""))
|
||||
|
||||
;; Create the user
|
||||
(t
|
||||
(if (create-user username email password :role :listener :active t)
|
||||
(progn
|
||||
;; Auto-login after successful registration
|
||||
(let ((user (find-user-by-username username)))
|
||||
(when user
|
||||
(let ((user-id (gethash "_id" user)))
|
||||
(setf (session:field "user-id") (if (listp user-id) (first user-id) user-id)))))
|
||||
(radiance:redirect "/asteroid/"))
|
||||
(render-template-with-plist "register"
|
||||
:title "Asteroid Radio - Register"
|
||||
:display-error "display: block;"
|
||||
:display-success "display: none;"
|
||||
:error-message "Registration failed. Please try again."
|
||||
:success-message ""))))
|
||||
;; Show registration form (no POST data)
|
||||
(render-template-with-plist "register"
|
||||
:title "Asteroid Radio - Register"
|
||||
:display-error "display: none;"
|
||||
:display-success "display: none;"
|
||||
:error-message ""
|
||||
:success-message ""))))
|
||||
|
||||
(define-page player #@"/player" ()
|
||||
(let ((template-path (merge-pathnames "template/player.chtml"
|
||||
(asdf:system-source-directory :asteroid))))
|
||||
|
|
|
|||
|
|
@ -64,6 +64,30 @@ body .nav a:hover{
|
|||
background: #2a3441;
|
||||
}
|
||||
|
||||
body .nav .btn-logout{
|
||||
background: #2a3441;
|
||||
border-color: #3a4551;
|
||||
color: #ff9999;
|
||||
}
|
||||
|
||||
body .nav .btn-logout:hover{
|
||||
background: #3a4551;
|
||||
border-color: #4a5561;
|
||||
color: #ffaaaa;
|
||||
}
|
||||
|
||||
body [data-show-if-logged-in]{
|
||||
display: none;
|
||||
}
|
||||
|
||||
body [data-show-if-logged-out]{
|
||||
display: none;
|
||||
}
|
||||
|
||||
body [data-show-if-admin]{
|
||||
display: none;
|
||||
}
|
||||
|
||||
body .controls{
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
|
@ -545,7 +569,7 @@ body .auth-form{
|
|||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
max-width: 600px;
|
||||
-moz-box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
-o-box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
-webkit-box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
|
|
@ -567,7 +591,7 @@ body .auth-form h3{
|
|||
}
|
||||
|
||||
body .form-group{
|
||||
margin-bottom: 3rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
body .form-group label{
|
||||
|
|
@ -639,26 +663,6 @@ body .auth-link a:hover{
|
|||
text-decoration: underline;
|
||||
}
|
||||
|
||||
body .profile-container{
|
||||
max-width: 600px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
body .profile-card{
|
||||
background-color: #1a2332;
|
||||
border: 1px solid #2a3441;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
body .profile-card h2{
|
||||
color: #00ffff;
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
body .profile-info{
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
|
@ -699,6 +703,143 @@ body .profile-actions{
|
|||
justify-content: center;
|
||||
}
|
||||
|
||||
body .artist-stats{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
body .artist-item{
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid #2a3441;
|
||||
}
|
||||
|
||||
body .artist-item:last-child{
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
body .artist-name{
|
||||
color: #e0e6ed;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
body .artist-plays{
|
||||
color: #8892b0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
body .track-item{
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid #2a3441;
|
||||
}
|
||||
|
||||
body .track-item:last-child{
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
body .track-info{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
body .track-title{
|
||||
color: #e0e6ed;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
body .track-artist{
|
||||
color: #8892b0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
body .track-meta{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.25rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
body .track-duration{
|
||||
color: #64ffda;
|
||||
font-size: 0.875rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
body .track-played-at{
|
||||
color: #8892b0;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
body .activity-chart{
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
body .chart-placeholder{
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
height: 120px;
|
||||
margin: 1rem 0;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
body .chart-bar{
|
||||
width: 8px;
|
||||
background-color: #64ffda;
|
||||
border-radius: 2px 2px 0 0;
|
||||
margin: 0 1px;
|
||||
min-height: 4px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
body .chart-bar:hover{
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
body .chart-note{
|
||||
color: #8892b0;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
body .stat-number{
|
||||
color: #64ffda;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
}
|
||||
|
||||
body .stat-text{
|
||||
color: #e0e6ed;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
}
|
||||
|
||||
body .toast{
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 12px 20px;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
z-index: 1000;
|
||||
-moz-transition: opacity 0.3s ease;
|
||||
-o-transition: opacity 0.3s ease;
|
||||
-webkit-transition: opacity 0.3s ease;
|
||||
-ms-transition: opacity 0.3s ease;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
body .user-management{
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,28 @@
|
|||
:margin-left "0")
|
||||
|
||||
((:and a :hover)
|
||||
:background "#2a3441"))
|
||||
:background "#2a3441")
|
||||
|
||||
;; Logout button styling - subtle, not alarming
|
||||
(.btn-logout
|
||||
:background "#2a3441"
|
||||
:border-color "#3a4551"
|
||||
:color "#ff9999")
|
||||
|
||||
((:and .btn-logout :hover)
|
||||
:background "#3a4551"
|
||||
:border-color "#4a5561"
|
||||
:color "#ffaaaa"))
|
||||
|
||||
;; Hide conditional auth elements by default (JavaScript will show them)
|
||||
(|[data-show-if-logged-in]|
|
||||
:display none)
|
||||
|
||||
(|[data-show-if-logged-out]|
|
||||
:display none)
|
||||
|
||||
(|[data-show-if-admin]|
|
||||
:display none)
|
||||
|
||||
(.controls
|
||||
:margin "20px 0"
|
||||
|
|
@ -434,7 +455,7 @@
|
|||
:border-radius 8px
|
||||
:padding 2rem
|
||||
:width "100%"
|
||||
:max-width 400px
|
||||
:max-width 600px
|
||||
:box-shadow "0 4px 6px rgba(0, 0, 0, 0.3)"
|
||||
|
||||
(h2 :color "#00ffff"
|
||||
|
|
@ -447,7 +468,7 @@
|
|||
:font-size 1.2rem))
|
||||
|
||||
(.form-group
|
||||
:margin-bottom 3rem
|
||||
:margin-bottom 1.5rem
|
||||
|
||||
(label :display block
|
||||
:color "#ccc"
|
||||
|
|
@ -501,22 +522,6 @@
|
|||
((:and a :hover) :text-decoration underline))
|
||||
|
||||
;; Profile Styles
|
||||
(.profile-container
|
||||
:max-width 600px
|
||||
:margin "2rem auto"
|
||||
:padding 0 1rem)
|
||||
|
||||
(.profile-card
|
||||
:background-color "#1a2332"
|
||||
:border "1px solid #2a3441"
|
||||
:border-radius 8px
|
||||
:padding 2rem
|
||||
:margin-bottom 2rem
|
||||
|
||||
(h2 :color "#00ffff"
|
||||
:margin-bottom 1.5rem
|
||||
:text-align center))
|
||||
|
||||
(.profile-info
|
||||
:margin-bottom 2rem)
|
||||
|
||||
|
|
@ -547,6 +552,120 @@
|
|||
:gap 1rem
|
||||
:justify-content center)
|
||||
|
||||
;; Additional Profile Page Styles
|
||||
(.artist-stats
|
||||
:display flex
|
||||
:flex-direction column
|
||||
:gap 0.75rem)
|
||||
|
||||
(.artist-item
|
||||
:display flex
|
||||
:justify-content space-between
|
||||
:align-items center
|
||||
:padding "0.5rem 0"
|
||||
:border-bottom "1px solid #2a3441")
|
||||
|
||||
((:and .artist-item :last-child)
|
||||
:border-bottom none)
|
||||
|
||||
(.artist-name
|
||||
:color "#e0e6ed"
|
||||
:font-weight 500)
|
||||
|
||||
(.artist-plays
|
||||
:color "#8892b0"
|
||||
:font-size 0.875rem)
|
||||
|
||||
(.track-item
|
||||
:display flex
|
||||
:justify-content space-between
|
||||
:align-items center
|
||||
:padding "0.75rem 0"
|
||||
:border-bottom "1px solid #2a3441")
|
||||
|
||||
((:and .track-item :last-child)
|
||||
:border-bottom none)
|
||||
|
||||
(.track-info
|
||||
:display flex
|
||||
:flex-direction column
|
||||
:gap 0.25rem)
|
||||
|
||||
(.track-title
|
||||
:color "#e0e6ed"
|
||||
:font-weight 500)
|
||||
|
||||
(.track-artist
|
||||
:color "#8892b0"
|
||||
:font-size 0.875rem)
|
||||
|
||||
(.track-meta
|
||||
:display flex
|
||||
:flex-direction column
|
||||
:align-items flex-end
|
||||
:gap 0.25rem
|
||||
:text-align right)
|
||||
|
||||
(.track-duration
|
||||
:color "#64ffda"
|
||||
:font-size 0.875rem
|
||||
:font-weight bold)
|
||||
|
||||
(.track-played-at
|
||||
:color "#8892b0"
|
||||
:font-size 0.75rem)
|
||||
|
||||
(.activity-chart
|
||||
:text-align center)
|
||||
|
||||
(.chart-placeholder
|
||||
:display flex
|
||||
:align-items flex-end
|
||||
:justify-content space-between
|
||||
:height 120px
|
||||
:margin "1rem 0"
|
||||
:padding "0 1rem")
|
||||
|
||||
(.chart-bar
|
||||
:width 8px
|
||||
:background-color "#64ffda"
|
||||
:border-radius "2px 2px 0 0"
|
||||
:margin "0 1px"
|
||||
:min-height 4px
|
||||
:opacity 0.8)
|
||||
|
||||
((:and .chart-bar :hover)
|
||||
:opacity 1)
|
||||
|
||||
(.chart-note
|
||||
:color "#8892b0"
|
||||
:font-size 0.875rem
|
||||
:margin-top 0.5rem)
|
||||
|
||||
(.stat-number
|
||||
:color "#64ffda"
|
||||
:font-size 1.5rem
|
||||
:font-weight bold
|
||||
:display block)
|
||||
|
||||
(.stat-text
|
||||
:color "#e0e6ed"
|
||||
:font-size 1.2rem
|
||||
:font-weight 500
|
||||
:display block)
|
||||
|
||||
;; Toast notification styles
|
||||
(.toast
|
||||
:position fixed
|
||||
:top 20px
|
||||
:right 20px
|
||||
:padding "12px 20px"
|
||||
:border-radius 4px
|
||||
:color white
|
||||
:font-weight bold
|
||||
:z-index 1000
|
||||
:transition "opacity 0.3s ease")
|
||||
|
||||
;; User Management Styles
|
||||
(.user-management :margin-top 2rem)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
// auth-ui.js - Handle authentication UI state across all pages
|
||||
|
||||
// Check if user is logged in by calling the API
|
||||
async function checkAuthStatus() {
|
||||
try {
|
||||
const response = await fetch('/asteroid/api/auth-status');
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error checking auth status:', error);
|
||||
return { loggedIn: false, isAdmin: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Update UI based on authentication status
|
||||
function updateAuthUI(authStatus) {
|
||||
// Show/hide elements based on login status
|
||||
document.querySelectorAll('[data-show-if-logged-in]').forEach(el => {
|
||||
el.style.display = authStatus.loggedIn ? 'inline-block' : 'none';
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-show-if-logged-out]').forEach(el => {
|
||||
el.style.display = authStatus.loggedIn ? 'none' : 'inline-block';
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-show-if-admin]').forEach(el => {
|
||||
el.style.display = authStatus.isAdmin ? 'inline-block' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize auth UI on page load
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
console.log('Auth UI initializing...');
|
||||
const authStatus = await checkAuthStatus();
|
||||
console.log('Auth status:', authStatus);
|
||||
updateAuthUI(authStatus);
|
||||
console.log('Auth UI updated');
|
||||
});
|
||||
|
|
@ -0,0 +1,293 @@
|
|||
// Profile page JavaScript functionality
|
||||
// Handles user profile data loading and interactions
|
||||
|
||||
let currentUser = null;
|
||||
let listeningData = null;
|
||||
|
||||
// Load profile data on page initialization
|
||||
function loadProfileData() {
|
||||
console.log('Loading profile data...');
|
||||
|
||||
// Load user info
|
||||
fetch('/asteroid/api/user/profile')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
currentUser = data.user;
|
||||
updateProfileDisplay(data.user);
|
||||
} else {
|
||||
console.error('Failed to load profile:', data.message);
|
||||
showError('Failed to load profile data');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading profile:', error);
|
||||
showError('Error loading profile data');
|
||||
});
|
||||
|
||||
// Load listening statistics
|
||||
loadListeningStats();
|
||||
|
||||
// Load recent tracks
|
||||
loadRecentTracks();
|
||||
|
||||
// Load top artists
|
||||
loadTopArtists();
|
||||
}
|
||||
|
||||
function updateProfileDisplay(user) {
|
||||
// Update basic user info
|
||||
updateElement('username', user.username || 'Unknown User');
|
||||
updateElement('user-role', formatRole(user.role || 'listener'));
|
||||
updateElement('join-date', formatDate(user.created_at || new Date()));
|
||||
updateElement('last-active', formatRelativeTime(user.last_active || new Date()));
|
||||
|
||||
// Show/hide admin link based on role
|
||||
const adminLink = document.querySelector('[data-show-if-admin]');
|
||||
if (adminLink) {
|
||||
adminLink.style.display = (user.role === 'admin') ? 'inline' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function loadListeningStats() {
|
||||
fetch('/asteroid/api/user/listening-stats')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
const stats = data.stats;
|
||||
updateElement('total-listen-time', formatDuration(stats.total_listen_time || 0));
|
||||
updateElement('tracks-played', stats.tracks_played || 0);
|
||||
updateElement('session-count', stats.session_count || 0);
|
||||
updateElement('favorite-genre', stats.favorite_genre || 'Unknown');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading listening stats:', error);
|
||||
// Set default values
|
||||
updateElement('total-listen-time', '0h 0m');
|
||||
updateElement('tracks-played', '0');
|
||||
updateElement('session-count', '0');
|
||||
updateElement('favorite-genre', 'Unknown');
|
||||
});
|
||||
}
|
||||
|
||||
function loadRecentTracks() {
|
||||
fetch('/asteroid/api/user/recent-tracks?limit=3')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success' && data.tracks.length > 0) {
|
||||
data.tracks.forEach((track, index) => {
|
||||
const trackNum = index + 1;
|
||||
updateElement(`recent-track-${trackNum}-title`, track.title || 'Unknown Track');
|
||||
updateElement(`recent-track-${trackNum}-artist`, track.artist || 'Unknown Artist');
|
||||
updateElement(`recent-track-${trackNum}-duration`, formatDuration(track.duration || 0));
|
||||
updateElement(`recent-track-${trackNum}-played-at`, formatRelativeTime(track.played_at));
|
||||
});
|
||||
} else {
|
||||
// Hide empty track items
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const trackItem = document.querySelector(`[data-text="recent-track-${i}-title"]`).closest('.track-item');
|
||||
if (trackItem && !data.tracks[i-1]) {
|
||||
trackItem.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading recent tracks:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function loadTopArtists() {
|
||||
fetch('/asteroid/api/user/top-artists?limit=5')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success' && data.artists.length > 0) {
|
||||
data.artists.forEach((artist, index) => {
|
||||
const artistNum = index + 1;
|
||||
updateElement(`top-artist-${artistNum}`, artist.name || 'Unknown Artist');
|
||||
updateElement(`top-artist-${artistNum}-plays`, `${artist.play_count || 0} plays`);
|
||||
});
|
||||
} else {
|
||||
// Hide empty artist items
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const artistItem = document.querySelector(`[data-text="top-artist-${i}"]`).closest('.artist-item');
|
||||
if (artistItem && !data.artists[i-1]) {
|
||||
artistItem.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading top artists:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function loadMoreRecentTracks() {
|
||||
// TODO: Implement pagination for recent tracks
|
||||
console.log('Loading more recent tracks...');
|
||||
showMessage('Loading more tracks...', 'info');
|
||||
}
|
||||
|
||||
function editProfile() {
|
||||
// TODO: Implement profile editing modal or redirect
|
||||
console.log('Edit profile clicked');
|
||||
showMessage('Profile editing coming soon!', 'info');
|
||||
}
|
||||
|
||||
function exportListeningData() {
|
||||
console.log('Exporting listening data...');
|
||||
showMessage('Preparing data export...', 'info');
|
||||
|
||||
fetch('/asteroid/api/user/export-data', {
|
||||
method: 'POST'
|
||||
})
|
||||
.then(response => response.blob())
|
||||
.then(blob => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.style.display = 'none';
|
||||
a.href = url;
|
||||
a.download = `asteroid-listening-data-${currentUser?.username || 'user'}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
showMessage('Data exported successfully!', 'success');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error exporting data:', error);
|
||||
showMessage('Failed to export data', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function clearListeningHistory() {
|
||||
if (!confirm('Are you sure you want to clear your listening history? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Clearing listening history...');
|
||||
showMessage('Clearing listening history...', 'info');
|
||||
|
||||
fetch('/asteroid/api/user/clear-history', {
|
||||
method: 'POST'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
showMessage('Listening history cleared successfully!', 'success');
|
||||
// Reload the page data
|
||||
setTimeout(() => {
|
||||
location.reload();
|
||||
}, 1500);
|
||||
} else {
|
||||
showMessage('Failed to clear history: ' + data.message, 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error clearing history:', error);
|
||||
showMessage('Failed to clear history', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
function updateElement(dataText, value) {
|
||||
const element = document.querySelector(`[data-text="${dataText}"]`);
|
||||
if (element && value !== undefined && value !== null) {
|
||||
element.textContent = value;
|
||||
}
|
||||
}
|
||||
|
||||
function formatRole(role) {
|
||||
const roleMap = {
|
||||
'admin': '👑 Admin',
|
||||
'dj': '🎧 DJ',
|
||||
'listener': '🎵 Listener'
|
||||
};
|
||||
return roleMap[role] || role;
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateString) {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
||||
|
||||
if (diffDays > 0) {
|
||||
return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
|
||||
} else if (diffHours > 0) {
|
||||
return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
|
||||
} else if (diffMinutes > 0) {
|
||||
return `${diffMinutes} minute${diffMinutes > 1 ? 's' : ''} ago`;
|
||||
} else {
|
||||
return 'Just now';
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
} else {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
}
|
||||
|
||||
function showMessage(message, type = 'info') {
|
||||
// Create a simple toast notification
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
toast.style.cssText = `
|
||||
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;
|
||||
`;
|
||||
|
||||
// Set background color based on type
|
||||
const colors = {
|
||||
'info': '#007bff',
|
||||
'success': '#28a745',
|
||||
'error': '#dc3545',
|
||||
'warning': '#ffc107'
|
||||
};
|
||||
toast.style.backgroundColor = colors[type] || colors.info;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// Fade in
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '1';
|
||||
}, 100);
|
||||
|
||||
// Remove after 3 seconds
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(toast);
|
||||
}, 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
showMessage(message, 'error');
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
|
||||
<script src="/asteroid/static/js/auth-ui.js"></script>
|
||||
<script src="/asteroid/static/js/admin.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -13,7 +14,9 @@
|
|||
<div class="nav">
|
||||
<a href="/asteroid/">← Back to Main</a>
|
||||
<a href="/asteroid/player/">Web Player</a>
|
||||
<a href="/asteroid/profile">Profile</a>
|
||||
<a href="/asteroid/admin/users">👥 Users</a>
|
||||
<a href="/asteroid/logout" class="btn-logout">Logout</a>
|
||||
</div>
|
||||
|
||||
<!-- System Status -->
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
|
||||
<script src="/asteroid/static/js/front-page.js"></script>
|
||||
<script src="/asteroid/static/js/auth-ui.js"></script>
|
||||
<script src="/asteroid/static/js/front-page.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
|
|
@ -14,10 +15,12 @@
|
|||
<nav class="nav">
|
||||
<a href="/asteroid/">Home</a>
|
||||
<a href="/asteroid/player">Player</a>
|
||||
<a href="/asteroid/admin">Admin</a>
|
||||
<a href="/asteroid/profile" data-show-if-logged-in>Profile</a>
|
||||
<a href="/asteroid/admin" data-show-if-admin>Admin</a>
|
||||
<a href="/asteroid/status">Status</a>
|
||||
<a href="/asteroid/login">Login</a>
|
||||
<a href="/asteroid/register">Register</a>
|
||||
<a href="/asteroid/login" data-show-if-logged-out>Login</a>
|
||||
<a href="/asteroid/register" data-show-if-logged-out>Register</a>
|
||||
<a href="/asteroid/logout" data-show-if-logged-in class="btn-logout">Logout</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
<div class="auth-container">
|
||||
<div class="auth-form">
|
||||
<h2>System Access</h2>
|
||||
<div class="message error" data-attr="style" data-attr-value="display-error">
|
||||
<div class="message error" data-attr="style" data-attr-value="display-error" style="display: none;">
|
||||
<span data-text="error-message">Invalid username or password</span>
|
||||
</div>
|
||||
<form method="post" action="/asteroid/login">
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
|
||||
<script src="/asteroid/static/js/auth-ui.js"></script>
|
||||
<script src="/asteroid/static/js/player.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -12,7 +13,9 @@
|
|||
<h1>🎵 WEB PLAYER</h1>
|
||||
<div class="nav">
|
||||
<a href="/asteroid/">← Back to Main</a>
|
||||
<a href="/asteroid/admin">Admin Dashboard</a>
|
||||
<a href="/asteroid/profile" data-show-if-logged-in>Profile</a>
|
||||
<a href="/asteroid/admin" data-show-if-admin>Admin Dashboard</a>
|
||||
<a href="/asteroid/logout" data-show-if-logged-in class="btn-logout">Logout</a>
|
||||
</div>
|
||||
|
||||
<!-- Live Stream Section -->
|
||||
|
|
|
|||
|
|
@ -0,0 +1,171 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title data-text="title">Asteroid Radio - User Profile</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
|
||||
<script src="/asteroid/static/js/auth-ui.js"></script>
|
||||
<script src="/asteroid/static/js/profile.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>👤 USER PROFILE</h1>
|
||||
<div class="nav">
|
||||
<a href="/asteroid/">← Back to Main</a>
|
||||
<a href="/asteroid/player/">Web Player</a>
|
||||
<a href="/asteroid/admin/" data-show-if-admin>Admin</a>
|
||||
<a href="/asteroid/logout" class="btn-logout">Logout</a>
|
||||
</div>
|
||||
|
||||
<!-- User Profile Header -->
|
||||
<div class="admin-section">
|
||||
<h2>🎧 User Profile</h2>
|
||||
<div class="profile-info">
|
||||
<div class="info-group">
|
||||
<span class="info-label">Username:</span>
|
||||
<span class="info-value" data-text="username">user</span>
|
||||
</div>
|
||||
<div class="info-group">
|
||||
<span class="info-label">Role:</span>
|
||||
<span class="info-value" data-text="user-role">listener</span>
|
||||
</div>
|
||||
<div class="info-group">
|
||||
<span class="info-label">Member Since:</span>
|
||||
<span class="info-value" data-text="join-date">2024-01-01</span>
|
||||
</div>
|
||||
<div class="info-group">
|
||||
<span class="info-label">Last Active:</span>
|
||||
<span class="info-value" data-text="last-active">Today</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Listening Statistics -->
|
||||
<div class="admin-section">
|
||||
<h2>📊 Listening Statistics</h2>
|
||||
<div class="admin-grid">
|
||||
<div class="status-card">
|
||||
<h3>Total Listen Time</h3>
|
||||
<p class="stat-number" data-text="total-listen-time">0h 0m</p>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<h3>Tracks Played</h3>
|
||||
<p class="stat-number" data-text="tracks-played">0</p>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<h3>Sessions</h3>
|
||||
<p class="stat-number" data-text="session-count">0</p>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<h3>Favorite Genre</h3>
|
||||
<p class="stat-text" data-text="favorite-genre">Unknown</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recently Played Tracks -->
|
||||
<div class="admin-section">
|
||||
<h2>🎵 Recently Played</h2>
|
||||
<div class="tracks-list" id="recent-tracks">
|
||||
<div class="track-item">
|
||||
<div class="track-info">
|
||||
<span class="track-title" data-text="recent-track-1-title">No recent tracks</span>
|
||||
<span class="track-artist" data-text="recent-track-1-artist"></span>
|
||||
</div>
|
||||
<div class="track-meta">
|
||||
<span class="track-duration" data-text="recent-track-1-duration"></span>
|
||||
<span class="track-played-at" data-text="recent-track-1-played-at"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="track-item">
|
||||
<div class="track-info">
|
||||
<span class="track-title" data-text="recent-track-2-title"></span>
|
||||
<span class="track-artist" data-text="recent-track-2-artist"></span>
|
||||
</div>
|
||||
<div class="track-meta">
|
||||
<span class="track-duration" data-text="recent-track-2-duration"></span>
|
||||
<span class="track-played-at" data-text="recent-track-2-played-at"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="track-item">
|
||||
<div class="track-info">
|
||||
<span class="track-title" data-text="recent-track-3-title"></span>
|
||||
<span class="track-artist" data-text="recent-track-3-artist"></span>
|
||||
</div>
|
||||
<div class="track-meta">
|
||||
<span class="track-duration" data-text="recent-track-3-duration"></span>
|
||||
<span class="track-played-at" data-text="recent-track-3-played-at"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profile-actions">
|
||||
<button class="btn btn-secondary" onclick="loadMoreRecentTracks()">Load More</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Artists -->
|
||||
<div class="admin-section">
|
||||
<h2>🎤 Top Artists</h2>
|
||||
<div class="artist-stats">
|
||||
<div class="artist-item">
|
||||
<span class="artist-name" data-text="top-artist-1">Unknown Artist</span>
|
||||
<span class="artist-plays" data-text="top-artist-1-plays">0 plays</span>
|
||||
</div>
|
||||
<div class="artist-item">
|
||||
<span class="artist-name" data-text="top-artist-2"></span>
|
||||
<span class="artist-plays" data-text="top-artist-2-plays"></span>
|
||||
</div>
|
||||
<div class="artist-item">
|
||||
<span class="artist-name" data-text="top-artist-3"></span>
|
||||
<span class="artist-plays" data-text="top-artist-3-plays"></span>
|
||||
</div>
|
||||
<div class="artist-item">
|
||||
<span class="artist-name" data-text="top-artist-4"></span>
|
||||
<span class="artist-plays" data-text="top-artist-4-plays"></span>
|
||||
</div>
|
||||
<div class="artist-item">
|
||||
<span class="artist-name" data-text="top-artist-5"></span>
|
||||
<span class="artist-plays" data-text="top-artist-5-plays"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Listening Activity Chart -->
|
||||
<div class="admin-section">
|
||||
<h2>📈 Listening Activity</h2>
|
||||
<div class="activity-chart">
|
||||
<p>Activity over the last 30 days</p>
|
||||
<div class="chart-placeholder">
|
||||
<div class="chart-bar" style="height: 20%" data-day="1"></div>
|
||||
<div class="chart-bar" style="height: 45%" data-day="2"></div>
|
||||
<div class="chart-bar" style="height: 30%" data-day="3"></div>
|
||||
<div class="chart-bar" style="height: 60%" data-day="4"></div>
|
||||
<div class="chart-bar" style="height: 80%" data-day="5"></div>
|
||||
<div class="chart-bar" style="height: 25%" data-day="6"></div>
|
||||
<div class="chart-bar" style="height: 40%" data-day="7"></div>
|
||||
<!-- More bars would be generated dynamically -->
|
||||
</div>
|
||||
<p class="chart-note">Listening hours per day</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Profile Actions -->
|
||||
<div class="admin-section">
|
||||
<h2>⚙️ Profile Settings</h2>
|
||||
<div class="profile-actions">
|
||||
<button class="btn btn-primary" onclick="editProfile()">✏️ Edit Profile</button>
|
||||
<button class="btn btn-secondary" onclick="exportListeningData()">📊 Export Data</button>
|
||||
<button class="btn btn-secondary" onclick="clearListeningHistory()">🗑️ Clear History</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Initialize profile page
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadProfileData();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title data-text="title">Asteroid Radio - Register</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🎵 ASTEROID RADIO - REGISTER</h1>
|
||||
<div class="nav">
|
||||
<a href="/asteroid/">← Back to Main</a>
|
||||
<a href="/asteroid/login">Login</a>
|
||||
</div>
|
||||
|
||||
<div class="auth-container">
|
||||
<div class="auth-form">
|
||||
<h2>Create Account</h2>
|
||||
<div class="message error" data-attr="style" data-attr-value="display-error" style="display: none;">
|
||||
<span data-text="error-message">Registration failed</span>
|
||||
</div>
|
||||
<div class="message success" data-attr="style" data-attr-value="display-success" style="display: none;">
|
||||
<span data-text="success-message">Registration successful!</span>
|
||||
</div>
|
||||
<form method="post" action="/asteroid/register">
|
||||
<div class="form-group">
|
||||
<label>Username:</label>
|
||||
<input type="text" name="username" required minlength="3" maxlength="50">
|
||||
<small style="color: #8892b0;">Minimum 3 characters</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Email:</label>
|
||||
<input type="email" name="email" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Password:</label>
|
||||
<input type="password" name="password" required minlength="6">
|
||||
<small style="color: #8892b0;">Minimum 6 characters</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Confirm Password:</label>
|
||||
<input type="password" name="confirm-password" required minlength="6">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" style="width: 100%;">CREATE ACCOUNT</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="auth-footer">
|
||||
<p>Already have an account? <a href="/asteroid/login">Login here</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -121,28 +121,61 @@
|
|||
(format t "Error getting current user: ~a~%" e)
|
||||
nil)))
|
||||
|
||||
(defun require-authentication ()
|
||||
"Require user to be authenticated"
|
||||
(handler-case
|
||||
(unless (session:field "user-id")
|
||||
(radiance:redirect "/asteroid/login"))
|
||||
(error (e)
|
||||
(format t "Authentication error: ~a~%" e)
|
||||
(radiance:redirect "/asteroid/login"))))
|
||||
(defun require-authentication (&key (api nil))
|
||||
"Require user to be authenticated.
|
||||
Returns T if authenticated, NIL if not (after emitting error response).
|
||||
If :api t, returns JSON error (401). Otherwise redirects to login page.
|
||||
Auto-detects API routes if not specified."
|
||||
(let* ((user-id (session:field "user-id"))
|
||||
(uri (uri-to-url (radiance:uri *request*) :representation :external))
|
||||
;; Use explicit flag if provided, otherwise auto-detect from URI
|
||||
(is-api-request (if api t (search "/api/" uri))))
|
||||
(format t "Authentication check - User ID: ~a, URI: ~a, Is API: ~a~%"
|
||||
user-id uri (if is-api-request "YES" "NO"))
|
||||
(if user-id
|
||||
t ; Authenticated - return T to continue
|
||||
;; Not authenticated - emit error
|
||||
(if is-api-request
|
||||
;; API request - emit JSON error and return the value from api-output
|
||||
(progn
|
||||
(format t "Authentication failed - returning JSON 401~%")
|
||||
(radiance:api-output
|
||||
'(("error" . "Authentication required"))
|
||||
:status 401
|
||||
:message "You must be logged in to access this resource"))
|
||||
;; Page request - redirect to login (redirect doesn't return)
|
||||
(progn
|
||||
(format t "Authentication failed - redirecting to login~%")
|
||||
(radiance:redirect "/asteroid/login"))))))
|
||||
|
||||
(defun require-role (role)
|
||||
"Require user to have a specific role"
|
||||
(handler-case
|
||||
(let ((current-user (get-current-user)))
|
||||
(format t "Current user for role check: ~a~%" (if current-user "FOUND" "NOT FOUND"))
|
||||
(when current-user
|
||||
(format t "User has role ~a: ~a~%" role (user-has-role-p current-user role)))
|
||||
(unless (and current-user (user-has-role-p current-user role))
|
||||
(format t "Role check failed - redirecting to login~%")
|
||||
(radiance:redirect "/asteroid/login")))
|
||||
(error (e)
|
||||
(format t "Role check error: ~a~%" e)
|
||||
(radiance:redirect "/asteroid/login"))))
|
||||
(defun require-role (role &key (api nil))
|
||||
"Require user to have a specific role.
|
||||
Returns T if authorized, NIL if not (after emitting error response).
|
||||
If :api t, returns JSON error (403). Otherwise redirects to login page.
|
||||
Auto-detects API routes if not specified."
|
||||
(let* ((current-user (get-current-user))
|
||||
(uri (uri-to-url (radiance:uri *request*) :representation :external))
|
||||
;; Use explicit flag if provided, otherwise auto-detect from URI
|
||||
(is-api-request (if api t (search "/api/" uri))))
|
||||
(format t "Current user for role check: ~a~%" (if current-user "FOUND" "NOT FOUND"))
|
||||
(format t "Request URI: ~a, Is API: ~a~%" uri (if is-api-request "YES" "NO"))
|
||||
(when current-user
|
||||
(format t "User has role ~a: ~a~%" role (user-has-role-p current-user role)))
|
||||
(if (and current-user (user-has-role-p current-user role))
|
||||
t ; Authorized - return T to continue
|
||||
;; Not authorized - emit error
|
||||
(if is-api-request
|
||||
;; API request - emit JSON error and return the value from api-output
|
||||
(progn
|
||||
(format t "Role check failed - returning JSON 403~%")
|
||||
(radiance:api-output
|
||||
'(("error" . "Forbidden"))
|
||||
:status 403
|
||||
:message (format nil "You must be logged in with ~a role to access this resource" role)))
|
||||
;; Page request - redirect to login (redirect doesn't return)
|
||||
(progn
|
||||
(format t "Role check failed - redirecting to login~%")
|
||||
(radiance:redirect "/asteroid/login"))))))
|
||||
|
||||
(defun update-user-role (user-id new-role)
|
||||
"Update a user's role"
|
||||
|
|
|
|||
Loading…
Reference in New Issue