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:
glenneth 2025-10-06 05:49:18 +03:00 committed by Brian O'Reilly
parent b31800a7db
commit 1b1445e25f
6 changed files with 861 additions and 0 deletions

View File

@ -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))))

View File

@ -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;
}

View File

@ -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)

293
static/js/profile.js Normal file
View File

@ -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');
}

View File

@ -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>

170
template/profile.chtml Normal file
View File

@ -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>