From dd6d0f3ec3530553749bfeb80b930e3c1add9316 Mon Sep 17 00:00:00 2001 From: Yajat Sharma Date: Sat, 5 Sep 2026 15:49:56 +0530 Subject: [PATCH 1/2] Refactor public profile and dashboard modal to use shared components --- static/css/profile.css | 142 ++++++ static/js/profile.js | 248 +++++++++++ templates/components/profile_content.html | 134 ++++++ templates/index.html | 93 +--- templates/public_profile.html | 507 +--------------------- 5 files changed, 551 insertions(+), 573 deletions(-) create mode 100644 static/css/profile.css create mode 100644 static/js/profile.js create mode 100644 templates/components/profile_content.html diff --git a/static/css/profile.css b/static/css/profile.css new file mode 100644 index 0000000..497e6cb --- /dev/null +++ b/static/css/profile.css @@ -0,0 +1,142 @@ +/* Profile Shared Styles */ +.profile-hero { position: relative; overflow: hidden; } +.profile-hero::before { + content: ''; + position: absolute; inset: 0; + background: radial-gradient(ellipse at 50% -20%, rgba(0,242,255,0.08) 0, transparent 65%); + pointer-events: none; +} +.avatar-ring { + width: 96px; height: 96px; + border-radius: 50%; + border: 3px solid var(--accent); + box-shadow: 0 0 24px rgba(0,242,255,0.3); + position: relative; overflow: hidden; flex-shrink: 0; + transition: box-shadow 0.3s ease; +} +.avatar-ring:hover { box-shadow: 0 0 36px rgba(0,242,255,0.5); } +.avatar-ring img { width: 100%; height: 100%; object-fit: cover; display: none; } +.avatar-ring img.loaded { display: block; } +.avatar-initials { + position: absolute; inset: 0; + display: flex; align-items: center; justify-content: center; + font-size: 2.2rem; font-weight: 800; + background: linear-gradient(135deg, rgba(0,242,255,0.15), rgba(0,242,255,0.04)); + color: var(--accent); +} +.profile-name { + font-size: 1.75rem; font-weight: 800; + background: linear-gradient(130deg, #fff, var(--accent)); + -webkit-background-clip: text; -webkit-text-fill-color: transparent; + background-clip: text; line-height: 1.15; +} +body.light-theme .profile-name { + background: linear-gradient(130deg, #0F172A, var(--accent)); + -webkit-background-clip: text; background-clip: text; +} +.profile-handle { font-size: 0.9rem; color: var(--text-tertiary); font-weight: 500; } +.profile-bio { color: var(--text-secondary); font-size: 0.93rem; max-width: 520px; line-height: 1.55; } +.next-race-pill { + display: inline-flex; align-items: center; gap: 0.4rem; + background: rgba(176,255,79,0.08); + border: 1px solid rgba(176,255,79,0.25); + color: var(--accent-lime); + border-radius: 999px; padding: 0.3rem 0.9rem; + font-size: 0.82rem; font-weight: 600; +} +.social-stat { text-align: center; } +.social-stat .val { font-size: 1.4rem; font-weight: 800; color: var(--accent); } +.social-stat .lbl { + font-size: 0.73rem; text-transform: uppercase; + letter-spacing: 0.08em; color: var(--text-tertiary); font-weight: 600; +} +.btn-follow { + border-radius: 999px; padding: 0.45rem 1.5rem; + font-weight: 700; font-size: 0.88rem; + border: 2px solid var(--accent); background: transparent; + color: var(--accent); transition: all 0.2s ease; cursor: pointer; +} +.btn-follow:hover, .btn-follow.following { background: var(--accent); color: #0a0c10; } +.btn-follow.following:hover { border-color: #ff6e3a; background: rgba(255,110,58,0.1); color: #ff6e3a; } +.stat-card { + padding: 1.2rem 1rem; border-radius: 16px; + background: var(--bg-card); border: 1px solid var(--border-color); + text-align: center; transition: transform 0.2s ease, box-shadow 0.2s ease; +} +.stat-card:hover { transform: translateY(-3px); box-shadow: 0 16px 40px rgba(0,0,0,0.5); } +.stat-card .icon { font-size: 1.4rem; margin-bottom: 0.35rem; display: block; } +.stat-card .val { font-size: 1.6rem; font-weight: 800; color: var(--accent); line-height: 1; } +.stat-card .unit { font-size: 0.75rem; color: var(--text-tertiary); font-weight: 500; margin-left: 0.2rem; } +.stat-card .lbl { font-size: 0.73rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-tertiary); font-weight: 600; margin-top: 0.3rem; } +.section-header { + font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.12em; + color: var(--text-tertiary); font-weight: 700; margin-bottom: 1rem; + display: flex; align-items: center; gap: 0.6rem; +} +.section-header::after { content: ''; flex: 1; height: 1px; background: var(--border-subtle); } +.badge-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 0.8rem; } +.badge-card { + border-radius: 14px; border: 1px solid var(--border-subtle); + background: var(--bg-card); padding: 1rem 0.8rem; text-align: center; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} +.badge-card.earned { border-color: rgba(0,242,255,0.25); background: rgba(0,242,255,0.04); } +.badge-card.earned:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,242,255,0.12); } +.badge-card.locked { opacity: 0.42; filter: grayscale(0.6); } +.badge-icon { font-size: 2rem; display: block; margin-bottom: 0.4rem; } +.badge-name { font-size: 0.78rem; font-weight: 700; color: var(--text-primary); margin-bottom: 0.2rem; } +.badge-desc { font-size: 0.68rem; color: var(--text-tertiary); } +.badge-date { font-size: 0.65rem; color: var(--accent); margin-top: 0.2rem; } +.badge-progress-bar { height: 4px; border-radius: 999px; background: var(--border-subtle); margin-top: 0.4rem; overflow: hidden; } +.badge-progress-fill { height: 100%; border-radius: 999px; background: linear-gradient(90deg, var(--accent), var(--accent-lime)); transition: width 1s ease; } +.badge-progress-text { font-size: 0.63rem; color: var(--text-tertiary); margin-top: 0.2rem; } +.pb-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 0.9rem; } +.pb-card { + border-radius: 14px; border: 1px solid var(--border-subtle); + background: var(--bg-card); padding: 1rem 1.1rem; + display: flex; align-items: center; gap: 0.9rem; transition: transform 0.2s ease; +} +.pb-card:hover { transform: translateY(-2px); } +.pb-card .pb-icon { font-size: 1.5rem; flex-shrink: 0; } +.pb-card .pb-val { font-size: 1.15rem; font-weight: 800; color: var(--accent); } +.pb-card .pb-label { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-tertiary); font-weight: 600; } +.pb-card .pb-date { font-size: 0.65rem; color: var(--text-tertiary); } +.heatmap-container { overflow-x: auto; padding: 0.5rem 0; } +.heatmap-grid { display: grid; grid-template-rows: repeat(7, 1fr); grid-auto-flow: column; gap: 3px; } +.heatmap-cell { width: 13px; height: 13px; border-radius: 3px; background: var(--border-subtle); transition: transform 0.15s ease; } +.heatmap-cell:hover { transform: scale(1.4); cursor: pointer; } +.heatmap-cell[data-level="1"] { background: rgba(0,242,255,0.25); } +.heatmap-cell[data-level="2"] { background: rgba(0,242,255,0.50); } +.heatmap-cell[data-level="3"] { background: rgba(0,242,255,0.75); } +.heatmap-cell[data-level="4"] { background: #00f2ff; box-shadow: 0 0 6px rgba(0,242,255,0.5); } +.edit-panel { + border: 1px solid rgba(176,255,79,0.2); + background: rgba(176,255,79,0.03); + border-radius: 16px; padding: 1.2rem 1.4rem; +} +.edit-panel h6 { color: var(--accent-lime); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.1em; font-weight: 700; margin-bottom: 1rem; } +.form-control-dark { + background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); + color: var(--text-primary); border-radius: 10px; font-size: 0.88rem; + padding: 0.55rem 0.8rem; transition: border-color 0.2s; width: 100%; +} +.form-control-dark:focus { + outline: none; border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(0,242,255,0.12); + background: rgba(0,0,0,0.4); color: var(--text-primary); +} +.char-count { font-size: 0.68rem; color: var(--text-tertiary); text-align: right; margin-top: 0.2rem; } +.avatar-upload-btn { + position: absolute; bottom: -4px; right: -4px; + width: 28px; height: 28px; border-radius: 50%; + background: var(--accent); border: 2px solid var(--bg-body); + display: flex; align-items: center; justify-content: center; + cursor: pointer; font-size: 0.65rem; color: #0a0c10; transition: transform 0.2s; +} +.avatar-upload-btn:hover { transform: scale(1.15); } +@media (max-width: 576px) { + .avatar-ring { width: 76px; height: 76px; } + .avatar-initials { font-size: 1.6rem; } + .profile-name { font-size: 1.35rem; } + .badge-grid { grid-template-columns: repeat(3, 1fr); } +} diff --git a/static/js/profile.js b/static/js/profile.js new file mode 100644 index 0000000..f4458b2 --- /dev/null +++ b/static/js/profile.js @@ -0,0 +1,248 @@ +var _toastTimer; +function profileShowToast(msg, type) { + type = type || 'success'; + var t = document.getElementById('profile-toast') || document.getElementById('toast'); + if (!t) return; + t.textContent = msg; + t.className = 'rr-toast ' + type + ' show'; + clearTimeout(_toastTimer); + _toastTimer = setTimeout(function() { t.classList.remove('show'); }, 3000); +} + +function fmtPace(pace) { + if (!pace || pace <= 0) return '-'; + var m = Math.floor(pace); + var s = Math.round((pace - m) * 60); + return m + ':' + (s < 10 ? '0' : '') + s; +} + +function fmtTime(min) { + if (!min || min <= 0) return '-'; + var h = Math.floor(min / 60); + var m = Math.round(min % 60); + return h > 0 ? h + 'h ' + m + 'm' : m + 'm'; +} + +function fmtDate(str) { + if (!str) return ''; + try { return new Date(str).toLocaleDateString('en-GB', {month:'short', year:'numeric'}); } + catch(e) { return str; } +} + +function initials(name) { + if (!name) return '?'; + return name.split(' ').map(function(w) { return w[0]; }).join('').slice(0,2).toUpperCase(); +} + +function loadProfile(username, isOwnProfile) { + fetch('/api/user/' + encodeURIComponent(username) + '/public-profile') + .then(function(res) { + if (!res.ok) { document.getElementById('profile-display-name').textContent = 'Profile not found'; return null; } + return res.json(); + }) + .then(function(d) { + if (!d) return; + var pageTitle = document.getElementById('page-title'); + if (pageTitle) { + document.title = d.display_name + ' (@' + d.username + ') - RunRush'; + } + + document.getElementById('profile-display-name').textContent = d.display_name; + document.getElementById('profile-handle').textContent = '@' + d.username; + + var bioEl = document.getElementById('profile-bio'); + if (d.bio) { + bioEl.textContent = d.bio; + bioEl.style.display = ''; + } else { + bioEl.style.display = 'none'; + } + + var nextRaceContainer = document.getElementById('next-race-container'); + if (d.next_race) { + document.getElementById('next-race-text').textContent = d.next_race; + nextRaceContainer.style.display = ''; + } else { + nextRaceContainer.style.display = 'none'; + } + + var initEl = document.getElementById('avatar-initials'); + initEl.textContent = initials(d.display_name); + initEl.style.display = ''; // Reset display in case of modal reopen with different user + + var img = document.getElementById('avatar-img'); + img.classList.remove('loaded'); + if (d.has_avatar) { + img.src = '/avatar/' + encodeURIComponent(username) + '?t=' + Date.now(); + img.onload = function() { img.classList.add('loaded'); initEl.style.display = 'none'; }; + } + + document.getElementById('run-count').textContent = d.run_count; + document.getElementById('follower-count').textContent = d.follower_count; + document.getElementById('following-count').textContent = d.following_count; + document.getElementById('stat-distance').textContent = d.total_distance_km; + document.getElementById('stat-time').textContent = fmtTime(d.total_time_min); + document.getElementById('stat-pace').textContent = fmtPace(d.avg_pace_min_per_km); + document.getElementById('stat-longest').textContent = d.longest_run_km; + + renderPB(d.personal_bests); + renderBadges(d.badges, d.total_distance_km); + + if (isOwnProfile) { + var bi = document.getElementById('edit-bio'); + var ri = document.getElementById('edit-next-race'); + if (bi) { bi.value = d.bio || ''; document.getElementById('bio-count').textContent = bi.value.length; } + if (ri) { ri.value = d.next_race || ''; document.getElementById('race-count').textContent = ri.value.length; } + } + }) + .catch(function(e) { console.error(e); }); +} + +function renderPB(pb) { + var items = [ + { icon: '🎍', label: 'Fastest 5K', + val: pb.fastest_5k ? fmtTime(pb.fastest_5k.time_min) : '-', + sub: pb.fastest_5k ? fmtPace(pb.fastest_5k.pace) + '/km' : 'No 5K run yet' }, + { icon: '🎯', label: 'Fastest 10K', + val: pb.fastest_10k ? fmtTime(pb.fastest_10k.time_min) : '-', + sub: pb.fastest_10k ? fmtPace(pb.fastest_10k.pace) + '/km' : 'No 10K run yet' }, + { icon: '📏', label: 'Longest Run', + val: pb.longest_distance ? pb.longest_distance.distance_km + ' km' : '-', + sub: pb.longest_distance ? fmtDate(pb.longest_distance.date) : 'No runs yet' }, + { icon: '⚡', label: 'Best Pace', + val: pb.best_pace ? fmtPace(pb.best_pace.pace) + '/km' : '-', + sub: pb.best_pace ? pb.best_pace.distance_km + ' km' : 'No qualifying run' }, + ]; + document.getElementById('pb-grid').innerHTML = items.map(function(item) { + return '
' + item.icon + '
' + + '
' + item.val + '
' + + '
' + item.label + '
' + + '
' + item.sub + '
'; + }).join(''); +} + +function renderBadges(badges, totalKm) { + document.getElementById('badge-grid').innerHTML = badges.map(function(b) { + var earned = b.earned; + var progressHtml = ''; + if (!earned && b.progress !== undefined) { + var pct = Math.round((b.progress / b.progress_target) * 100); + progressHtml = '
' + + '
' + b.progress.toFixed(1) + '/' + b.progress_target + ' km
'; + } + var dateStr = earned && b.unlocked_at ? '
Earned ' + fmtDate(b.unlocked_at) + '
' : ''; + return '
' + + '' + b.icon + '' + + '
' + b.name + '
' + + '
' + b.description + '
' + + progressHtml + dateStr + '
'; + }).join(''); +} + +function loadHeatmap(username) { + fetch('/api/user/' + encodeURIComponent(username) + '/heatmap') + .then(function(r) { return r.ok ? r.json() : null; }) + .then(function(data) { + if (!data) return; + var grid = document.getElementById('heatmap-grid'); + grid.innerHTML = data.days.map(function(d) { + var km = d.km; + var level = km === 0 ? 0 : km < 3 ? 1 : km < 6 ? 2 : km < 10 ? 3 : 4; + return '
'; + }).join(''); + }) + .catch(function() {}); +} + +function initProfileEvents(username) { + var followBtn = document.getElementById('follow-btn'); + if (followBtn) { + followBtn.addEventListener('click', function() { + var following = followBtn.dataset.following === 'true'; + var action = following ? 'unfollow' : 'follow'; + var followUser = followBtn.dataset.username; + fetch('/' + action + '/' + encodeURIComponent(followUser), { method: 'POST' }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data.success !== undefined || data.following !== undefined) { + var nowFollowing = action === 'follow'; + followBtn.dataset.following = String(nowFollowing); + followBtn.textContent = nowFollowing ? 'Unfollow' : 'Follow'; + followBtn.classList.toggle('following', nowFollowing); + var fc = document.getElementById('follower-count'); + var n = parseInt(fc.textContent) || 0; + fc.textContent = nowFollowing ? n + 1 : Math.max(0, n - 1); + profileShowToast(nowFollowing ? 'Following ' + followUser : 'Unfollowed ' + followUser); + } + }) + .catch(function() { profileShowToast('Something went wrong', 'error'); }); + }); + } + + var saveBtn = document.getElementById('save-profile-btn'); + if (saveBtn) { + var bioInput = document.getElementById('edit-bio'); + var raceInput = document.getElementById('edit-next-race'); + bioInput.addEventListener('input', function() { + document.getElementById('bio-count').textContent = bioInput.value.length; + }); + raceInput.addEventListener('input', function() { + document.getElementById('race-count').textContent = raceInput.value.length; + }); + saveBtn.addEventListener('click', function() { + saveBtn.disabled = true; + saveBtn.textContent = 'Saving...'; + fetch('/api/profile/details', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ bio: bioInput.value, next_race: raceInput.value }) + }) + .then(function(r) { return r.json(); }) + .then(function(d) { + if (d.success) { + profileShowToast('Profile updated!'); + var bioEl = document.getElementById('profile-bio'); + bioEl.textContent = bioInput.value; + bioEl.style.display = bioInput.value ? '' : 'none'; + if (raceInput.value) { + document.getElementById('next-race-text').textContent = raceInput.value; + document.getElementById('next-race-container').style.display = ''; + } else { + document.getElementById('next-race-container').style.display = 'none'; + } + } else { + profileShowToast(d.error || 'Failed to save', 'error'); + } + }) + .catch(function() { profileShowToast('Network error', 'error'); }) + .finally(function() { saveBtn.disabled = false; saveBtn.textContent = 'Save Changes'; }); + }); + } + + var avatarInput = document.getElementById('avatar-file-input'); + if (avatarInput) { + avatarInput.addEventListener('change', function() { + var file = avatarInput.files[0]; + if (!file) return; + var fd = new FormData(); + fd.append('avatar', file); + fetch('/api/profile/avatar', { method: 'POST', body: fd }) + .then(function(r) { return r.json(); }) + .then(function(d) { + if (d.success) { + profileShowToast('Avatar updated!'); + var img = document.getElementById('avatar-img'); + img.src = '/avatar/' + encodeURIComponent(username) + '?t=' + Date.now(); + img.onload = function() { + img.classList.add('loaded'); + document.getElementById('avatar-initials').style.display = 'none'; + }; + } else { + profileShowToast(d.error || 'Upload failed', 'error'); + } + }) + .catch(function() { profileShowToast('Upload failed', 'error'); }) + .finally(function() { avatarInput.value = ''; }); + }); + } +} diff --git a/templates/components/profile_content.html b/templates/components/profile_content.html new file mode 100644 index 0000000..b39216c --- /dev/null +++ b/templates/components/profile_content.html @@ -0,0 +1,134 @@ +
+
+
+
+
?
+ avatar +
+ {% if is_own_profile %} + + + {% endif %} +
+
+
Loading...
+
@{{ profile_username or username }}
+
+ +
+ {% if viewer_username and not is_own_profile %} +
+ +
+ {% endif %} +
+
+ + + +
+
+ + {% if is_own_profile %} +
+
Edit Your Profile
+
+
+ + +
0/200
+
+
+ + +
0/100
+
+
+ +
+
+
+ {% endif %} + +
+
Lifetime Stats
+
+
+
+ 🏃 +
-km
+
Total Distance
+
+
+
+
+ ⏱ +
-
+
Total Time
+
+
+
+
+ ⚡ +
-/km
+
Avg Pace
+
+
+
+
+ 📏 +
-km
+
Longest Run
+
+
+
+
+ +
+
Activity - Last 365 Days
+
+
+
+
+ Less +
+
+
+
+
+ More +
+
+ +
+
Personal Bests
+
+
+ +
+
Achievements
+
+
diff --git a/templates/index.html b/templates/index.html index 8a193a5..749385b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1292,6 +1292,7 @@ animation: scSpin 0.6s linear infinite; } + @@ -4653,88 +4654,24 @@

🏆 Achievements

PROFILE MODAL ============================================== -->