feat: add ability to like books

This commit is contained in:
2026-02-04 21:14:13 -08:00
parent a9764a4a82
commit 729f410443
19 changed files with 1256 additions and 8 deletions
+14
View File
@@ -178,6 +178,7 @@ model User {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
comments Comment[] comments Comment[]
suggestions Suggestion[] suggestions Suggestion[]
likes Like[]
} }
model Comment { model Comment {
@@ -226,6 +227,8 @@ enum AuditAction {
ENTRY_CREATE ENTRY_CREATE
ENTRY_UPDATE ENTRY_UPDATE
ENTRY_DELETE ENTRY_DELETE
LIKE
UNLIKE
USER_BAN USER_BAN
USER_UNBAN USER_UNBAN
RATE_LIMIT_EXCEEDED RATE_LIMIT_EXCEEDED
@@ -275,3 +278,14 @@ enum SuggestionStatus {
ACCEPTED ACCEPTED
DECLINED 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])
}
+170
View File
@@ -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;
+2 -1
View File
@@ -11,4 +11,5 @@ export { MusicService } from "./music.service";
export { ShowService } from "./show.service"; export { ShowService } from "./show.service";
export { MangaService } from "./manga.service"; export { MangaService } from "./manga.service";
export { AuditService } from "./audit.service"; export { AuditService } from "./audit.service";
export { SuggestionService } from "./suggestion.service"; export { SuggestionService } from "./suggestion.service";
export { LikeService } from "./like.service";
+181
View File
@@ -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<LikeResponse> {
// 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<number> {
return await prisma.like.count({
where: {
entityType,
entityId
}
});
}
async getUserLikeStatus(userId: string, entityType: Like['entityType'], entityId: string): Promise<boolean> {
const like = await prisma.like.findUnique({
where: {
userId_entityType_entityId: {
userId,
entityType,
entityId
}
}
});
return !!like;
}
async getLikeCounts(entityType: Like['entityType'], entityIds: string[]): Promise<LikeCountDto[]> {
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<Record<string, boolean>> {
const likes = await prisma.like.findMany({
where: {
userId,
entityType,
entityId: { in: entityIds }
},
select: {
entityId: true
}
});
const likeMap: Record<string, boolean> = {};
entityIds.forEach(id => {
likeMap[id] = false;
});
likes.forEach(like => {
likeMap[like.entityId] = true;
});
return likeMap;
}
async getUserLikedItems(userId: string, entityType?: Like['entityType']): Promise<LikedItemDto[]> {
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;
}
}
+4
View File
@@ -45,6 +45,10 @@ export const appRoutes: Route[] = [
path: 'my-suggestions', path: 'my-suggestions',
loadComponent: () => import('./components/my-suggestions/my-suggestions.component').then(m => m.MySuggestionsComponent) 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: '**', path: '**',
redirectTo: '' redirectTo: ''
@@ -13,12 +13,13 @@ import { CommentsService } from '../../services/comments.service';
import { SanitizeService } from '../../services/sanitize.service'; 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 { 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], imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
template: ` template: `
<div class="container"> <div class="container">
<div class="header-section"> <div class="header-section">
@@ -408,6 +409,11 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
<p class="artist">by {{ art.artist }}</p> <p class="artist">by {{ art.artist }}</p>
<p class="date-added">Added: {{ formatDate(art.dateAdded) }}</p> <p class="date-added">Added: {{ formatDate(art.dateAdded) }}</p>
<app-like-button
entityType="art"
[entityId]="art.id"
></app-like-button>
@if (art.tags && art.tags.length > 0) { @if (art.tags && art.tags.length > 0) {
<div class="tags-display"> <div class="tags-display">
@for (tag of art.tags; track tag) { @for (tag of art.tags; track tag) {
@@ -13,12 +13,13 @@ import { CommentsService } from '../../services/comments.service';
import { SanitizeService } from '../../services/sanitize.service'; 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 { 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], imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
template: ` template: `
<div class="container"> <div class="container">
<div class="header-section"> <div class="header-section">
@@ -516,6 +517,11 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
</div> </div>
} }
<app-like-button
entityType="book"
[entityId]="book.id"
></app-like-button>
@if (book.isbn) { @if (book.isbn) {
<p class="isbn">ISBN: {{ book.isbn }}</p> <p class="isbn">ISBN: {{ book.isbn }}</p>
} }
@@ -13,12 +13,13 @@ import { CommentsService } from '../../services/comments.service';
import { SanitizeService } from '../../services/sanitize.service'; 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 { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEntity, Link } from '@library/shared-types'; import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
@Component({ @Component({
selector: 'app-games-list', selector: 'app-games-list',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, PaginationComponent], imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
template: ` template: `
<div class="container"> <div class="container">
<div class="header-section"> <div class="header-section">
@@ -476,6 +477,11 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
</div> </div>
} }
<app-like-button
entityType="game"
[entityId]="game.id"
></app-like-button>
@if (game.notes) { @if (game.notes) {
<p class="notes">{{ game.notes }}</p> <p class="notes">{{ game.notes }}</p>
} }
@@ -35,6 +35,7 @@ import { AuthService } from '../../services/auth.service';
@if (!user.isAdmin) { @if (!user.isAdmin) {
<a routerLink="/my-suggestions" class="user-link">My Suggestions</a> <a routerLink="/my-suggestions" class="user-link">My Suggestions</a>
} }
<a routerLink="/my-likes" class="user-link">My Likes</a>
@if (user.isAdmin) { @if (user.isAdmin) {
<a routerLink="/admin/users" class="admin-badge">Users</a> <a routerLink="/admin/users" class="admin-badge">Users</a>
<a routerLink="/admin/audit" class="admin-badge">Audit</a> <a routerLink="/admin/audit" class="admin-badge">Audit</a>
@@ -13,12 +13,13 @@ import { CommentsService } from '../../services/comments.service';
import { SanitizeService } from '../../services/sanitize.service'; 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 { 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], imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
template: ` template: `
<div class="container"> <div class="container">
<div class="header-section"> <div class="header-section">
@@ -477,6 +478,11 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
</div> </div>
} }
<app-like-button
entityType="manga"
[entityId]="manga.id"
></app-like-button>
@if (manga.notes) { @if (manga.notes) {
<p class="notes">{{ manga.notes }}</p> <p class="notes">{{ manga.notes }}</p>
} }
@@ -13,12 +13,13 @@ import { CommentsService } from '../../services/comments.service';
import { SanitizeService } from '../../services/sanitize.service'; 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 { 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], imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
template: ` template: `
<div class="container"> <div class="container">
<div class="header-section"> <div class="header-section">
@@ -553,6 +554,11 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
</div> </div>
} }
<app-like-button
entityType="music"
[entityId]="music.id"
></app-like-button>
@if (music.notes) { @if (music.notes) {
<p class="notes">{{ music.notes }}</p> <p class="notes">{{ music.notes }}</p>
} }
@@ -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: `
<div class="container">
<div class="header-section">
<h2>My Likes</h2>
<p class="subtitle">All the items you've liked across the library</p>
</div>
@if (!authService.isAuthenticated()) {
<div class="not-authenticated">
<p>Please log in to view your liked items.</p>
</div>
} @else if (loading()) {
<div class="loading">Loading your liked items...</div>
} @else if (likedItems().length === 0) {
<div class="empty-state">
<p>You haven't liked any items yet!</p>
<p class="hint">Explore the library and click the heart button on items you enjoy.</p>
</div>
} @else {
<div class="filters">
<button
(click)="setFilter('all')"
[class.active]="typeFilter() === 'all'"
class="filter-btn"
>
All ({{ likedItems().length }})
</button>
<button
(click)="setFilter('book')"
[class.active]="typeFilter() === 'book'"
class="filter-btn"
>
Books ({{ bookCount() }})
</button>
<button
(click)="setFilter('game')"
[class.active]="typeFilter() === 'game'"
class="filter-btn"
>
Games ({{ gameCount() }})
</button>
<button
(click)="setFilter('show')"
[class.active]="typeFilter() === 'show'"
class="filter-btn"
>
Shows ({{ showCount() }})
</button>
<button
(click)="setFilter('manga')"
[class.active]="typeFilter() === 'manga'"
class="filter-btn"
>
Manga ({{ mangaCount() }})
</button>
<button
(click)="setFilter('music')"
[class.active]="typeFilter() === 'music'"
class="filter-btn"
>
Music ({{ musicCount() }})
</button>
<button
(click)="setFilter('art')"
[class.active]="typeFilter() === 'art'"
class="filter-btn"
>
Art ({{ artCount() }})
</button>
</div>
<app-pagination
[currentPage]="currentPage()"
[pageSize]="pageSize()"
[totalItems]="totalFilteredItems()"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
></app-pagination>
<div class="liked-items-grid">
@for (likedItem of paginatedItems(); track likedItem.like.id) {
<div class="liked-item-card">
<div class="item-type-badge">{{ getTypeBadgeLabel(likedItem.like.entityType) }}</div>
@if (getItemImage(likedItem)) {
<img
[src]="getItemImage(likedItem)"
[alt]="getItemTitle(likedItem)"
class="item-image"
>
} @else {
<div class="item-image placeholder">{{ getTypePlaceholderEmoji(likedItem.like.entityType) }}</div>
}
<div class="item-info">
<h3>{{ getItemTitle(likedItem) }}</h3>
<p class="item-subtitle">{{ getItemSubtitle(likedItem) }}</p>
<p class="liked-date">Liked: {{ formatDate(likedItem.like.createdAt) }}</p>
<app-like-button
[entityType]="likedItem.like.entityType"
[entityId]="likedItem.like.entityId"
></app-like-button>
<a
[routerLink]="getItemLink(likedItem)"
class="view-link"
>
View Details →
</a>
</div>
</div>
}
</div>
<app-pagination
[currentPage]="currentPage()"
[pageSize]="pageSize()"
[totalItems]="totalFilteredItems()"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
></app-pagination>
}
</div>
`,
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<LikedItemDto[]>([]);
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'
});
}
}
@@ -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: `
<div class="like-container">
<button
type="button"
class="like-button"
[class.liked]="liked()"
[disabled]="loading() || !isAuthenticated()"
(click)="toggleLike()"
[title]="getTitle()"
>
<span class="heart-icon">{{ liked() ? '❤️' : '🤍' }}</span>
<span class="like-count">{{ count() }}</span>
</button>
</div>
`,
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';
}
}
@@ -13,12 +13,13 @@ import { CommentsService } from '../../services/comments.service';
import { SanitizeService } from '../../services/sanitize.service'; 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 { 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], imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
template: ` template: `
<div class="container"> <div class="container">
<div class="header-section"> <div class="header-section">
@@ -471,6 +472,11 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
</div> </div>
} }
<app-like-button
entityType="show"
[entityId]="show.id"
></app-like-button>
@if (show.notes) { @if (show.notes) {
<p class="notes">{{ show.notes }}</p> <p class="notes">{{ show.notes }}</p>
} }
@@ -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<Map<string, LikeState>>(new Map());
/**
* Toggle like on an item.
*/
toggleLike(entityType: Like['entityType'], entityId: string): Observable<LikeResponse> {
const dto: CreateLikeDto = { entityType, entityId };
return this.api.post<LikeResponse>('/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<number> {
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<boolean> {
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<LikedItemDto[]> {
const query = entityType ? `?entityType=${entityType}` : '';
return this.api.get<LikedItemDto[]>(`/likes/user${query}`);
}
/**
* Get bulk like statuses for multiple items.
*/
getBulkLikeStatuses(items: Array<{ entityType: string; entityId: string }>): Observable<Array<{
entityType: string;
entityId: string;
liked: boolean;
count: number;
}>> {
return this.api.post<Array<{
entityType: string;
entityId: string;
liked: boolean;
count: number;
}>>('/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}`;
}
}
+7
View File
@@ -19,6 +19,13 @@
--border: var(--witch-plum); --border: var(--witch-plum);
--highlight: var(--witch-mauve); --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; font-size: 14pt;
line-height: 1.6; line-height: 1.6;
} }
+2 -1
View File
@@ -13,4 +13,5 @@ export type * from "./lib/auth.types";
export * from "./lib/comment.types"; export * from "./lib/comment.types";
export * from "./lib/audit.types"; export * from "./lib/audit.types";
export * from "./lib/suggestion.types"; export * from "./lib/suggestion.types";
export * from "./lib/common.types"; export * from "./lib/common.types";
export * from "./lib/like.types";
+2
View File
@@ -8,6 +8,8 @@ export enum AuditAction {
ENTRY_CREATE = "ENTRY_CREATE", ENTRY_CREATE = "ENTRY_CREATE",
ENTRY_UPDATE = "ENTRY_UPDATE", ENTRY_UPDATE = "ENTRY_UPDATE",
ENTRY_DELETE = "ENTRY_DELETE", ENTRY_DELETE = "ENTRY_DELETE",
LIKE = "LIKE",
UNLIKE = "UNLIKE",
USER_BAN = "USER_BAN", USER_BAN = "USER_BAN",
USER_UNBAN = "USER_UNBAN", USER_UNBAN = "USER_UNBAN",
RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED", RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED",
+32
View File
@@ -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<Like, 'entityType' | 'entityId'>;
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;
}