Add user profile page with clip template styling
- Created profile.chtml template with listening statistics, recent tracks, and top artists - Added profile.js for dynamic data loading and user interactions - Extended LASS styles for profile-specific elements (artist stats, track items, activity charts) - Implemented /profile route with authentication and template rendering - Added profile API endpoints for user data, stats, recent tracks, and top artists - Added profile link to main navigation - Includes placeholder functionality for future listening metrics implementation
This commit is contained in:
parent
8af85afe0e
commit
4b5e5d7fcc
146
asteroid.lisp
146
asteroid.lisp
|
|
@ -384,6 +384,75 @@
|
||||||
`(("status" . "success")
|
`(("status" . "success")
|
||||||
("player" . ,(get-player-status)))))
|
("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
|
;; Front page
|
||||||
(define-page front-page #@"/" ()
|
(define-page front-page #@"/" ()
|
||||||
"Main front page"
|
"Main front page"
|
||||||
|
|
@ -459,6 +528,83 @@
|
||||||
(plump:parse (alexandria:read-file-into-string template-path))
|
(plump:parse (alexandria:read-file-into-string template-path))
|
||||||
:title "🎵 ASTEROID RADIO - User Management")))
|
: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" ()
|
(define-page player #@"/player" ()
|
||||||
(let ((template-path (merge-pathnames "template/player.chtml"
|
(let ((template-path (merge-pathnames "template/player.chtml"
|
||||||
(asdf:system-source-directory :asteroid))))
|
(asdf:system-source-directory :asteroid))))
|
||||||
|
|
|
||||||
|
|
@ -699,6 +699,143 @@ body .profile-actions{
|
||||||
justify-content: center;
|
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{
|
body .user-management{
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -547,6 +547,120 @@
|
||||||
:gap 1rem
|
:gap 1rem
|
||||||
:justify-content center)
|
: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 Styles
|
||||||
(.user-management :margin-top 2rem)
|
(.user-management :margin-top 2rem)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<a href="/asteroid/">Home</a>
|
<a href="/asteroid/">Home</a>
|
||||||
<a href="/asteroid/player">Player</a>
|
<a href="/asteroid/player">Player</a>
|
||||||
|
<a href="/asteroid/profile">Profile</a>
|
||||||
<a href="/asteroid/admin">Admin</a>
|
<a href="/asteroid/admin">Admin</a>
|
||||||
<a href="/asteroid/status">Status</a>
|
<a href="/asteroid/status">Status</a>
|
||||||
<a href="/asteroid/login">Login</a>
|
<a href="/asteroid/login">Login</a>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
<!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/profile.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="profile-container">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Profile Header -->
|
||||||
|
<div class="profile-card">
|
||||||
|
<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="profile-card">
|
||||||
|
<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="profile-card">
|
||||||
|
<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="profile-card">
|
||||||
|
<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="profile-card">
|
||||||
|
<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="profile-card">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Initialize profile page
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
loadProfileData();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in New Issue