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
b31800a7db
commit
1b1445e25f
146
asteroid.lisp
146
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))))
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<a href="/asteroid/">Home</a>
|
||||
<a href="/asteroid/player">Player</a>
|
||||
<a href="/asteroid/profile">Profile</a>
|
||||
<a href="/asteroid/admin">Admin</a>
|
||||
<a href="/asteroid/status">Status</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