feat: implement user profiles with achievements and primary badge system #58

Merged
naomi merged 17 commits from feat/user-profiles into main 2026-02-19 22:21:18 -08:00
5 changed files with 120 additions and 251 deletions
Showing only changes of commit e1bac9fd3e - Show all commits
@@ -14,12 +14,13 @@ import { SanitizeService } from '../../services/sanitize.service';
import { SuggestionService } from '../../services/suggestion.service'; import { SuggestionService } from '../../services/suggestion.service';
import { PaginationComponent } from '../shared/pagination.component'; import { PaginationComponent } from '../shared/pagination.component';
import { LikeButtonComponent } from '../shared/like-button.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'; import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
@Component({ @Component({
selector: 'app-art-gallery', selector: 'app-art-gallery',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent], imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent],
template: ` template: `
<div class="container"> <div class="container">
<div class="header-section"> <div class="header-section">
@@ -468,56 +469,11 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
} }
} }
@if (commentsLoading()[art.id]) { <app-comment-display
<div class="comments-loading">Loading comments...</div> [comments]="getCommentsSignal(art.id)"
} @else { (onEdit)="handleCommentEdit(art.id, $event)"
@for (comment of comments()[art.id] || []; track comment.id) { (onDelete)="deleteComment(art.id, $event)"
<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>
}
}
</div> </div>
} }
</div> </div>
@@ -1615,4 +1571,21 @@ export class ArtGalleryComponent implements OnInit {
toggleFilters() { toggleFilters() {
this.showFilters.update(v => !v); 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 { SuggestionService } from '../../services/suggestion.service';
import { PaginationComponent } from '../shared/pagination.component'; import { PaginationComponent } from '../shared/pagination.component';
import { LikeButtonComponent } from '../shared/like-button.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'; import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
@Component({ @Component({
selector: 'app-books-list', selector: 'app-books-list',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent], imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent],
template: ` template: `
<div class="container"> <div class="container">
<div class="header-section"> <div class="header-section">
@@ -655,52 +656,11 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
@if (commentsLoading()[book.id]) { @if (commentsLoading()[book.id]) {
<div class="comments-loading">Loading comments...</div> <div class="comments-loading">Loading comments...</div>
} @else { } @else {
@for (comment of comments()[book.id] || []; track comment.id) { <app-comment-display
<div class="comment"> [comments]="getCommentsSignal(book.id)"
<div class="comment-header"> (onEdit)="handleCommentEdit(book.id, $event)"
@if (comment.user.avatar) { (onDelete)="deleteComment(book.id, $event)"
<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>
}
} }
</div> </div>
} }
@@ -1982,4 +1942,21 @@ export class BooksListComponent implements OnInit {
alert('Failed to submit suggestion. Please try again.'); 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] || []);
}
} }
@@ -14,12 +14,13 @@ import { SanitizeService } from '../../services/sanitize.service';
import { SuggestionService } from '../../services/suggestion.service'; import { SuggestionService } from '../../services/suggestion.service';
import { PaginationComponent } from '../shared/pagination.component'; import { PaginationComponent } from '../shared/pagination.component';
import { LikeButtonComponent } from '../shared/like-button.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'; import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
@Component({ @Component({
selector: 'app-manga-list', selector: 'app-manga-list',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent], imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent],
template: ` template: `
<div class="container"> <div class="container">
<div class="header-section"> <div class="header-section">
@@ -610,56 +611,11 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
} }
} }
@if (commentsLoading()[manga.id]) { <app-comment-display
<div class="comments-loading">Loading comments...</div> [comments]="getCommentsSignal(manga.id)"
} @else { (onEdit)="handleCommentEdit(manga.id, $event)"
@for (comment of comments()[manga.id] || []; track comment.id) { (onDelete)="deleteComment(manga.id, $event)"
<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>
}
}
</div> </div>
} }
</div> </div>
@@ -1780,4 +1736,21 @@ export class MangaListComponent implements OnInit {
alert('Failed to submit suggestion. Please try again.'); 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 { SuggestionService } from '../../services/suggestion.service';
import { PaginationComponent } from '../shared/pagination.component'; import { PaginationComponent } from '../shared/pagination.component';
import { LikeButtonComponent } from '../shared/like-button.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'; import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
@Component({ @Component({
selector: 'app-music-list', selector: 'app-music-list',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent], imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent],
template: ` template: `
<div class="container"> <div class="container">
<div class="header-section"> <div class="header-section">
@@ -686,56 +687,11 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
} }
} }
@if (commentsLoading()[music.id]) { <app-comment-display
<div class="comments-loading">Loading comments...</div> [comments]="getCommentsSignal(music.id)"
} @else { (onEdit)="handleCommentEdit(music.id, $event)"
@for (comment of comments()[music.id] || []; track comment.id) { (onDelete)="deleteComment(music.id, $event)"
<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>
}
}
</div> </div>
} }
</div> </div>
@@ -2014,4 +1970,21 @@ export class MusicListComponent implements OnInit {
alert('Failed to submit suggestion. Please try again.'); 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] || []);
}
} }
@@ -14,12 +14,13 @@ import { SanitizeService } from '../../services/sanitize.service';
import { SuggestionService } from '../../services/suggestion.service'; import { SuggestionService } from '../../services/suggestion.service';
import { PaginationComponent } from '../shared/pagination.component'; import { PaginationComponent } from '../shared/pagination.component';
import { LikeButtonComponent } from '../shared/like-button.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'; import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
@Component({ @Component({
selector: 'app-shows-list', selector: 'app-shows-list',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent], imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent],
template: ` template: `
<div class="container"> <div class="container">
<div class="header-section"> <div class="header-section">
@@ -604,56 +605,11 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
} }
} }
@if (commentsLoading()[show.id]) { <app-comment-display
<div class="comments-loading">Loading comments...</div> [comments]="getCommentsSignal(show.id)"
} @else { (onEdit)="handleCommentEdit(show.id, $event)"
@for (comment of comments()[show.id] || []; track comment.id) { (onDelete)="deleteComment(show.id, $event)"
<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>
}
}
</div> </div>
} }
</div> </div>
@@ -1783,4 +1739,21 @@ export class ShowsListComponent implements OnInit {
alert('Failed to submit suggestion. Please try again.'); 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] || []);
}
} }