Files
library/apps/frontend/src/app/components/manga/manga-list.component.ts
T
hikari 5ad9b50dc8 feat: apply time tracking UI to Books, Music, Shows, and Manga
Completes time tracking implementation across all media types by
applying the same pattern established in Games component.

Changes Applied to Books, Music, Shows, and Manga:

UI Components:
- Added time tracking state properties (hours/minutes for new and edit)
- Integrated hour/minute input fields in Add forms (after rating)
- Integrated hour/minute input fields in Edit forms (after edit-rating)
- Added time spent display on media cards with appropriate emojis:
  * Books: 📖 Reading Time
  * Music: 🎵 Listening Time
  * Shows: 📺 Watch Time
  * Manga: 📚 Reading Time

Form Management:
- Updated resetForm() to clear time tracking fields
- Added updateNew[Type]TimeSpent() conversion methods
- Added updateEdit[Type]TimeSpent() conversion methods
- Updated startEdit() to populate time fields from stored data

Helper Methods:
- Added formatTimeSpent() to format minutes as "Xh Ym", "Xh", or "Ym"
- Converts hours/minutes to total minutes for storage
- Splits total minutes back to hours/minutes for editing

Styling:
- Added .form-row CSS for side-by-side hour/minute inputs
- Added .time-spent CSS with coloured display:
  * Books: Green (#10b981)
  * Music: Purple (#8b5cf6)
  * Shows: Purple (#8b5cf6)
  * Manga: Green (#10b981)

All implementations follow the exact same pattern for consistency
and maintainability across the application.
2026-02-19 23:55:16 -08:00

1873 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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 { FormsModule } from '@angular/forms';
import { MangaService } from '../../services/manga.service';
import { AuthService } from '../../services/auth.service';
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 { CommentDisplayComponent } from '../comment-display/comment-display.component';
import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
@Component({
selector: 'app-manga-list',
standalone: true,
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent],
template: `
<div class="container">
<div class="header-section">
<h2>My Manga Collection</h2>
@if (authService.isAdmin()) {
<button (click)="toggleAddForm()" class="btn btn-primary">
{{ showAddForm() ? 'Cancel' : 'Add Manga' }}
</button>
} @else if (authService.isAuthenticated() && !authService.user()?.isBanned) {
<button (click)="toggleSuggestForm()" class="btn btn-primary">
{{ showSuggestForm() ? 'Cancel' : 'Suggest a Manga' }}
</button>
}
</div>
@if (showAddForm() && authService.isAdmin()) {
<form (ngSubmit)="addManga()" class="add-form">
<h3>Add New Manga</h3>
<div class="form-group">
<label for="title">Title</label>
<input
type="text"
id="title"
[(ngModel)]="newManga.title"
name="title"
required
placeholder="Enter manga title"
>
</div>
<div class="form-group">
<label for="author">Author/Artist</label>
<input
type="text"
id="author"
[(ngModel)]="newManga.author"
name="author"
required
placeholder="Enter author or artist name"
>
</div>
<div class="form-group">
<label for="status">Status</label>
<select id="status" [(ngModel)]="newManga.status" name="status" required>
<option [value]="MangaStatus.reading">Currently Reading</option>
<option [value]="MangaStatus.completed">Completed</option>
<option [value]="MangaStatus.wantToRead">Want to Read</option>
<option [value]="MangaStatus.retired">Retired</option>
</select>
</div>
<div class="form-group">
<label for="dateStarted">Date Started</label>
<input
type="date"
id="dateStarted"
[(ngModel)]="newManga.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="dateFinished">Date Finished</label>
<input
type="date"
id="dateFinished"
[(ngModel)]="newManga.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group">
<label for="rating">Rating (1-10)</label>
<input
type="number"
id="rating"
[(ngModel)]="newManga.rating"
name="rating"
min="1"
max="10"
>
</div>
<div class="form-row">
<div class="form-group">
<label for="timeHours">Time Spent (Hours)</label>
<input
type="number"
id="timeHours"
[(ngModel)]="newMangaTimeHours"
name="timeHours"
min="0"
placeholder="0"
(ngModelChange)="updateNewMangaTimeSpent()"
>
</div>
<div class="form-group">
<label for="timeMinutes">Time Spent (Minutes)</label>
<input
type="number"
id="timeMinutes"
[(ngModel)]="newMangaTimeMinutes"
name="timeMinutes"
min="0"
max="59"
placeholder="0"
(ngModelChange)="updateNewMangaTimeSpent()"
>
</div>
</div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea
id="notes"
[(ngModel)]="newManga.notes"
name="notes"
rows="3"
placeholder="Any thoughts about the manga..."
></textarea>
</div>
<div class="form-group">
<label for="coverImage">Cover Art (max 500KB)</label>
<input
type="file"
id="coverImage"
name="coverImage"
accept="image/*"
(change)="onImageSelected($event, 'new')"
>
@if (newMangaImagePreview()) {
<div class="image-preview">
<img [src]="newMangaImagePreview()" alt="Cover preview">
<button type="button" (click)="clearImage('new')" class="btn btn-danger btn-sm">Remove</button>
</div>
}
@if (imageError()) {
<span class="error-text">{{ imageError() }}</span>
}
</div>
<div class="form-group">
<div class="tags-input-container" aria-label="Tags">
@for (tag of newManga.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
<button type="button" (click)="removeTag(i, 'new')" class="tag-remove">×</button>
</span>
}
<input
type="text"
[(ngModel)]="newTagInput"
name="newTagInput"
placeholder="Add a tag and press Enter"
(keydown.enter)="addTag('new'); $event.preventDefault()"
>
</div>
</div>
<div class="form-group" aria-label="External Links">
<div class="links-list">
@for (link of newManga.links; track link.url; let i = $index) {
<div class="link-item">
<span>{{ link.title }}: {{ link.url }}</span>
<button type="button" (click)="removeLink(i, 'new')" class="btn btn-danger btn-sm">×</button>
</div>
}
</div>
<div class="link-add-form">
<input
type="text"
[(ngModel)]="newLinkTitle"
name="newLinkTitle"
placeholder="Link title (e.g., MyAnimeList)"
>
<input
type="url"
[(ngModel)]="newLinkUrl"
name="newLinkUrl"
placeholder="https://..."
>
<button type="button" (click)="addLink('new')" class="btn btn-secondary btn-sm">Add Link</button>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Add Manga</button>
<button type="button" (click)="toggleAddForm()" class="btn btn-secondary">Cancel</button>
</div>
</form>
}
@if (editingManga() && authService.isAdmin()) {
<form (ngSubmit)="saveEdit()" class="add-form">
<h3>Edit Manga</h3>
<div class="form-group">
<label for="edit-title">Title</label>
<input
type="text"
id="edit-title"
[(ngModel)]="editManga.title"
name="title"
required
placeholder="Enter manga title"
>
</div>
<div class="form-group">
<label for="edit-author">Author/Artist</label>
<input
type="text"
id="edit-author"
[(ngModel)]="editManga.author"
name="author"
required
placeholder="Enter author or artist name"
>
</div>
<div class="form-group">
<label for="edit-status">Status</label>
<select id="edit-status" [(ngModel)]="editManga.status" name="status" required>
<option [value]="MangaStatus.reading">Currently Reading</option>
<option [value]="MangaStatus.completed">Completed</option>
<option [value]="MangaStatus.wantToRead">Want to Read</option>
<option [value]="MangaStatus.retired">Retired</option>
</select>
</div>
<div class="form-group">
<label for="edit-dateStarted">Date Started</label>
<input
type="date"
id="edit-dateStarted"
[(ngModel)]="editManga.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="edit-dateFinished">Date Finished</label>
<input
type="date"
id="edit-dateFinished"
[(ngModel)]="editManga.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group">
<label for="edit-rating">Rating (1-10)</label>
<input
type="number"
id="edit-rating"
[(ngModel)]="editManga.rating"
name="rating"
min="1"
max="10"
>
</div>
<div class="form-row">
<div class="form-group">
<label for="edit-timeHours">Time Spent (Hours)</label>
<input
type="number"
id="edit-timeHours"
[(ngModel)]="editMangaTimeHours"
name="timeHours"
min="0"
placeholder="0"
(ngModelChange)="updateEditMangaTimeSpent()"
>
</div>
<div class="form-group">
<label for="edit-timeMinutes">Time Spent (Minutes)</label>
<input
type="number"
id="edit-timeMinutes"
[(ngModel)]="editMangaTimeMinutes"
name="timeMinutes"
min="0"
max="59"
placeholder="0"
(ngModelChange)="updateEditMangaTimeSpent()"
>
</div>
</div>
<div class="form-group">
<label for="edit-notes">Notes</label>
<textarea
id="edit-notes"
[(ngModel)]="editManga.notes"
name="notes"
rows="3"
placeholder="Any thoughts about the manga..."
></textarea>
</div>
<div class="form-group">
<label for="edit-coverImage">Cover Art (max 500KB)</label>
<input
type="file"
id="edit-coverImage"
name="coverImage"
accept="image/*"
(change)="onImageSelected($event, 'edit')"
>
@if (editMangaImagePreview()) {
<div class="image-preview">
<img [src]="editMangaImagePreview()" alt="Cover preview">
<button type="button" (click)="clearImage('edit')" class="btn btn-danger btn-sm">Remove</button>
</div>
}
@if (imageError()) {
<span class="error-text">{{ imageError() }}</span>
}
</div>
<div class="form-group">
<div class="tags-input-container" aria-label="Tags">
@for (tag of editManga.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
<button type="button" (click)="removeTag(i, 'edit')" class="tag-remove">×</button>
</span>
}
<input
type="text"
[(ngModel)]="editTagInput"
name="editTagInput"
placeholder="Add a tag and press Enter"
(keydown.enter)="addTag('edit'); $event.preventDefault()"
>
</div>
</div>
<div class="form-group" aria-label="External Links">
<div class="links-list">
@for (link of editManga.links; track link.url; let i = $index) {
<div class="link-item">
<span>{{ link.title }}: {{ link.url }}</span>
<button type="button" (click)="removeLink(i, 'edit')" class="btn btn-danger btn-sm">×</button>
</div>
}
</div>
<div class="link-add-form">
<input
type="text"
[(ngModel)]="editLinkTitle"
name="editLinkTitle"
placeholder="Link title (e.g., MyAnimeList)"
>
<input
type="url"
[(ngModel)]="editLinkUrl"
name="editLinkUrl"
placeholder="https://..."
>
<button type="button" (click)="addLink('edit')" class="btn btn-secondary btn-sm">Add Link</button>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Save Changes</button>
<button type="button" (click)="cancelEdit()" class="btn btn-secondary">Cancel</button>
</div>
</form>
}
@if (showSuggestForm() && !authService.isAdmin() && authService.isAuthenticated()) {
<form (ngSubmit)="submitSuggestion()" class="add-form suggest-form">
<h3>Suggest a Manga</h3>
<p class="suggest-note">Your suggestion will be reviewed by Naomi. If accepted, it will be added to the reading list!</p>
<div class="form-group">
<label for="suggest-title">Title</label>
<input
type="text"
id="suggest-title"
[(ngModel)]="suggestedManga.title"
name="title"
required
placeholder="Enter manga title"
>
</div>
<div class="form-group">
<label for="suggest-author">Author/Artist</label>
<input
type="text"
id="suggest-author"
[(ngModel)]="suggestedManga.author"
name="author"
required
placeholder="Enter author or artist name"
>
</div>
<div class="form-group">
<label for="suggest-notes">Notes (why should Naomi read this?)</label>
<textarea
id="suggest-notes"
[(ngModel)]="suggestedManga.notes"
name="notes"
rows="3"
placeholder="Tell Naomi why this manga is worth reading..."
></textarea>
</div>
<div class="form-group">
<label for="suggest-coverImage">Cover Art (max 500KB)</label>
<input
type="file"
id="suggest-coverImage"
name="coverImage"
accept="image/*"
(change)="onImageSelected($event, 'suggest')"
>
@if (suggestMangaImagePreview()) {
<div class="image-preview">
<img [src]="suggestMangaImagePreview()" alt="Cover preview">
<button type="button" (click)="clearImage('suggest')" class="btn btn-danger btn-sm">Remove</button>
</div>
}
@if (imageError()) {
<span class="error-text">{{ imageError() }}</span>
}
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Submit Suggestion</button>
<button type="button" (click)="toggleSuggestForm()" class="btn btn-secondary">Cancel</button>
</div>
</form>
}
<div class="search-section">
<input
type="text"
[value]="searchQuery()"
(input)="searchQuery.set($any($event.target).value); currentPage.set(1)"
name="search"
placeholder="Search by title, author, or notes..."
class="search-input"
>
<button (click)="toggleFilters()" class="btn btn-secondary btn-sm">
{{ showFilters() ? 'Hide' : 'Show' }} Advanced Filters
@if (selectedTags().length > 0) {
({{ selectedTags().length }})
}
</button>
@if (searchQuery() || selectedTags().length > 0) {
<button (click)="clearFilters()" class="btn btn-secondary btn-sm">
Clear All Filters
</button>
}
</div>
@if (showFilters()) {
<div class="advanced-filters">
<div class="filter-group">
<h4>Filter by Tags</h4>
<div class="tags-filter">
@for (tag of allTags(); track tag) {
<label class="tag-checkbox">
<input
type="checkbox"
[checked]="selectedTags().includes(tag)"
(change)="toggleTag(tag)"
>
<span>{{ tag }}</span>
</label>
}
@empty {
<p class="no-tags">No tags available</p>
}
</div>
</div>
</div>
}
<div class="filters">
<button
(click)="setFilter('all')"
[class.active]="statusFilter() === 'all'"
class="filter-btn"
>
All ({{ mangaList().length }})
</button>
<button
(click)="setFilter(MangaStatus.reading)"
[class.active]="statusFilter() === MangaStatus.reading"
class="filter-btn"
>
Reading ({{ readingCount() }})
</button>
<button
(click)="setFilter(MangaStatus.completed)"
[class.active]="statusFilter() === MangaStatus.completed"
class="filter-btn"
>
Completed ({{ completedCount() }})
</button>
<button
(click)="setFilter(MangaStatus.wantToRead)"
[class.active]="statusFilter() === MangaStatus.wantToRead"
class="filter-btn"
>
Want to Read ({{ wantToReadCount() }})
</button>
<button
(click)="setFilter(MangaStatus.retired)"
[class.active]="statusFilter() === MangaStatus.retired"
class="filter-btn"
>
Retired ({{ retiredCount() }})
</button>
</div>
@if (loading()) {
<div class="loading">Loading manga...</div>
} @else if (filteredManga().length === 0) {
<div class="empty-state">
<p>No manga found in this category.</p>
</div>
} @else {
<app-pagination
[currentPage]="currentPage()"
[pageSize]="pageSize()"
[totalItems]="totalFilteredManga()"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
></app-pagination>
<div class="manga-grid">
@for (manga of paginatedManga(); track manga.id) {
<div class="manga-card" [class.completed]="manga.status === MangaStatus.completed">
@if (manga.coverImage) {
<img [src]="manga.coverImage" [alt]="manga.title" class="manga-cover">
}
<div class="manga-info">
<h3>{{ manga.title }}</h3>
<p class="author">by {{ manga.author }}</p>
<span class="status status-{{ manga.status }}">
{{ getStatusLabel(manga.status) }}
</span>
@if (manga.rating) {
<div class="rating">
@for (star of [1,2,3,4,5,6,7,8,9,10]; track star) {
<span [class.filled]="star <= manga.rating!">★</span>
}
</div>
}
@if (manga.timeSpent) {
<p class="time-spent">
📚 Reading Time: {{ formatTimeSpent(manga.timeSpent) }}
</p>
}
<app-like-button
entityType="manga"
[entityId]="manga.id"
></app-like-button>
@if (manga.notes) {
<p class="notes">{{ manga.notes }}</p>
}
@if (manga.tags && manga.tags.length > 0) {
<div class="tags-display">
@for (tag of manga.tags; track tag) {
<span class="tag-chip">{{ tag }}</span>
}
</div>
}
@if (manga.links && manga.links.length > 0) {
<div class="links-display">
@for (link of manga.links; track link.url) {
<a [href]="link.url" target="_blank" rel="noopener noreferrer" class="external-link">
{{ link.title }} ↗
</a>
}
</div>
}
@if (manga.dateStarted) {
<p class="date-started">
Started: {{ formatDate(manga.dateStarted) }}
</p>
}
@if (manga.dateFinished) {
<p class="date-finished">
Finished: {{ formatDate(manga.dateFinished) }}
</p>
}
@if (manga.createdAt) {
<p class="date-added">
Added: {{ formatDate(manga.createdAt) }}
</p>
}
@if (manga.updatedAt) {
<p class="date-updated">
Updated: {{ formatDate(manga.updatedAt) }}
</p>
}
@if (authService.isAdmin()) {
<div class="actions">
<button (click)="startEdit(manga)" class="btn btn-secondary btn-sm">
Edit
</button>
<button (click)="deleteManga(manga)" class="btn btn-danger btn-sm">
Delete
</button>
</div>
}
<div class="comments-section">
<button (click)="toggleComments(manga.id)" class="btn btn-secondary btn-sm comments-toggle">
{{ expandedComments()[manga.id] ? 'Hide' : 'Show' }} Comments{{ comments()[manga.id] ? ' (' + getCommentCount(manga.id) + ')' : '' }}
</button>
@if (expandedComments()[manga.id]) {
<div class="comments-container">
@if (authService.isAuthenticated()) {
@if (authService.user()?.isBanned) {
<div class="banned-notice">
You have been banned from commenting.
</div>
} @else {
<form (ngSubmit)="addComment(manga.id)" class="comment-form">
<textarea
[(ngModel)]="newCommentContent[manga.id]"
name="comment"
placeholder="Add a comment (Markdown supported)..."
rows="2"
></textarea>
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
</form>
}
}
<app-comment-display
[comments]="getCommentsSignal(manga.id)"
(edit)="handleCommentEdit(manga.id, $event)"
(delete)="deleteComment(manga.id, $event)"
/>
</div>
}
</div>
</div>
</div>
}
</div>
<app-pagination
[currentPage]="currentPage()"
[pageSize]="pageSize()"
[totalItems]="totalFilteredManga()"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
></app-pagination>
}
</div>
`,
styles: [`
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.header-section {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
h2 {
margin: 0;
}
.add-form {
background: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.suggest-form {
border: 2px solid #00b894;
background: #f5fffc;
}
.suggest-note {
color: #666;
font-size: 0.9rem;
margin-bottom: 1rem;
font-style: italic;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
.search-section {
display: flex;
gap: 1rem;
align-items: center;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.search-input {
flex: 1;
min-width: 250px;
padding: 0.5rem 1rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.search-input:focus {
outline: none;
border-color: #00b894;
}
.advanced-filters {
background: #f8f9fa;
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
}
.filter-group h4 {
margin: 0 0 0.75rem 0;
color: #374151;
font-size: 0.95rem;
font-weight: 600;
}
.tags-filter {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.tag-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
padding: 0.25rem 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 20px;
background: white;
transition: all 0.2s;
}
.tag-checkbox:hover {
border-color: #00b894;
background: #f5fffc;
}
.tag-checkbox input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
.tag-checkbox span {
font-size: 0.875rem;
color: #374151;
}
.no-tags {
color: #6b7280;
font-style: italic;
font-size: 0.875rem;
}
.filters {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.filter-btn {
padding: 0.5rem 1rem;
background: #e5e7eb;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
}
.filter-btn:hover {
background: #d1d5db;
}
.filter-btn.active {
background: #00b894;
color: white;
}
.loading {
text-align: center;
padding: 2rem;
color: #666;
}
.empty-state {
text-align: center;
padding: 3rem;
color: #666;
}
.manga-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
.manga-card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
transition: transform 0.3s, box-shadow 0.3s;
}
.manga-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.manga-card.completed {
opacity: 0.8;
}
.manga-cover {
width: 100%;
height: 200px;
object-fit: cover;
}
.manga-info {
padding: 1rem;
}
.manga-info h3 {
margin: 0 0 0.5rem 0;
font-size: 1.1rem;
}
.author {
color: #666;
font-size: 0.9rem;
margin: 0.5rem 0;
font-style: italic;
}
.status {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 500;
}
.status-READING { background: #fef3c7; color: #92400e; }
.status-COMPLETED { background: #d1fae5; color: #065f46; }
.status-WANT_TO_READ { background: #fce7f3; color: #9d174d; }
.rating {
margin: 0.5rem 0;
}
.rating span {
color: #e5e7eb;
font-size: 1rem;
}
.rating span.filled {
color: #ec4899;
}
.notes {
font-size: 0.9rem;
color: #4b5563;
margin: 0.5rem 0;
}
.time-spent {
font-size: 0.9rem;
color: #10b981;
font-weight: 500;
margin: 0.5rem 0;
}
.date-started,
.date-finished,
.date-added,
.date-updated {
font-size: 0.85rem;
color: #4b5563;
margin-top: 0.5rem;
}
.actions {
margin-top: 1rem;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: opacity 0.3s;
}
.btn:hover {
opacity: 0.8;
}
.btn-primary { background: #00b894; color: white; }
.btn-secondary { background: #6b7280; color: white; }
.btn-danger { background: #ef4444; color: white; }
.btn-sm { padding: 0.25rem 0.75rem; font-size: 0.85rem; }
.btn-xs { padding: 0.15rem 0.5rem; font-size: 0.75rem; }
.comments-section {
margin-top: 1rem;
border-top: 1px solid #e5e7eb;
padding-top: 1rem;
}
.comments-toggle {
width: 100%;
}
.comments-container {
margin-top: 1rem;
}
.comment-form {
margin-bottom: 1rem;
}
.comment-form textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
resize: vertical;
margin-bottom: 0.5rem;
}
.comment {
background: #f8f9fa;
border: 1px solid #e5e7eb;
border-radius: 4px;
padding: 0.75rem;
margin-bottom: 0.5rem;
}
.comment-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.comment-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
}
.comment-author {
font-weight: 500;
color: #374151;
}
.discord-badge {
background: #5865f2;
color: white;
padding: 0.1rem 0.4rem;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 500;
}
.vip-badge {
background: linear-gradient(135deg, #ffd700, #ffaa00);
color: #1a1a1a;
padding: 0.1rem 0.4rem;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 600;
}
.mod-badge {
background: linear-gradient(135deg, #00b894, #00cec9);
color: white;
padding: 0.1rem 0.4rem;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 600;
}
.staff-badge {
background: linear-gradient(135deg, #e84393, #fd79a8);
color: white;
padding: 0.1rem 0.4rem;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 600;
}
.comment-date {
font-size: 0.75rem;
color: #6b7280;
}
.comment-content {
font-size: 0.9rem;
color: #4b5563;
}
.comments-loading,
.no-comments {
text-align: center;
padding: 1rem;
color: #6b7280;
font-size: 0.9rem;
}
.banned-notice {
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
padding: 0.75rem 1rem;
border-radius: 4px;
text-align: center;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.comment-edit-form {
margin-top: 0.5rem;
}
.comment-edit-form textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
resize: vertical;
}
.comment-edit-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.image-preview {
margin-top: 0.5rem;
display: flex;
align-items: center;
gap: 1rem;
}
.image-preview img {
max-width: 100px;
max-height: 150px;
border-radius: 4px;
border: 2px solid #e5e7eb;
}
.error-text {
color: #ef4444;
font-size: 0.875rem;
display: block;
margin-top: 0.25rem;
}
input[type="file"] {
padding: 0.5rem;
border: 2px dashed #e5e7eb;
border-radius: 4px;
background: #f9fafb;
cursor: pointer;
}
input[type="file"]:hover {
border-color: #ec4899;
}
.tags-input-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
background: #f9fafb;
}
.tags-input-container input {
flex: 1;
min-width: 150px;
border: none;
padding: 0.25rem;
font-size: 0.9rem;
background: transparent;
}
.tags-input-container input:focus {
outline: none;
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: #00b894;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.85rem;
}
.tag-remove {
background: none;
border: none;
color: white;
cursor: pointer;
padding: 0;
font-size: 1rem;
line-height: 1;
}
.tag-remove:hover {
opacity: 0.8;
}
.tags-display {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0.5rem 0;
}
.tag-chip {
background: rgba(0, 184, 148, 0.2);
color: #00b894;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
}
.links-list {
margin-bottom: 0.5rem;
}
.link-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
background: #f9fafb;
border-radius: 4px;
margin-bottom: 0.25rem;
}
.link-add-form {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.link-add-form input {
flex: 1;
min-width: 120px;
}
.links-display {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0.5rem 0;
}
.external-link {
color: #00b894;
text-decoration: none;
font-size: 0.85rem;
padding: 0.2rem 0.5rem;
background: rgba(0, 184, 148, 0.1);
border-radius: 4px;
transition: background 0.2s;
}
.external-link:hover {
background: rgba(0, 184, 148, 0.2);
text-decoration: underline;
}
`]
})
export class MangaListComponent implements OnInit {
mangaService = inject(MangaService);
authService = inject(AuthService);
commentsService = inject(CommentsService);
sanitizeService = inject(SanitizeService);
suggestionService = inject(SuggestionService);
mangaList = signal<Manga[]>([]);
loading = signal(true);
showAddForm = signal(false);
editingManga = signal<Manga | null>(null);
statusFilter = signal<'all' | MangaStatus>('all');
// Pagination state
currentPage = signal(1);
pageSize = signal(25);
// Search and filter state
searchQuery = signal('');
selectedTags = signal<string[]>([]);
showFilters = signal(false);
comments = signal<Record<string, Comment[]>>({});
commentsLoading = signal<Record<string, boolean>>({});
expandedComments = signal<Record<string, boolean>>({});
newCommentContent: Record<string, string> = {};
editingCommentId = signal<string | null>(null);
editCommentContent = '';
newMangaImagePreview = signal<string | null>(null);
editMangaImagePreview = signal<string | null>(null);
suggestMangaImagePreview = signal<string | null>(null);
imageError = signal<string | null>(null);
private readonly MAX_IMAGE_SIZE = 500 * 1024;
// Suggestion state
showSuggestForm = signal(false);
suggestedManga: { title: string; author: string; notes?: string; coverImage?: string } = {
title: '',
author: '',
notes: '',
coverImage: undefined
};
MangaStatus = MangaStatus;
readingCount = computed(() => this.mangaList().filter(m => m.status === MangaStatus.reading).length);
completedCount = computed(() => this.mangaList().filter(m => m.status === MangaStatus.completed).length);
wantToReadCount = computed(() => this.mangaList().filter(m => m.status === MangaStatus.wantToRead).length);
retiredCount = computed(() => this.mangaList().filter(m => m.status === MangaStatus.retired).length);
allTags = computed(() => {
const tagsSet = new Set<string>();
this.mangaList().forEach(manga => {
manga.tags?.forEach(tag => tagsSet.add(tag));
});
return Array.from(tagsSet).sort();
});
filteredManga = computed(() => {
let manga = this.mangaList();
// Apply status filter
const statusFilter = this.statusFilter();
if (statusFilter !== 'all') {
manga = manga.filter(m => m.status === statusFilter);
}
// Apply search filter
const searchQuery = this.searchQuery().toLowerCase().trim();
if (searchQuery) {
manga = manga.filter(m =>
m.title.toLowerCase().includes(searchQuery) ||
m.author.toLowerCase().includes(searchQuery) ||
m.notes?.toLowerCase().includes(searchQuery)
);
}
// Apply tag filter
const selectedTags = this.selectedTags();
if (selectedTags.length > 0) {
manga = manga.filter(m =>
selectedTags.every(tag => m.tags?.includes(tag))
);
}
return manga;
});
paginatedManga = computed(() => {
const manga = this.filteredManga();
const start = (this.currentPage() - 1) * this.pageSize();
const end = start + this.pageSize();
return manga.slice(start, end);
});
totalFilteredManga = computed(() => this.filteredManga().length);
newManga: Partial<CreateMangaDto> & { dateStarted?: Date; dateFinished?: Date } = {
title: '',
author: '',
status: MangaStatus.wantToRead,
dateStarted: undefined,
dateFinished: undefined,
rating: undefined,
notes: '',
tags: [],
links: []
};
editManga: Partial<UpdateMangaDto> = {};
// Time tracking state
newMangaTimeHours = 0;
newMangaTimeMinutes = 0;
editMangaTimeHours = 0;
editMangaTimeMinutes = 0;
// Tags and links input state
newTagInput = '';
editTagInput = '';
newLinkTitle = '';
newLinkUrl = '';
editLinkTitle = '';
editLinkUrl = '';
ngOnInit() {
this.loadManga();
}
loadManga() {
this.loading.set(true);
this.mangaService.getAllManga().subscribe({
next: (manga) => {
this.mangaList.set(manga);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
}
});
}
setFilter(filter: 'all' | MangaStatus) {
this.statusFilter.set(filter);
this.currentPage.set(1); // Reset to first page when filter changes
}
toggleTag(tag: string) {
const current = this.selectedTags();
if (current.includes(tag)) {
this.selectedTags.set(current.filter(t => t !== tag));
} else {
this.selectedTags.set([...current, tag]);
}
this.currentPage.set(1); // Reset to first page when tags change
}
clearFilters() {
this.searchQuery.set('');
this.selectedTags.set([]);
this.statusFilter.set('all');
this.currentPage.set(1);
}
toggleFilters() {
this.showFilters.update(v => !v);
}
onPageChange(page: number) {
this.currentPage.set(page);
}
onPageSizeChange(pageSize: number) {
this.pageSize.set(pageSize);
// Calculate new current page to stay on approximately the same content
const firstItemIndex = (this.currentPage() - 1) * this.pageSize();
const newPage = Math.floor(firstItemIndex / pageSize) + 1;
this.currentPage.set(newPage);
}
getStatusLabel(status: MangaStatus): string {
switch (status) {
case MangaStatus.reading: return 'Currently Reading';
case MangaStatus.completed: return 'Completed';
case MangaStatus.wantToRead: return 'Want to Read';
case MangaStatus.retired: return 'Retired';
}
}
toggleAddForm() {
this.showAddForm.update(v => !v);
if (!this.showAddForm()) {
this.resetForm();
}
}
resetForm() {
this.newManga = {
title: '',
author: '',
status: MangaStatus.wantToRead,
dateStarted: undefined,
dateFinished: undefined,
rating: undefined,
notes: '',
coverImage: undefined,
tags: [],
links: []
};
this.newMangaTimeHours = 0;
this.newMangaTimeMinutes = 0;
this.newMangaImagePreview.set(null);
this.imageError.set(null);
this.newTagInput = '';
this.newLinkTitle = '';
this.newLinkUrl = '';
}
updateNewMangaTimeSpent() {
const totalMinutes = (this.newMangaTimeHours * 60) + this.newMangaTimeMinutes;
this.newManga.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
}
updateEditMangaTimeSpent() {
const totalMinutes = (this.editMangaTimeHours * 60) + this.editMangaTimeMinutes;
this.editManga.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
}
addTag(target: 'new' | 'edit') {
const input = target === 'new' ? this.newTagInput.trim() : this.editTagInput.trim();
if (!input) return;
if (target === 'new') {
this.newManga.tags = [...(this.newManga.tags || []), input];
this.newTagInput = '';
} else {
this.editManga.tags = [...(this.editManga.tags || []), input];
this.editTagInput = '';
}
}
removeTag(index: number, target: 'new' | 'edit') {
if (target === 'new') {
this.newManga.tags = (this.newManga.tags || []).filter((_, i) => i !== index);
} else {
this.editManga.tags = (this.editManga.tags || []).filter((_, i) => i !== index);
}
}
addLink(target: 'new' | 'edit') {
const title = target === 'new' ? this.newLinkTitle.trim() : this.editLinkTitle.trim();
const url = target === 'new' ? this.newLinkUrl.trim() : this.editLinkUrl.trim();
if (!title || !url) return;
if (target === 'new') {
this.newManga.links = [...(this.newManga.links || []), { title, url }];
this.newLinkTitle = '';
this.newLinkUrl = '';
} else {
this.editManga.links = [...(this.editManga.links || []), { title, url }];
this.editLinkTitle = '';
this.editLinkUrl = '';
}
}
removeLink(index: number, target: 'new' | 'edit') {
if (target === 'new') {
this.newManga.links = (this.newManga.links || []).filter((_, i) => i !== index);
} else {
this.editManga.links = (this.editManga.links || []).filter((_, i) => i !== index);
}
}
addManga() {
if (!this.newManga.title || !this.newManga.author || !this.newManga.status) return;
const mangaToAdd: CreateMangaDto = {
title: this.newManga.title,
author: this.newManga.author,
status: this.newManga.status,
dateStarted: this.newManga.dateStarted ? new Date(this.newManga.dateStarted) : undefined,
dateFinished: this.newManga.dateFinished ? new Date(this.newManga.dateFinished) : undefined,
rating: this.newManga.rating,
notes: this.newManga.notes,
coverImage: this.newManga.coverImage,
tags: this.newManga.tags || [],
links: this.newManga.links || []
};
this.mangaService.createManga(mangaToAdd).subscribe(() => {
this.loadManga();
this.toggleAddForm();
});
}
deleteManga(manga: Manga) {
if (confirm(`Are you sure you want to delete "${manga.title}"?`)) {
this.mangaService.deleteManga(manga.id).subscribe(() => {
this.loadManga();
});
}
}
startEdit(manga: Manga) {
this.editingManga.set(manga);
this.editManga = {
title: manga.title,
author: manga.author,
status: manga.status,
dateStarted: manga.dateStarted,
dateFinished: manga.dateFinished,
rating: manga.rating,
notes: manga.notes,
coverImage: manga.coverImage,
tags: [...(manga.tags || [])],
links: [...(manga.links || [])],
timeSpent: manga.timeSpent
};
// Populate time fields from existing timeSpent
if (manga.timeSpent) {
this.editMangaTimeHours = Math.floor(manga.timeSpent / 60);
this.editMangaTimeMinutes = manga.timeSpent % 60;
} else {
this.editMangaTimeHours = 0;
this.editMangaTimeMinutes = 0;
}
this.editMangaImagePreview.set(manga.coverImage || null);
this.showAddForm.set(false);
this.imageError.set(null);
this.editTagInput = '';
this.editLinkTitle = '';
this.editLinkUrl = '';
}
cancelEdit() {
this.editingManga.set(null);
this.editManga = {};
this.editMangaImagePreview.set(null);
this.imageError.set(null);
this.editTagInput = '';
this.editLinkTitle = '';
this.editLinkUrl = '';
}
saveEdit() {
const manga = this.editingManga();
if (!manga || !this.editManga.title || !this.editManga.author || !this.editManga.status) return;
const updateData = {
...this.editManga,
dateStarted: this.editManga.dateStarted ? new Date(this.editManga.dateStarted) : undefined,
dateFinished: this.editManga.dateFinished ? new Date(this.editManga.dateFinished) : undefined,
};
this.mangaService.updateManga(manga.id, updateData).subscribe(() => {
this.loadManga();
this.cancelEdit();
});
}
onImageSelected(event: Event, target: 'new' | 'edit' | 'suggest') {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
this.imageError.set(null);
if (file.size > this.MAX_IMAGE_SIZE) {
this.imageError.set(`Image too large. Maximum size is ${this.MAX_IMAGE_SIZE / 1024}KB.`);
input.value = '';
return;
}
if (!file.type.startsWith('image/')) {
this.imageError.set('Please select an image file.');
input.value = '';
return;
}
const reader = new FileReader();
reader.onload = () => {
const base64 = reader.result as string;
if (target === 'new') {
this.newMangaImagePreview.set(base64);
this.newManga.coverImage = base64;
} else if (target === 'edit') {
this.editMangaImagePreview.set(base64);
this.editManga.coverImage = base64;
} else {
this.suggestMangaImagePreview.set(base64);
this.suggestedManga.coverImage = base64;
}
};
reader.readAsDataURL(file);
}
clearImage(target: 'new' | 'edit' | 'suggest') {
if (target === 'new') {
this.newMangaImagePreview.set(null);
this.newManga.coverImage = undefined;
} else if (target === 'edit') {
this.editMangaImagePreview.set(null);
this.editManga.coverImage = undefined;
} else {
this.suggestMangaImagePreview.set(null);
this.suggestedManga.coverImage = undefined;
}
this.imageError.set(null);
}
formatDate(date: Date | string): string {
return new Date(date).toLocaleDateString();
}
formatTimeSpent(minutes: number): string {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours === 0) {
return `${mins}m`;
} else if (mins === 0) {
return `${hours}h`;
} else {
return `${hours}h ${mins}m`;
}
}
toggleComments(mangaId: string) {
const expanded = this.expandedComments();
const isCurrentlyExpanded = expanded[mangaId];
this.expandedComments.set({
...expanded,
[mangaId]: !isCurrentlyExpanded
});
if (!isCurrentlyExpanded && !this.comments()[mangaId]) {
this.loadComments(mangaId);
}
}
loadComments(mangaId: string) {
this.commentsLoading.set({
...this.commentsLoading(),
[mangaId]: true
});
this.commentsService.getCommentsForManga(mangaId).subscribe({
next: (comments) => {
this.comments.set({
...this.comments(),
[mangaId]: comments
});
this.commentsLoading.set({
...this.commentsLoading(),
[mangaId]: false
});
},
error: () => {
this.commentsLoading.set({
...this.commentsLoading(),
[mangaId]: false
});
}
});
}
getCommentCount(mangaId: string): number {
return this.comments()[mangaId]?.length || 0;
}
addComment(mangaId: string) {
const content = this.newCommentContent[mangaId];
if (!content?.trim()) return;
this.commentsService.addCommentToManga(mangaId, { content }).subscribe({
next: (comment) => {
this.comments.set({
...this.comments(),
[mangaId]: [comment, ...(this.comments()[mangaId] || [])]
});
this.newCommentContent[mangaId] = '';
}
});
}
deleteComment(mangaId: string, commentId: string) {
if (!confirm('Are you sure you want to delete this comment?')) return;
this.commentsService.deleteCommentFromManga(mangaId, commentId).subscribe({
next: () => {
this.comments.set({
...this.comments(),
[mangaId]: (this.comments()[mangaId] || []).filter(c => c.id !== commentId)
});
}
});
}
canEditComment(comment: Comment): boolean {
const user = this.authService.user();
if (!user) return false;
return comment.userId === user.id || this.authService.isAdmin();
}
canDeleteComment(comment: Comment): boolean {
const user = this.authService.user();
if (!user) return false;
return comment.userId === user.id || this.authService.isAdmin();
}
startEditComment(mangaId: string, comment: Comment) {
this.editingCommentId.set(comment.id);
this.editCommentContent = comment.rawContent ?? comment.content;
}
cancelCommentEdit() {
this.editingCommentId.set(null);
this.editCommentContent = '';
}
saveCommentEdit(mangaId: string, commentId: string) {
if (!this.editCommentContent.trim()) return;
this.commentsService.updateCommentOnManga(mangaId, commentId, this.editCommentContent).subscribe({
next: (updatedComment) => {
this.comments.set({
...this.comments(),
[mangaId]: (this.comments()[mangaId] || []).map(c =>
c.id === commentId ? updatedComment : c
)
});
this.cancelCommentEdit();
}
});
}
// Suggestion methods
toggleSuggestForm() {
this.showSuggestForm.update(v => !v);
if (!this.showSuggestForm()) {
this.resetSuggestForm();
}
}
resetSuggestForm() {
this.suggestedManga = {
title: '',
author: '',
notes: '',
coverImage: undefined
};
this.suggestMangaImagePreview.set(null);
this.imageError.set(null);
}
async submitSuggestion() {
if (!this.suggestedManga.title || !this.suggestedManga.author) return;
try {
await this.suggestionService.createSuggestion({
entityType: SuggestionEntity.manga,
title: this.suggestedManga.title,
author: this.suggestedManga.author,
notes: this.suggestedManga.notes,
coverImage: this.suggestedManga.coverImage
});
alert('Thank you for your suggestion! It will be reviewed soon.');
this.toggleSuggestForm();
} catch {
alert('Failed to submit suggestion. Please try again.');
}
}
handleCommentEdit(mangaId: string, event: { commentId: string; content: string }) {
this.commentsService.updateCommentOnManga(mangaId, event.commentId, event.content).subscribe({
next: (updatedComment) => {
this.comments.set({
...this.comments(),
[mangaId]: (this.comments()[mangaId] || []).map(c =>
c.id === event.commentId ? updatedComment : c
)
});
}
});
}
getCommentsSignal(mangaId: string) {
return signal(this.comments()[mangaId] || []);
}
}