Files
library/apps/frontend/src/app/components/header/header.component.ts
T
hikari f839059dd2 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
2026-02-19 23:31:41 -08:00

310 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Component, inject, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { AuthService } from '../../services/auth.service';
import { ApiService } from '../../services/api.service';
@Component({
selector: 'app-header',
standalone: true,
imports: [CommonModule, RouterModule],
template: `
<header class="header">
<nav class="navbar">
<div class="nav-brand">
<h1><a routerLink="/">Naomi's Library</a></h1>
@if (version()) {
<span class="version">v{{ version() }}</span>
}
</div>
<ul class="nav-links">
<li><a routerLink="/games" routerLinkActive="active">Games</a></li>
<li><a routerLink="/books" routerLinkActive="active">Books</a></li>
<li><a routerLink="/music" routerLinkActive="active">Music</a></li>
<li><a routerLink="/shows" routerLinkActive="active">Shows</a></li>
<li><a routerLink="/manga" routerLinkActive="active">Manga</a></li>
<li><a routerLink="/art" routerLinkActive="active">Art</a></li>
</ul>
<div class="auth-section">
@if (authService.user(); as user) {
<div class="user-menu">
@if (user.avatar) {
<img
[src]="user.avatar"
[alt]="user.username"
class="user-avatar"
(click)="toggleDropdown()"
(keyup.enter)="toggleDropdown()"
(keyup.space)="toggleDropdown()"
tabindex="0"
role="button"
/>
}
@if (showDropdown()) {
<div class="dropdown-menu">
<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>
}
<a routerLink="/my-likes" class="dropdown-item" (click)="closeDropdown()">My Likes</a>
@if (user.isAdmin) {
<a routerLink="/admin/users" class="dropdown-item" (click)="closeDropdown()">Users</a>
<a routerLink="/admin/audit" class="dropdown-item" (click)="closeDropdown()">Audit</a>
<a routerLink="/admin/suggestions" class="dropdown-item" (click)="closeDropdown()">Suggestions</a>
<a routerLink="/admin/reports" class="dropdown-item" (click)="closeDropdown()">Reports</a>
}
<button (click)="logout()" class="dropdown-item logout-btn">Logout</button>
</div>
}
</div>
} @else {
<button (click)="login()" class="btn btn-primary">Login with Discord</button>
}
</div>
</nav>
</header>
`,
styles: [`
.header {
background-color: var(--witch-purple);
color: var(--witch-moon);
padding: 0;
box-shadow: 0 2px 8px var(--witch-shadow);
}
.navbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
max-width: 1200px;
margin: 0 auto;
}
.nav-brand h1 {
margin: 0;
font-size: 1.5rem;
}
.nav-brand a {
color: var(--witch-moon);
text-decoration: none;
font-weight: 600;
}
.version {
font-size: 0.7rem;
color: var(--witch-lavender);
opacity: 0.8;
margin-left: 0.5rem;
}
.nav-links {
display: flex;
list-style: none;
gap: 1rem;
margin: 0;
padding: 0;
flex-wrap: wrap;
}
.nav-links a {
color: var(--witch-lavender);
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: all 0.3s;
}
.nav-links a:hover,
.nav-links a.active {
background-color: var(--witch-plum);
color: var(--witch-moon);
}
.auth-section {
display: flex;
align-items: center;
gap: 1rem;
}
.welcome {
font-size: 0.9rem;
color: var(--witch-lavender);
}
.user-menu {
position: relative;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
border: 2px solid var(--witch-lavender);
transition: all 0.3s;
cursor: pointer;
}
.user-avatar:hover {
border-color: var(--witch-moon);
transform: scale(1.1);
}
.dropdown-menu {
position: absolute;
top: 50px;
right: 0;
background-color: var(--witch-purple);
border: 2px solid var(--witch-lavender);
border-radius: 8px;
padding: 0.5rem 0;
min-width: 180px;
box-shadow: 0 4px 12px var(--witch-shadow);
z-index: 1000;
animation: fadeIn 0.2s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.dropdown-item {
display: block;
width: 100%;
padding: 0.75rem 1rem;
color: var(--witch-lavender);
text-decoration: none;
background: none;
border: none;
text-align: left;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
}
.dropdown-item:hover {
background-color: var(--witch-plum);
color: var(--witch-moon);
}
.logout-btn {
border-top: 1px solid var(--witch-lavender);
margin-top: 0.5rem;
padding-top: 0.75rem;
font-weight: 500;
}
.admin-badge {
background-color: var(--witch-rose);
color: var(--witch-moon);
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
text-decoration: none;
cursor: pointer;
transition: all 0.3s;
}
.admin-badge:hover {
background-color: var(--witch-plum);
transform: translateY(-2px);
}
.user-link {
color: var(--witch-lavender);
text-decoration: none;
font-size: 0.9rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
transition: all 0.3s;
}
.user-link:hover {
background-color: var(--witch-plum);
color: var(--witch-moon);
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.3s;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px var(--witch-shadow);
}
.btn-primary {
background-color: var(--witch-rose);
color: var(--witch-moon);
}
.btn-primary:hover {
background-color: var(--witch-plum);
}
.btn-secondary {
background-color: var(--witch-mauve);
color: var(--witch-purple);
}
.btn-secondary:hover {
background-color: var(--witch-rose);
color: var(--witch-moon);
}
`]
})
export class HeaderComponent implements OnInit {
authService = inject(AuthService);
private apiService = inject(ApiService);
version = signal<string | null>(null);
showDropdown = signal<boolean>(false);
ngOnInit() {
this.apiService.get<{ version: string }>('/version').subscribe({
next: (response) => this.version.set(response.version),
error: () => this.version.set(null)
});
}
toggleDropdown() {
this.showDropdown.update(v => !v);
}
closeDropdown() {
this.showDropdown.set(false);
}
login() {
this.authService.login();
}
logout() {
this.closeDropdown();
this.authService.logout().subscribe();
}
}