generated from nhcarrigan/template
feat: add ability to like books
This commit is contained in:
@@ -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}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user