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:
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FastifyPluginAsync } from "fastify";
|
||||||
|
import type {
|
||||||
|
LeaderboardResponse,
|
||||||
|
SuggestionsLeaderboard,
|
||||||
|
LikesLeaderboard,
|
||||||
|
CommentsLeaderboard,
|
||||||
|
OverallLeaderboard,
|
||||||
|
} from "@library/shared-types";
|
||||||
|
import { LeaderboardService } from "../../services/leaderboard.service";
|
||||||
|
|
||||||
|
const leaderboardRoutes: FastifyPluginAsync = async (app) => {
|
||||||
|
const leaderboardService = new LeaderboardService();
|
||||||
|
/**
|
||||||
|
* Get all leaderboards at once.
|
||||||
|
*/
|
||||||
|
app.get<{
|
||||||
|
Querystring: { limit?: number };
|
||||||
|
Reply: LeaderboardResponse;
|
||||||
|
}>("/", async (request) => {
|
||||||
|
const limit = request.query.limit && request.query.limit > 0
|
||||||
|
? Math.min(request.query.limit, 100)
|
||||||
|
: 25;
|
||||||
|
return leaderboardService.getAllLeaderboards(limit);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get top users by suggestions.
|
||||||
|
*/
|
||||||
|
app.get<{
|
||||||
|
Querystring: { limit?: number };
|
||||||
|
Reply: SuggestionsLeaderboard[];
|
||||||
|
}>("/suggestions", async (request) => {
|
||||||
|
const limit = request.query.limit && request.query.limit > 0
|
||||||
|
? Math.min(request.query.limit, 100)
|
||||||
|
: 25;
|
||||||
|
return leaderboardService.getTopSuggestions(limit);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get top users by likes.
|
||||||
|
*/
|
||||||
|
app.get<{
|
||||||
|
Querystring: { limit?: number };
|
||||||
|
Reply: LikesLeaderboard[];
|
||||||
|
}>("/likes", async (request) => {
|
||||||
|
const limit = request.query.limit && request.query.limit > 0
|
||||||
|
? Math.min(request.query.limit, 100)
|
||||||
|
: 25;
|
||||||
|
return leaderboardService.getTopLikes(limit);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get top users by comments.
|
||||||
|
*/
|
||||||
|
app.get<{
|
||||||
|
Querystring: { limit?: number };
|
||||||
|
Reply: CommentsLeaderboard[];
|
||||||
|
}>("/comments", async (request) => {
|
||||||
|
const limit = request.query.limit && request.query.limit > 0
|
||||||
|
? Math.min(request.query.limit, 100)
|
||||||
|
: 25;
|
||||||
|
return leaderboardService.getTopComments(limit);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get overall leaderboard.
|
||||||
|
*/
|
||||||
|
app.get<{
|
||||||
|
Querystring: { limit?: number };
|
||||||
|
Reply: OverallLeaderboard[];
|
||||||
|
}>("/overall", async (request) => {
|
||||||
|
const limit = request.query.limit && request.query.limit > 0
|
||||||
|
? Math.min(request.query.limit, 100)
|
||||||
|
: 25;
|
||||||
|
return leaderboardService.getOverallLeaderboard(limit);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default leaderboardRoutes;
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
LeaderboardResponse,
|
||||||
|
SuggestionsLeaderboard,
|
||||||
|
LikesLeaderboard,
|
||||||
|
CommentsLeaderboard,
|
||||||
|
OverallLeaderboard,
|
||||||
|
} from "@library/shared-types";
|
||||||
|
import { prisma } from "../lib/prisma";
|
||||||
|
|
||||||
|
export class LeaderboardService {
|
||||||
|
private prisma = prisma;
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get top users by suggestions submitted and accepted.
|
||||||
|
*/
|
||||||
|
async getTopSuggestions(limit = 25): Promise<SuggestionsLeaderboard[]> {
|
||||||
|
const users = await this.prisma.user.findMany({
|
||||||
|
where: { profilePublic: true, isBanned: false },
|
||||||
|
include: {
|
||||||
|
suggestions: {
|
||||||
|
select: {
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const leaderboard = users
|
||||||
|
.map((user) => {
|
||||||
|
const totalSuggestions = user.suggestions.length;
|
||||||
|
const acceptedSuggestions = user.suggestions.filter(
|
||||||
|
(s) => s.status === "ACCEPTED"
|
||||||
|
).length;
|
||||||
|
const acceptanceRate =
|
||||||
|
totalSuggestions > 0
|
||||||
|
? Math.round((acceptedSuggestions / totalSuggestions) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
slug: user.slug,
|
||||||
|
avatar: user.avatar,
|
||||||
|
primaryBadge: user.primaryBadge,
|
||||||
|
isVip: user.isVip,
|
||||||
|
isMod: user.isMod,
|
||||||
|
isStaff: user.isStaff,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
totalSuggestions,
|
||||||
|
acceptedSuggestions,
|
||||||
|
acceptanceRate,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((user) => user.totalSuggestions > 0)
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (b.totalSuggestions !== a.totalSuggestions) {
|
||||||
|
return b.totalSuggestions - a.totalSuggestions;
|
||||||
|
}
|
||||||
|
return b.acceptanceRate - a.acceptanceRate;
|
||||||
|
})
|
||||||
|
.slice(0, limit);
|
||||||
|
|
||||||
|
return leaderboard;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get top users by likes given.
|
||||||
|
*/
|
||||||
|
async getTopLikes(limit = 25): Promise<LikesLeaderboard[]> {
|
||||||
|
const users = await this.prisma.user.findMany({
|
||||||
|
where: { profilePublic: true, isBanned: false },
|
||||||
|
include: {
|
||||||
|
likes: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const leaderboard = users
|
||||||
|
.map((user) => ({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
slug: user.slug,
|
||||||
|
avatar: user.avatar,
|
||||||
|
primaryBadge: user.primaryBadge,
|
||||||
|
isVip: user.isVip,
|
||||||
|
isMod: user.isMod,
|
||||||
|
isStaff: user.isStaff,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
totalLikes: user.likes.length,
|
||||||
|
}))
|
||||||
|
.filter((user) => user.totalLikes > 0)
|
||||||
|
.sort((a, b) => b.totalLikes - a.totalLikes)
|
||||||
|
.slice(0, limit);
|
||||||
|
|
||||||
|
return leaderboard;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get top users by comments posted.
|
||||||
|
*/
|
||||||
|
async getTopComments(limit = 25): Promise<CommentsLeaderboard[]> {
|
||||||
|
const users = await this.prisma.user.findMany({
|
||||||
|
where: { profilePublic: true, isBanned: false },
|
||||||
|
include: {
|
||||||
|
comments: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const leaderboard = users
|
||||||
|
.map((user) => ({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
slug: user.slug,
|
||||||
|
avatar: user.avatar,
|
||||||
|
primaryBadge: user.primaryBadge,
|
||||||
|
isVip: user.isVip,
|
||||||
|
isMod: user.isMod,
|
||||||
|
isStaff: user.isStaff,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
totalComments: user.comments.length,
|
||||||
|
}))
|
||||||
|
.filter((user) => user.totalComments > 0)
|
||||||
|
.sort((a, b) => b.totalComments - a.totalComments)
|
||||||
|
.slice(0, limit);
|
||||||
|
|
||||||
|
return leaderboard;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get overall leaderboard based on combined engagement.
|
||||||
|
*/
|
||||||
|
async getOverallLeaderboard(limit = 25): Promise<OverallLeaderboard[]> {
|
||||||
|
const users = await this.prisma.user.findMany({
|
||||||
|
where: { profilePublic: true, isBanned: false },
|
||||||
|
include: {
|
||||||
|
suggestions: true,
|
||||||
|
likes: true,
|
||||||
|
comments: true,
|
||||||
|
userAchievements: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const leaderboard = users
|
||||||
|
.map((user) => {
|
||||||
|
const totalSuggestions = user.suggestions.length;
|
||||||
|
const totalLikes = user.likes.length;
|
||||||
|
const totalComments = user.comments.length;
|
||||||
|
const achievementCount = user.userAchievements.length;
|
||||||
|
|
||||||
|
const diversityScore =
|
||||||
|
(totalSuggestions > 0 ? 1 : 0) +
|
||||||
|
(totalLikes > 0 ? 1 : 0) +
|
||||||
|
(totalComments > 0 ? 1 : 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
slug: user.slug,
|
||||||
|
avatar: user.avatar,
|
||||||
|
primaryBadge: user.primaryBadge,
|
||||||
|
isVip: user.isVip,
|
||||||
|
isMod: user.isMod,
|
||||||
|
isStaff: user.isStaff,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
totalSuggestions,
|
||||||
|
totalLikes,
|
||||||
|
totalComments,
|
||||||
|
achievementCount,
|
||||||
|
achievementPoints: user.achievementPoints,
|
||||||
|
currentStreak: user.currentStreak,
|
||||||
|
diversityScore,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(
|
||||||
|
(user) =>
|
||||||
|
user.totalSuggestions > 0 ||
|
||||||
|
user.totalLikes > 0 ||
|
||||||
|
user.totalComments > 0
|
||||||
|
)
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (b.achievementPoints !== a.achievementPoints) {
|
||||||
|
return b.achievementPoints - a.achievementPoints;
|
||||||
|
}
|
||||||
|
if (b.diversityScore !== a.diversityScore) {
|
||||||
|
return b.diversityScore - a.diversityScore;
|
||||||
|
}
|
||||||
|
const totalA = a.totalSuggestions + a.totalLikes + a.totalComments;
|
||||||
|
const totalB = b.totalSuggestions + b.totalLikes + b.totalComments;
|
||||||
|
return totalB - totalA;
|
||||||
|
})
|
||||||
|
.slice(0, limit);
|
||||||
|
|
||||||
|
return leaderboard;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all leaderboards at once.
|
||||||
|
*/
|
||||||
|
async getAllLeaderboards(limit = 25): Promise<LeaderboardResponse> {
|
||||||
|
const [topSuggestions, topLikes, topComments, topOverall] =
|
||||||
|
await Promise.all([
|
||||||
|
this.getTopSuggestions(limit),
|
||||||
|
this.getTopLikes(limit),
|
||||||
|
this.getTopComments(limit),
|
||||||
|
this.getOverallLeaderboard(limit),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
topSuggestions,
|
||||||
|
topLikes,
|
||||||
|
topComments,
|
||||||
|
topOverall,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,6 +65,10 @@ export const appRoutes: Route[] = [
|
|||||||
path: 'achievements',
|
path: 'achievements',
|
||||||
loadComponent: () => import('./components/achievements/achievements.component').then(m => m.AchievementsComponent)
|
loadComponent: () => import('./components/achievements/achievements.component').then(m => m.AchievementsComponent)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'leaderboard',
|
||||||
|
loadComponent: () => import('./components/leaderboard/leaderboard.component').then(m => m.LeaderboardComponent)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'about',
|
path: 'about',
|
||||||
loadComponent: () => import('./components/about/about.component').then(m => m.AboutComponent)
|
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]="['/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="/settings" class="dropdown-item" (click)="closeDropdown()">Settings</a>
|
||||||
<a routerLink="/achievements" class="dropdown-item" (click)="closeDropdown()">🏆 Achievements</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>
|
<a routerLink="/about" class="dropdown-item" (click)="closeDropdown()">ℹ️ About</a>
|
||||||
@if (!user.isAdmin) {
|
@if (!user.isAdmin) {
|
||||||
<a routerLink="/my-suggestions" class="dropdown-item" (click)="closeDropdown()">My Suggestions</a>
|
<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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ export * from "./lib/book.types";
|
|||||||
export type * from "./lib/comment.types";
|
export type * from "./lib/comment.types";
|
||||||
export type * from "./lib/common.types";
|
export type * from "./lib/common.types";
|
||||||
export * from "./lib/game.types";
|
export * from "./lib/game.types";
|
||||||
|
export type * from "./lib/leaderboard.types";
|
||||||
export type * from "./lib/like.types";
|
export type * from "./lib/like.types";
|
||||||
export * from "./lib/manga.types";
|
export * from "./lib/manga.types";
|
||||||
export * from "./lib/music.types";
|
export * from "./lib/music.types";
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface LeaderboardUser {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
slug: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
primaryBadge: string | null;
|
||||||
|
isVip: boolean;
|
||||||
|
isMod: boolean;
|
||||||
|
isStaff: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SuggestionsLeaderboard extends LeaderboardUser {
|
||||||
|
totalSuggestions: number;
|
||||||
|
acceptedSuggestions: number;
|
||||||
|
acceptanceRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LikesLeaderboard extends LeaderboardUser {
|
||||||
|
totalLikes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommentsLeaderboard extends LeaderboardUser {
|
||||||
|
totalComments: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OverallLeaderboard extends LeaderboardUser {
|
||||||
|
totalSuggestions: number;
|
||||||
|
totalLikes: number;
|
||||||
|
totalComments: number;
|
||||||
|
achievementCount: number;
|
||||||
|
achievementPoints: number;
|
||||||
|
currentStreak: number;
|
||||||
|
diversityScore: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LeaderboardResponse {
|
||||||
|
topSuggestions: SuggestionsLeaderboard[];
|
||||||
|
topLikes: LikesLeaderboard[];
|
||||||
|
topComments: CommentsLeaderboard[];
|
||||||
|
topOverall: OverallLeaderboard[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
type LeaderboardUser,
|
||||||
|
type SuggestionsLeaderboard,
|
||||||
|
type LikesLeaderboard,
|
||||||
|
type CommentsLeaderboard,
|
||||||
|
type OverallLeaderboard,
|
||||||
|
type LeaderboardResponse,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user