generated from nhcarrigan/template
feat: add ability to like books
This commit is contained in:
@@ -178,6 +178,7 @@ model User {
|
||||
updatedAt DateTime @updatedAt
|
||||
comments Comment[]
|
||||
suggestions Suggestion[]
|
||||
likes Like[]
|
||||
}
|
||||
|
||||
model Comment {
|
||||
@@ -226,6 +227,8 @@ enum AuditAction {
|
||||
ENTRY_CREATE
|
||||
ENTRY_UPDATE
|
||||
ENTRY_DELETE
|
||||
LIKE
|
||||
UNLIKE
|
||||
USER_BAN
|
||||
USER_UNBAN
|
||||
RATE_LIMIT_EXCEEDED
|
||||
@@ -275,3 +278,14 @@ enum SuggestionStatus {
|
||||
ACCEPTED
|
||||
DECLINED
|
||||
}
|
||||
|
||||
model Like {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
userId String @db.ObjectId
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
entityType String // 'book', 'game', 'show', 'manga', 'music', 'art'
|
||||
entityId String @db.ObjectId
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([userId, entityType, entityId])
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -11,4 +11,5 @@ export { MusicService } from "./music.service";
|
||||
export { ShowService } from "./show.service";
|
||||
export { MangaService } from "./manga.service";
|
||||
export { AuditService } from "./audit.service";
|
||||
export { SuggestionService } from "./suggestion.service";
|
||||
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',
|
||||
loadComponent: () => import('./components/my-suggestions/my-suggestions.component').then(m => m.MySuggestionsComponent)
|
||||
},
|
||||
{
|
||||
path: 'my-likes',
|
||||
loadComponent: () => import('./components/my-likes/my-likes.component').then(m => m.MyLikesComponent)
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
redirectTo: ''
|
||||
|
||||
@@ -13,12 +13,13 @@ import { CommentsService } from '../../services/comments.service';
|
||||
import { SanitizeService } from '../../services/sanitize.service';
|
||||
import { SuggestionService } from '../../services/suggestion.service';
|
||||
import { PaginationComponent } from '../shared/pagination.component';
|
||||
import { LikeButtonComponent } from '../shared/like-button.component';
|
||||
import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-art-gallery',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, PaginationComponent],
|
||||
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
|
||||
template: `
|
||||
<div class="container">
|
||||
<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="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) {
|
||||
<div class="tags-display">
|
||||
@for (tag of art.tags; track tag) {
|
||||
|
||||
@@ -13,12 +13,13 @@ import { CommentsService } from '../../services/comments.service';
|
||||
import { SanitizeService } from '../../services/sanitize.service';
|
||||
import { SuggestionService } from '../../services/suggestion.service';
|
||||
import { PaginationComponent } from '../shared/pagination.component';
|
||||
import { LikeButtonComponent } from '../shared/like-button.component';
|
||||
import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-books-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, PaginationComponent],
|
||||
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="header-section">
|
||||
@@ -516,6 +517,11 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-like-button
|
||||
entityType="book"
|
||||
[entityId]="book.id"
|
||||
></app-like-button>
|
||||
|
||||
@if (book.isbn) {
|
||||
<p class="isbn">ISBN: {{ book.isbn }}</p>
|
||||
}
|
||||
|
||||
@@ -13,12 +13,13 @@ import { CommentsService } from '../../services/comments.service';
|
||||
import { SanitizeService } from '../../services/sanitize.service';
|
||||
import { SuggestionService } from '../../services/suggestion.service';
|
||||
import { PaginationComponent } from '../shared/pagination.component';
|
||||
import { LikeButtonComponent } from '../shared/like-button.component';
|
||||
import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-games-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, PaginationComponent],
|
||||
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="header-section">
|
||||
@@ -476,6 +477,11 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-like-button
|
||||
entityType="game"
|
||||
[entityId]="game.id"
|
||||
></app-like-button>
|
||||
|
||||
@if (game.notes) {
|
||||
<p class="notes">{{ game.notes }}</p>
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import { AuthService } from '../../services/auth.service';
|
||||
@if (!user.isAdmin) {
|
||||
<a routerLink="/my-suggestions" class="user-link">My Suggestions</a>
|
||||
}
|
||||
<a routerLink="/my-likes" class="user-link">My Likes</a>
|
||||
@if (user.isAdmin) {
|
||||
<a routerLink="/admin/users" class="admin-badge">Users</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 { SuggestionService } from '../../services/suggestion.service';
|
||||
import { PaginationComponent } from '../shared/pagination.component';
|
||||
import { LikeButtonComponent } from '../shared/like-button.component';
|
||||
import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-manga-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, PaginationComponent],
|
||||
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="header-section">
|
||||
@@ -477,6 +478,11 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-like-button
|
||||
entityType="manga"
|
||||
[entityId]="manga.id"
|
||||
></app-like-button>
|
||||
|
||||
@if (manga.notes) {
|
||||
<p class="notes">{{ manga.notes }}</p>
|
||||
}
|
||||
|
||||
@@ -13,12 +13,13 @@ import { CommentsService } from '../../services/comments.service';
|
||||
import { SanitizeService } from '../../services/sanitize.service';
|
||||
import { SuggestionService } from '../../services/suggestion.service';
|
||||
import { PaginationComponent } from '../shared/pagination.component';
|
||||
import { LikeButtonComponent } from '../shared/like-button.component';
|
||||
import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-music-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, PaginationComponent],
|
||||
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="header-section">
|
||||
@@ -553,6 +554,11 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-like-button
|
||||
entityType="music"
|
||||
[entityId]="music.id"
|
||||
></app-like-button>
|
||||
|
||||
@if (music.notes) {
|
||||
<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 { SuggestionService } from '../../services/suggestion.service';
|
||||
import { PaginationComponent } from '../shared/pagination.component';
|
||||
import { LikeButtonComponent } from '../shared/like-button.component';
|
||||
import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-shows-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, PaginationComponent],
|
||||
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="header-section">
|
||||
@@ -471,6 +472,11 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-like-button
|
||||
entityType="show"
|
||||
[entityId]="show.id"
|
||||
></app-like-button>
|
||||
|
||||
@if (show.notes) {
|
||||
<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);
|
||||
--highlight: var(--witch-mauve);
|
||||
|
||||
/* Additional variables for components */
|
||||
--primary-color: var(--witch-rose);
|
||||
--border-color: var(--witch-plum);
|
||||
--hover-background: var(--witch-lavender);
|
||||
--text-color: var(--witch-purple);
|
||||
--primary-light-background: rgba(168, 87, 126, 0.1);
|
||||
|
||||
font-size: 14pt;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@@ -13,4 +13,5 @@ export type * from "./lib/auth.types";
|
||||
export * from "./lib/comment.types";
|
||||
export * from "./lib/audit.types";
|
||||
export * from "./lib/suggestion.types";
|
||||
export * from "./lib/common.types";
|
||||
export * from "./lib/common.types";
|
||||
export * from "./lib/like.types";
|
||||
@@ -8,6 +8,8 @@ export enum AuditAction {
|
||||
ENTRY_CREATE = "ENTRY_CREATE",
|
||||
ENTRY_UPDATE = "ENTRY_UPDATE",
|
||||
ENTRY_DELETE = "ENTRY_DELETE",
|
||||
LIKE = "LIKE",
|
||||
UNLIKE = "UNLIKE",
|
||||
USER_BAN = "USER_BAN",
|
||||
USER_UNBAN = "USER_UNBAN",
|
||||
RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED",
|
||||
|
||||
@@ -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