generated from nhcarrigan/template
feat: implement comprehensive leaderboard feature
Implements issue #55 with multiple leaderboard categories: - Top Suggestions (by count and acceptance rate) - Top Likes (by total likes given) - Top Comments (by total comments posted) - Overall Leaders (weighted by achievement points and engagement diversity) Features: - Tabbed UI with reactive state management - Medal indicators for top 3 positions - User avatars and badges display - Current user highlighting - Privacy controls via profilePublic setting - Configurable result limits (max 100) - Detailed statistics per category Backend: - Created LeaderboardService with aggregation logic - Filters for public profiles and non-banned users - Efficient sorting algorithms for each category - Parallel data fetching for all leaderboards Frontend: - Standalone Angular component with signals - Responsive card-based layout - Integration with existing user profile system - Navigation link in header dropdown Technical notes: - Uses Fastify AutoLoad with FastifyPluginAsync pattern - Shared types across monorepo for type safety - Leverages existing achievement system data
This commit is contained in:
@@ -65,6 +65,10 @@ export const appRoutes: Route[] = [
|
||||
path: 'achievements',
|
||||
loadComponent: () => import('./components/achievements/achievements.component').then(m => m.AchievementsComponent)
|
||||
},
|
||||
{
|
||||
path: 'leaderboard',
|
||||
loadComponent: () => import('./components/leaderboard/leaderboard.component').then(m => m.LeaderboardComponent)
|
||||
},
|
||||
{
|
||||
path: 'about',
|
||||
loadComponent: () => import('./components/about/about.component').then(m => m.AboutComponent)
|
||||
|
||||
@@ -53,6 +53,7 @@ import { ApiService } from '../../services/api.service';
|
||||
<a [routerLink]="['/profile', user.slug || user.id]" class="dropdown-item" (click)="closeDropdown()">My Profile</a>
|
||||
<a routerLink="/settings" class="dropdown-item" (click)="closeDropdown()">Settings</a>
|
||||
<a routerLink="/achievements" class="dropdown-item" (click)="closeDropdown()">🏆 Achievements</a>
|
||||
<a routerLink="/leaderboard" class="dropdown-item" (click)="closeDropdown()">🏆 Leaderboard</a>
|
||||
<a routerLink="/about" class="dropdown-item" (click)="closeDropdown()">ℹ️ About</a>
|
||||
@if (!user.isAdmin) {
|
||||
<a routerLink="/my-suggestions" class="dropdown-item" (click)="closeDropdown()">My Suggestions</a>
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import type {
|
||||
SuggestionsLeaderboard,
|
||||
LikesLeaderboard,
|
||||
CommentsLeaderboard,
|
||||
OverallLeaderboard,
|
||||
} from '@library/shared-types';
|
||||
import { LeaderboardService } from '../../services/leaderboard.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
|
||||
type LeaderboardTab = 'overall' | 'suggestions' | 'likes' | 'comments';
|
||||
|
||||
@Component({
|
||||
selector: 'app-leaderboard',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterModule],
|
||||
template: `
|
||||
<div class="container">
|
||||
<h1>🏆 Community Leaderboard</h1>
|
||||
<p class="subtitle">Celebrating our most engaged community members!</p>
|
||||
|
||||
<div class="tabs">
|
||||
<button
|
||||
(click)="setTab('overall')"
|
||||
[class.active]="activeTab() === 'overall'"
|
||||
class="tab-btn"
|
||||
>
|
||||
🌟 Overall
|
||||
</button>
|
||||
<button
|
||||
(click)="setTab('suggestions')"
|
||||
[class.active]="activeTab() === 'suggestions'"
|
||||
class="tab-btn"
|
||||
>
|
||||
💡 Suggestions
|
||||
</button>
|
||||
<button
|
||||
(click)="setTab('likes')"
|
||||
[class.active]="activeTab() === 'likes'"
|
||||
class="tab-btn"
|
||||
>
|
||||
❤️ Likes
|
||||
</button>
|
||||
<button
|
||||
(click)="setTab('comments')"
|
||||
[class.active]="activeTab() === 'comments'"
|
||||
class="tab-btn"
|
||||
>
|
||||
💬 Comments
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading">Loading leaderboard...</div>
|
||||
} @else {
|
||||
@if (activeTab() === 'overall') {
|
||||
<div class="leaderboard">
|
||||
<h2>Overall Leaders</h2>
|
||||
<p class="description">Ranked by achievement points, diversity of engagement, and total activity</p>
|
||||
@if (overallLeaderboard().length === 0) {
|
||||
<p class="empty">No users on the leaderboard yet!</p>
|
||||
} @else {
|
||||
<div class="leaderboard-list">
|
||||
@for (user of overallLeaderboard(); track user.id; let i = $index) {
|
||||
<div class="leaderboard-item" [class.highlight]="user.id === authService.user()?.id">
|
||||
<div class="rank">
|
||||
@if (i === 0) {
|
||||
<span class="medal">🥇</span>
|
||||
} @else if (i === 1) {
|
||||
<span class="medal">🥈</span>
|
||||
} @else if (i === 2) {
|
||||
<span class="medal">🥉</span>
|
||||
} @else {
|
||||
<span class="rank-number">#{{ i + 1 }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div class="user-header">
|
||||
@if (user.avatar) {
|
||||
<img [src]="user.avatar" [alt]="user.username" class="avatar">
|
||||
}
|
||||
<div class="user-details">
|
||||
<a [routerLink]="['/profile', user.slug || user.id]" class="username">
|
||||
{{ user.username }}
|
||||
</a>
|
||||
<div class="badges">
|
||||
@if (user.primaryBadge === 'STAFF') {
|
||||
<span class="badge staff-badge">STAFF</span>
|
||||
}
|
||||
@if (user.primaryBadge === 'MOD') {
|
||||
<span class="badge mod-badge">MOD</span>
|
||||
}
|
||||
@if (user.primaryBadge === 'VIP') {
|
||||
<span class="badge vip-badge">VIP</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<span class="stat">🎯 {{ user.achievementPoints }} pts</span>
|
||||
<span class="stat">🏅 {{ user.achievementCount }} achievements</span>
|
||||
<span class="stat">💡 {{ user.totalSuggestions }} suggestions</span>
|
||||
<span class="stat">❤️ {{ user.totalLikes }} likes</span>
|
||||
<span class="stat">💬 {{ user.totalComments }} comments</span>
|
||||
<span class="stat">🔥 {{ user.currentStreak }} day streak</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (activeTab() === 'suggestions') {
|
||||
<div class="leaderboard">
|
||||
<h2>Top Suggestions</h2>
|
||||
<p class="description">Ranked by total suggestions and acceptance rate</p>
|
||||
@if (suggestionsLeaderboard().length === 0) {
|
||||
<p class="empty">No users on the leaderboard yet!</p>
|
||||
} @else {
|
||||
<div class="leaderboard-list">
|
||||
@for (user of suggestionsLeaderboard(); track user.id; let i = $index) {
|
||||
<div class="leaderboard-item" [class.highlight]="user.id === authService.user()?.id">
|
||||
<div class="rank">
|
||||
@if (i === 0) {
|
||||
<span class="medal">🥇</span>
|
||||
} @else if (i === 1) {
|
||||
<span class="medal">🥈</span>
|
||||
} @else if (i === 2) {
|
||||
<span class="medal">🥉</span>
|
||||
} @else {
|
||||
<span class="rank-number">#{{ i + 1 }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div class="user-header">
|
||||
@if (user.avatar) {
|
||||
<img [src]="user.avatar" [alt]="user.username" class="avatar">
|
||||
}
|
||||
<div class="user-details">
|
||||
<a [routerLink]="['/profile', user.slug || user.id]" class="username">
|
||||
{{ user.username }}
|
||||
</a>
|
||||
<div class="badges">
|
||||
@if (user.primaryBadge === 'STAFF') {
|
||||
<span class="badge staff-badge">STAFF</span>
|
||||
}
|
||||
@if (user.primaryBadge === 'MOD') {
|
||||
<span class="badge mod-badge">MOD</span>
|
||||
}
|
||||
@if (user.primaryBadge === 'VIP') {
|
||||
<span class="badge vip-badge">VIP</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<span class="stat">💡 {{ user.totalSuggestions }} suggestions</span>
|
||||
<span class="stat">✅ {{ user.acceptedSuggestions }} accepted</span>
|
||||
<span class="stat">📊 {{ user.acceptanceRate }}% acceptance rate</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (activeTab() === 'likes') {
|
||||
<div class="leaderboard">
|
||||
<h2>Top Likers</h2>
|
||||
<p class="description">Ranked by total likes given</p>
|
||||
@if (likesLeaderboard().length === 0) {
|
||||
<p class="empty">No users on the leaderboard yet!</p>
|
||||
} @else {
|
||||
<div class="leaderboard-list">
|
||||
@for (user of likesLeaderboard(); track user.id; let i = $index) {
|
||||
<div class="leaderboard-item" [class.highlight]="user.id === authService.user()?.id">
|
||||
<div class="rank">
|
||||
@if (i === 0) {
|
||||
<span class="medal">🥇</span>
|
||||
} @else if (i === 1) {
|
||||
<span class="medal">🥈</span>
|
||||
} @else if (i === 2) {
|
||||
<span class="medal">🥉</span>
|
||||
} @else {
|
||||
<span class="rank-number">#{{ i + 1 }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div class="user-header">
|
||||
@if (user.avatar) {
|
||||
<img [src]="user.avatar" [alt]="user.username" class="avatar">
|
||||
}
|
||||
<div class="user-details">
|
||||
<a [routerLink]="['/profile', user.slug || user.id]" class="username">
|
||||
{{ user.username }}
|
||||
</a>
|
||||
<div class="badges">
|
||||
@if (user.primaryBadge === 'STAFF') {
|
||||
<span class="badge staff-badge">STAFF</span>
|
||||
}
|
||||
@if (user.primaryBadge === 'MOD') {
|
||||
<span class="badge mod-badge">MOD</span>
|
||||
}
|
||||
@if (user.primaryBadge === 'VIP') {
|
||||
<span class="badge vip-badge">VIP</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<span class="stat">❤️ {{ user.totalLikes }} likes given</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (activeTab() === 'comments') {
|
||||
<div class="leaderboard">
|
||||
<h2>Top Commenters</h2>
|
||||
<p class="description">Ranked by total comments posted</p>
|
||||
@if (commentsLeaderboard().length === 0) {
|
||||
<p class="empty">No users on the leaderboard yet!</p>
|
||||
} @else {
|
||||
<div class="leaderboard-list">
|
||||
@for (user of commentsLeaderboard(); track user.id; let i = $index) {
|
||||
<div class="leaderboard-item" [class.highlight]="user.id === authService.user()?.id">
|
||||
<div class="rank">
|
||||
@if (i === 0) {
|
||||
<span class="medal">🥇</span>
|
||||
} @else if (i === 1) {
|
||||
<span class="medal">🥈</span>
|
||||
} @else if (i === 2) {
|
||||
<span class="medal">🥉</span>
|
||||
} @else {
|
||||
<span class="rank-number">#{{ i + 1 }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div class="user-header">
|
||||
@if (user.avatar) {
|
||||
<img [src]="user.avatar" [alt]="user.username" class="avatar">
|
||||
}
|
||||
<div class="user-details">
|
||||
<a [routerLink]="['/profile', user.slug || user.id]" class="username">
|
||||
{{ user.username }}
|
||||
</a>
|
||||
<div class="badges">
|
||||
@if (user.primaryBadge === 'STAFF') {
|
||||
<span class="badge staff-badge">STAFF</span>
|
||||
}
|
||||
@if (user.primaryBadge === 'MOD') {
|
||||
<span class="badge mod-badge">MOD</span>
|
||||
}
|
||||
@if (user.primaryBadge === 'VIP') {
|
||||
<span class="badge vip-badge">VIP</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<span class="stat">💬 {{ user.totalComments }} comments</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
color: var(--witch-purple);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
color: var(--witch-plum);
|
||||
margin-bottom: 2rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--witch-lavender);
|
||||
color: var(--witch-purple);
|
||||
border: 2px solid var(--witch-lavender);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
background: var(--witch-mauve);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px var(--witch-shadow);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: var(--witch-rose);
|
||||
color: var(--witch-moon);
|
||||
border-color: var(--witch-rose);
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--witch-plum);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.leaderboard h2 {
|
||||
color: var(--witch-purple);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--witch-plum);
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--witch-mauve);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.leaderboard-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.leaderboard-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 2px solid var(--witch-lavender);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.leaderboard-item:hover {
|
||||
transform: translateX(4px);
|
||||
box-shadow: 0 4px 12px var(--witch-shadow);
|
||||
border-color: var(--witch-mauve);
|
||||
}
|
||||
|
||||
.leaderboard-item.highlight {
|
||||
background: linear-gradient(135deg, rgba(255, 215, 245, 0.3), rgba(255, 240, 250, 0.3));
|
||||
border-color: var(--witch-rose);
|
||||
box-shadow: 0 0 20px rgba(168, 87, 126, 0.2);
|
||||
}
|
||||
|
||||
.rank {
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.medal {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.rank-number {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--witch-plum);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--witch-lavender);
|
||||
}
|
||||
|
||||
.user-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--witch-purple);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.username:hover {
|
||||
color: var(--witch-rose);
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.staff-badge {
|
||||
background: linear-gradient(135deg, #e84393, #fd79a8);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mod-badge {
|
||||
background: linear-gradient(135deg, #00b894, #00cec9);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.vip-badge {
|
||||
background: linear-gradient(135deg, #ffd700, #ffaa00);
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
font-size: 0.9rem;
|
||||
color: var(--witch-plum);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tabs {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.leaderboard-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.rank {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.stats {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class LeaderboardComponent implements OnInit {
|
||||
leaderboardService = inject(LeaderboardService);
|
||||
authService = inject(AuthService);
|
||||
|
||||
activeTab = signal<LeaderboardTab>('overall');
|
||||
loading = signal(true);
|
||||
|
||||
overallLeaderboard = signal<OverallLeaderboard[]>([]);
|
||||
suggestionsLeaderboard = signal<SuggestionsLeaderboard[]>([]);
|
||||
likesLeaderboard = signal<LikesLeaderboard[]>([]);
|
||||
commentsLeaderboard = signal<CommentsLeaderboard[]>([]);
|
||||
|
||||
ngOnInit() {
|
||||
this.loadLeaderboards();
|
||||
}
|
||||
|
||||
loadLeaderboards() {
|
||||
this.loading.set(true);
|
||||
this.leaderboardService.getAllLeaderboards(25).subscribe({
|
||||
next: (data) => {
|
||||
this.overallLeaderboard.set(data.topOverall);
|
||||
this.suggestionsLeaderboard.set(data.topSuggestions);
|
||||
this.likesLeaderboard.set(data.topLikes);
|
||||
this.commentsLeaderboard.set(data.topComments);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setTab(tab: LeaderboardTab) {
|
||||
this.activeTab.set(tab);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import type {
|
||||
LeaderboardResponse,
|
||||
SuggestionsLeaderboard,
|
||||
LikesLeaderboard,
|
||||
CommentsLeaderboard,
|
||||
OverallLeaderboard,
|
||||
} from '@library/shared-types';
|
||||
import { ApiService } from './api.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class LeaderboardService {
|
||||
private apiService = inject(ApiService);
|
||||
|
||||
/**
|
||||
* Get all leaderboards at once.
|
||||
*/
|
||||
getAllLeaderboards(limit = 25): Observable<LeaderboardResponse> {
|
||||
return this.apiService.get<LeaderboardResponse>(`/leaderboard?limit=${limit}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top users by suggestions.
|
||||
*/
|
||||
getTopSuggestions(limit = 25): Observable<SuggestionsLeaderboard[]> {
|
||||
return this.apiService.get<SuggestionsLeaderboard[]>(`/leaderboard/suggestions?limit=${limit}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top users by likes.
|
||||
*/
|
||||
getTopLikes(limit = 25): Observable<LikesLeaderboard[]> {
|
||||
return this.apiService.get<LikesLeaderboard[]>(`/leaderboard/likes?limit=${limit}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top users by comments.
|
||||
*/
|
||||
getTopComments(limit = 25): Observable<CommentsLeaderboard[]> {
|
||||
return this.apiService.get<CommentsLeaderboard[]>(`/leaderboard/comments?limit=${limit}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get overall leaderboard.
|
||||
*/
|
||||
getOverallLeaderboard(limit = 25): Observable<OverallLeaderboard[]> {
|
||||
return this.apiService.get<OverallLeaderboard[]>(`/leaderboard/overall?limit=${limit}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user