From 729f41044374d58d7be3ccbb70e096d22a0cce36 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Wed, 4 Feb 2026 21:14:13 -0800 Subject: [PATCH] feat: add ability to like books --- api/prisma/schema.prisma | 14 + api/src/app/routes/likes/index.ts | 170 +++++++ api/src/app/services/index.ts | 3 +- api/src/app/services/like.service.ts | 181 +++++++ apps/frontend/src/app/app.routes.ts | 4 + .../components/art/art-gallery.component.ts | 8 +- .../components/books/books-list.component.ts | 8 +- .../components/games/games-list.component.ts | 8 +- .../app/components/header/header.component.ts | 1 + .../components/manga/manga-list.component.ts | 8 +- .../components/music/music-list.component.ts | 8 +- .../components/my-likes/my-likes.component.ts | 460 ++++++++++++++++++ .../shared/like-button.component.ts | 167 +++++++ .../components/shows/shows-list.component.ts | 8 +- .../src/app/services/likes.service.ts | 172 +++++++ apps/frontend/src/styles.scss | 7 + shared-types/src/index.ts | 3 +- shared-types/src/lib/audit.types.ts | 2 + shared-types/src/lib/like.types.ts | 32 ++ 19 files changed, 1256 insertions(+), 8 deletions(-) create mode 100644 api/src/app/routes/likes/index.ts create mode 100644 api/src/app/services/like.service.ts create mode 100644 apps/frontend/src/app/components/my-likes/my-likes.component.ts create mode 100644 apps/frontend/src/app/components/shared/like-button.component.ts create mode 100644 apps/frontend/src/app/services/likes.service.ts create mode 100644 shared-types/src/lib/like.types.ts diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index b6154ba..c031860 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -178,6 +178,7 @@ model User { updatedAt DateTime @updatedAt comments Comment[] suggestions Suggestion[] + likes Like[] } model Comment { @@ -226,6 +227,8 @@ enum AuditAction { ENTRY_CREATE ENTRY_UPDATE ENTRY_DELETE + LIKE + UNLIKE USER_BAN USER_UNBAN RATE_LIMIT_EXCEEDED @@ -275,3 +278,14 @@ enum SuggestionStatus { ACCEPTED DECLINED } + +model Like { + id String @id @default(auto()) @map("_id") @db.ObjectId + userId String @db.ObjectId + user User @relation(fields: [userId], references: [id]) + entityType String // 'book', 'game', 'show', 'manga', 'music', 'art' + entityId String @db.ObjectId + createdAt DateTime @default(now()) + + @@unique([userId, entityType, entityId]) +} diff --git a/api/src/app/routes/likes/index.ts b/api/src/app/routes/likes/index.ts new file mode 100644 index 0000000..2cd5c2a --- /dev/null +++ b/api/src/app/routes/likes/index.ts @@ -0,0 +1,170 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { FastifyPluginAsync } from "fastify"; +import { CreateLikeDto, LikeResponse, LikedItemDto, AuditAction, AuditCategory } from "@library/shared-types"; +import { LikeService } from "../../services/like.service"; +import { AuditService } from "../../services/audit.service"; +import { bannedGuard } from "../../middleware/banned-guard"; + +const likesRoutes: FastifyPluginAsync = async (app) => { + const likeService = new LikeService(); + + /** + * Toggle like on an item (authenticated users). + */ + app.post<{ Body: CreateLikeDto; Reply: LikeResponse }>( + "/toggle", + { + preValidation: [app.authenticate, bannedGuard], + preHandler: [app.csrfProtection], + }, + async (request) => { + const userId = request.user.id; + const { entityType, entityId } = request.body; + const result = await likeService.toggleLike(userId, entityType, entityId, request); + return result; + } + ); + + /** + * Get like count for an item (public route). + */ + app.get<{ + Querystring: { entityType: string; entityId: string }; + }>( + "/count", + async (request, reply) => { + const { entityType, entityId } = request.query; + + // Validate entityType + const validTypes = ['book', 'game', 'show', 'manga', 'music', 'art']; + if (!validTypes.includes(entityType)) { + reply.code(400); + return { error: "Invalid entity type" }; + } + + if (!entityId) { + reply.code(400); + return { error: "Entity ID is required" }; + } + + const count = await likeService.getLikeCount(entityType as any, entityId); + return { count }; + } + ); + + /** + * Check if current user has liked an item (authenticated users). + */ + app.get<{ + Querystring: { entityType: string; entityId: string }; + }>( + "/status", + { + preValidation: [app.authenticate], + }, + async (request, reply) => { + const userId = request.user.id; + const { entityType, entityId } = request.query; + + // Validate entityType + const validTypes = ['book', 'game', 'show', 'manga', 'music', 'art']; + if (!validTypes.includes(entityType)) { + reply.code(400); + return { error: "Invalid entity type" }; + } + + if (!entityId) { + reply.code(400); + return { error: "Entity ID is required" }; + } + + const liked = await likeService.getUserLikeStatus(userId, entityType as any, entityId); + return { liked }; + } + ); + + /** + * Get all items liked by the current user (authenticated users). + */ + app.get<{ + Querystring: { entityType?: string }; + }>( + "/user", + { + preValidation: [app.authenticate], + }, + async (request, reply) => { + const userId = request.user.id; + const { entityType } = request.query; + + // Validate entityType if provided + if (entityType) { + const validTypes = ['book', 'game', 'show', 'manga', 'music', 'art']; + if (!validTypes.includes(entityType)) { + reply.code(400); + return { error: "Invalid entity type" }; + } + } + + const likedItems = await likeService.getUserLikedItems(userId, entityType as any); + return likedItems; + } + ); + + /** + * Get bulk like statuses for multiple items (authenticated users). + * Useful for efficiently loading like status for lists. + */ + app.post<{ + Body: { items: Array<{ entityType: string; entityId: string }> }; + }>( + "/bulk-status", + { + preValidation: [app.authenticate], + }, + async (request, reply) => { + const userId = request.user.id; + const { items } = request.body; + + if (!items || !Array.isArray(items)) { + reply.code(400); + return { error: "Items array is required" }; + } + + const validTypes = ['book', 'game', 'show', 'manga', 'music', 'art']; + const results = await Promise.all( + items.map(async (item) => { + if (!validTypes.includes(item.entityType)) { + return { + entityType: item.entityType, + entityId: item.entityId, + liked: false, + count: 0, + }; + } + + const [liked, count] = await Promise.all([ + likeService.getUserLikeStatus(userId, item.entityType as any, item.entityId), + likeService.getLikeCount(item.entityType as any, item.entityId), + ]); + + return { + entityType: item.entityType, + entityId: item.entityId, + liked, + count, + }; + }) + ); + + return results; + } + ); +}; + +export default likesRoutes; \ No newline at end of file diff --git a/api/src/app/services/index.ts b/api/src/app/services/index.ts index 9c3ed98..e29a9a9 100644 --- a/api/src/app/services/index.ts +++ b/api/src/app/services/index.ts @@ -11,4 +11,5 @@ export { MusicService } from "./music.service"; export { ShowService } from "./show.service"; export { MangaService } from "./manga.service"; export { AuditService } from "./audit.service"; -export { SuggestionService } from "./suggestion.service"; \ No newline at end of file +export { SuggestionService } from "./suggestion.service"; +export { LikeService } from "./like.service"; \ No newline at end of file diff --git a/api/src/app/services/like.service.ts b/api/src/app/services/like.service.ts new file mode 100644 index 0000000..8c48d2d --- /dev/null +++ b/api/src/app/services/like.service.ts @@ -0,0 +1,181 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import type { FastifyRequest } from 'fastify'; +import { prisma } from '../lib/prisma'; +import { AuditService } from './audit.service'; +import type { Like, LikeCountDto, LikedItemDto, LikeResponse } from '@library/shared-types'; +import { AuditAction, AuditCategory } from '@library/shared-types'; + +export class LikeService { + async toggleLike(userId: string, entityType: Like['entityType'], entityId: string, req: FastifyRequest): Promise { + // Check if like exists + const existingLike = await prisma.like.findUnique({ + where: { + userId_entityType_entityId: { + userId, + entityType, + entityId + } + } + }); + + if (existingLike) { + // Unlike + await prisma.like.delete({ + where: { + id: existingLike.id + } + }); + + await AuditService.logFromRequest(req, { + action: AuditAction.UNLIKE, + category: AuditCategory.CONTENT, + resourceType: entityType, + resourceId: entityId, + details: `Unliked ${entityType}` + }); + + const count = await this.getLikeCount(entityType, entityId); + return { liked: false, count }; + } else { + // Like + await prisma.like.create({ + data: { + userId, + entityType, + entityId + } + }); + + await AuditService.logFromRequest(req, { + action: AuditAction.LIKE, + category: AuditCategory.CONTENT, + resourceType: entityType, + resourceId: entityId, + details: `Liked ${entityType}` + }); + + const count = await this.getLikeCount(entityType, entityId); + return { liked: true, count }; + } + } + + async getLikeCount(entityType: Like['entityType'], entityId: string): Promise { + return await prisma.like.count({ + where: { + entityType, + entityId + } + }); + } + + async getUserLikeStatus(userId: string, entityType: Like['entityType'], entityId: string): Promise { + const like = await prisma.like.findUnique({ + where: { + userId_entityType_entityId: { + userId, + entityType, + entityId + } + } + }); + + return !!like; + } + + async getLikeCounts(entityType: Like['entityType'], entityIds: string[]): Promise { + const likes = await prisma.like.groupBy({ + by: ['entityId'], + where: { + entityType, + entityId: { in: entityIds } + }, + _count: true + }); + + return likes.map(like => ({ + entityId: like.entityId, + entityType, + count: like._count + })); + } + + async getUserLikeStatuses(userId: string, entityType: Like['entityType'], entityIds: string[]): Promise> { + const likes = await prisma.like.findMany({ + where: { + userId, + entityType, + entityId: { in: entityIds } + }, + select: { + entityId: true + } + }); + + const likeMap: Record = {}; + entityIds.forEach(id => { + likeMap[id] = false; + }); + likes.forEach(like => { + likeMap[like.entityId] = true; + }); + + return likeMap; + } + + async getUserLikedItems(userId: string, entityType?: Like['entityType']): Promise { + const likes = await prisma.like.findMany({ + where: { + userId, + ...(entityType ? { entityType } : {}) + }, + orderBy: { + createdAt: 'desc' + } + }); + + // Fetch the actual items for each like + const likedItems: LikedItemDto[] = []; + + for (const like of likes) { + let item: any = null; + + switch (like.entityType) { + case 'book': + item = await prisma.book.findUnique({ where: { id: like.entityId } }); + break; + case 'game': + item = await prisma.game.findUnique({ where: { id: like.entityId } }); + break; + case 'show': + item = await prisma.show.findUnique({ where: { id: like.entityId } }); + break; + case 'manga': + item = await prisma.manga.findUnique({ where: { id: like.entityId } }); + break; + case 'music': + item = await prisma.music.findUnique({ where: { id: like.entityId } }); + break; + case 'art': + item = await prisma.art.findUnique({ where: { id: like.entityId } }); + break; + } + + if (item) { + likedItems.push({ + like: { + ...like, + entityType: like.entityType as Like['entityType'] + }, + item + }); + } + } + + return likedItems; + } +} \ No newline at end of file diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index 7172663..9e77a19 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -45,6 +45,10 @@ export const appRoutes: Route[] = [ path: 'my-suggestions', loadComponent: () => import('./components/my-suggestions/my-suggestions.component').then(m => m.MySuggestionsComponent) }, + { + path: 'my-likes', + loadComponent: () => import('./components/my-likes/my-likes.component').then(m => m.MyLikesComponent) + }, { path: '**', redirectTo: '' diff --git a/apps/frontend/src/app/components/art/art-gallery.component.ts b/apps/frontend/src/app/components/art/art-gallery.component.ts index d8d876f..2d962fe 100644 --- a/apps/frontend/src/app/components/art/art-gallery.component.ts +++ b/apps/frontend/src/app/components/art/art-gallery.component.ts @@ -13,12 +13,13 @@ import { CommentsService } from '../../services/comments.service'; 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 { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from '@library/shared-types'; @Component({ selector: 'app-art-gallery', standalone: true, - imports: [CommonModule, FormsModule, PaginationComponent], + imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent], template: `
@@ -408,6 +409,11 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from

by {{ art.artist }}

Added: {{ formatDate(art.dateAdded) }}

+ + @if (art.tags && art.tags.length > 0) {
@for (tag of art.tags; track tag) { diff --git a/apps/frontend/src/app/components/books/books-list.component.ts b/apps/frontend/src/app/components/books/books-list.component.ts index 10d3fd2..bd180cc 100644 --- a/apps/frontend/src/app/components/books/books-list.component.ts +++ b/apps/frontend/src/app/components/books/books-list.component.ts @@ -13,12 +13,13 @@ import { CommentsService } from '../../services/comments.service'; 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 { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEntity, Link } from '@library/shared-types'; @Component({ selector: 'app-books-list', standalone: true, - imports: [CommonModule, FormsModule, PaginationComponent], + imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent], template: `
@@ -516,6 +517,11 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
} + + @if (book.isbn) {

ISBN: {{ book.isbn }}

} diff --git a/apps/frontend/src/app/components/games/games-list.component.ts b/apps/frontend/src/app/components/games/games-list.component.ts index 0adc344..af2ec66 100644 --- a/apps/frontend/src/app/components/games/games-list.component.ts +++ b/apps/frontend/src/app/components/games/games-list.component.ts @@ -13,12 +13,13 @@ import { CommentsService } from '../../services/comments.service'; 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 { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEntity, Link } from '@library/shared-types'; @Component({ selector: 'app-games-list', standalone: true, - imports: [CommonModule, FormsModule, PaginationComponent], + imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent], template: `
@@ -476,6 +477,11 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
} + + @if (game.notes) {

{{ game.notes }}

} diff --git a/apps/frontend/src/app/components/header/header.component.ts b/apps/frontend/src/app/components/header/header.component.ts index 4bdd921..deacb42 100644 --- a/apps/frontend/src/app/components/header/header.component.ts +++ b/apps/frontend/src/app/components/header/header.component.ts @@ -35,6 +35,7 @@ import { AuthService } from '../../services/auth.service'; @if (!user.isAdmin) { My Suggestions } + My Likes @if (user.isAdmin) { Users Audit diff --git a/apps/frontend/src/app/components/manga/manga-list.component.ts b/apps/frontend/src/app/components/manga/manga-list.component.ts index 3db278a..3fa1bdc 100644 --- a/apps/frontend/src/app/components/manga/manga-list.component.ts +++ b/apps/frontend/src/app/components/manga/manga-list.component.ts @@ -13,12 +13,13 @@ import { CommentsService } from '../../services/comments.service'; 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 { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, SuggestionEntity, Link } from '@library/shared-types'; @Component({ selector: 'app-manga-list', standalone: true, - imports: [CommonModule, FormsModule, PaginationComponent], + imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent], template: `
@@ -477,6 +478,11 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
} + + @if (manga.notes) {

{{ manga.notes }}

} diff --git a/apps/frontend/src/app/components/music/music-list.component.ts b/apps/frontend/src/app/components/music/music-list.component.ts index 1274de6..a59dc4d 100644 --- a/apps/frontend/src/app/components/music/music-list.component.ts +++ b/apps/frontend/src/app/components/music/music-list.component.ts @@ -13,12 +13,13 @@ import { CommentsService } from '../../services/comments.service'; 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 { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment, SuggestionEntity, Link } from '@library/shared-types'; @Component({ selector: 'app-music-list', standalone: true, - imports: [CommonModule, FormsModule, PaginationComponent], + imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent], template: `
@@ -553,6 +554,11 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
} + + @if (music.notes) {

{{ music.notes }}

} diff --git a/apps/frontend/src/app/components/my-likes/my-likes.component.ts b/apps/frontend/src/app/components/my-likes/my-likes.component.ts new file mode 100644 index 0000000..cde8192 --- /dev/null +++ b/apps/frontend/src/app/components/my-likes/my-likes.component.ts @@ -0,0 +1,460 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { Component, OnInit, inject, signal, computed } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterLink } from '@angular/router'; +import { LikesService } from '../../services/likes.service'; +import { AuthService } from '../../services/auth.service'; +import { PaginationComponent } from '../shared/pagination.component'; +import { LikeButtonComponent } from '../shared/like-button.component'; +import { LikedItemDto, Like } from '@library/shared-types'; + +@Component({ + selector: 'app-my-likes', + standalone: true, + imports: [CommonModule, RouterLink, PaginationComponent, LikeButtonComponent], + template: ` +
+
+

My Likes

+

All the items you've liked across the library

+
+ + @if (!authService.isAuthenticated()) { +
+

Please log in to view your liked items.

+
+ } @else if (loading()) { +
Loading your liked items...
+ } @else if (likedItems().length === 0) { +
+

You haven't liked any items yet!

+

Explore the library and click the heart button on items you enjoy.

+
+ } @else { +
+ + + + + + + +
+ + + +
+ @for (likedItem of paginatedItems(); track likedItem.like.id) { +
+
{{ getTypeBadgeLabel(likedItem.like.entityType) }}
+ + @if (getItemImage(likedItem)) { + + } @else { +
{{ getTypePlaceholderEmoji(likedItem.like.entityType) }}
+ } + +
+

{{ getItemTitle(likedItem) }}

+

{{ getItemSubtitle(likedItem) }}

+

Liked: {{ formatDate(likedItem.like.createdAt) }}

+ + + + + View Details → + +
+
+ } +
+ + + } +
+ `, + styles: [` + .container { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; + } + + .header-section { + margin-bottom: 2rem; + } + + .header-section h2 { + color: var(--witch-purple); + font-size: 2.5rem; + margin-bottom: 0.5rem; + } + + .subtitle { + color: var(--witch-plum); + font-size: 1.1rem; + margin: 0; + } + + .not-authenticated, .loading, .empty-state { + background: var(--witch-lavender); + padding: 3rem; + text-align: center; + border-radius: 12px; + margin: 2rem 0; + } + + .empty-state .hint { + color: var(--witch-plum); + margin-top: 1rem; + } + + .filters { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 2px solid var(--witch-plum); + } + + .filter-btn { + padding: 0.5rem 1rem; + border: 2px solid var(--witch-plum); + background: transparent; + color: var(--witch-purple); + border-radius: 20px; + cursor: pointer; + transition: all 0.3s ease; + } + + .filter-btn:hover { + background: var(--witch-lavender); + } + + .filter-btn.active { + background: var(--witch-rose); + color: white; + border-color: var(--witch-rose); + } + + .liked-items-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 2rem; + margin: 2rem 0; + } + + .liked-item-card { + background: white; + border: 2px solid var(--witch-plum); + border-radius: 12px; + overflow: hidden; + transition: all 0.3s ease; + position: relative; + } + + .liked-item-card:hover { + transform: translateY(-4px); + box-shadow: 0 8px 16px var(--witch-shadow); + } + + .item-type-badge { + position: absolute; + top: 10px; + right: 10px; + background: var(--witch-rose); + color: white; + padding: 0.25rem 0.75rem; + border-radius: 20px; + font-size: 0.85rem; + text-transform: capitalize; + z-index: 1; + } + + .item-image { + width: 100%; + height: 200px; + object-fit: cover; + } + + .item-image.placeholder { + display: flex; + align-items: center; + justify-content: center; + background: var(--witch-lavender); + font-size: 4rem; + } + + .item-info { + padding: 1.5rem; + } + + .item-info h3 { + color: var(--witch-purple); + margin-bottom: 0.5rem; + font-size: 1.3rem; + } + + .item-subtitle { + color: var(--witch-plum); + margin-bottom: 0.5rem; + } + + .liked-date { + color: var(--witch-silver); + font-size: 0.9rem; + margin-bottom: 1rem; + } + + .view-link { + display: inline-block; + margin-top: 1rem; + color: var(--witch-rose); + text-decoration: none; + font-weight: 600; + transition: all 0.3s ease; + } + + .view-link:hover { + color: var(--witch-plum); + transform: translateX(4px); + } + + @media (max-width: 768px) { + .liked-items-grid { + grid-template-columns: 1fr; + } + } + `] +}) +export class MyLikesComponent implements OnInit { + private likesService = inject(LikesService); + authService = inject(AuthService); + + loading = signal(false); + likedItems = signal([]); + typeFilter = signal<'all' | Like['entityType']>('all'); + currentPage = signal(1); + pageSize = signal(12); + + // Computed signals for filtering and pagination + filteredItems = computed(() => { + const filter = this.typeFilter(); + const items = this.likedItems(); + + if (filter === 'all') { + return items; + } + + return items.filter(item => item.like.entityType === filter); + }); + + totalFilteredItems = computed(() => this.filteredItems().length); + + paginatedItems = computed(() => { + const items = this.filteredItems(); + const page = this.currentPage(); + const size = this.pageSize(); + const start = (page - 1) * size; + const end = start + size; + + return items.slice(start, end); + }); + + // Computed counts for each type + bookCount = computed(() => this.likedItems().filter(item => item.like.entityType === 'book').length); + gameCount = computed(() => this.likedItems().filter(item => item.like.entityType === 'game').length); + showCount = computed(() => this.likedItems().filter(item => item.like.entityType === 'show').length); + mangaCount = computed(() => this.likedItems().filter(item => item.like.entityType === 'manga').length); + musicCount = computed(() => this.likedItems().filter(item => item.like.entityType === 'music').length); + artCount = computed(() => this.likedItems().filter(item => item.like.entityType === 'art').length); + + ngOnInit() { + if (this.authService.isAuthenticated()) { + this.loadLikedItems(); + } + } + + loadLikedItems() { + this.loading.set(true); + this.likesService.getUserLikedItems().subscribe({ + next: (items) => { + this.likedItems.set(items); + this.loading.set(false); + }, + error: () => { + this.loading.set(false); + } + }); + } + + setFilter(filter: 'all' | Like['entityType']) { + this.typeFilter.set(filter); + this.currentPage.set(1); // Reset to first page when filtering + } + + onPageChange(page: number) { + this.currentPage.set(page); + } + + onPageSizeChange(size: number) { + this.pageSize.set(size); + this.currentPage.set(1); // Reset to first page when changing page size + } + + getItemTitle(likedItem: LikedItemDto): string { + const item = likedItem.item; + return item.title || item.name || 'Untitled'; + } + + getItemSubtitle(likedItem: LikedItemDto): string { + const item = likedItem.item; + const type = likedItem.like.entityType; + + switch (type) { + case 'book': + return `by ${item.author || 'Unknown author'}`; + case 'game': + return item.platform || 'Platform not specified'; + case 'show': + return item.type === 'movie' ? 'Movie' : 'TV Show'; + case 'manga': + return item.volumeCount ? `${item.volumeCount} volumes` : 'Ongoing'; + case 'music': + return `${item.artist || 'Unknown artist'} - ${item.type || 'Album'}`; + case 'art': + return `by ${item.artist || 'Unknown artist'}`; + default: + return ''; + } + } + + getItemImage(likedItem: LikedItemDto): string | null { + const item = likedItem.item; + return item.coverImage || item.imageUrl || null; + } + + getItemLink(likedItem: LikedItemDto): string[] { + const type = likedItem.like.entityType; + const id = likedItem.like.entityId; + + switch (type) { + case 'book': + return ['/books']; + case 'game': + return ['/games']; + case 'show': + return ['/shows']; + case 'manga': + return ['/manga']; + case 'music': + return ['/music']; + case 'art': + return ['/art']; + default: + return ['/']; + } + } + + getTypeBadgeLabel(type: Like['entityType']): string { + return type.charAt(0).toUpperCase() + type.slice(1); + } + + getTypePlaceholderEmoji(type: Like['entityType']): string { + switch (type) { + case 'book': + return '📚'; + case 'game': + return '🎮'; + case 'show': + return '🎬'; + case 'manga': + return '📖'; + case 'music': + return '🎵'; + case 'art': + return '🎨'; + default: + return '❤️'; + } + } + + formatDate(date: Date | string): string { + const d = new Date(date); + return d.toLocaleDateString('en-GB', { + day: 'numeric', + month: 'short', + year: 'numeric' + }); + } +} \ No newline at end of file diff --git a/apps/frontend/src/app/components/shared/like-button.component.ts b/apps/frontend/src/app/components/shared/like-button.component.ts new file mode 100644 index 0000000..6d95188 --- /dev/null +++ b/apps/frontend/src/app/components/shared/like-button.component.ts @@ -0,0 +1,167 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { Component, Input, inject, OnInit, signal, effect } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { LikesService } from '../../services/likes.service'; +import { AuthService } from '../../services/auth.service'; +import { Like } from '@library/shared-types'; +import { take } from 'rxjs'; + +@Component({ + selector: 'app-like-button', + standalone: true, + imports: [CommonModule], + template: ` + + `, + styles: [` + .like-container { + display: inline-flex; + align-items: center; + } + + .like-button { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0.75rem; + border: 2px solid var(--border-color); + border-radius: 9999px; + background: transparent; + cursor: pointer; + transition: all 0.2s ease; + font-size: 0.9rem; + } + + .like-button:hover:not(:disabled) { + background: var(--hover-background); + transform: scale(1.05); + } + + .like-button:disabled { + cursor: not-allowed; + opacity: 0.6; + } + + .like-button.liked { + border-color: var(--primary-color); + background: var(--primary-light-background); + } + + .heart-icon { + font-size: 1.1rem; + transition: transform 0.2s ease; + } + + .like-button:hover:not(:disabled) .heart-icon { + transform: scale(1.2); + } + + .like-button:active:not(:disabled) .heart-icon { + transform: scale(0.9); + } + + .like-count { + font-weight: 600; + color: var(--text-color); + } + `] +}) +export class LikeButtonComponent implements OnInit { + @Input({ required: true }) entityType!: Like['entityType']; + @Input({ required: true }) entityId!: string; + + private likesService = inject(LikesService); + private authService = inject(AuthService); + + liked = signal(false); + count = signal(0); + loading = signal(false); + isAuthenticated = signal(false); + + ngOnInit() { + // Set authentication state + this.isAuthenticated.set(this.authService.isAuthenticated()); + + // Load initial state + this.loadLikeState(); + } + + private loadLikeState() { + // Check cache first + const cachedState = this.likesService.getCachedLikeState(this.entityType, this.entityId); + if (cachedState) { + this.liked.set(cachedState.liked); + this.count.set(cachedState.count); + } + + // Always get count (public endpoint) + this.likesService.getLikeCount(this.entityType, this.entityId) + .pipe(take(1)) + .subscribe(count => { + this.count.set(count); + }); + + // Get user like status if authenticated + if (this.isAuthenticated()) { + this.likesService.getUserLikeStatus(this.entityType, this.entityId) + .pipe(take(1)) + .subscribe(liked => { + this.liked.set(liked); + }); + } + } + + toggleLike() { + if (!this.isAuthenticated() || this.loading()) { + return; + } + + this.loading.set(true); + + // Optimistic update + const newLiked = !this.liked(); + const newCount = newLiked ? this.count() + 1 : Math.max(0, this.count() - 1); + this.liked.set(newLiked); + this.count.set(newCount); + + this.likesService.toggleLike(this.entityType, this.entityId) + .pipe(take(1)) + .subscribe({ + next: (response) => { + this.liked.set(response.liked); + this.count.set(response.count); + this.loading.set(false); + }, + error: () => { + // Revert on error + this.liked.set(!newLiked); + this.count.set(newLiked ? Math.max(0, newCount - 1) : newCount + 1); + this.loading.set(false); + } + }); + } + + getTitle(): string { + if (!this.isAuthenticated()) { + return 'Sign in to like'; + } + return this.liked() ? 'Unlike' : 'Like'; + } +} \ No newline at end of file diff --git a/apps/frontend/src/app/components/shows/shows-list.component.ts b/apps/frontend/src/app/components/shows/shows-list.component.ts index 094351c..8c11c3b 100644 --- a/apps/frontend/src/app/components/shows/shows-list.component.ts +++ b/apps/frontend/src/app/components/shows/shows-list.component.ts @@ -13,12 +13,13 @@ import { CommentsService } from '../../services/comments.service'; 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 { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, SuggestionEntity, Link } from '@library/shared-types'; @Component({ selector: 'app-shows-list', standalone: true, - imports: [CommonModule, FormsModule, PaginationComponent], + imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent], template: `
@@ -471,6 +472,11 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
} + + @if (show.notes) {

{{ show.notes }}

} diff --git a/apps/frontend/src/app/services/likes.service.ts b/apps/frontend/src/app/services/likes.service.ts new file mode 100644 index 0000000..1adf495 --- /dev/null +++ b/apps/frontend/src/app/services/likes.service.ts @@ -0,0 +1,172 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { Injectable, inject, signal, computed } from '@angular/core'; +import { Observable, map, tap, catchError, of } from 'rxjs'; +import { ApiService } from './api.service'; +import { CreateLikeDto, LikeResponse, LikedItemDto, Like } from '@library/shared-types'; + +interface LikeState { + entityType: Like['entityType']; + entityId: string; + liked: boolean; + count: number; +} + +@Injectable({ + providedIn: 'root' +}) +export class LikesService { + private api = inject(ApiService); + + // Store like states for caching + private likeStates = signal>(new Map()); + + /** + * Toggle like on an item. + */ + toggleLike(entityType: Like['entityType'], entityId: string): Observable { + const dto: CreateLikeDto = { entityType, entityId }; + + return this.api.post('/likes/toggle', dto).pipe( + tap(response => { + const key = this.getCacheKey(entityType, entityId); + this.likeStates.update(states => { + const newStates = new Map(states); + newStates.set(key, { + entityType, + entityId, + liked: response.liked, + count: response.count + }); + return newStates; + }); + }), + catchError(() => { + // On error, return current state or default + const key = this.getCacheKey(entityType, entityId); + const currentState = this.likeStates().get(key); + return of({ + liked: currentState?.liked || false, + count: currentState?.count || 0 + }); + }) + ); + } + + /** + * Get like count for an item. + */ + getLikeCount(entityType: Like['entityType'], entityId: string): Observable { + return this.api.get<{ count: number }>(`/likes/count?entityType=${entityType}&entityId=${entityId}`).pipe( + map(response => response.count), + tap(count => { + const key = this.getCacheKey(entityType, entityId); + this.likeStates.update(states => { + const newStates = new Map(states); + const currentState = newStates.get(key); + newStates.set(key, { + entityType, + entityId, + liked: currentState?.liked || false, + count + }); + return newStates; + }); + }) + ); + } + + /** + * Check if current user has liked an item. + */ + getUserLikeStatus(entityType: Like['entityType'], entityId: string): Observable { + return this.api.get<{ liked: boolean }>(`/likes/status?entityType=${entityType}&entityId=${entityId}`).pipe( + map(response => response.liked), + tap(liked => { + const key = this.getCacheKey(entityType, entityId); + this.likeStates.update(states => { + const newStates = new Map(states); + const currentState = newStates.get(key); + newStates.set(key, { + entityType, + entityId, + liked, + count: currentState?.count || 0 + }); + return newStates; + }); + }) + ); + } + + /** + * Get all items liked by the current user. + */ + getUserLikedItems(entityType?: Like['entityType']): Observable { + const query = entityType ? `?entityType=${entityType}` : ''; + return this.api.get(`/likes/user${query}`); + } + + /** + * Get bulk like statuses for multiple items. + */ + getBulkLikeStatuses(items: Array<{ entityType: string; entityId: string }>): Observable> { + return this.api.post>('/likes/bulk-status', { items }).pipe( + tap(results => { + this.likeStates.update(states => { + const newStates = new Map(states); + results.forEach(result => { + const key = this.getCacheKey(result.entityType as Like['entityType'], result.entityId); + newStates.set(key, { + entityType: result.entityType as Like['entityType'], + entityId: result.entityId, + liked: result.liked, + count: result.count + }); + }); + return newStates; + }); + }) + ); + } + + /** + * Get like state from cache. + */ + getCachedLikeState(entityType: Like['entityType'], entityId: string): LikeState | undefined { + const key = this.getCacheKey(entityType, entityId); + return this.likeStates().get(key); + } + + /** + * Create computed signal for specific item's like state. + */ + createLikeStateSignal(entityType: Like['entityType'], entityId: string) { + const key = this.getCacheKey(entityType, entityId); + return computed(() => { + const state = this.likeStates().get(key); + return { + liked: state?.liked || false, + count: state?.count || 0 + }; + }); + } + + private getCacheKey(entityType: Like['entityType'], entityId: string): string { + return `${entityType}:${entityId}`; + } +} \ No newline at end of file diff --git a/apps/frontend/src/styles.scss b/apps/frontend/src/styles.scss index f110d2b..93304aa 100644 --- a/apps/frontend/src/styles.scss +++ b/apps/frontend/src/styles.scss @@ -19,6 +19,13 @@ --border: var(--witch-plum); --highlight: var(--witch-mauve); + /* Additional variables for components */ + --primary-color: var(--witch-rose); + --border-color: var(--witch-plum); + --hover-background: var(--witch-lavender); + --text-color: var(--witch-purple); + --primary-light-background: rgba(168, 87, 126, 0.1); + font-size: 14pt; line-height: 1.6; } diff --git a/shared-types/src/index.ts b/shared-types/src/index.ts index 2aad423..a906df5 100644 --- a/shared-types/src/index.ts +++ b/shared-types/src/index.ts @@ -13,4 +13,5 @@ export type * from "./lib/auth.types"; export * from "./lib/comment.types"; export * from "./lib/audit.types"; export * from "./lib/suggestion.types"; -export * from "./lib/common.types"; \ No newline at end of file +export * from "./lib/common.types"; +export * from "./lib/like.types"; \ No newline at end of file diff --git a/shared-types/src/lib/audit.types.ts b/shared-types/src/lib/audit.types.ts index a4da961..2222d7a 100644 --- a/shared-types/src/lib/audit.types.ts +++ b/shared-types/src/lib/audit.types.ts @@ -8,6 +8,8 @@ export enum AuditAction { ENTRY_CREATE = "ENTRY_CREATE", ENTRY_UPDATE = "ENTRY_UPDATE", ENTRY_DELETE = "ENTRY_DELETE", + LIKE = "LIKE", + UNLIKE = "UNLIKE", USER_BAN = "USER_BAN", USER_UNBAN = "USER_UNBAN", RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED", diff --git a/shared-types/src/lib/like.types.ts b/shared-types/src/lib/like.types.ts new file mode 100644 index 0000000..b9767b5 --- /dev/null +++ b/shared-types/src/lib/like.types.ts @@ -0,0 +1,32 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +export interface Like { + id: string; + userId: string; + entityType: 'book' | 'game' | 'show' | 'manga' | 'music' | 'art'; + entityId: string; + createdAt: Date; +} + +export type CreateLikeDto = Pick; + +export interface LikeCountDto { + entityId: string; + entityType: string; + count: number; +} + +export interface LikedItemDto { + like: Like; + item: any; // This will be the actual entity (Book, Game, etc.) +} + +// Response types +export interface LikeResponse { + liked: boolean; + count: number; +} \ No newline at end of file