generated from nhcarrigan/template
feat: implement user profiles with achievements and primary badge system (#58)
## Summary This PR implements comprehensive user profile enhancements including: - User profile pages showing stats, badges, social links, and bio - Achievement system with 62 achievements across 5 categories - Primary badge selection allowing users to display their preferred badge - Admin profile editing capabilities ## Changes ### User Profiles (#45) - **Frontend**: User profile pages with stats display - Profile cards showing avatar, display name, username, and bio - Social links section (Website, GitHub, Bluesky, LinkedIn, Twitch, YouTube, Discord) - Stats display (suggestions, accepted suggestions, likes, comments) - Recent achievements section - Badge display - Report button for other users' profiles - **Backend**: Profile API endpoints - Get user profile by username or ID - Profile includes stats, badges, and achievement points ### Achievement System (#48) - **Database**: UserAchievement model for tracking progress - **62 Total Achievements** across 5 categories: - **Suggestions (15)**: First suggestion through ultimate curator - **Likes (12)**: First like through legendary fan - **Comments (12)**: First comment through review legend - **Engagement (15)**: Login streaks and activity milestones - **Reports (8)**: Valid reports and accuracy tracking - **Backend**: AchievementService with real-time checking - Integrated into all user interaction points - API endpoints for achievement data - Progress tracking to avoid recalculation - **Frontend**: Achievements page and profile integration - Full achievements page with category filtering - Tier-based styling (Bronze, Silver, Gold, Platinum, Diamond) - Progress indicators for in-progress achievements - Recent achievements on profile pages ### Primary Badge System (#49) - **Database**: Add primaryBadge field to User model - **Backend**: Update profile endpoints to include primary badge - **Frontend**: Primary badge selection in settings - Only shows badges the user has earned - Displayed on profile page - Displayed in comments (next to username) - Falls back to no badge if selection is invalid - **Admin Features**: Admin can edit any user's primary badge ### Admin Enhancements - Comprehensive profile editing modal for admins - Edit display name, bio, slug, social links - Set primary badge for users - Visual feedback for save/error states - Admin action buttons in report review modals - Ban user, delete comment, edit profile - Integrated with report workflow ### Quality Improvements - Improved dropdown option contrast for readability - Hide all badges when no primary badge is selected - "View All" achievements link only shown on own profile - Improved achievement text readability ## Testing - ✅ User profiles display correctly with stats and badges - ✅ Achievement checking works for all interaction types - ✅ Primary badge selection persists and displays correctly - ✅ Admin profile editing saves successfully - ✅ Report workflow integrated with admin actions - ✅ Achievements page shows all 62 achievements with filtering - ✅ Text readability improved across components Closes #45 Closes #48 Closes #49 Co-authored-by: Hikari <hikari@nhcarrigan.com> Reviewed-on: #58 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #58.
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {
|
||||
AchievementDefinition,
|
||||
AchievementProgress,
|
||||
UserAchievementSummary,
|
||||
} from '@library/shared-types';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { ApiService } from './api.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AchievementService {
|
||||
private readonly api = inject(ApiService);
|
||||
|
||||
/**
|
||||
* Get all achievement definitions.
|
||||
*/
|
||||
getAchievementDefinitions(): Observable<AchievementDefinition[]> {
|
||||
return this.api.get<AchievementDefinition[]>('/achievements/definitions');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific achievement definition by key.
|
||||
*/
|
||||
getAchievementDefinition(key: string): Observable<AchievementDefinition> {
|
||||
return this.api.get<AchievementDefinition>(`/achievements/definitions/${key}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user's achievement summary.
|
||||
*/
|
||||
getCurrentUserSummary(): Observable<UserAchievementSummary> {
|
||||
return this.api.get<UserAchievementSummary>('/achievements/summary');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user's achievement progress.
|
||||
*/
|
||||
getCurrentUserProgress(): Observable<AchievementProgress[]> {
|
||||
return this.api.get<AchievementProgress[]>('/achievements/progress');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get another user's achievement summary.
|
||||
*/
|
||||
getUserSummary(userId: string): Observable<UserAchievementSummary> {
|
||||
return this.api.get<UserAchievementSummary>(`/achievements/users/${userId}/summary`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get another user's achievement progress.
|
||||
*/
|
||||
getUserProgress(userId: string): Observable<AchievementProgress[]> {
|
||||
return this.api.get<AchievementProgress[]>(`/achievements/users/${userId}/progress`);
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,10 @@ export class AuthService {
|
||||
return this.user() !== null;
|
||||
}
|
||||
|
||||
updateUser(user: User): void {
|
||||
this.currentUser.set(user);
|
||||
}
|
||||
|
||||
isAdmin(): boolean {
|
||||
return this.user()?.isAdmin === true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import type {
|
||||
CommentReportWithDetails,
|
||||
CreateCommentReportDto,
|
||||
UpdateCommentReportDto,
|
||||
ReportStatus,
|
||||
} from '@library/shared-types';
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class CommentReportService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly apiUrl = `${environment.apiUrl}/comment-reports`;
|
||||
|
||||
createReport(
|
||||
dto: CreateCommentReportDto,
|
||||
): Observable<CommentReportWithDetails> {
|
||||
return this.http.post<CommentReportWithDetails>(this.apiUrl, dto);
|
||||
}
|
||||
|
||||
getAllReports(
|
||||
status?: ReportStatus,
|
||||
): Observable<CommentReportWithDetails[]> {
|
||||
const params: Record<string, string> = {};
|
||||
if (status) {
|
||||
params['status'] = status;
|
||||
}
|
||||
return this.http.get<CommentReportWithDetails[]>(this.apiUrl, { params });
|
||||
}
|
||||
|
||||
getReportById(id: string): Observable<CommentReportWithDetails> {
|
||||
return this.http.get<CommentReportWithDetails>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
updateReport(
|
||||
id: string,
|
||||
dto: UpdateCommentReportDto,
|
||||
): Observable<CommentReportWithDetails> {
|
||||
return this.http.put<CommentReportWithDetails>(`${this.apiUrl}/${id}`, dto);
|
||||
}
|
||||
}
|
||||
@@ -111,4 +111,13 @@ export class CommentsService {
|
||||
updateCommentOnManga(mangaId: string, commentId: string, content: string): Observable<Comment> {
|
||||
return this.api.put<Comment>(`/manga/${mangaId}/comments/${commentId}`, { content });
|
||||
}
|
||||
|
||||
// Admin methods - work with comment ID directly
|
||||
adminUpdateComment(commentId: string, content: string): Observable<Comment> {
|
||||
return this.api.put<Comment>(`/comments/${commentId}`, { content });
|
||||
}
|
||||
|
||||
adminDeleteComment(commentId: string): Observable<{ success: boolean }> {
|
||||
return this.api.delete<{ success: boolean }>(`/comments/${commentId}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,4 +8,5 @@ export { ApiService } from './api.service';
|
||||
export { AuthService } from './auth.service';
|
||||
export { BooksService } from './books.service';
|
||||
export { GamesService } from './games.service';
|
||||
export { MusicService } from './music.service';
|
||||
export { MusicService } from './music.service';
|
||||
export { ReportService } from './report.service';
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApiService } from './api.service';
|
||||
import type {
|
||||
ProfileReportWithUsers,
|
||||
CreateReportDto,
|
||||
ReportStatus
|
||||
} from '@library/shared-types';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ReportService {
|
||||
private api = inject(ApiService);
|
||||
|
||||
/**
|
||||
* Create a new profile report.
|
||||
*
|
||||
* @param reportedUserId - The ID of the user being reported
|
||||
* @param reason - The reason for the report
|
||||
* @param details - Additional details about the report
|
||||
* @returns Observable of the created report
|
||||
*/
|
||||
createReport(reportedUserId: string, reason: string, details: string): Observable<ProfileReportWithUsers> {
|
||||
const dto: CreateReportDto = {
|
||||
reportedUserId,
|
||||
reason: reason as CreateReportDto['reason'],
|
||||
details
|
||||
};
|
||||
return this.api.post<ProfileReportWithUsers>('/reports', dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all reports (admin only). Optionally filter by status.
|
||||
*
|
||||
* @param status - Optional status to filter by
|
||||
* @returns Observable of all matching reports
|
||||
*/
|
||||
getAllReports(status?: ReportStatus): Observable<ProfileReportWithUsers[]> {
|
||||
const url = status ? `/reports?status=${status}` : '/reports';
|
||||
return this.api.get<ProfileReportWithUsers[]>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific report by ID (admin only).
|
||||
*
|
||||
* @param id - The report ID
|
||||
* @returns Observable of the report
|
||||
*/
|
||||
getReportById(id: string): Observable<ProfileReportWithUsers> {
|
||||
return this.api.get<ProfileReportWithUsers>(`/reports/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a report's status and review notes (admin only).
|
||||
*
|
||||
* @param id - The report ID
|
||||
* @param status - The new status
|
||||
* @param reviewNotes - Optional review notes
|
||||
* @returns Observable of the updated report
|
||||
*/
|
||||
updateReport(id: string, status: ReportStatus, reviewNotes?: string): Observable<ProfileReportWithUsers> {
|
||||
return this.api.put<ProfileReportWithUsers>(`/reports/${id}`, {
|
||||
status,
|
||||
reviewNotes
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,53 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApiService } from './api.service';
|
||||
import { User } from '@library/shared-types';
|
||||
import { User, PrimaryBadge } from '@library/shared-types';
|
||||
|
||||
export interface UserProfileResponse {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName?: string;
|
||||
avatar?: string;
|
||||
bio?: string;
|
||||
slug?: string;
|
||||
primaryBadge?: PrimaryBadge;
|
||||
website?: string;
|
||||
discordServer?: string;
|
||||
bluesky?: string;
|
||||
github?: string;
|
||||
linkedin?: string;
|
||||
twitch?: string;
|
||||
youtube?: string;
|
||||
achievementPoints: number;
|
||||
badges: {
|
||||
isStaff: boolean;
|
||||
isMod: boolean;
|
||||
isVip: boolean;
|
||||
inDiscord: boolean;
|
||||
};
|
||||
stats: {
|
||||
suggestionsCount: number;
|
||||
suggestionsAcceptedCount: number;
|
||||
likesCount: number;
|
||||
commentsCount: number;
|
||||
};
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface UpdateUserSettingsRequest {
|
||||
slug?: string;
|
||||
displayName?: string;
|
||||
bio?: string;
|
||||
profilePublic?: boolean;
|
||||
primaryBadge?: PrimaryBadge;
|
||||
website?: string;
|
||||
discordServer?: string;
|
||||
bluesky?: string;
|
||||
github?: string;
|
||||
linkedin?: string;
|
||||
twitch?: string;
|
||||
youtube?: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -26,4 +72,24 @@ export class UserService {
|
||||
unbanUser(userId: string): Observable<User> {
|
||||
return this.api.post<User>(`/users/${userId}/unban`, {});
|
||||
}
|
||||
|
||||
getMe(): Observable<User> {
|
||||
return this.api.get<User>('/users/me');
|
||||
}
|
||||
|
||||
updateSettings(settings: UpdateUserSettingsRequest): Observable<User> {
|
||||
return this.api.put<User>('/users/me', settings);
|
||||
}
|
||||
|
||||
getProfile(identifier: string): Observable<UserProfileResponse> {
|
||||
return this.api.get<UserProfileResponse>(`/users/profile/${identifier}`);
|
||||
}
|
||||
|
||||
makeProfilePrivate(userId: string): Observable<User> {
|
||||
return this.api.post<User>(`/users/${userId}/make-private`, {});
|
||||
}
|
||||
|
||||
adminUpdateUser(userId: string, settings: UpdateUserSettingsRequest): Observable<User> {
|
||||
return this.api.put<User>(`/users/${userId}`, settings);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user