generated from nhcarrigan/template
feat: add ability to like books
This commit is contained in:
@@ -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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -12,3 +12,4 @@ 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";
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,3 +14,4 @@ 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";
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user