diff --git a/api/src/app/routes/leaderboard/index.ts b/api/src/app/routes/leaderboard/index.ts new file mode 100644 index 0000000..aac20c1 --- /dev/null +++ b/api/src/app/routes/leaderboard/index.ts @@ -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; diff --git a/api/src/app/services/leaderboard.service.ts b/api/src/app/services/leaderboard.service.ts new file mode 100644 index 0000000..ffa5dc6 --- /dev/null +++ b/api/src/app/services/leaderboard.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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, + }; + } +} diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index 4e07a5a..8f100c7 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -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) diff --git a/apps/frontend/src/app/components/header/header.component.ts b/apps/frontend/src/app/components/header/header.component.ts index 735336a..afff703 100644 --- a/apps/frontend/src/app/components/header/header.component.ts +++ b/apps/frontend/src/app/components/header/header.component.ts @@ -53,6 +53,7 @@ import { ApiService } from '../../services/api.service'; My Profile Settings 🏆 Achievements + 🏆 Leaderboard â„šī¸ About @if (!user.isAdmin) { My Suggestions diff --git a/apps/frontend/src/app/components/leaderboard/leaderboard.component.ts b/apps/frontend/src/app/components/leaderboard/leaderboard.component.ts new file mode 100644 index 0000000..e764046 --- /dev/null +++ b/apps/frontend/src/app/components/leaderboard/leaderboard.component.ts @@ -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: ` +
+

🏆 Community Leaderboard

+

Celebrating our most engaged community members!

+ +
+ + + + +
+ + @if (loading()) { +
Loading leaderboard...
+ } @else { + @if (activeTab() === 'overall') { +
+

Overall Leaders

+

Ranked by achievement points, diversity of engagement, and total activity

+ @if (overallLeaderboard().length === 0) { +

No users on the leaderboard yet!

+ } @else { +
+ @for (user of overallLeaderboard(); track user.id; let i = $index) { +
+
+ @if (i === 0) { + đŸĨ‡ + } @else if (i === 1) { + đŸĨˆ + } @else if (i === 2) { + đŸĨ‰ + } @else { + #{{ i + 1 }} + } +
+ +
+ } +
+ } +
+ } + + @if (activeTab() === 'suggestions') { +
+

Top Suggestions

+

Ranked by total suggestions and acceptance rate

+ @if (suggestionsLeaderboard().length === 0) { +

No users on the leaderboard yet!

+ } @else { +
+ @for (user of suggestionsLeaderboard(); track user.id; let i = $index) { +
+
+ @if (i === 0) { + đŸĨ‡ + } @else if (i === 1) { + đŸĨˆ + } @else if (i === 2) { + đŸĨ‰ + } @else { + #{{ i + 1 }} + } +
+ +
+ } +
+ } +
+ } + + @if (activeTab() === 'likes') { +
+

Top Likers

+

Ranked by total likes given

+ @if (likesLeaderboard().length === 0) { +

No users on the leaderboard yet!

+ } @else { +
+ @for (user of likesLeaderboard(); track user.id; let i = $index) { +
+
+ @if (i === 0) { + đŸĨ‡ + } @else if (i === 1) { + đŸĨˆ + } @else if (i === 2) { + đŸĨ‰ + } @else { + #{{ i + 1 }} + } +
+ +
+ } +
+ } +
+ } + + @if (activeTab() === 'comments') { +
+

Top Commenters

+

Ranked by total comments posted

+ @if (commentsLeaderboard().length === 0) { +

No users on the leaderboard yet!

+ } @else { +
+ @for (user of commentsLeaderboard(); track user.id; let i = $index) { +
+
+ @if (i === 0) { + đŸĨ‡ + } @else if (i === 1) { + đŸĨˆ + } @else if (i === 2) { + đŸĨ‰ + } @else { + #{{ i + 1 }} + } +
+ +
+ } +
+ } +
+ } + } +
+ `, + 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('overall'); + loading = signal(true); + + overallLeaderboard = signal([]); + suggestionsLeaderboard = signal([]); + likesLeaderboard = signal([]); + commentsLeaderboard = signal([]); + + 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); + } +} diff --git a/apps/frontend/src/app/services/leaderboard.service.ts b/apps/frontend/src/app/services/leaderboard.service.ts new file mode 100644 index 0000000..f1d2936 --- /dev/null +++ b/apps/frontend/src/app/services/leaderboard.service.ts @@ -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 { + return this.apiService.get(`/leaderboard?limit=${limit}`); + } + + /** + * Get top users by suggestions. + */ + getTopSuggestions(limit = 25): Observable { + return this.apiService.get(`/leaderboard/suggestions?limit=${limit}`); + } + + /** + * Get top users by likes. + */ + getTopLikes(limit = 25): Observable { + return this.apiService.get(`/leaderboard/likes?limit=${limit}`); + } + + /** + * Get top users by comments. + */ + getTopComments(limit = 25): Observable { + return this.apiService.get(`/leaderboard/comments?limit=${limit}`); + } + + /** + * Get overall leaderboard. + */ + getOverallLeaderboard(limit = 25): Observable { + return this.apiService.get(`/leaderboard/overall?limit=${limit}`); + } +} diff --git a/shared-types/src/index.ts b/shared-types/src/index.ts index dc596c9..b11520a 100644 --- a/shared-types/src/index.ts +++ b/shared-types/src/index.ts @@ -12,6 +12,7 @@ export * from "./lib/book.types"; export type * from "./lib/comment.types"; export type * from "./lib/common.types"; export * from "./lib/game.types"; +export type * from "./lib/leaderboard.types"; export type * from "./lib/like.types"; export * from "./lib/manga.types"; export * from "./lib/music.types"; diff --git a/shared-types/src/lib/auth.types.ts b/shared-types/src/lib/auth.types.ts index 8fda2c2..59c9183 100644 --- a/shared-types/src/lib/auth.types.ts +++ b/shared-types/src/lib/auth.types.ts @@ -34,9 +34,9 @@ interface User { isAdmin: boolean; isBanned: boolean; inDiscord: boolean; - isVip: boolean; - isMod: boolean; - isStaff: boolean; + isVip: boolean; + isMod: boolean; + isStaff: boolean; } interface JwtPayload { diff --git a/shared-types/src/lib/leaderboard.types.ts b/shared-types/src/lib/leaderboard.types.ts new file mode 100644 index 0000000..3140d46 --- /dev/null +++ b/shared-types/src/lib/leaderboard.types.ts @@ -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, +};