feat: add ability to like books

This commit is contained in:
2026-02-04 21:14:13 -08:00
parent a9764a4a82
commit 729f410443
19 changed files with 1256 additions and 8 deletions
@@ -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';
}
}