feat: implement user profiles with achievements and primary badge system (#58)
Node.js CI / CI (push) Successful in 1m21s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m22s

## 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:
2026-02-19 22:21:17 -08:00
committed by Naomi Carrigan
parent 7579f1ec97
commit 86404497f0
58 changed files with 8325 additions and 449 deletions
+1 -1
View File
@@ -18,4 +18,4 @@ describe("frontend-e2e", () => {
// Function helper example, see `../support/app.po.ts` file
getGreeting().contains(/Welcome/);
});
});
});
+6 -1
View File
@@ -4,4 +4,9 @@
* @license Naomi's Public License
*/
export const getGreeting = (): Cypress.Chainable => cy.get("h1");
/**
*
*/
export const getGreeting = (): Cypress.Chainable => {
return cy.get("h1");
};
+14 -8
View File
@@ -13,14 +13,14 @@
*
* For more comprehensive examples of custom
* commands please read more here:
* https://on.cypress.io/custom-commands
* https://on.cypress.io/custom-commands.
*/
// eslint-disable-next-line @typescript-eslint/no-namespace -- Required for Cypress type extensions
declare namespace Cypress {
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Subject is required for type definition
interface Chainable<Subject> {
login: (email: string, password: string) => void;
login: (email: string, password: string)=> void;
}
}
@@ -30,11 +30,17 @@ Cypress.Commands.add("login", (email, password) => {
console.log("Custom command example: Login", email, password);
});
// -- This is a child command --
// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
/*
* -- This is a child command --
* Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
*/
// -- This is a dual command --
// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
/*
* -- This is a dual command --
* Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
*/
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
/*
* -- This will overwrite an existing command --
* Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
*/
+1 -1
View File
@@ -16,7 +16,7 @@
* 'supportFile' configuration option.
*
* You can read more here:
* https://on.cypress.io/configuration
* https://on.cypress.io/configuration.
*/
// Import commands.ts using ES2015 syntax:
+16
View File
@@ -41,6 +41,10 @@ export const appRoutes: Route[] = [
path: 'admin/suggestions',
loadComponent: () => import('./components/admin/admin-suggestions.component').then(m => m.AdminSuggestionsComponent)
},
{
path: 'admin/reports',
loadComponent: () => import('./components/admin-reports/admin-reports.component').then(m => m.AdminReportsComponent)
},
{
path: 'my-suggestions',
loadComponent: () => import('./components/my-suggestions/my-suggestions.component').then(m => m.MySuggestionsComponent)
@@ -49,6 +53,18 @@ export const appRoutes: Route[] = [
path: 'my-likes',
loadComponent: () => import('./components/my-likes/my-likes.component').then(m => m.MyLikesComponent)
},
{
path: 'profile/:identifier',
loadComponent: () => import('./components/profile/profile.component').then(m => m.ProfileComponent)
},
{
path: 'settings',
loadComponent: () => import('./components/settings/settings.component').then(m => m.SettingsComponent)
},
{
path: 'achievements',
loadComponent: () => import('./components/achievements/achievements.component').then(m => m.AchievementsComponent)
},
{
path: '**',
redirectTo: ''
@@ -0,0 +1,437 @@
/**
* @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 {
AchievementCategory,
AchievementProgress,
AchievementTier,
} from '@library/shared-types';
import { AchievementService } from '../../services/achievement.service';
@Component({
selector: 'app-achievements',
standalone: true,
imports: [CommonModule],
template: `
<div class="achievements-container">
<div class="achievements-header">
<h1>🏆 Achievements</h1>
<p class="subtitle">Track your progress across all achievement categories</p>
@if (totalPoints() > 0) {
<div class="total-points">
<span class="points-label">Total Points:</span>
<span class="points-value">{{ totalPoints() }}</span>
</div>
}
</div>
<div class="filter-buttons">
<button
[class.active]="selectedCategory() === null"
(click)="selectCategory(null)">
All ({{ totalEarned() }}/{{ totalAchievements() }})
</button>
<button
[class.active]="selectedCategory() === AchievementCategory.Suggestion"
(click)="selectCategory(AchievementCategory.Suggestion)">
📝 Suggestions ({{ getCategoryCount(AchievementCategory.Suggestion) }})
</button>
<button
[class.active]="selectedCategory() === AchievementCategory.Like"
(click)="selectCategory(AchievementCategory.Like)">
❤️ Likes ({{ getCategoryCount(AchievementCategory.Like) }})
</button>
<button
[class.active]="selectedCategory() === AchievementCategory.Comment"
(click)="selectCategory(AchievementCategory.Comment)">
💬 Comments ({{ getCategoryCount(AchievementCategory.Comment) }})
</button>
<button
[class.active]="selectedCategory() === AchievementCategory.Engagement"
(click)="selectCategory(AchievementCategory.Engagement)">
🎯 Engagement ({{ getCategoryCount(AchievementCategory.Engagement) }})
</button>
<button
[class.active]="selectedCategory() === AchievementCategory.Report"
(click)="selectCategory(AchievementCategory.Report)">
🛡️ Reports ({{ getCategoryCount(AchievementCategory.Report) }})
</button>
</div>
@if (loading()) {
<div class="loading">Loading achievements...</div>
} @else if (error()) {
<div class="error">{{ error() }}</div>
} @else {
<div class="achievements-grid">
@for (achievement of filteredAchievements(); track achievement.definition.key) {
<div
class="achievement-card"
[class.earned]="achievement.earned"
[class.locked]="!achievement.earned"
[attr.data-tier]="achievement.definition.tier.toLowerCase()">
<div class="achievement-icon">
{{ achievement.definition.icon }}
</div>
<div class="achievement-content">
<h3 class="achievement-title">
{{ achievement.definition.title }}
@if (achievement.earned) {
<span class="earned-check">✓</span>
}
</h3>
<p class="achievement-description">
{{ achievement.definition.description }}
</p>
<div class="achievement-footer">
<span class="achievement-tier" [attr.data-tier]="achievement.definition.tier.toLowerCase()">
{{ getTierLabel(achievement.definition.tier) }}
</span>
<span class="achievement-points">{{ achievement.definition.points }} pts</span>
</div>
@if (!achievement.earned && achievement.progress > 0) {
<div class="progress-bar">
<div class="progress-fill" [style.width.%]="achievement.progress"></div>
</div>
<div class="progress-text">{{ achievement.progress }}% complete</div>
}
@if (achievement.earned && achievement.earnedAt) {
<div class="earned-date">
Earned {{ formatDate(achievement.earnedAt) }}
</div>
}
</div>
</div>
}
</div>
}
</div>
`,
styles: [`
.achievements-container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
.achievements-header {
text-align: center;
margin-bottom: 2rem;
}
.achievements-header h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.subtitle {
color: #a0aec0;
font-size: 1.1rem;
}
.total-points {
margin-top: 1rem;
padding: 1rem 2rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
display: inline-block;
color: white;
font-size: 1.2rem;
font-weight: bold;
}
.points-label {
margin-right: 0.5rem;
}
.points-value {
font-size: 1.5rem;
}
.filter-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: center;
margin-bottom: 2rem;
}
.filter-buttons button {
padding: 0.75rem 1.5rem;
background: #2d3748;
color: #cbd5e0;
border: 2px solid #4a5568;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
font-size: 1rem;
}
.filter-buttons button:hover {
background: #4a5568;
border-color: #667eea;
}
.filter-buttons button.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-color: #667eea;
color: white;
}
.achievements-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 1.5rem;
}
.achievement-card {
background: #2d3748;
border-radius: 12px;
padding: 1.5rem;
display: flex;
gap: 1rem;
border: 2px solid #4a5568;
transition: all 0.3s;
}
.achievement-card.earned {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
border-color: #667eea;
}
.achievement-card.earned:hover {
transform: translateY(-4px);
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.3);
}
.achievement-card.locked {
opacity: 0.6;
}
.achievement-icon {
font-size: 3rem;
flex-shrink: 0;
}
.achievement-content {
flex: 1;
}
.achievement-title {
font-size: 1.25rem;
margin-bottom: 0.5rem;
color: #e2e8f0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.earned-check {
color: #48bb78;
font-size: 1.5rem;
}
.achievement-description {
color: #a0aec0;
margin-bottom: 1rem;
line-height: 1.5;
}
.achievement-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.achievement-tier {
padding: 0.25rem 0.75rem;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
}
.achievement-tier[data-tier="bronze"] {
background: linear-gradient(135deg, #cd7f32 0%, #b87333 100%);
color: white;
}
.achievement-tier[data-tier="silver"] {
background: linear-gradient(135deg, #c0c0c0 0%, #a8a8a8 100%);
color: #2d3748;
}
.achievement-tier[data-tier="gold"] {
background: linear-gradient(135deg, #ffd700 0%, #ffed4e 100%);
color: #2d3748;
}
.achievement-tier[data-tier="platinum"] {
background: linear-gradient(135deg, #e5e4e2 0%, #bdb8af 100%);
color: #2d3748;
}
.achievement-tier[data-tier="diamond"] {
background: linear-gradient(135deg, #b9f2ff 0%, #7ec8e3 100%);
color: #2d3748;
}
.achievement-points {
color: #667eea;
font-weight: 600;
}
.progress-bar {
width: 100%;
height: 8px;
background: #4a5568;
border-radius: 4px;
overflow: hidden;
margin: 0.5rem 0;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
transition: width 0.3s;
}
.progress-text {
font-size: 0.875rem;
color: #a0aec0;
text-align: right;
}
.earned-date {
font-size: 0.875rem;
color: #48bb78;
margin-top: 0.5rem;
}
.loading, .error {
text-align: center;
padding: 3rem;
font-size: 1.2rem;
color: #a0aec0;
}
.error {
color: #fc8181;
}
`],
})
export class AchievementsComponent implements OnInit {
private readonly achievementService = inject(AchievementService);
achievements = signal<AchievementProgress[]>([]);
selectedCategory = signal<AchievementCategory | null>(null);
loading = signal(true);
error = signal<string | null>(null);
// Expose AchievementCategory enum for template
readonly AchievementCategory = AchievementCategory;
ngOnInit(): void {
this.loadAchievements();
}
private loadAchievements(): void {
this.achievementService.getCurrentUserProgress().subscribe({
next: (achievements) => {
this.achievements.set(achievements);
this.loading.set(false);
},
error: (err) => {
this.error.set('Failed to load achievements');
this.loading.set(false);
console.error('Error loading achievements:', err);
},
});
}
filteredAchievements(): AchievementProgress[] {
const category = this.selectedCategory();
if (!category) {
return this.achievements();
}
return this.achievements().filter(
(a) => a.definition.category === category,
);
}
selectCategory(category: AchievementCategory | null): void {
this.selectedCategory.set(category);
}
totalAchievements(): number {
return this.achievements().length;
}
totalEarned(): number {
return this.achievements().filter((a) => a.earned).length;
}
totalPoints(): number {
return this.achievements()
.filter((a) => a.earned)
.reduce((sum, a) => sum + a.definition.points, 0);
}
getCategoryCount(category: AchievementCategory): string {
const total = this.achievements().filter(
(a) => a.definition.category === category,
).length;
const earned = this.achievements().filter(
(a) => a.definition.category === category && a.earned,
).length;
return `${earned}/${total}`;
}
getTierLabel(tier: AchievementTier): string {
return tier;
}
formatDate(date: Date): string {
const d = new Date(date);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return 'today';
}
if (diffDays === 1) {
return 'yesterday';
}
if (diffDays < 7) {
return `${diffDays} days ago`;
}
if (diffDays < 30) {
const weeks = Math.floor(diffDays / 7);
return `${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago`;
}
if (diffDays < 365) {
const months = Math.floor(diffDays / 30);
return `${months} ${months === 1 ? 'month' : 'months'} ago`;
}
const years = Math.floor(diffDays / 365);
return `${years} ${years === 1 ? 'year' : 'years'} ago`;
}
}
File diff suppressed because it is too large Load Diff
@@ -14,12 +14,13 @@ import { SanitizeService } from '../../services/sanitize.service';
import { SuggestionService } from '../../services/suggestion.service';
import { PaginationComponent } from '../shared/pagination.component';
import { LikeButtonComponent } from '../shared/like-button.component';
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
@Component({
selector: 'app-art-gallery',
standalone: true,
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent],
template: `
<div class="container">
<div class="header-section">
@@ -468,56 +469,11 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
}
}
@if (commentsLoading()[art.id]) {
<div class="comments-loading">Loading comments...</div>
} @else {
@for (comment of comments()[art.id] || []; track comment.id) {
<div class="comment">
<div class="comment-header">
@if (comment.user.avatar) {
<img [src]="comment.user.avatar" [alt]="comment.user.username" class="comment-avatar">
}
<span class="comment-author">{{ comment.user.username }}</span>
@if (comment.user.inDiscord) {
<span class="discord-badge">Discord</span>
}
@if (comment.user.isVip) {
<span class="vip-badge">VIP</span>
}
@if (comment.user.isMod) {
<span class="mod-badge">Mod</span>
}
@if (comment.user.isStaff) {
<span class="staff-badge">Staff</span>
}
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
@if (canEditComment(comment)) {
<button (click)="startEditComment(art.id, comment)" class="btn btn-secondary btn-xs">Edit</button>
}
@if (canDeleteComment(comment)) {
<button (click)="deleteComment(art.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
}
</div>
@if (editingCommentId() === comment.id) {
<div class="comment-edit-form">
<textarea
[(ngModel)]="editCommentContent"
name="editComment"
rows="3"
></textarea>
<div class="comment-edit-actions">
<button (click)="saveCommentEdit(art.id, comment.id)" class="btn btn-primary btn-xs">Save</button>
<button (click)="cancelCommentEdit()" class="btn btn-secondary btn-xs">Cancel</button>
</div>
</div>
} @else {
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
}
</div>
} @empty {
<div class="no-comments">No comments yet. Be the first to comment!</div>
}
}
<app-comment-display
[comments]="getCommentsSignal(art.id)"
(edit)="handleCommentEdit(art.id, $event)"
(delete)="deleteComment(art.id, $event)"
/>
</div>
}
</div>
@@ -1615,4 +1571,21 @@ export class ArtGalleryComponent implements OnInit {
toggleFilters() {
this.showFilters.update(v => !v);
}
handleCommentEdit(artId: string, event: { commentId: string; content: string }) {
this.commentsService.updateCommentOnArt(artId, event.commentId, event.content).subscribe({
next: (updatedComment) => {
this.comments.set({
...this.comments(),
[artId]: (this.comments()[artId] || []).map(c =>
c.id === event.commentId ? updatedComment : c
)
});
}
});
}
getCommentsSignal(artId: string) {
return signal(this.comments()[artId] || []);
}
}
@@ -14,12 +14,13 @@ import { SanitizeService } from '../../services/sanitize.service';
import { SuggestionService } from '../../services/suggestion.service';
import { PaginationComponent } from '../shared/pagination.component';
import { LikeButtonComponent } from '../shared/like-button.component';
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
@Component({
selector: 'app-books-list',
standalone: true,
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent],
template: `
<div class="container">
<div class="header-section">
@@ -655,52 +656,11 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
@if (commentsLoading()[book.id]) {
<div class="comments-loading">Loading comments...</div>
} @else {
@for (comment of comments()[book.id] || []; track comment.id) {
<div class="comment">
<div class="comment-header">
@if (comment.user.avatar) {
<img [src]="comment.user.avatar" [alt]="comment.user.username" class="comment-avatar">
}
<span class="comment-author">{{ comment.user.username }}</span>
@if (comment.user.inDiscord) {
<span class="discord-badge">Discord</span>
}
@if (comment.user.isVip) {
<span class="vip-badge">VIP</span>
}
@if (comment.user.isMod) {
<span class="mod-badge">Mod</span>
}
@if (comment.user.isStaff) {
<span class="staff-badge">Staff</span>
}
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
@if (canEditComment(comment)) {
<button (click)="startEditComment(book.id, comment)" class="btn btn-secondary btn-xs">Edit</button>
}
@if (canDeleteComment(comment)) {
<button (click)="deleteComment(book.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
}
</div>
@if (editingCommentId() === comment.id) {
<div class="comment-edit-form">
<textarea
[(ngModel)]="editCommentContent"
name="editComment"
rows="3"
></textarea>
<div class="comment-edit-actions">
<button (click)="saveCommentEdit(book.id, comment.id)" class="btn btn-primary btn-xs">Save</button>
<button (click)="cancelCommentEdit()" class="btn btn-secondary btn-xs">Cancel</button>
</div>
</div>
} @else {
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
}
</div>
} @empty {
<div class="no-comments">No comments yet. Be the first to comment!</div>
}
<app-comment-display
[comments]="getCommentsSignal(book.id)"
(edit)="handleCommentEdit(book.id, $event)"
(delete)="deleteComment(book.id, $event)"
/>
}
</div>
}
@@ -1982,4 +1942,21 @@ export class BooksListComponent implements OnInit {
alert('Failed to submit suggestion. Please try again.');
}
}
handleCommentEdit(bookId: string, event: { commentId: string; content: string }) {
this.commentsService.updateCommentOnBook(bookId, event.commentId, event.content).subscribe({
next: (updatedComment) => {
this.comments.set({
...this.comments(),
[bookId]: (this.comments()[bookId] || []).map(c =>
c.id === event.commentId ? updatedComment : c
)
});
}
});
}
getCommentsSignal(bookId: string) {
return signal(this.comments()[bookId] || []);
}
}
@@ -0,0 +1,317 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Component, Input, Output, EventEmitter, signal, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import type { Comment } from '@library/shared-types';
import { PrimaryBadge } from '@library/shared-types';
import { AuthService } from '../../services/auth.service';
import { SanitizeService } from '../../services/sanitize.service';
import { ReportModalComponent } from '../report-modal/report-modal.component';
@Component({
selector: 'app-comment-display',
standalone: true,
imports: [CommonModule, FormsModule, ReportModalComponent],
template: `
<div class="comments-section">
@if (comments().length > 0) {
@for (comment of comments(); track comment.id) {
<div class="comment">
<div class="comment-header">
@if (comment.user.avatar) {
<img [src]="comment.user.avatar" [alt]="comment.user.username" class="comment-avatar">
}
<span class="comment-author">{{ comment.user.username }}</span>
@if (comment.user.primaryBadge) {
<!-- Show only the selected primary badge -->
@if (comment.user.primaryBadge === PrimaryBadge.STAFF && comment.user.isStaff) {
<span class="staff-badge">Staff</span>
}
@if (comment.user.primaryBadge === PrimaryBadge.MOD && comment.user.isMod) {
<span class="mod-badge">Mod</span>
}
@if (comment.user.primaryBadge === PrimaryBadge.VIP && comment.user.isVip) {
<span class="vip-badge">VIP</span>
}
@if (comment.user.primaryBadge === PrimaryBadge.DISCORD && comment.user.inDiscord) {
<span class="discord-badge">Discord</span>
}
}
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
@if (canEditComment(comment)) {
<button (click)="startEdit(comment)" class="btn btn-secondary btn-xs">Edit</button>
}
@if (canDeleteComment(comment)) {
<button (click)="delete.emit(comment.id)" class="btn btn-danger btn-xs">Delete</button>
}
@if (canReportComment(comment)) {
<button (click)="openReportModal(comment)" class="btn btn-warning btn-xs">Report</button>
}
</div>
@if (editingCommentId() === comment.id) {
<div class="comment-edit-form">
<textarea
[(ngModel)]="editCommentContent"
name="editComment"
rows="3"
></textarea>
<div class="comment-edit-actions">
<button (click)="saveEdit(comment.id)" class="btn btn-primary btn-xs">Save</button>
<button (click)="cancelEdit()" class="btn btn-secondary btn-xs">Cancel</button>
</div>
</div>
} @else {
@if (comment.hasPendingReports) {
<div class="comment-pending-review">[comment pending admin review]</div>
} @else {
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
}
}
</div>
}
} @else {
<div class="no-comments">No comments yet. Be the first to comment!</div>
}
</div>
@if (showReportModal()) {
<app-report-modal
[reportType]="'comment'"
[targetId]="reportingCommentId()"
(closeModal)="closeReportModal()"
/>
}
`,
styles: [`
.comments-section {
margin-top: 1rem;
}
.comment {
background: #f9fafb;
padding: 0.75rem;
border-radius: 0.375rem;
margin-bottom: 0.75rem;
}
.comment-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.comment-avatar {
width: 2rem;
height: 2rem;
border-radius: 50%;
object-fit: cover;
}
.comment-author {
font-weight: 600;
color: #1f2937;
}
.discord-badge,
.vip-badge,
.mod-badge,
.staff-badge {
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 600;
}
.discord-badge {
background: #5865f2;
color: white;
}
.vip-badge {
background: #fbbf24;
color: #78350f;
}
.mod-badge {
background: #10b981;
color: white;
}
.staff-badge {
background: #8b5cf6;
color: white;
}
.comment-date {
font-size: 0.75rem;
color: #6b7280;
}
.comment-content {
font-size: 0.9rem;
color: #4b5563;
}
.comment-pending-review {
font-size: 0.9rem;
color: #9b59b6;
font-style: italic;
padding: 0.5rem;
background: #f3e8ff;
border-radius: 0.25rem;
}
.comment-edit-form {
margin-top: 0.5rem;
}
.comment-edit-form textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-family: inherit;
resize: vertical;
}
.comment-edit-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.no-comments {
text-align: center;
color: #6b7280;
padding: 2rem;
font-style: italic;
}
.btn {
padding: 0.25rem 0.75rem;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
}
.btn-xs {
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover {
background: #2563eb;
}
.btn-secondary {
background: #6b7280;
color: white;
}
.btn-secondary:hover {
background: #4b5563;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-warning {
background: #f59e0b;
color: white;
}
.btn-warning:hover {
background: #d97706;
}
`]
})
export class CommentDisplayComponent {
private readonly authService = inject(AuthService);
readonly sanitizeService = inject(SanitizeService);
// Expose PrimaryBadge enum for template
readonly PrimaryBadge = PrimaryBadge;
@Input({ required: true }) comments = signal<Comment[]>([]);
@Output() edit = new EventEmitter<{ commentId: string; content: string }>();
@Output() delete = new EventEmitter<string>();
editingCommentId = signal<string | null>(null);
editCommentContent = '';
showReportModal = signal(false);
reportingCommentId = signal<string>('');
formatDate(date: Date | string): string {
const d = new Date(date);
return d.toLocaleDateString('en-GB', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
canEditComment(comment: Comment): boolean {
const user = this.authService.user();
if (!user) return false;
return comment.userId === user.id || this.authService.isAdmin();
}
canDeleteComment(comment: Comment): boolean {
const user = this.authService.user();
if (!user) return false;
return comment.userId === user.id || this.authService.isAdmin();
}
canReportComment(comment: Comment): boolean {
const user = this.authService.user();
if (!user) return false;
// Users can report comments they didn't write (but not their own)
return comment.userId !== user.id;
}
startEdit(comment: Comment): void {
this.editingCommentId.set(comment.id);
this.editCommentContent = comment.rawContent ?? comment.content;
}
saveEdit(commentId: string): void {
this.edit.emit({ commentId, content: this.editCommentContent });
this.cancelEdit();
}
cancelEdit(): void {
this.editingCommentId.set(null);
this.editCommentContent = '';
}
openReportModal(comment: Comment): void {
this.reportingCommentId.set(comment.id);
this.showReportModal.set(true);
}
closeReportModal(): void {
this.showReportModal.set(false);
this.reportingCommentId.set('');
}
}
@@ -14,12 +14,13 @@ import { SanitizeService } from '../../services/sanitize.service';
import { SuggestionService } from '../../services/suggestion.service';
import { PaginationComponent } from '../shared/pagination.component';
import { LikeButtonComponent } from '../shared/like-button.component';
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
@Component({
selector: 'app-games-list',
standalone: true,
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent],
template: `
<div class="container">
<div class="header-section">
@@ -608,52 +609,11 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
@if (commentsLoading()[game.id]) {
<div class="comments-loading">Loading comments...</div>
} @else {
@for (comment of comments()[game.id] || []; track comment.id) {
<div class="comment">
<div class="comment-header">
@if (comment.user.avatar) {
<img [src]="comment.user.avatar" [alt]="comment.user.username" class="comment-avatar">
}
<span class="comment-author">{{ comment.user.username }}</span>
@if (comment.user.inDiscord) {
<span class="discord-badge">Discord</span>
}
@if (comment.user.isVip) {
<span class="vip-badge">VIP</span>
}
@if (comment.user.isMod) {
<span class="mod-badge">Mod</span>
}
@if (comment.user.isStaff) {
<span class="staff-badge">Staff</span>
}
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
@if (canEditComment(comment)) {
<button (click)="startEditComment(game.id, comment)" class="btn btn-secondary btn-xs">Edit</button>
}
@if (canDeleteComment(comment)) {
<button (click)="deleteComment(game.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
}
</div>
@if (editingCommentId() === comment.id) {
<div class="comment-edit-form">
<textarea
[(ngModel)]="editCommentContent"
name="editComment"
rows="3"
></textarea>
<div class="comment-edit-actions">
<button (click)="saveCommentEdit(game.id, comment.id)" class="btn btn-primary btn-xs">Save</button>
<button (click)="cancelCommentEdit()" class="btn btn-secondary btn-xs">Cancel</button>
</div>
</div>
} @else {
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
}
</div>
} @empty {
<div class="no-comments">No comments yet. Be the first to comment!</div>
}
<app-comment-display
[comments]="getCommentsSignal(game.id)"
(edit)="handleCommentEdit(game.id, $event)"
(delete)="deleteComment(game.id, $event)"
/>
}
</div>
}
@@ -1739,6 +1699,23 @@ export class GamesListComponent implements OnInit {
});
}
handleCommentEdit(gameId: string, event: { commentId: string; content: string }) {
this.commentsService.updateCommentOnGame(gameId, event.commentId, event.content).subscribe({
next: (updatedComment) => {
this.comments.set({
...this.comments(),
[gameId]: (this.comments()[gameId] || []).map(c =>
c.id === event.commentId ? updatedComment : c
)
});
}
});
}
getCommentsSignal(gameId: string) {
return signal(this.comments()[gameId] || []);
}
// Suggestion methods
toggleSuggestForm() {
this.showSuggestForm.update(v => !v);
@@ -50,6 +50,9 @@ import { ApiService } from '../../services/api.service';
}
@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>
@if (!user.isAdmin) {
<a routerLink="/my-suggestions" class="dropdown-item" (click)="closeDropdown()">My Suggestions</a>
}
@@ -58,6 +61,7 @@ import { ApiService } from '../../services/api.service';
<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>
@@ -14,12 +14,13 @@ import { SanitizeService } from '../../services/sanitize.service';
import { SuggestionService } from '../../services/suggestion.service';
import { PaginationComponent } from '../shared/pagination.component';
import { LikeButtonComponent } from '../shared/like-button.component';
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
@Component({
selector: 'app-manga-list',
standalone: true,
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent],
template: `
<div class="container">
<div class="header-section">
@@ -610,56 +611,11 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
}
}
@if (commentsLoading()[manga.id]) {
<div class="comments-loading">Loading comments...</div>
} @else {
@for (comment of comments()[manga.id] || []; track comment.id) {
<div class="comment">
<div class="comment-header">
@if (comment.user.avatar) {
<img [src]="comment.user.avatar" [alt]="comment.user.username" class="comment-avatar">
}
<span class="comment-author">{{ comment.user.username }}</span>
@if (comment.user.inDiscord) {
<span class="discord-badge">Discord</span>
}
@if (comment.user.isVip) {
<span class="vip-badge">VIP</span>
}
@if (comment.user.isMod) {
<span class="mod-badge">Mod</span>
}
@if (comment.user.isStaff) {
<span class="staff-badge">Staff</span>
}
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
@if (canEditComment(comment)) {
<button (click)="startEditComment(manga.id, comment)" class="btn btn-secondary btn-xs">Edit</button>
}
@if (canDeleteComment(comment)) {
<button (click)="deleteComment(manga.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
}
</div>
@if (editingCommentId() === comment.id) {
<div class="comment-edit-form">
<textarea
[(ngModel)]="editCommentContent"
name="editComment"
rows="3"
></textarea>
<div class="comment-edit-actions">
<button (click)="saveCommentEdit(manga.id, comment.id)" class="btn btn-primary btn-xs">Save</button>
<button (click)="cancelCommentEdit()" class="btn btn-secondary btn-xs">Cancel</button>
</div>
</div>
} @else {
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
}
</div>
} @empty {
<div class="no-comments">No comments yet. Be the first to comment!</div>
}
}
<app-comment-display
[comments]="getCommentsSignal(manga.id)"
(edit)="handleCommentEdit(manga.id, $event)"
(delete)="deleteComment(manga.id, $event)"
/>
</div>
}
</div>
@@ -1780,4 +1736,21 @@ export class MangaListComponent implements OnInit {
alert('Failed to submit suggestion. Please try again.');
}
}
handleCommentEdit(mangaId: string, event: { commentId: string; content: string }) {
this.commentsService.updateCommentOnManga(mangaId, event.commentId, event.content).subscribe({
next: (updatedComment) => {
this.comments.set({
...this.comments(),
[mangaId]: (this.comments()[mangaId] || []).map(c =>
c.id === event.commentId ? updatedComment : c
)
});
}
});
}
getCommentsSignal(mangaId: string) {
return signal(this.comments()[mangaId] || []);
}
}
@@ -14,12 +14,13 @@ import { SanitizeService } from '../../services/sanitize.service';
import { SuggestionService } from '../../services/suggestion.service';
import { PaginationComponent } from '../shared/pagination.component';
import { LikeButtonComponent } from '../shared/like-button.component';
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
@Component({
selector: 'app-music-list',
standalone: true,
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent],
template: `
<div class="container">
<div class="header-section">
@@ -686,56 +687,11 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
}
}
@if (commentsLoading()[music.id]) {
<div class="comments-loading">Loading comments...</div>
} @else {
@for (comment of comments()[music.id] || []; track comment.id) {
<div class="comment">
<div class="comment-header">
@if (comment.user.avatar) {
<img [src]="comment.user.avatar" [alt]="comment.user.username" class="comment-avatar">
}
<span class="comment-author">{{ comment.user.username }}</span>
@if (comment.user.inDiscord) {
<span class="discord-badge">Discord</span>
}
@if (comment.user.isVip) {
<span class="vip-badge">VIP</span>
}
@if (comment.user.isMod) {
<span class="mod-badge">Mod</span>
}
@if (comment.user.isStaff) {
<span class="staff-badge">Staff</span>
}
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
@if (canEditComment(comment)) {
<button (click)="startEditComment(music.id, comment)" class="btn btn-secondary btn-xs">Edit</button>
}
@if (canDeleteComment(comment)) {
<button (click)="deleteComment(music.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
}
</div>
@if (editingCommentId() === comment.id) {
<div class="comment-edit-form">
<textarea
[(ngModel)]="editCommentContent"
name="editComment"
rows="3"
></textarea>
<div class="comment-edit-actions">
<button (click)="saveCommentEdit(music.id, comment.id)" class="btn btn-primary btn-xs">Save</button>
<button (click)="cancelCommentEdit()" class="btn btn-secondary btn-xs">Cancel</button>
</div>
</div>
} @else {
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
}
</div>
} @empty {
<div class="no-comments">No comments yet. Be the first to comment!</div>
}
}
<app-comment-display
[comments]="getCommentsSignal(music.id)"
(edit)="handleCommentEdit(music.id, $event)"
(delete)="deleteComment(music.id, $event)"
/>
</div>
}
</div>
@@ -2014,4 +1970,21 @@ export class MusicListComponent implements OnInit {
alert('Failed to submit suggestion. Please try again.');
}
}
handleCommentEdit(musicId: string, event: { commentId: string; content: string }) {
this.commentsService.updateCommentOnMusic(musicId, event.commentId, event.content).subscribe({
next: (updatedComment) => {
this.comments.set({
...this.comments(),
[musicId]: (this.comments()[musicId] || []).map(c =>
c.id === event.commentId ? updatedComment : c
)
});
}
});
}
getCommentsSignal(musicId: string) {
return signal(this.comments()[musicId] || []);
}
}
@@ -0,0 +1,667 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Component, inject, signal, OnInit } from '@angular/core';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { CommonModule } from '@angular/common';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { faGlobe, faCloud, faFlag } from '@fortawesome/free-solid-svg-icons';
import { faGithub, faLinkedin, faTwitch, faYoutube, faDiscord } from '@fortawesome/free-brands-svg-icons';
import { UserService, UserProfileResponse } from '../../services/user.service';
import { AchievementService } from '../../services/achievement.service';
import { ToastService } from '../../services/toast.service';
import { AuthService } from '../../services/auth.service';
import { ReportModalComponent } from '../report-modal/report-modal.component';
import { PrimaryBadge, AchievementProgress } from '@library/shared-types';
@Component({
selector: 'app-profile',
standalone: true,
imports: [CommonModule, FontAwesomeModule, ReportModalComponent, RouterModule],
template: `
<div class="profile-container">
@if (loading()) {
<div class="loading">Loading profile...</div>
} @else if (error()) {
<div class="error">{{ error() }}</div>
} @else if (profile()) {
<div class="profile-card">
<div class="profile-header">
@if (profile()?.avatar) {
<img [src]="profile()!.avatar" [alt]="profile()!.username" class="profile-avatar" />
} @else {
<div class="profile-avatar-placeholder">
{{ profile()!.username[0]?.toUpperCase() }}
</div>
}
<div class="profile-info">
<h1 class="profile-username">{{ profile()!.displayName || profile()!.username }}</h1>
@if (profile()!.displayName) {
<p class="profile-handle">\@{{ profile()!.username }}</p>
}
@if (profile()!.slug) {
<p class="profile-slug">library.nhcarrigan.com/profile/{{ profile()!.slug }}</p>
}
</div>
@if (showReportButton()) {
<button
class="report-button"
(click)="openReportModal()"
[attr.aria-label]="'Report ' + profile()!.username + ' profile'"
type="button"
>
<fa-icon [icon]="faFlag"></fa-icon>
<span>Report</span>
</button>
}
</div>
@if (profile()!.primaryBadge) {
<div class="badges-section">
<!-- Show only the selected primary badge -->
@if (profile()!.primaryBadge === PrimaryBadge.STAFF && profile()!.badges.isStaff) {
<span class="badge badge-staff">Staff</span>
}
@if (profile()!.primaryBadge === PrimaryBadge.MOD && profile()!.badges.isMod) {
<span class="badge badge-mod">Moderator</span>
}
@if (profile()!.primaryBadge === PrimaryBadge.VIP && profile()!.badges.isVip) {
<span class="badge badge-vip">VIP</span>
}
@if (profile()!.primaryBadge === PrimaryBadge.DISCORD && profile()!.badges.inDiscord) {
<span class="badge badge-member">Discord Member</span>
}
</div>
}
@if (profile()!.bio) {
<div class="bio-section">
<p class="bio-text">{{ profile()!.bio }}</p>
</div>
}
@if (profile()!.website || profile()!.github || profile()!.bluesky || profile()!.linkedin || profile()!.twitch || profile()!.youtube || profile()!.discordServer) {
<div class="social-links-section">
<h2>Social Links</h2>
<div class="social-links">
@if (profile()!.website) {
<a [href]="profile()!.website" target="_blank" rel="noopener noreferrer" class="social-link" title="Website">
<fa-icon [icon]="faGlobe" class="icon"></fa-icon>
<span class="label">Website</span>
</a>
}
@if (profile()!.github) {
<a [href]="'https://github.com/' + profile()!.github" target="_blank" rel="noopener noreferrer" class="social-link" title="GitHub">
<fa-icon [icon]="faGithub" class="icon"></fa-icon>
<span class="label">GitHub</span>
</a>
}
@if (profile()!.bluesky) {
<a [href]="'https://bsky.app/profile/' + profile()!.bluesky" target="_blank" rel="noopener noreferrer" class="social-link" title="Bluesky">
<fa-icon [icon]="faCloud" class="icon"></fa-icon>
<span class="label">Bluesky</span>
</a>
}
@if (profile()!.linkedin) {
<a [href]="'https://linkedin.com/in/' + profile()!.linkedin" target="_blank" rel="noopener noreferrer" class="social-link" title="LinkedIn">
<fa-icon [icon]="faLinkedin" class="icon"></fa-icon>
<span class="label">LinkedIn</span>
</a>
}
@if (profile()!.twitch) {
<a [href]="'https://twitch.tv/' + profile()!.twitch" target="_blank" rel="noopener noreferrer" class="social-link" title="Twitch">
<fa-icon [icon]="faTwitch" class="icon"></fa-icon>
<span class="label">Twitch</span>
</a>
}
@if (profile()!.youtube) {
<a [href]="'https://youtube.com/' + profile()!.youtube" target="_blank" rel="noopener noreferrer" class="social-link" title="YouTube">
<fa-icon [icon]="faYoutube" class="icon"></fa-icon>
<span class="label">YouTube</span>
</a>
}
@if (profile()!.discordServer) {
<a [href]="'https://discord.gg/' + profile()!.discordServer" target="_blank" rel="noopener noreferrer" class="social-link" title="Discord Server">
<fa-icon [icon]="faDiscord" class="icon"></fa-icon>
<span class="label">Discord</span>
</a>
}
</div>
</div>
}
<div class="stats-section">
<h2>Activity Statistics</h2>
<div class="stats-grid">
<div class="stat-card">
<span class="stat-value">{{ profile()!.stats.suggestionsCount }}</span>
<span class="stat-label">Suggestions</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ profile()!.stats.suggestionsAcceptedCount }}</span>
<span class="stat-label">Accepted</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ profile()!.stats.likesCount }}</span>
<span class="stat-label">Likes</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ profile()!.stats.commentsCount }}</span>
<span class="stat-label">Comments</span>
</div>
@if (profile()!.achievementPoints > 0) {
<div class="stat-card achievement-points-card">
<span class="stat-value">{{ profile()!.achievementPoints }}</span>
<span class="stat-label">🏆 Achievement Points</span>
</div>
}
</div>
</div>
@if (recentAchievements().length > 0) {
<div class="achievements-section">
<div class="section-header">
<h2>Recent Achievements</h2>
@if (isOwnProfile()) {
<a routerLink="/achievements" class="view-all-link">View All →</a>
}
</div>
<div class="achievements-grid">
@for (achievement of recentAchievements(); track achievement.definition.key) {
<div class="achievement-badge" [attr.data-tier]="achievement.definition.tier.toLowerCase()">
<div class="achievement-icon">{{ achievement.definition.icon }}</div>
<div class="achievement-info">
<div class="achievement-title">{{ achievement.definition.title }}</div>
<div class="achievement-points">{{ achievement.definition.points }} pts</div>
</div>
</div>
}
</div>
</div>
}
<div class="footer-section">
<p class="member-since">Member since {{ formatDate(profile()!.createdAt) }}</p>
</div>
</div>
}
@if (reportModalOpen()) {
<app-report-modal
[reportType]="'profile'"
[targetId]="profile()!.id"
[reportedUsername]="profile()!.displayName || profile()!.username"
(closeModal)="closeReportModal()"
/>
}
</div>
`,
styles: [`
.profile-container {
max-width: 800px;
margin: 2rem auto;
padding: 0 1rem;
}
.loading, .error {
text-align: center;
padding: 2rem;
font-size: 1.2rem;
}
.error {
color: var(--error-colour, #c41e3a);
}
.profile-card {
background: var(--card-background, #1a1a2e);
border-radius: 12px;
padding: 2rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.profile-header {
display: flex;
align-items: center;
gap: 1.5rem;
margin-bottom: 1.5rem;
position: relative;
}
.profile-avatar {
width: 100px;
height: 100px;
border-radius: 50%;
border: 3px solid var(--accent-colour, #9b59b6);
}
.profile-avatar-placeholder {
width: 100px;
height: 100px;
border-radius: 50%;
background: var(--accent-colour, #9b59b6);
display: flex;
align-items: center;
justify-content: center;
font-size: 2.5rem;
font-weight: bold;
color: white;
}
.profile-info {
flex: 1;
}
.profile-username {
margin: 0;
font-size: 2rem;
color: var(--text-colour, #e0e0e0);
}
.profile-handle {
margin: 0.25rem 0;
color: var(--text-muted, #a0a0a0);
font-size: 1.1rem;
}
.profile-slug {
margin: 0.25rem 0;
color: var(--text-muted, #a0a0a0);
font-size: 0.9rem;
}
.report-button {
position: absolute;
top: 0;
right: 0;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1rem;
background: linear-gradient(135deg, #c41e3a 0%, #e74c3c 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.report-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(196, 30, 58, 0.4);
}
.report-button fa-icon {
font-size: 1rem;
}
.badges-section {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.badge {
padding: 0.4rem 0.8rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 600;
}
.badge-staff {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.badge-mod {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
}
.badge-vip {
background: linear-gradient(135deg, #ffd89b 0%, #19547b 100%);
color: white;
}
.badge-member {
background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
color: #333;
}
.bio-section {
margin-bottom: 1.5rem;
padding: 1rem;
background: rgba(155, 89, 182, 0.1);
border-radius: 8px;
}
.bio-text {
margin: 0;
color: var(--text-colour, #e0e0e0);
line-height: 1.6;
white-space: pre-wrap;
}
.social-links-section {
margin-bottom: 1.5rem;
}
.social-links-section h2 {
margin: 0 0 1rem 0;
color: var(--accent-colour, #9b59b6);
font-size: 1.5rem;
}
.social-links {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.social-link {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: rgba(155, 89, 182, 0.2);
border: 1px solid rgba(155, 89, 182, 0.3);
border-radius: 8px;
color: var(--text-colour, #e0e0e0);
text-decoration: none;
transition: all 0.2s ease;
}
.social-link:hover {
background: rgba(155, 89, 182, 0.3);
border-color: var(--accent-colour, #9b59b6);
transform: translateY(-2px);
}
.social-link fa-icon {
font-size: 1.3rem;
width: 1.5rem;
}
.social-link fa-icon ::ng-deep svg {
width: 1.3rem;
height: 1.3rem;
}
.social-link .label {
font-weight: 500;
}
.stats-section {
margin-bottom: 1.5rem;
}
.stats-section h2, .achievements-section h2 {
margin: 0 0 1rem 0;
color: var(--accent-colour, #9b59b6);
font-size: 1.5rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 1rem;
}
.stat-card {
text-align: center;
padding: 1rem;
background: rgba(155, 89, 182, 0.2);
border-radius: 8px;
border: 1px solid rgba(155, 89, 182, 0.3);
}
.stat-value {
display: block;
font-size: 2rem;
font-weight: bold;
color: var(--accent-colour, #9b59b6);
}
.stat-label {
display: block;
font-size: 0.9rem;
color: var(--text-muted, #a0a0a0);
margin-top: 0.25rem;
}
.footer-section {
text-align: center;
padding-top: 1rem;
border-top: 1px solid rgba(155, 89, 182, 0.3);
}
.member-since {
margin: 0;
color: var(--text-muted, #a0a0a0);
font-size: 0.9rem;
}
.achievement-points-card {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.2) 0%, rgba(118, 75, 162, 0.2) 100%);
border: 1px solid rgba(102, 126, 234, 0.5);
}
.achievements-section {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid rgba(155, 89, 182, 0.3);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.view-all-link {
color: var(--accent-colour, #9b59b6);
text-decoration: none;
font-size: 0.9rem;
transition: opacity 0.2s;
}
.view-all-link:hover {
opacity: 0.8;
}
.achievements-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
}
.achievement-badge {
background: var(--card-background, #16213e);
border-radius: 8px;
padding: 1rem;
display: flex;
align-items: center;
gap: 0.75rem;
border: 2px solid transparent;
transition: all 0.2s;
}
.achievement-badge[data-tier="bronze"] {
border-color: #cd7f32;
}
.achievement-badge[data-tier="silver"] {
border-color: #c0c0c0;
}
.achievement-badge[data-tier="gold"] {
border-color: #ffd700;
}
.achievement-badge[data-tier="platinum"] {
border-color: #e5e4e2;
}
.achievement-badge[data-tier="diamond"] {
border-color: #b9f2ff;
}
.achievement-badge:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(155, 89, 182, 0.3);
}
.achievement-icon {
font-size: 2rem;
flex-shrink: 0;
}
.achievement-info {
flex: 1;
min-width: 0;
}
.achievement-title {
font-weight: 600;
color: var(--text-colour, #ffffff);
font-size: 1rem;
line-height: 1.3;
word-wrap: break-word;
}
.achievement-points {
color: var(--text-colour, #ffffff);
opacity: 0.8;
font-size: 0.85rem;
margin-top: 0.25rem;
font-weight: 500;
}
`]
})
export class ProfileComponent implements OnInit {
private route = inject(ActivatedRoute);
private userService = inject(UserService);
private achievementService = inject(AchievementService);
private toastService = inject(ToastService);
private authService = inject(AuthService);
profile = signal<UserProfileResponse | null>(null);
recentAchievements = signal<AchievementProgress[]>([]);
loading = signal(true);
error = signal<string | null>(null);
reportModalOpen = signal(false);
// Expose PrimaryBadge enum for template
readonly PrimaryBadge = PrimaryBadge;
// Font Awesome icons
faGlobe = faGlobe;
faGithub = faGithub;
faCloud = faCloud;
faLinkedin = faLinkedin;
faTwitch = faTwitch;
faYoutube = faYoutube;
faDiscord = faDiscord;
faFlag = faFlag;
ngOnInit(): void {
const identifier = this.route.snapshot.paramMap.get('identifier');
if (!identifier) {
this.error.set('No user identifier provided');
this.loading.set(false);
return;
}
this.userService.getProfile(identifier).subscribe({
next: (profileData: UserProfileResponse) => {
this.profile.set(profileData);
this.loading.set(false);
// Load recent achievements
this.achievementService.getUserProgress(profileData.id).subscribe({
next: (achievements) => {
// Get earned achievements sorted by earned date, take top 6
const earned = achievements
.filter(a => a.earned && a.earnedAt)
.sort((a, b) => {
const dateA = a.earnedAt ? new Date(a.earnedAt).getTime() : 0;
const dateB = b.earnedAt ? new Date(b.earnedAt).getTime() : 0;
return dateB - dateA;
})
.slice(0, 6);
this.recentAchievements.set(earned);
},
error: (err) => {
console.error('Error loading achievements:', err);
// Don't show error toast for achievements failure
}
});
},
error: (err: Error) => {
console.error('Error loading profile:', err);
this.error.set('Failed to load profile');
this.loading.set(false);
this.toastService.error('Failed to load profile');
}
});
}
formatDate(date: Date | string): string {
return new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
/**
* Determine whether to show the report button.
* Only show if the user is authenticated and viewing someone else's profile.
*/
showReportButton(): boolean {
const currentUser = this.authService.user();
const profileData = this.profile();
if (!currentUser || !profileData) {
return false;
}
// Don't show report button on your own profile
return currentUser.id !== profileData.id;
}
/**
* Determine whether viewing your own profile.
*/
isOwnProfile(): boolean {
const currentUser = this.authService.user();
const profileData = this.profile();
if (!currentUser || !profileData) {
return false;
}
return currentUser.id === profileData.id;
}
/**
* Open the report modal.
*/
openReportModal(): void {
this.reportModalOpen.set(true);
}
/**
* Close the report modal.
*/
closeReportModal(): void {
this.reportModalOpen.set(false);
}
}
@@ -0,0 +1,398 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Component, inject, signal, input, output, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ReportService } from '../../services/report.service';
import { CommentReportService } from '../../services/comment-report.service';
import { ToastService } from '../../services/toast.service';
import { ReportReason } from '@library/shared-types';
@Component({
selector: 'app-report-modal',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="modal-overlay" (click)="onOverlayClick($event)" (keydown.escape)="closeModal.emit()" tabindex="-1">
<div class="modal-card" role="dialog" aria-labelledby="modal-title" aria-modal="true">
<div class="modal-header">
<h2 id="modal-title">{{ modalTitle() }}</h2>
<button
class="close-button"
(click)="onClose()"
aria-label="Close modal"
type="button"
>
×
</button>
</div>
<div class="modal-body">
<p class="report-info">
{{ reportInfo() }}
</p>
<form (ngSubmit)="onSubmit()" #reportForm="ngForm">
<div class="form-group">
<label for="reason">Reason *</label>
<select
id="reason"
name="reason"
[(ngModel)]="selectedReason"
required
class="form-control"
aria-required="true"
>
<option value="" disabled>Select a reason</option>
<option [value]="ReportReason.INAPPROPRIATE_CONTENT">Inappropriate Content</option>
<option [value]="ReportReason.HARASSMENT">Harassment</option>
<option [value]="ReportReason.SPAM">Spam</option>
<option [value]="ReportReason.IMPERSONATION">Impersonation</option>
<option [value]="ReportReason.OFFENSIVE_NAME">Offensive Name</option>
<option [value]="ReportReason.MALICIOUS_LINKS">Malicious Links</option>
<option [value]="ReportReason.OTHER">Other</option>
</select>
</div>
<div class="form-group">
<label for="details">Details *</label>
<textarea
id="details"
name="details"
[(ngModel)]="details"
required
minlength="10"
maxlength="1000"
rows="5"
class="form-control"
placeholder="Please provide specific details about why you are reporting this profile (10-1000 characters)"
aria-required="true"
[attr.aria-invalid]="detailsInvalid()"
></textarea>
<div class="character-count" [class.invalid]="detailsInvalid()">
{{ details().length }} / 1000 characters
@if (details().length < 10 && details().length > 0) {
<span class="error-text">(minimum 10 characters)</span>
}
</div>
</div>
<div class="modal-footer">
<button
type="button"
class="button button-secondary"
(click)="onClose()"
[disabled]="submitting()"
>
Cancel
</button>
<button
type="submit"
class="button button-danger"
[disabled]="!reportForm.valid || submitting()"
>
@if (submitting()) {
<span>Submitting...</span>
} @else {
<span>Submit Report</span>
}
</button>
</div>
</form>
</div>
</div>
</div>
`,
styles: [`
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal-card {
background: var(--card-background, #1a1a2e);
border-radius: 12px;
width: 100%;
max-width: 600px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 2px solid rgba(155, 89, 182, 0.3);
}
.modal-header h2 {
margin: 0;
color: var(--accent-colour, #9b59b6);
font-size: 1.5rem;
}
.close-button {
background: none;
border: none;
font-size: 2rem;
color: var(--text-colour, #e0e0e0);
cursor: pointer;
padding: 0;
width: 2rem;
height: 2rem;
line-height: 1;
transition: color 0.2s ease;
}
.close-button:hover {
color: var(--accent-colour, #9b59b6);
}
.modal-body {
padding: 1.5rem;
}
.report-info {
margin: 0 0 1.5rem 0;
color: var(--text-colour, #e0e0e0);
line-height: 1.6;
}
.report-info strong {
color: var(--accent-colour, #9b59b6);
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: var(--text-colour, #e0e0e0);
font-weight: 500;
}
.form-control {
width: 100%;
padding: 0.75rem;
background: rgba(155, 89, 182, 0.1);
border: 2px solid rgba(155, 89, 182, 0.3);
border-radius: 8px;
color: var(--text-colour, #e0e0e0);
font-size: 1rem;
font-family: inherit;
transition: border-color 0.2s ease;
}
.form-control:focus {
outline: none;
border-color: var(--accent-colour, #9b59b6);
}
.form-control:invalid:not(:focus):not(:placeholder-shown) {
border-color: var(--error-colour, #c41e3a);
}
textarea.form-control {
resize: vertical;
min-height: 120px;
}
select.form-control {
cursor: pointer;
appearance: none;
background-image: url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%23e0e0e0"><path d="M7 10l5 5 5-5z"/></svg>');
background-repeat: no-repeat;
background-position: right 0.75rem center;
background-size: 1.5rem;
padding-right: 2.5rem;
}
select.form-control option {
background: #2d2d2d;
color: #e0e0e0;
padding: 0.5rem;
}
.character-count {
margin-top: 0.5rem;
font-size: 0.875rem;
color: var(--text-muted, #a0a0a0);
text-align: right;
}
.character-count.invalid {
color: var(--error-colour, #c41e3a);
}
.error-text {
color: var(--error-colour, #c41e3a);
margin-left: 0.5rem;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 2px solid rgba(155, 89, 182, 0.3);
}
.button {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.button-secondary {
background: rgba(155, 89, 182, 0.2);
color: var(--text-colour, #e0e0e0);
border: 2px solid rgba(155, 89, 182, 0.3);
}
.button-secondary:hover:not(:disabled) {
background: rgba(155, 89, 182, 0.3);
border-color: var(--accent-colour, #9b59b6);
}
.button-danger {
background: linear-gradient(135deg, #c41e3a 0%, #e74c3c 100%);
color: white;
}
.button-danger:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(196, 30, 58, 0.4);
}
`]
})
export class ReportModalComponent {
private reportService = inject(ReportService);
private commentReportService = inject(CommentReportService);
private toastService = inject(ToastService);
// Inputs
reportType = input.required<'profile' | 'comment'>();
targetId = input.required<string>(); // userId for profile, commentId for comment
reportedUsername = input<string>(''); // Only used for profile reports
// Outputs
closeModal = output<void>();
// State
selectedReason = signal<string>('');
details = signal<string>('');
submitting = signal<boolean>(false);
// Computed values
modalTitle = computed(() => {
return this.reportType() === 'profile' ? 'Report Profile' : 'Report Comment';
});
reportInfo = computed(() => {
if (this.reportType() === 'profile') {
return `You are reporting ${this.reportedUsername()}. Please provide a reason and details for this report.`;
} else {
return 'You are reporting this comment. Please provide a reason and details for this report.';
}
});
// Expose enum for template
ReportReason = ReportReason;
/**
* Check if details are invalid (less than 10 characters or more than 1000).
*/
detailsInvalid(): boolean {
const length = this.details().length;
return (length > 0 && length < 10) || length > 1000;
}
/**
* Handle overlay click to close modal.
*/
onOverlayClick(event: MouseEvent): void {
if ((event.target as HTMLElement).classList.contains('modal-overlay')) {
this.onClose();
}
}
/**
* Close the modal.
*/
onClose(): void {
this.closeModal.emit();
}
/**
* Submit the report.
*/
onSubmit(): void {
// Validate form
if (!this.selectedReason() || this.detailsInvalid()) {
this.toastService.error('Please fill in all required fields correctly');
return;
}
this.submitting.set(true);
if (this.reportType() === 'profile') {
this.reportService.createReport(
this.targetId(),
this.selectedReason(),
this.details()
).subscribe(this.getSubscribeHandlers());
} else {
this.commentReportService.createReport({
reportedCommentId: this.targetId(),
reason: this.selectedReason() as ReportReason,
details: this.details(),
}).subscribe(this.getSubscribeHandlers());
}
}
private getSubscribeHandlers() {
return {
next: () => {
this.toastService.success('Report submitted successfully');
this.onClose();
},
error: (err: { status?: number; error?: { error?: string } }) => {
console.error('Error submitting report:', err);
// Check if it's a conflict error (duplicate pending report or rate limit)
if (err.status === 409 && err.error?.error) {
this.toastService.error(err.error.error);
} else {
this.toastService.error('Failed to submit report. Please try again.');
}
this.submitting.set(false);
}
};
}
}
@@ -0,0 +1,491 @@
/**
* @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 { FormsModule } from '@angular/forms';
import { UserService, UpdateUserSettingsRequest } from '../../services/user.service';
import { AuthService } from '../../services/auth.service';
import { ToastService } from '../../services/toast.service';
import { User, PrimaryBadge } from '@library/shared-types';
@Component({
selector: 'app-settings',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="settings-container">
<h1>Profile Settings</h1>
@if (loading()) {
<div class="loading">Loading settings...</div>
} @else if (user()) {
<form class="settings-form" (ngSubmit)="saveSettings()">
<div class="form-section">
<h2>Profile Information</h2>
<div class="form-group">
<label for="displayName">Display Name</label>
<input
type="text"
id="displayName"
name="displayName"
[(ngModel)]="formData.displayName"
placeholder="Your display name"
maxlength="50"
/>
<small class="form-help">This will be shown instead of your username</small>
</div>
<div class="form-group">
<label for="slug">Profile URL Slug</label>
<input
type="text"
id="slug"
name="slug"
[(ngModel)]="formData.slug"
placeholder="your-custom-url"
maxlength="30"
pattern="[a-z0-9-]+"
/>
<small class="form-help">
Your profile will be at: library.nhcarrigan.com/profile/{{ formData.slug || 'your-slug' }}
</small>
<small class="form-help">Only lowercase letters, numbers, and hyphens allowed</small>
</div>
<div class="form-group">
<label for="bio">Bio</label>
<textarea
id="bio"
name="bio"
[(ngModel)]="formData.bio"
placeholder="Tell us about yourself..."
rows="4"
maxlength="500"
></textarea>
<small class="form-help">{{ (formData.bio?.length || 0) }} / 500 characters</small>
</div>
<div class="form-group">
<label for="primaryBadge">Primary Badge</label>
<select
id="primaryBadge"
name="primaryBadge"
[(ngModel)]="formData.primaryBadge"
>
<option [ngValue]="undefined">None (hide all badges)</option>
@if (user()!.isStaff) {
<option [ngValue]="PrimaryBadge.STAFF">Staff</option>
}
@if (user()!.isMod) {
<option [ngValue]="PrimaryBadge.MOD">Moderator</option>
}
@if (user()!.isVip) {
<option [ngValue]="PrimaryBadge.VIP">VIP</option>
}
@if (user()!.inDiscord) {
<option [ngValue]="PrimaryBadge.DISCORD">Discord Member</option>
}
</select>
<small class="form-help">Choose one badge to display on your profile and comments</small>
</div>
</div>
<div class="form-section">
<h2>Social Links</h2>
<div class="form-group">
<label for="website">Website</label>
<input
type="text"
id="website"
name="website"
[(ngModel)]="formData.website"
placeholder="https://yourwebsite.com"
pattern="https?://.+"
/>
<small class="form-help">Your personal website or portfolio (must start with http:// or https://)</small>
</div>
<div class="form-group">
<label for="github">GitHub</label>
<input
type="text"
id="github"
name="github"
[(ngModel)]="formData.github"
placeholder="username"
pattern="[a-zA-Z0-9]([a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?"
/>
<small class="form-help">Just your GitHub username (alphanumeric and hyphens, 1-39 characters)</small>
</div>
<div class="form-group">
<label for="bluesky">Bluesky</label>
<input
type="text"
id="bluesky"
name="bluesky"
[(ngModel)]="formData.bluesky"
placeholder="username.bsky.social"
pattern="[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"
/>
<small class="form-help">Your full Bluesky handle (e.g., username.bsky.social)</small>
</div>
<div class="form-group">
<label for="linkedin">LinkedIn</label>
<input
type="text"
id="linkedin"
name="linkedin"
[(ngModel)]="formData.linkedin"
placeholder="username"
pattern="[a-zA-Z0-9-]{3,100}"
/>
<small class="form-help">Just your LinkedIn username (alphanumeric and hyphens, 3-100 characters)</small>
</div>
<div class="form-group">
<label for="twitch">Twitch</label>
<input
type="text"
id="twitch"
name="twitch"
[(ngModel)]="formData.twitch"
placeholder="username"
pattern="[a-zA-Z0-9_]{4,25}"
/>
<small class="form-help">Just your Twitch username (alphanumeric and underscores, 4-25 characters)</small>
</div>
<div class="form-group">
<label for="youtube">YouTube</label>
<input
type="text"
id="youtube"
name="youtube"
[(ngModel)]="formData.youtube"
placeholder="@username or channel-id"
pattern="(@[a-zA-Z0-9_.-]{3,30}|UC[a-zA-Z0-9_-]{22})"
/>
<small class="form-help">Your YouTube handle (@username) or channel ID (UC...)</small>
</div>
<div class="form-group">
<label for="discordServer">Discord Server</label>
<input
type="text"
id="discordServer"
name="discordServer"
[(ngModel)]="formData.discordServer"
placeholder="invite-code"
pattern="[a-zA-Z0-9]{2,32}"
/>
<small class="form-help">Just your Discord server invite code (alphanumeric, 2-32 characters)</small>
</div>
</div>
<div class="form-section">
<h2>Privacy</h2>
<div class="form-group checkbox-group">
<label>
<input
type="checkbox"
name="profilePublic"
[(ngModel)]="formData.profilePublic"
/>
<span>Make my profile public</span>
</label>
<small class="form-help">
When disabled, only you can view your profile
</small>
</div>
</div>
<div class="form-section">
<h2>Account Information</h2>
<div class="info-item">
<strong>Username:</strong> {{ user()!.username }}
</div>
<div class="info-item">
<strong>Email:</strong> {{ user()!.email }}
</div>
@if (user()!.avatar) {
<div class="info-item">
<strong>Avatar:</strong>
<img [src]="user()!.avatar" alt="Avatar" class="avatar-preview" />
</div>
}
<small class="form-help">
Username, email, and avatar are managed through Discord and cannot be changed here
</small>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" [disabled]="saving()">
{{ saving() ? 'Saving...' : 'Save Changes' }}
</button>
</div>
</form>
}
</div>
`,
styles: [`
.settings-container {
max-width: 700px;
margin: 2rem auto;
padding: 0 1rem;
}
h1 {
color: var(--accent-colour, #9b59b6);
margin-bottom: 2rem;
}
.loading {
text-align: center;
padding: 2rem;
font-size: 1.2rem;
}
.settings-form {
background: var(--card-background, #1a1a2e);
border-radius: 12px;
padding: 2rem;
}
.form-section {
margin-bottom: 2rem;
padding-bottom: 2rem;
border-bottom: 1px solid rgba(155, 89, 182, 0.3);
}
.form-section:last-of-type {
border-bottom: none;
}
.form-section h2 {
color: var(--accent-colour, #9b59b6);
font-size: 1.3rem;
margin-bottom: 1rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: var(--text-colour, #e0e0e0);
font-weight: 600;
}
.form-group input[type="text"],
.form-group textarea,
.form-group select {
width: 100%;
padding: 0.75rem;
border: 1px solid rgba(155, 89, 182, 0.5);
border-radius: 8px;
background: rgba(0, 0, 0, 0.2);
color: var(--text-colour, #e0e0e0);
font-size: 1rem;
font-family: inherit;
}
.form-group select {
cursor: pointer;
}
.form-group select option {
background: #1a1a2e;
color: var(--text-colour, #e0e0e0);
}
.form-group input[type="text"]:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: var(--accent-colour, #9b59b6);
box-shadow: 0 0 0 2px rgba(155, 89, 182, 0.3);
}
.form-group input[type="text"]:invalid:not(:focus):not(:placeholder-shown),
.form-group textarea:invalid:not(:focus):not(:placeholder-shown) {
border-color: var(--error-colour, #c41e3a);
}
.form-group input[type="text"]:valid:not(:placeholder-shown),
.form-group textarea:valid:not(:placeholder-shown) {
border-color: rgba(46, 204, 113, 0.5);
}
.form-group textarea {
resize: vertical;
min-height: 100px;
}
.form-help {
display: block;
margin-top: 0.25rem;
color: var(--text-muted, #a0a0a0);
font-size: 0.85rem;
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-group input[type="checkbox"] {
width: 20px;
height: 20px;
cursor: pointer;
}
.info-item {
margin-bottom: 1rem;
color: var(--text-colour, #e0e0e0);
}
.info-item strong {
color: var(--accent-colour, #9b59b6);
}
.avatar-preview {
width: 50px;
height: 50px;
border-radius: 50%;
margin-left: 0.5rem;
vertical-align: middle;
}
.form-actions {
display: flex;
justify-content: flex-end;
padding-top: 1rem;
}
.btn {
padding: 0.75rem 2rem;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`]
})
export class SettingsComponent implements OnInit {
private userService = inject(UserService);
private authService = inject(AuthService);
private toastService = inject(ToastService);
user = signal<User | null>(null);
loading = signal(true);
saving = signal(false);
// Expose PrimaryBadge enum for template
readonly PrimaryBadge = PrimaryBadge;
formData: UpdateUserSettingsRequest & { bio?: string } = {
displayName: '',
slug: '',
bio: '',
profilePublic: true,
primaryBadge: undefined,
website: '',
discordServer: '',
bluesky: '',
github: '',
linkedin: '',
twitch: '',
youtube: ''
};
ngOnInit(): void {
this.userService.getMe().subscribe({
next: (userData: User) => {
this.user.set(userData);
this.formData = {
displayName: userData.displayName || '',
slug: userData.slug || '',
bio: userData.bio || '',
profilePublic: userData.profilePublic ?? true,
primaryBadge: userData.primaryBadge || undefined,
website: userData.website || '',
discordServer: userData.discordServer || '',
bluesky: userData.bluesky || '',
github: userData.github || '',
linkedin: userData.linkedin || '',
twitch: userData.twitch || '',
youtube: userData.youtube || ''
};
this.loading.set(false);
},
error: (err: Error) => {
console.error('Error loading user profile:', err);
this.toastService.error('Failed to load profile');
this.loading.set(false);
}
});
}
saveSettings(): void {
this.saving.set(true);
const updates: UpdateUserSettingsRequest = {
displayName: this.formData.displayName || undefined,
slug: this.formData.slug || undefined,
bio: this.formData.bio || undefined,
profilePublic: this.formData.profilePublic,
primaryBadge: this.formData.primaryBadge || undefined,
website: this.formData.website || undefined,
discordServer: this.formData.discordServer || undefined,
bluesky: this.formData.bluesky || undefined,
github: this.formData.github || undefined,
linkedin: this.formData.linkedin || undefined,
twitch: this.formData.twitch || undefined,
youtube: this.formData.youtube || undefined
};
this.userService.updateSettings(updates).subscribe({
next: (updatedUser: User) => {
this.user.set(updatedUser);
this.authService.updateUser(updatedUser);
this.saving.set(false);
this.toastService.success('Settings saved successfully!');
},
error: (err: Error) => {
console.error('Error saving settings:', err);
this.toastService.error('Failed to save settings');
this.saving.set(false);
}
});
}
}
@@ -14,12 +14,13 @@ import { SanitizeService } from '../../services/sanitize.service';
import { SuggestionService } from '../../services/suggestion.service';
import { PaginationComponent } from '../shared/pagination.component';
import { LikeButtonComponent } from '../shared/like-button.component';
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
@Component({
selector: 'app-shows-list',
standalone: true,
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent],
template: `
<div class="container">
<div class="header-section">
@@ -604,56 +605,11 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
}
}
@if (commentsLoading()[show.id]) {
<div class="comments-loading">Loading comments...</div>
} @else {
@for (comment of comments()[show.id] || []; track comment.id) {
<div class="comment">
<div class="comment-header">
@if (comment.user.avatar) {
<img [src]="comment.user.avatar" [alt]="comment.user.username" class="comment-avatar">
}
<span class="comment-author">{{ comment.user.username }}</span>
@if (comment.user.inDiscord) {
<span class="discord-badge">Discord</span>
}
@if (comment.user.isVip) {
<span class="vip-badge">VIP</span>
}
@if (comment.user.isMod) {
<span class="mod-badge">Mod</span>
}
@if (comment.user.isStaff) {
<span class="staff-badge">Staff</span>
}
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
@if (canEditComment(comment)) {
<button (click)="startEditComment(show.id, comment)" class="btn btn-secondary btn-xs">Edit</button>
}
@if (canDeleteComment(comment)) {
<button (click)="deleteComment(show.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
}
</div>
@if (editingCommentId() === comment.id) {
<div class="comment-edit-form">
<textarea
[(ngModel)]="editCommentContent"
name="editComment"
rows="3"
></textarea>
<div class="comment-edit-actions">
<button (click)="saveCommentEdit(show.id, comment.id)" class="btn btn-primary btn-xs">Save</button>
<button (click)="cancelCommentEdit()" class="btn btn-secondary btn-xs">Cancel</button>
</div>
</div>
} @else {
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
}
</div>
} @empty {
<div class="no-comments">No comments yet. Be the first to comment!</div>
}
}
<app-comment-display
[comments]="getCommentsSignal(show.id)"
(edit)="handleCommentEdit(show.id, $event)"
(delete)="deleteComment(show.id, $event)"
/>
</div>
}
</div>
@@ -1783,4 +1739,21 @@ export class ShowsListComponent implements OnInit {
alert('Failed to submit suggestion. Please try again.');
}
}
handleCommentEdit(showId: string, event: { commentId: string; content: string }) {
this.commentsService.updateCommentOnShow(showId, event.commentId, event.content).subscribe({
next: (updatedComment) => {
this.comments.set({
...this.comments(),
[showId]: (this.comments()[showId] || []).map(c =>
c.id === event.commentId ? updatedComment : c
)
});
}
});
}
getCommentsSignal(showId: string) {
return signal(this.comments()[showId] || []);
}
}
@@ -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}`);
}
}
+2 -1
View File
@@ -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
});
}
}
+67 -1
View File
@@ -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);
}
}