diff --git a/asteroid.lisp b/asteroid.lisp index e8e7611..a02f487 100644 --- a/asteroid.lisp +++ b/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)))) diff --git a/static/asteroid.css b/static/asteroid.css index 9d63414..1e897b5 100644 --- a/static/asteroid.css +++ b/static/asteroid.css @@ -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; } diff --git a/static/asteroid.lass b/static/asteroid.lass index 60b8566..c60f6db 100644 --- a/static/asteroid.lass +++ b/static/asteroid.lass @@ -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) diff --git a/static/js/auth-ui.js b/static/js/auth-ui.js new file mode 100644 index 0000000..a3143cf --- /dev/null +++ b/static/js/auth-ui.js @@ -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'); +}); diff --git a/static/js/profile.js b/static/js/profile.js new file mode 100644 index 0000000..d141819 --- /dev/null +++ b/static/js/profile.js @@ -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'); +} diff --git a/template/admin.chtml b/template/admin.chtml index 807379d..0b72c9b 100644 --- a/template/admin.chtml +++ b/template/admin.chtml @@ -5,6 +5,7 @@ + @@ -13,7 +14,9 @@ diff --git a/template/front-page.chtml b/template/front-page.chtml index 95b7c8d..df13e02 100644 --- a/template/front-page.chtml +++ b/template/front-page.chtml @@ -5,7 +5,8 @@ - + +
@@ -14,10 +15,12 @@ diff --git a/template/login.chtml b/template/login.chtml index 086246e..e20f416 100644 --- a/template/login.chtml +++ b/template/login.chtml @@ -12,7 +12,7 @@

System Access

-
+
diff --git a/template/player.chtml b/template/player.chtml index 2470f57..17db19c 100644 --- a/template/player.chtml +++ b/template/player.chtml @@ -5,6 +5,7 @@ + @@ -12,7 +13,9 @@

🎵 WEB PLAYER

diff --git a/template/profile.chtml b/template/profile.chtml new file mode 100644 index 0000000..15412d7 --- /dev/null +++ b/template/profile.chtml @@ -0,0 +1,171 @@ + + + + Asteroid Radio - User Profile + + + + + + + +
+

👤 USER PROFILE

+ + + +
+

🎧 User Profile

+
+
+ Username: + user +
+
+ Role: + listener +
+
+ Member Since: + 2024-01-01 +
+
+ Last Active: + Today +
+
+
+ + +
+

📊 Listening Statistics

+
+
+

Total Listen Time

+

0h 0m

+
+
+

Tracks Played

+

0

+
+
+

Sessions

+

0

+
+
+

Favorite Genre

+

Unknown

+
+
+
+ + +
+

🎵 Recently Played

+
+
+
+ No recent tracks + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ +
+
+ + +
+

🎤 Top Artists

+
+
+ Unknown Artist + 0 plays +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

📈 Listening Activity

+
+

Activity over the last 30 days

+
+
+
+
+
+
+
+
+ +
+

Listening hours per day

+
+
+ + +
+

⚙️ Profile Settings

+
+ + + +
+
+
+ + + + diff --git a/template/register.chtml b/template/register.chtml new file mode 100644 index 0000000..7e0c5cf --- /dev/null +++ b/template/register.chtml @@ -0,0 +1,56 @@ + + + + Asteroid Radio - Register + + + + + +
+

🎵 ASTEROID RADIO - REGISTER

+ + +
+
+

Create Account

+ + + +
+ + + Minimum 3 characters +
+
+ + +
+
+ + + Minimum 6 characters +
+
+ + +
+
+ +
+ + +
+
+
+ + diff --git a/user-management.lisp b/user-management.lisp index 39c0005..6cda908 100644 --- a/user-management.lisp +++ b/user-management.lisp @@ -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"