Skip to content

Commit 4e6df2a

Browse files
Leaderboard → Profile: click user to view their full profile
- Added viewUserId prop to UserProfilePanel for viewing other users - Added loadUserProfile() to store — fetches stats + special badges - Leaderboard detail view has "View Full Profile →" button - Other user profiles show stats, badges, special awards - Hides edit controls (flag picker, settings, bio, recent cells) for others - Profile header adapts: name, email, flag from Supabase data Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 103cbc3 commit 4e6df2a

File tree

4 files changed

+141
-36
lines changed

4 files changed

+141
-36
lines changed

src/components/ExtensionBar.vue

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,10 @@ const {volumes} = useVolumesStore();
5959
6060
onMounted(() => {
6161
(document.querySelector('.ng-extend-logo > a > img')! as HTMLImageElement).src = logoImage;
62-
document.addEventListener('nge:open-profile', () => { showProfile.value = true; });
62+
document.addEventListener('nge:open-profile', ((e: CustomEvent) => {
63+
profileUserId.value = e.detail?.userId || null;
64+
showProfile.value = true;
65+
}) as EventListener);
6366
});
6467
6568
const statsStore = useUserStatsStore();
@@ -79,6 +82,7 @@ watch(() => statsStore.recentEditEvent, (ev) => {
7982
8083
const showModal = ref(false);
8184
const showProfile = ref(false);
85+
const profileUserId = ref<string | null>(null);
8286
const showRecap = ref(false);
8387
const showLeaderboard = ref(false);
8488
const showSettings = ref(false);
@@ -216,7 +220,7 @@ function activateTool(toolType: 'multicut' | 'merge') {
216220
<cell-library-panel v-if="showCellLibrary" :initial-tab="cellLibraryInitialTab" @hide="showCellLibrary = false; cellLibraryInitialTab = undefined" />
217221
<batch-processor-panel v-if="showBatchProcessor" @hide="showBatchProcessor = false" />
218222
<volumes-overlay v-visible="showModal" @hide="showModal = false" />
219-
<user-profile-panel v-if="showProfile" @hide="showProfile = false" @open-settings="showSettings = true" />
223+
<user-profile-panel v-if="showProfile" :view-user-id="profileUserId" @hide="showProfile = false; profileUserId = null" @open-settings="showSettings = true" />
220224
<weekly-recap-panel v-if="showRecap" @hide="showRecap = false" />
221225
<leaderboard-panel v-if="showLeaderboard" @hide="showLeaderboard = false" />
222226
<settings-panel v-if="showSettings" @hide="showSettings = false" />
@@ -261,7 +265,7 @@ function activateTool(toolType: 'multicut' | 'merge') {
261265
</div>
262266
</Transition>
263267

264-
<button v-if="login.sessions.length > 0" class="nge-icon-btn" @click="showProfile = true" id="profileBtn" title="My Profile" style="margin-left: 12px; margin-right: 14px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="white" style="vertical-align:middle"><circle cx="12" cy="8" r="4"/><path d="M20 21c0-4.4-3.6-8-8-8s-8 3.6-8 8"/></svg></button>
268+
<button v-if="login.sessions.length > 0" class="nge-icon-btn" @click="profileUserId = null; showProfile = true" id="profileBtn" title="My Profile" style="margin-left: 12px; margin-right: 14px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="white" style="vertical-align:middle"><circle cx="12" cy="8" r="4"/><path d="M20 21c0-4.4-3.6-8-8-8s-8 3.6-8 8"/></svg></button>
265269
<dropdown-list dropdown-group="extension-bar-right" id="hamburger" class="rightMost">
266270
<template #buttonTitle>☰</template>
267271
<template #listItems>

src/components/LeaderboardPanel.vue

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,11 @@ function selectedBadgeDef() {
107107
return BADGE_DEFINITIONS.find(b => b.id === selectedBadgeId.value) ?? null;
108108
}
109109
110+
function openFullProfile(userId: string) {
111+
document.dispatchEvent(new CustomEvent('nge:open-profile', { detail: { userId } }));
112+
emit('hide');
113+
}
114+
110115
const RANK_MEDAL: Record<number, string> = {1: '🥇', 2: '🥈', 3: '🥉'};
111116
112117
/** Convert flag emoji to a CDN image URL (cross-platform, Windows compat). */
@@ -213,6 +218,7 @@ const emit = defineEmits({hide: null});
213218
<div class="nge-lb-detail-name">{{ selectedUser.name }}</div>
214219
</div>
215220
<div class="nge-lb-detail-bio" v-if="selectedUser.bio">{{ selectedUser.bio }}</div>
221+
<button class="nge-lb-view-profile" @click="openFullProfile(selectedUser.id)">View Full Profile →</button>
216222
</div>
217223

218224
<!-- Stats grid (2-column) -->
@@ -767,4 +773,20 @@ const emit = defineEmits({hide: null});
767773
.lb-badge-detail-leave-active { transition: all 0.15s ease-in; }
768774
.lb-badge-detail-enter-from,
769775
.lb-badge-detail-leave-to { opacity: 0; transform: translateY(8px) scale(0.96); }
776+
777+
.nge-lb-view-profile {
778+
margin-top: 8px;
779+
background: rgba(74, 158, 255, 0.1);
780+
border: 1px solid rgba(74, 158, 255, 0.25);
781+
color: #58a6ff;
782+
padding: 6px 14px;
783+
border-radius: 6px;
784+
font-size: 12px;
785+
cursor: pointer;
786+
transition: all 0.15s;
787+
}
788+
.nge-lb-view-profile:hover {
789+
background: rgba(74, 158, 255, 0.2);
790+
border-color: rgba(74, 158, 255, 0.4);
791+
}
770792
</style>

src/components/UserProfilePanel.vue

Lines changed: 88 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import {ref, computed, onMounted, onUnmounted} from 'vue';
2+
import {ref, computed, onMounted, onUnmounted, watch} from 'vue';
33
import {storeToRefs} from 'pinia';
44
import ModalOverlay from 'components/ModalOverlay.vue';
55
@@ -9,6 +9,11 @@ import {BADGE_IMAGE_MAP} from '../widgets/badge_images';
99
import {DEMO_USERS, DEMO_COMMUNITY_EDITS_WEEK, DEMO_COMMUNITY_EDITS_MONTH} from '../data/demo-users';
1010
import pyrIcon from '../../static/badges/pyr/pyr-icon.png';
1111
12+
// ── Props ─────────────────────────────────────────────────────────────────────
13+
const props = defineProps<{
14+
viewUserId?: string | null;
15+
}>();
16+
1217
// ── Stores ────────────────────────────────────────────────────────────────────
1318
const {sessions} = storeToRefs(useLoginStore());
1419
const statsStore = useUserStatsStore();
@@ -18,13 +23,58 @@ const {prefs} = storeToRefs(prefsStore);
1823
const historyStore = useCellHistoryStore();
1924
const {cells: cellHistory} = storeToRefs(historyStore);
2025
21-
// Refresh stats from Supabase when profile opens
2226
const backendStore = useProofreadingBackendStore();
23-
backendStore.loadUserStats();
27+
28+
// ── Viewing another user's profile ────────────────────────────────────────────
29+
const viewingOtherUser = computed(() => !!props.viewUserId && props.viewUserId !== backendStore.userId);
30+
const otherUserProfile = ref<any>(null);
31+
32+
async function loadOtherUser() {
33+
if (viewingOtherUser.value && props.viewUserId) {
34+
otherUserProfile.value = await backendStore.loadUserProfile(props.viewUserId);
35+
} else {
36+
otherUserProfile.value = null;
37+
backendStore.loadUserStats();
38+
}
39+
}
40+
loadOtherUser();
41+
42+
// Profile display name & email — works for both self and other users
43+
const profileName = computed(() => {
44+
if (viewingOtherUser.value && otherUserProfile.value) return otherUserProfile.value.display_name || 'Anonymous';
45+
return backendStore.userName || sessions.value?.[0]?.name || 'Researcher';
46+
});
47+
const profileEmail = computed(() => {
48+
if (viewingOtherUser.value && otherUserProfile.value) return otherUserProfile.value.middleauth_email || '';
49+
return backendStore.userEmail || sessions.value?.[0]?.email || '';
50+
});
51+
const profileFlag = computed(() => {
52+
if (viewingOtherUser.value && otherUserProfile.value) return otherUserProfile.value.flag || '';
53+
return prefs.value.flag || '';
54+
});
55+
const profileStats = computed(() => {
56+
if (viewingOtherUser.value && otherUserProfile.value) {
57+
const u = otherUserProfile.value;
58+
return {
59+
editsAllTime: u.total_edits || 0, mergesAllTime: u.total_merges || 0, splitsAllTime: u.total_splits || 0,
60+
cellsSubmitted: u.cells_completed || 0, currentStreak: u.current_streak || 0, longestStreak: u.longest_streak || 0,
61+
// Weekly/monthly/today not available for other users — show 0
62+
editsThisWeek: 0, mergesThisWeek: 0, splitsThisWeek: 0,
63+
editsThisMonth: 0, mergesThisMonth: 0, splitsThisMonth: 0,
64+
editsToday: 0, mergesToday: 0, splitsToday: 0,
65+
};
66+
}
67+
return stats.value;
68+
});
69+
const profileSpecialBadges = computed(() => {
70+
if (viewingOtherUser.value && otherUserProfile.value) return otherUserProfile.value.specialBadges || [];
71+
return profileSpecialBadges;
72+
});
2473
2574
// Player assists — help requests resolved by current user
2675
const helpStore = useHelpRequestStore();
2776
const playerAssists = computed(() => {
77+
if (viewingOtherUser.value) return 0;
2878
const name = backendStore.userName || backendStore.userEmail?.split('@')[0];
2979
if (!name) return 0;
3080
return helpStore.requests.filter(r => r.resolved && r.resolvedByName === name).length;
@@ -126,9 +176,10 @@ function getBadgeUrl(imageKey: string): string {
126176
127177
/** Get the player's current count for a given track. */
128178
function statForTrack(track: BadgeTrack): number {
179+
const s = profileStats.value;
129180
return track === 'building'
130-
? (stats.value.editsAllTime ?? 0)
131-
: (stats.value.cellsSubmitted ?? 0);
181+
? (s.editsAllTime ?? 0)
182+
: (s.cellsSubmitted ?? 0);
132183
}
133184
134185
function isBadgeEarned(badge: BadgeDefinition): boolean {
@@ -181,7 +232,7 @@ const favoriteBadge = computed<BadgeDefinition | null>(() => {
181232
/** Check if the favorite is a special badge (not a regular track badge). */
182233
const favoriteSpecialBadge = computed(() => {
183234
if (!favoriteBadgeSlug.value || favoriteBadge.value) return null;
184-
return backendStore.mySpecialBadges.find(
235+
return profileSpecialBadges.find(
185236
(a: any) => (a.badge?.slug || `special-${a.id}`) === favoriteBadgeSlug.value
186237
) || null;
187238
});
@@ -231,10 +282,10 @@ function nextForTrack(badges: BadgeDefinition[], current: number) {
231282
}
232283
233284
const nextBuildingAchievement = computed(() =>
234-
nextForTrack(BUILDING_BADGES, stats.value.editsAllTime ?? 0)
285+
nextForTrack(BUILDING_BADGES, profileStats.value.editsAllTime ?? 0)
235286
);
236287
const nextExplorationAchievement = computed(() =>
237-
nextForTrack(EXPLORATION_BADGES, stats.value.cellsSubmitted ?? 0)
288+
nextForTrack(EXPLORATION_BADGES, profileStats.value.cellsSubmitted ?? 0)
238289
);
239290
240291
// ── Current dataset helper (for filtering cells) ────────────────────────────
@@ -377,18 +428,18 @@ const emit = defineEmits({hide: null, 'open-settings': null});
377428
<div class="nge-profile-col nge-profile-col--left">
378429

379430
<!-- Header -->
380-
<div class="nge-profile-header" v-if="sessions.length > 0">
431+
<div class="nge-profile-header" v-if="sessions.length > 0 || viewingOtherUser">
381432
<div class="nge-profile-name-row">
382433

383-
<!-- Flag with inline picker -->
384-
<div id="nge-profile-flag-wrap" class="nge-profile-flag-wrap" @click.stop>
434+
<!-- Flag (editable only for own profile) -->
435+
<div v-if="!viewingOtherUser" id="nge-profile-flag-wrap" class="nge-profile-flag-wrap" @click.stop>
385436
<button
386437
class="nge-profile-flag"
387438
:class="{ 'nge-profile-flag--active': showFlagPicker }"
388439
@click="showFlagPicker = !showFlagPicker"
389440
title="Click to change flag"
390441
>
391-
<img v-if="flagImgUrl(prefs.flag || '')" :src="flagImgUrl(prefs.flag)" class="nge-flag-img" />
442+
<img v-if="flagImgUrl(profileFlag)" :src="flagImgUrl(profileFlag)" class="nge-flag-img" />
392443
<img v-else :src="pyrIcon" class="nge-flag-img nge-pyr-icon" />
393444
</button>
394445
<Transition name="nge-flag-picker">
@@ -402,30 +453,35 @@ const emit = defineEmits({hide: null, 'open-settings': null});
402453
</div>
403454
</Transition>
404455
</div>
456+
<div v-else class="nge-profile-flag-wrap">
457+
<img v-if="flagImgUrl(profileFlag)" :src="flagImgUrl(profileFlag)" class="nge-flag-img" style="width:28px;height:20px;" />
458+
</div>
405459

406-
<div class="nge-profile-name">{{ sessions[0].name || sessions[0].email?.split('@')[0] || 'Explorer' }}</div>
460+
<div class="nge-profile-name">{{ profileName }}</div>
407461

408-
<button class="nge-profile-edit-btn"
462+
<button v-if="!viewingOtherUser" class="nge-profile-edit-btn"
409463
@click="emit('open-settings')"
410464
title="Edit Profile — set bio, flag, and more">⚙</button>
411465
</div>
412466

413-
<div class="nge-profile-email">{{ sessions[0].email }}</div>
467+
<div class="nge-profile-email">{{ profileEmail }}</div>
414468

415-
<div class="nge-profile-bio" v-if="prefs.bio">{{ prefs.bio }}</div>
416-
<button v-else class="nge-profile-bio-add" @click="emit('open-settings')">
417-
+ Add a bio
418-
</button>
469+
<template v-if="!viewingOtherUser">
470+
<div class="nge-profile-bio" v-if="prefs.bio">{{ prefs.bio }}</div>
471+
<button v-else class="nge-profile-bio-add" @click="emit('open-settings')">
472+
+ Add a bio
473+
</button>
474+
</template>
419475
</div>
420476

421477
<!-- Edits stats -->
422478
<div class="nge-profile-section nge-profile-section--edits">
423479
<div class="nge-profile-section-label">▌ Edits</div>
424480
<div class="nge-profile-stat-row">
425481
<div class="nge-profile-stat-col" v-for="(col, i) in [
426-
{label:'Today', val:stats.editsToday, merges:stats.mergesToday, splits:stats.splitsToday},
427-
{label:'Past 7d', val:stats.editsThisWeek, merges:stats.mergesThisWeek, splits:stats.splitsThisWeek},
428-
{label:'All Time', val:stats.editsAllTime, merges:stats.mergesAllTime, splits:stats.splitsAllTime},
482+
{label:'Today', val:profileStats.editsToday, merges:profileStats.mergesToday, splits:profileStats.splitsToday},
483+
{label:'Past 7d', val:profileStats.editsThisWeek, merges:profileStats.mergesThisWeek, splits:profileStats.splitsThisWeek},
484+
{label:'All Time', val:profileStats.editsAllTime, merges:profileStats.mergesAllTime, splits:profileStats.splitsAllTime},
429485
]" :key="i">
430486
<div class="nge-profile-stat-label">{{ col.label }}</div>
431487
<div class="nge-profile-stat-val">{{ col.val.toLocaleString() }}</div>
@@ -457,7 +513,7 @@ const emit = defineEmits({hide: null, 'open-settings': null});
457513
</div>
458514

459515
<!-- Cells stats -->
460-
<div class="nge-profile-section nge-profile-section--cells">
516+
<div v-if="!viewingOtherUser" class="nge-profile-section nge-profile-section--cells">
461517
<div class="nge-profile-section-label">▌ Cells</div>
462518
<div class="nge-profile-stat-row">
463519
<div class="nge-profile-stat-col">
@@ -652,13 +708,13 @@ const emit = defineEmits({hide: null, 'open-settings': null});
652708
</div>
653709

654710
<!-- Special Awards (admin-awarded badges) -->
655-
<template v-if="backendStore.mySpecialBadges.length > 0">
711+
<template v-if="profileSpecialBadges.length > 0">
656712
<div class="nge-profile-badges-divider"></div>
657713
<div class="nge-profile-section nge-profile-section--badges nge-profile-section--special">
658714
<div class="nge-profile-section-label">★ Special Awards</div>
659715
<div class="nge-profile-badges-grid">
660716
<div
661-
v-for="award in backendStore.mySpecialBadges"
717+
v-for="award in profileSpecialBadges"
662718
:key="award.id"
663719
class="nge-profile-badge"
664720
:class="{ 'nge-profile-badge--selected': selectedSpecialBadge?.id === award.id }"
@@ -774,15 +830,15 @@ const emit = defineEmits({hide: null, 'open-settings': null});
774830
<!-- Streak + Activity Chart -->
775831
<div class="nge-profile-section nge-profile-section--streak">
776832
<div class="nge-profile-section-label nge-profile-section-label--amber">▌ Streak</div>
777-
<div class="nge-profile-streak-row" v-if="stats.currentStreak > 0 || stats.longestStreak > 0">
833+
<div class="nge-profile-streak-row" v-if="profileStats.currentStreak > 0 || profileStats.longestStreak > 0">
778834
<div class="nge-profile-streak-current">
779835
<span class="nge-profile-streak-flame">🔥</span>
780-
<span class="nge-profile-streak-count">{{ stats.currentStreak }}</span>
781-
<span class="nge-profile-streak-unit">day{{ stats.currentStreak === 1 ? '' : 's' }} current</span>
836+
<span class="nge-profile-streak-count">{{ profileStats.currentStreak }}</span>
837+
<span class="nge-profile-streak-unit">day{{ profileStats.currentStreak === 1 ? '' : 's' }} current</span>
782838
</div>
783-
<div class="nge-profile-streak-best" v-if="stats.longestStreak > 0">
839+
<div class="nge-profile-streak-best" v-if="profileStats.longestStreak > 0">
784840
<span class="nge-profile-streak-best-label">Best</span>
785-
<span class="nge-profile-streak-best-val">{{ stats.longestStreak }}d</span>
841+
<span class="nge-profile-streak-best-val">{{ profileStats.longestStreak }}d</span>
786842
</div>
787843
</div>
788844

@@ -973,11 +1029,11 @@ const emit = defineEmits({hide: null, 'open-settings': null});
9731029
</div>
9741030

9751031
<!-- Special Awards -->
976-
<div v-if="backendStore.mySpecialBadges.length > 0" class="nge-trophy-track">
1032+
<div v-if="profileSpecialBadges.length > 0" class="nge-trophy-track">
9771033
<div class="nge-trophy-track-label">★ Special Awards</div>
9781034
<div class="nge-trophy-grid">
9791035
<div
980-
v-for="award in backendStore.mySpecialBadges"
1036+
v-for="award in profileSpecialBadges"
9811037
:key="award.id"
9821038
class="nge-trophy-badge"
9831039
:class="{ 'nge-trophy-badge--selected': selectedSpecialBadge?.id === award.id }"

src/store.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2177,6 +2177,29 @@ export const useProofreadingBackendStore = defineStore('proofreadingBackend', ()
21772177
}
21782178
}
21792179

2180+
// ── Load another user's profile data ────────────────────────────────
2181+
async function loadUserProfile(targetUserId: string): Promise<any | null> {
2182+
try {
2183+
const { data: user } = await supabase
2184+
.from('users')
2185+
.select('id, display_name, middleauth_email, flag, total_edits, total_merges, total_splits, cells_completed, current_streak, longest_streak, last_edit_date, favorite_badge')
2186+
.eq('id', targetUserId)
2187+
.single();
2188+
if (!user) return null;
2189+
2190+
// Load their special badges
2191+
const { data: awards } = await supabase
2192+
.from('special_badge_awards')
2193+
.select('*, badge:special_badges(*)')
2194+
.eq('user_id', targetUserId);
2195+
2196+
return { ...user, specialBadges: awards || [] };
2197+
} catch (e: any) {
2198+
console.warn('[backend] loadUserProfile error:', e.message);
2199+
return null;
2200+
}
2201+
}
2202+
21802203
// ── Leaderboard: top users from Supabase ────────────────────────────
21812204
const leaderboard: Ref<any[]> = ref([]);
21822205

@@ -2788,7 +2811,7 @@ export const useProofreadingBackendStore = defineStore('proofreadingBackend', ()
27882811
leaderboard,
27892812
syncUser, loadTasks, claimTask, releaseTask, completeTask,
27902813
logEdit, postActivity, subscribeToFeed, unsubscribeFromFeed,
2791-
importFromGoogleSheet, syncStats, loadUserStats, loadLeaderboard,
2814+
importFromGoogleSheet, syncStats, loadUserStats, loadUserProfile, loadLeaderboard,
27922815
// Claim-by-segment
27932816
claimBySegment, releaseBySegment, isMyClaimedSegment, isClaimedSegment, myActiveClaimCount,
27942817
MAX_CLAIMS,

0 commit comments

Comments
 (0)