diff --git a/asteroid.lisp b/asteroid.lisp index e8e7611..ec3d403 100644 --- a/asteroid.lisp +++ b/asteroid.lisp @@ -384,6 +384,75 @@ `(("status" . "success") ("player" . ,(get-player-status))))) +;; Profile API Routes +(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 +528,83 @@ (plump:parse (alexandria:read-file-into-string template-path)) :title "🎵 ASTEROID RADIO - User Management"))) +;; Helper functions for profile page +(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)) + (user-role (gethash "role" current-user)) + (join-date (gethash "created_at" current-user)) + (last-active (gethash "last_active" current-user))) + (render-template-with-plist "profile" + :title (format nil "🎧 ~a - Profile | Asteroid Radio" username) + :username username + :user-role (or user-role "listener") + :join-date (if join-date + (format-timestamp nil join-date) + "Unknown") + :last-active (if last-active + (format-relative-time last-active) + "Unknown") + ;; Default listening statistics (will be populated by JavaScript) + :total-listen-time "0h 0m" + :tracks-played "0" + :session-count "0" + :favorite-genre "Unknown" + ;; Default recent tracks (will be populated by JavaScript) + :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 "" + ;; Default top artists (will be populated by JavaScript) + :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 ""))) + (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..0ab6541 100644 --- a/static/asteroid.css +++ b/static/asteroid.css @@ -699,6 +699,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..2ebb4a5 100644 --- a/static/asteroid.lass +++ b/static/asteroid.lass @@ -547,6 +547,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/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/front-page.chtml b/template/front-page.chtml index 95b7c8d..2b0791a 100644 --- a/template/front-page.chtml +++ b/template/front-page.chtml @@ -14,6 +14,7 @@