feat: Multiple Features, Accessibility, Security, and UX Improvements #59

Merged
naomi merged 27 commits from feat/polish into main 2026-02-20 01:51:25 -08:00
9 changed files with 976 additions and 3 deletions
Showing only changes of commit f839059dd2 - Show all commits
+85
View File
@@ -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;
+222
View File
@@ -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,
};
}
}
+4
View File
@@ -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}`);
}
}
+1
View File
@@ -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";
+57
View File
@@ -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,
};