feat: base64 uploads, reusable forms, Discord roles, and UX improvements #66

Merged
naomi merged 8 commits from fix/base64 into main 2026-02-20 20:32:52 -08:00
19 changed files with 3914 additions and 238 deletions
Showing only changes of commit b781034fce - Show all commits
@@ -10,12 +10,18 @@ import { FormsModule } from '@angular/forms';
import { SuggestionService } from '../../services/suggestion.service'; import { SuggestionService } from '../../services/suggestion.service';
import { AuthService } from '../../services/auth.service'; import { AuthService } from '../../services/auth.service';
import { PaginationComponent } from '../shared/pagination.component'; import { PaginationComponent } from '../shared/pagination.component';
import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-types'; import { GameFormComponent } from '../shared/game-form.component';
import { BookFormComponent } from '../shared/book-form.component';
import { MusicFormComponent } from '../shared/music-form.component';
import { ShowFormComponent } from '../shared/show-form.component';
import { MangaFormComponent } from '../shared/manga-form.component';
import { ArtFormComponent } from '../shared/art-form.component';
import { Suggestion, SuggestionStatus, SuggestionEntity, CreateGameDto, UpdateGameDto, GameStatus, CreateBookDto, UpdateBookDto, BookStatus, CreateMusicDto, UpdateMusicDto, MusicStatus, MusicType, CreateShowDto, UpdateShowDto, ShowStatus, ShowType, CreateMangaDto, UpdateMangaDto, MangaStatus, CreateArtDto, UpdateArtDto } from '@library/shared-types';
@Component({ @Component({
selector: 'app-admin-suggestions', selector: 'app-admin-suggestions',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, PaginationComponent], imports: [CommonModule, FormsModule, PaginationComponent, GameFormComponent, BookFormComponent, MusicFormComponent, ShowFormComponent, MangaFormComponent, ArtFormComponent],
template: ` template: `
<div class="container"> <div class="container">
<div class="header-section"> <div class="header-section">
@@ -231,158 +237,62 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
@if (showEditModal()) { @if (showEditModal()) {
<div class="modal-overlay" (click)="closeEditModal()" (keyup.escape)="closeEditModal()" tabindex="0" role="button"> <div class="modal-overlay" (click)="closeEditModal()" (keyup.escape)="closeEditModal()" tabindex="0" role="button">
<div class="modal edit-modal" (click)="$event.stopPropagation()" (keyup)="$event.stopPropagation()" tabindex="-1"> <div class="modal edit-modal" (click)="$event.stopPropagation()" (keyup)="$event.stopPropagation()" tabindex="-1">
<h3>Review & Edit Before Accepting</h3>
<p>Review and edit the details before adding to your collection.</p>
@if (editingSuggestion()) { @if (editingSuggestion()) {
<form (ngSubmit)="confirmAcceptWithEdits()"> @if (editingSuggestion()!.entityType === SuggestionEntity.game) {
<div class="form-group"> <h3>Review & Edit Game Before Accepting</h3>
<label for="edit-title">Title</label> <p>Review and edit all the details before adding to your collection.</p>
<input <app-game-form
type="text" mode="add"
id="edit-title" [initialData]="getGameInitialData(editingSuggestion()!)"
[(ngModel)]="editedData.title" (save)="saveGameFromSuggestion($event)"
name="title" (cancel)="closeEditModal()"
required ></app-game-form>
> } @else if (editingSuggestion()!.entityType === SuggestionEntity.book) {
</div> <h3>Review & Edit Book Before Accepting</h3>
<p>Review and edit all the details before adding to your collection.</p>
@switch (editingSuggestion()!.entityType) { <app-book-form
@case (SuggestionEntity.book) { mode="add"
<div class="form-group"> [initialData]="getBookInitialData(editingSuggestion()!)"
<label for="edit-author">Author</label> (save)="saveBookFromSuggestion($event)"
<input (cancel)="closeEditModal()"
type="text" ></app-book-form>
id="edit-author" } @else if (editingSuggestion()!.entityType === SuggestionEntity.music) {
[(ngModel)]="editedData.author" <h3>Review & Edit Music Before Accepting</h3>
name="author" <p>Review and edit all the details before adding to your collection.</p>
required <app-music-form
> mode="add"
</div> [initialData]="getMusicInitialData(editingSuggestion()!)"
<div class="form-group"> (save)="saveMusicFromSuggestion($event)"
<label for="edit-isbn">ISBN</label> (cancel)="closeEditModal()"
<input ></app-music-form>
type="text" } @else if (editingSuggestion()!.entityType === SuggestionEntity.show) {
id="edit-isbn" <h3>Review & Edit Show Before Accepting</h3>
[(ngModel)]="editedData.isbn" <p>Review and edit all the details before adding to your collection.</p>
name="isbn" <app-show-form
> mode="add"
</div> [initialData]="getShowInitialData(editingSuggestion()!)"
} (save)="saveShowFromSuggestion($event)"
@case (SuggestionEntity.game) { (cancel)="closeEditModal()"
<div class="form-group"> ></app-show-form>
<label for="edit-platform">Platform</label> } @else if (editingSuggestion()!.entityType === SuggestionEntity.manga) {
<input <h3>Review & Edit Manga Before Accepting</h3>
type="text" <p>Review and edit all the details before adding to your collection.</p>
id="edit-platform" <app-manga-form
[(ngModel)]="editedData.platform" mode="add"
name="platform" [initialData]="getMangaInitialData(editingSuggestion()!)"
> (save)="saveMangaFromSuggestion($event)"
</div> (cancel)="closeEditModal()"
} ></app-manga-form>
@case (SuggestionEntity.music) { } @else if (editingSuggestion()!.entityType === SuggestionEntity.art) {
<div class="form-group"> <h3>Review & Edit Art Before Accepting</h3>
<label for="edit-artist">Artist</label> <p>Review and edit all the details before adding to your collection.</p>
<input <app-art-form
type="text" mode="add"
id="edit-artist" [initialData]="getArtInitialData(editingSuggestion()!)"
[(ngModel)]="editedData.artist" (save)="saveArtFromSuggestion($event)"
name="artist" (cancel)="closeEditModal()"
required ></app-art-form>
> }
</div>
<div class="form-group">
<label for="edit-type">Type</label>
<select id="edit-type" [(ngModel)]="editedData.type" name="type" required>
<option value="ALBUM">Album</option>
<option value="SINGLE">Single</option>
<option value="EP">EP</option>
</select>
</div>
}
@case (SuggestionEntity.art) {
<div class="form-group">
<label for="edit-artist">Artist</label>
<input
type="text"
id="edit-artist"
[(ngModel)]="editedData.artist"
name="artist"
required
>
</div>
<div class="form-group">
<label for="edit-description">Description</label>
<textarea
id="edit-description"
[(ngModel)]="editedData.description"
name="description"
rows="3"
></textarea>
</div>
<div class="form-group">
<label for="edit-imageUrl">Image URL</label>
<input
type="text"
id="edit-imageUrl"
[(ngModel)]="editedData.imageUrl"
name="imageUrl"
required
>
</div>
}
@case (SuggestionEntity.show) {
<div class="form-group">
<label for="edit-type">Type</label>
<select id="edit-type" [(ngModel)]="editedData.type" name="type" required>
<option value="TV_SERIES">TV Series</option>
<option value="ANIME">Anime</option>
<option value="FILM">Film</option>
<option value="DOCUMENTARY">Documentary</option>
</select>
</div>
}
@case (SuggestionEntity.manga) {
<div class="form-group">
<label for="edit-author">Author</label>
<input
type="text"
id="edit-author"
[(ngModel)]="editedData.author"
name="author"
required
>
</div>
}
}
<div class="form-group">
<label for="edit-notes">Notes</label>
<textarea
id="edit-notes"
[(ngModel)]="editedData.notes"
name="notes"
rows="3"
></textarea>
</div>
@if (editingSuggestion()!.entityType !== SuggestionEntity.art) {
<div class="form-group">
<label for="edit-coverImage">Cover Image URL</label>
<input
type="text"
id="edit-coverImage"
[(ngModel)]="editedData.coverImage"
name="coverImage"
>
</div>
}
<div class="modal-actions">
<button type="submit" class="btn btn-accept">Accept with Edits</button>
<button type="button" (click)="closeEditModal()" class="btn btn-secondary">Cancel</button>
</div>
</form>
} }
</div> </div>
</div> </div>
@@ -810,59 +720,174 @@ export class AdminSuggestionsComponent implements OnInit {
return new Date(date).toLocaleDateString(); return new Date(date).toLocaleDateString();
} }
getGameInitialData(suggestion: Suggestion): Partial<CreateGameDto> {
const gameData = suggestion.gameData as any;
return {
title: suggestion.title,
platform: gameData?.platform || undefined,
status: GameStatus.backlog, // Default to backlog for new suggestions
notes: gameData?.notes || undefined,
coverImage: gameData?.coverImage || undefined,
tags: [],
links: []
};
}
async saveGameFromSuggestion(data: CreateGameDto | UpdateGameDto) {
const suggestion = this.editingSuggestion();
if (!suggestion) return;
try {
// Treat it as CreateGameDto since we're creating a new item from a suggestion
await this.suggestionService.acceptSuggestionWithEdits(suggestion.id, data as CreateGameDto);
alert(`"${(data as CreateGameDto).title}" has been added to your collection!`);
this.closeEditModal();
this.loadSuggestions();
} catch (error) {
alert('Failed to accept suggestion. Please try again.');
}
}
getBookInitialData(suggestion: Suggestion): Partial<CreateBookDto> {
const bookData = suggestion.bookData as any;
return {
title: suggestion.title,
author: bookData?.author || '',
isbn: bookData?.isbn || undefined,
status: BookStatus.toRead,
notes: bookData?.notes || undefined,
coverImage: bookData?.coverImage || undefined,
tags: [],
links: []
};
}
async saveBookFromSuggestion(data: CreateBookDto | UpdateBookDto) {
const suggestion = this.editingSuggestion();
if (!suggestion) return;
try {
await this.suggestionService.acceptSuggestionWithEdits(suggestion.id, data as CreateBookDto);
alert(`"${(data as CreateBookDto).title}" has been added to your collection!`);
this.closeEditModal();
this.loadSuggestions();
} catch (error) {
alert('Failed to accept suggestion. Please try again.');
}
}
getMusicInitialData(suggestion: Suggestion): Partial<CreateMusicDto> {
const musicData = suggestion.musicData as any;
return {
title: suggestion.title,
artist: musicData?.artist || '',
type: musicData?.type || MusicType.album,
status: MusicStatus.wantToListen,
notes: musicData?.notes || undefined,
coverArt: musicData?.coverArt || undefined,
tags: [],
links: []
};
}
async saveMusicFromSuggestion(data: CreateMusicDto | UpdateMusicDto) {
const suggestion = this.editingSuggestion();
if (!suggestion) return;
try {
await this.suggestionService.acceptSuggestionWithEdits(suggestion.id, data as CreateMusicDto);
alert(`"${(data as CreateMusicDto).title}" has been added to your collection!`);
this.closeEditModal();
this.loadSuggestions();
} catch (error) {
alert('Failed to accept suggestion. Please try again.');
}
}
getShowInitialData(suggestion: Suggestion): Partial<CreateShowDto> {
const showData = suggestion.showData as any;
return {
title: suggestion.title,
type: showData?.type || ShowType.tvSeries,
status: ShowStatus.wantToWatch,
notes: showData?.notes || undefined,
coverImage: showData?.coverImage || undefined,
tags: [],
links: []
};
}
async saveShowFromSuggestion(data: CreateShowDto | UpdateShowDto) {
const suggestion = this.editingSuggestion();
if (!suggestion) return;
try {
await this.suggestionService.acceptSuggestionWithEdits(suggestion.id, data as CreateShowDto);
alert(`"${(data as CreateShowDto).title}" has been added to your collection!`);
this.closeEditModal();
this.loadSuggestions();
} catch (error) {
alert('Failed to accept suggestion. Please try again.');
}
}
getMangaInitialData(suggestion: Suggestion): Partial<CreateMangaDto> {
const mangaData = suggestion.mangaData as any;
return {
title: suggestion.title,
author: mangaData?.author || '',
status: MangaStatus.wantToRead,
notes: mangaData?.notes || undefined,
coverImage: mangaData?.coverImage || undefined,
tags: [],
links: []
};
}
async saveMangaFromSuggestion(data: CreateMangaDto | UpdateMangaDto) {
const suggestion = this.editingSuggestion();
if (!suggestion) return;
try {
await this.suggestionService.acceptSuggestionWithEdits(suggestion.id, data as CreateMangaDto);
alert(`"${(data as CreateMangaDto).title}" has been added to your collection!`);
this.closeEditModal();
this.loadSuggestions();
} catch (error) {
alert('Failed to accept suggestion. Please try again.');
}
}
getArtInitialData(suggestion: Suggestion): Partial<CreateArtDto> {
const artData = suggestion.artData as any;
return {
title: suggestion.title,
artist: artData?.artist || '',
description: artData?.description || undefined,
imageUrl: artData?.imageUrl || '',
tags: [],
links: []
};
}
async saveArtFromSuggestion(data: CreateArtDto | UpdateArtDto) {
const suggestion = this.editingSuggestion();
if (!suggestion) return;
try {
await this.suggestionService.acceptSuggestionWithEdits(suggestion.id, data as CreateArtDto);
alert(`"${(data as CreateArtDto).title}" has been added to your collection!`);
this.closeEditModal();
this.loadSuggestions();
} catch (error) {
alert('Failed to accept suggestion. Please try again.');
}
}
async acceptSuggestion(suggestion: Suggestion) { async acceptSuggestion(suggestion: Suggestion) {
this.editingSuggestion.set(suggestion); this.editingSuggestion.set(suggestion);
// Pre-populate the edit form with suggestion data // For all entity types, we'll use the form components which have their own initialization logic
this.editedData = {
title: suggestion.title,
notes: '',
coverImage: '',
coverArt: ''
};
// Add entity-specific data
switch (suggestion.entityType) {
case SuggestionEntity.book:
const bookData = suggestion.bookData as any;
this.editedData.author = bookData?.author || '';
this.editedData.isbn = bookData?.isbn || '';
this.editedData.notes = bookData?.notes || '';
this.editedData.coverImage = bookData?.coverImage || '';
break;
case SuggestionEntity.game:
const gameData = suggestion.gameData as any;
this.editedData.platform = gameData?.platform || '';
this.editedData.notes = gameData?.notes || '';
this.editedData.coverImage = gameData?.coverImage || '';
break;
case SuggestionEntity.music:
const musicData = suggestion.musicData as any;
this.editedData.artist = musicData?.artist || '';
this.editedData.type = musicData?.type || 'ALBUM';
this.editedData.notes = musicData?.notes || '';
this.editedData.coverArt = musicData?.coverArt || '';
break;
case SuggestionEntity.art:
const artData = suggestion.artData as any;
this.editedData.artist = artData?.artist || '';
this.editedData.description = artData?.description || '';
this.editedData.imageUrl = artData?.imageUrl || '';
break;
case SuggestionEntity.show:
const showData = suggestion.showData as any;
this.editedData.type = showData?.type || 'TV_SERIES';
this.editedData.notes = showData?.notes || '';
this.editedData.coverImage = showData?.coverImage || '';
break;
case SuggestionEntity.manga:
const mangaData = suggestion.mangaData as any;
this.editedData.author = mangaData?.author || '';
this.editedData.notes = mangaData?.notes || '';
this.editedData.coverImage = mangaData?.coverImage || '';
break;
}
this.showEditModal.set(true); this.showEditModal.set(true);
} }
@@ -884,20 +909,6 @@ export class AdminSuggestionsComponent implements OnInit {
this.editedData = {}; this.editedData = {};
} }
async confirmAcceptWithEdits() {
const suggestion = this.editingSuggestion();
if (!suggestion) return;
try {
await this.suggestionService.acceptSuggestionWithEdits(suggestion.id, this.editedData);
alert(`"${this.editedData.title}" has been added to your collection with your edits!`);
this.closeEditModal();
this.loadSuggestions();
} catch (error) {
alert('Failed to accept suggestion. Please try again.');
}
}
async confirmDecline() { async confirmDecline() {
const suggestion = this.decliningsuggestion(); const suggestion = this.decliningsuggestion();
if (!suggestion) return; if (!suggestion) return;
@@ -14,18 +14,28 @@ import { AuthService } from '../../services/auth.service';
import { SanitizeService } from '../../services/sanitize.service'; import { SanitizeService } from '../../services/sanitize.service';
import { CommentDisplayComponent } from '../comment-display/comment-display.component'; import { CommentDisplayComponent } from '../comment-display/comment-display.component';
import { LikeButtonComponent } from '../shared/like-button.component'; import { LikeButtonComponent } from '../shared/like-button.component';
import { Art, Comment } from '@library/shared-types'; import { ArtFormComponent } from '../shared/art-form.component';
import { Art, Comment, UpdateArtDto } from '@library/shared-types';
@Component({ @Component({
selector: 'app-art-detail', selector: 'app-art-detail',
standalone: true, standalone: true,
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent], imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent, ArtFormComponent],
template: ` template: `
<div class="container"> <div class="container">
<div class="breadcrumb"> <div class="breadcrumb">
<a routerLink="/art" class="breadcrumb-link">← Back to Art</a> <a routerLink="/art" class="breadcrumb-link">← Back to Art</a>
</div> </div>
@if (showEditForm() && authService.user()?.isAdmin && art()) {
<app-art-form
mode="edit"
[art]="art()!"
(save)="saveEdit($event)"
(cancel)="cancelEdit()"
></app-art-form>
}
@if (loading()) { @if (loading()) {
<div class="loading">Loading artwork details...</div> <div class="loading">Loading artwork details...</div>
} @else if (error()) { } @else if (error()) {
@@ -48,6 +58,12 @@ import { Art, Comment } from '@library/shared-types';
</div> </div>
<p class="artist">by {{ art()!.artist }}</p> <p class="artist">by {{ art()!.artist }}</p>
@if (authService.user()?.isAdmin) {
<div class="admin-actions">
<button (click)="toggleEditForm()" class="btn btn-edit">✏️ {{ showEditForm() ? 'Cancel Edit' : 'Edit' }}</button>
<button (click)="deleteArt()" class="btn btn-delete">🗑️ Delete</button>
</div>
}
<div class="info-row"> <div class="info-row">
<span class="info-label">Added:</span> <span class="info-label">Added:</span>
@@ -232,6 +248,35 @@ import { Art, Comment } from '@library/shared-types';
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
} }
.admin-actions {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: #fef3c7;
border-radius: 4px;
border: 1px solid #fbbf24;
}
.btn-edit {
background: #3b82f6;
color: white;
}
.btn-edit:hover {
background: #2563eb;
}
.btn-delete {
background: #ef4444;
color: white;
}
.btn-delete:hover {
background: #dc2626;
}
.info-row { .info-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -426,7 +471,36 @@ import { Art, Comment } from '@library/shared-types';
font-size: 1.5rem; font-size: 1.5rem;
} }
.info-row {
.admin-actions {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: #fef3c7;
border-radius: 4px;
border: 1px solid #fbbf24;
}
.btn-edit {
background: #3b82f6;
color: white;
}
.btn-edit:hover {
background: #2563eb;
}
.btn-delete {
background: #ef4444;
color: white;
}
.btn-delete:hover {
background: #dc2626;
}
.info-row {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: 0.25rem; gap: 0.25rem;
@@ -452,6 +526,7 @@ export class ArtDetailComponent implements OnInit {
commentsLoading = signal(false); commentsLoading = signal(false);
error = signal<string | null>(null); error = signal<string | null>(null);
newCommentContent = ''; newCommentContent = '';
showEditForm = signal(false);
ngOnInit() { ngOnInit() {
const artId = this.route.snapshot.paramMap.get('id'); const artId = this.route.snapshot.paramMap.get('id');
@@ -539,4 +614,45 @@ export class ArtDetailComponent implements OnInit {
day: 'numeric' day: 'numeric'
}); });
} }
}
toggleEditForm() {
this.showEditForm.update(value => !value);
}
saveEdit(data: UpdateArtDto) {
const art = this.art();
if (!art) return;
this.artService.updateArt(art.id, data).subscribe({
next: (updatedArt) => {
this.art.set(updatedArt);
this.showEditForm.set(false);
},
error: (err) => {
alert('Failed to update art: ' + (err.error?.message || 'Unknown error'));
}
});
}
cancelEdit() {
this.showEditForm.set(false);
}
deleteArt() {
const art = this.art();
if (!art) return;
if (!confirm(`Are you sure you want to delete "${art.title}"? This action cannot be undone.`)) {
return;
}
this.artService.deleteArt(art.id).subscribe({
next: () => {
this.router.navigate(['/art']);
},
error: (err) => {
alert('Failed to delete art: ' + (err.error?.message || 'Unknown error'));
}
});
}
}
@@ -1121,6 +1121,9 @@ export class ArtGalleryComponent implements OnInit {
this.editTagInput = ''; this.editTagInput = '';
this.editLinkTitle = ''; this.editLinkTitle = '';
this.editLinkUrl = ''; this.editLinkUrl = '';
// Scroll to top so the form is visible
window.scrollTo({ top: 0, behavior: 'smooth' });
} }
cancelEdit() { cancelEdit() {
@@ -14,18 +14,28 @@ import { AuthService } from '../../services/auth.service';
import { SanitizeService } from '../../services/sanitize.service'; import { SanitizeService } from '../../services/sanitize.service';
import { CommentDisplayComponent } from '../comment-display/comment-display.component'; import { CommentDisplayComponent } from '../comment-display/comment-display.component';
import { LikeButtonComponent } from '../shared/like-button.component'; import { LikeButtonComponent } from '../shared/like-button.component';
import { Book, Comment, BookStatus } from '@library/shared-types'; import { BookFormComponent } from '../shared/book-form.component';
import { Book, Comment, BookStatus, UpdateBookDto } from '@library/shared-types';
@Component({ @Component({
selector: 'app-book-detail', selector: 'app-book-detail',
standalone: true, standalone: true,
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent], imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent, BookFormComponent],
template: ` template: `
<div class="container"> <div class="container">
<div class="breadcrumb"> <div class="breadcrumb">
<a routerLink="/books" class="breadcrumb-link">← Back to Books</a> <a routerLink="/books" class="breadcrumb-link">← Back to Books</a>
</div> </div>
@if (showEditForm() && authService.user()?.isAdmin && book()) {
<app-book-form
mode="edit"
[book]="book()!"
(save)="saveEdit($event)"
(cancel)="cancelEdit()"
></app-book-form>
}
@if (loading()) { @if (loading()) {
<div class="loading">Loading book details...</div> <div class="loading">Loading book details...</div>
} @else if (error()) { } @else if (error()) {
@@ -51,6 +61,12 @@ import { Book, Comment, BookStatus } from '@library/shared-types';
</div> </div>
<p class="author">by {{ book()!.author }}</p> <p class="author">by {{ book()!.author }}</p>
@if (authService.user()?.isAdmin) {
<div class="admin-actions">
<button (click)="editBook()" class="btn btn-edit">✏️ Edit</button>
<button (click)="deleteBook()" class="btn btn-delete">🗑️ Delete</button>
</div>
}
@if (book()!.series) { @if (book()!.series) {
<p class="series"> <p class="series">
@@ -309,6 +325,35 @@ import { Book, Comment, BookStatus } from '@library/shared-types';
color: #4b5563; color: #4b5563;
} }
.admin-actions {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: #fef3c7;
border-radius: 4px;
border: 1px solid #fbbf24;
}
.btn-edit {
background: #3b82f6;
color: white;
}
.btn-edit:hover {
background: #2563eb;
}
.btn-delete {
background: #ef4444;
color: white;
}
.btn-delete:hover {
background: #dc2626;
}
.series { .series {
color: #8b6f47; color: #8b6f47;
font-size: 1rem; font-size: 1rem;
@@ -542,6 +587,7 @@ export class BookDetailComponent implements OnInit {
commentsLoading = signal(false); commentsLoading = signal(false);
error = signal<string | null>(null); error = signal<string | null>(null);
newCommentContent = ''; newCommentContent = '';
showEditForm = signal(false);
ngOnInit() { ngOnInit() {
const bookId = this.route.snapshot.paramMap.get('id'); const bookId = this.route.snapshot.paramMap.get('id');
@@ -651,4 +697,45 @@ export class BookDetailComponent implements OnInit {
return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`; return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`;
} }
} }
}
editBook() {
this.showEditForm.set(true);
}
cancelEdit() {
this.showEditForm.set(false);
}
saveEdit(data: UpdateBookDto) {
const book = this.book();
if (!book) return;
this.booksService.updateBook(book.id, data).subscribe({
next: (updatedBook) => {
this.book.set(updatedBook);
this.showEditForm.set(false);
},
error: (err) => {
alert('Failed to update book: ' + (err.error?.message || 'Unknown error'));
}
});
}
deleteBook() {
const book = this.book();
if (!book) return;
if (!confirm(`Are you sure you want to delete "${book.title}"? This action cannot be undone.`)) {
return;
}
this.booksService.deleteBook(book.id).subscribe({
next: () => {
this.router.navigate(['/books']);
},
error: (err) => {
alert('Failed to delete book: ' + (err.error?.message || 'Unknown error'));
}
});
}
}
@@ -1561,6 +1561,9 @@ export class BooksListComponent implements OnInit {
this.editBookTimeHours = 0; this.editBookTimeHours = 0;
this.editBookTimeMinutes = 0; this.editBookTimeMinutes = 0;
} }
// Scroll to top so the form is visible
window.scrollTo({ top: 0, behavior: 'smooth' });
} }
cancelEdit() { cancelEdit() {
@@ -14,12 +14,13 @@ import { AuthService } from '../../services/auth.service';
import { SanitizeService } from '../../services/sanitize.service'; import { SanitizeService } from '../../services/sanitize.service';
import { CommentDisplayComponent } from '../comment-display/comment-display.component'; import { CommentDisplayComponent } from '../comment-display/comment-display.component';
import { LikeButtonComponent } from '../shared/like-button.component'; import { LikeButtonComponent } from '../shared/like-button.component';
import { Game, Comment, GameStatus } from '@library/shared-types'; import { GameFormComponent } from '../shared/game-form.component';
import { Game, Comment, GameStatus, UpdateGameDto } from '@library/shared-types';
@Component({ @Component({
selector: 'app-game-detail', selector: 'app-game-detail',
standalone: true, standalone: true,
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent], imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent, GameFormComponent],
template: ` template: `
<div class="container"> <div class="container">
<div class="breadcrumb"> <div class="breadcrumb">
@@ -50,6 +51,22 @@ import { Game, Comment, GameStatus } from '@library/shared-types';
</span> </span>
</div> </div>
@if (authService.user()?.isAdmin) {
<div class="admin-actions">
<button (click)="toggleEditForm()" class="btn btn-edit">✏️ {{ showEditForm() ? 'Cancel Edit' : 'Edit' }}</button>
<button (click)="deleteGame()" class="btn btn-delete">🗑️ Delete</button>
</div>
}
@if (showEditForm() && authService.user()?.isAdmin && game()) {
<app-game-form
mode="edit"
[game]="game()!"
(save)="saveEdit($event)"
(cancel)="cancelEdit()"
></app-game-form>
}
@if (game()!.series) { @if (game()!.series) {
<p class="series"> <p class="series">
📚 {{ game()!.series }}@if (game()!.seriesOrder) { #{{ game()!.seriesOrder }}} 📚 {{ game()!.series }}@if (game()!.seriesOrder) { #{{ game()!.seriesOrder }}}
@@ -300,6 +317,34 @@ import { Game, Comment, GameStatus } from '@library/shared-types';
color: #4b5563; color: #4b5563;
} }
.admin-actions {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: #fef3c7;
border-radius: 4px;
border: 1px solid #fbbf24;
}
.btn-edit {
background: #3b82f6;
color: white;
}
.btn-edit:hover {
background: #2563eb;
}
.btn-delete {
background: #ef4444;
color: white;
}
.btn-delete:hover {
background: #dc2626;
}
.series { .series {
color: #8b6f47; color: #8b6f47;
font-size: 1rem; font-size: 1rem;
@@ -533,6 +578,7 @@ export class GameDetailComponent implements OnInit {
commentsLoading = signal(false); commentsLoading = signal(false);
error = signal<string | null>(null); error = signal<string | null>(null);
newCommentContent = ''; newCommentContent = '';
showEditForm = signal(false);
ngOnInit() { ngOnInit() {
const gameId = this.route.snapshot.paramMap.get('id'); const gameId = this.route.snapshot.paramMap.get('id');
@@ -642,4 +688,45 @@ export class GameDetailComponent implements OnInit {
return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`; return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`;
} }
} }
toggleEditForm() {
this.showEditForm.update(value => !value);
}
saveEdit(data: UpdateGameDto) {
const game = this.game();
if (!game) return;
this.gamesService.updateGame(game.id, data).subscribe({
next: (updatedGame) => {
this.game.set(updatedGame);
this.showEditForm.set(false);
},
error: (err) => {
alert('Failed to update game: ' + (err.error?.message || 'Unknown error'));
}
});
}
cancelEdit() {
this.showEditForm.set(false);
}
deleteGame() {
const game = this.game();
if (!game) return;
if (!confirm(`Are you sure you want to delete "${game.title}"? This action cannot be undone.`)) {
return;
}
this.gamesService.deleteGame(game.id).subscribe({
next: () => {
this.router.navigate(['/games']);
},
error: (err) => {
alert('Failed to delete game: ' + (err.error?.message || 'Unknown error'));
}
});
}
} }
@@ -1434,6 +1434,9 @@ export class GamesListComponent implements OnInit {
this.editTagInput = ''; this.editTagInput = '';
this.editLinkTitle = ''; this.editLinkTitle = '';
this.editLinkUrl = ''; this.editLinkUrl = '';
// Scroll to top so the form is visible
window.scrollTo({ top: 0, behavior: 'smooth' });
} }
cancelEdit() { cancelEdit() {
@@ -14,18 +14,28 @@ import { AuthService } from '../../services/auth.service';
import { SanitizeService } from '../../services/sanitize.service'; import { SanitizeService } from '../../services/sanitize.service';
import { CommentDisplayComponent } from '../comment-display/comment-display.component'; import { CommentDisplayComponent } from '../comment-display/comment-display.component';
import { LikeButtonComponent } from '../shared/like-button.component'; import { LikeButtonComponent } from '../shared/like-button.component';
import { Manga, Comment, MangaStatus } from '@library/shared-types'; import { MangaFormComponent } from '../shared/manga-form.component';
import { Manga, Comment, MangaStatus, UpdateMangaDto } from '@library/shared-types';
@Component({ @Component({
selector: 'app-manga-detail', selector: 'app-manga-detail',
standalone: true, standalone: true,
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent], imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent, MangaFormComponent],
template: ` template: `
<div class="container"> <div class="container">
<div class="breadcrumb"> <div class="breadcrumb">
<a routerLink="/manga" class="breadcrumb-link">← Back to Manga</a> <a routerLink="/manga" class="breadcrumb-link">← Back to Manga</a>
</div> </div>
@if (showEditForm() && authService.user()?.isAdmin && manga()) {
<app-manga-form
mode="edit"
[manga]="manga()!"
(save)="saveEdit($event)"
(cancel)="cancelEdit()"
></app-manga-form>
}
@if (loading()) { @if (loading()) {
<div class="loading">Loading manga details...</div> <div class="loading">Loading manga details...</div>
} @else if (error()) { } @else if (error()) {
@@ -51,6 +61,12 @@ import { Manga, Comment, MangaStatus } from '@library/shared-types';
</div> </div>
<p class="author">by {{ manga()!.author }}</p> <p class="author">by {{ manga()!.author }}</p>
@if (authService.user()?.isAdmin) {
<div class="admin-actions">
<button (click)="toggleEditForm()" class="btn btn-edit">✏️ {{ showEditForm() ? 'Cancel Edit' : 'Edit' }}</button>
<button (click)="deleteManga()" class="btn btn-delete">🗑️ Delete</button>
</div>
}
@if (manga()!.rating) { @if (manga()!.rating) {
<div class="info-row"> <div class="info-row">
@@ -296,6 +312,35 @@ import { Manga, Comment, MangaStatus } from '@library/shared-types';
color: #4b5563; color: #4b5563;
} }
.admin-actions {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: #fef3c7;
border-radius: 4px;
border: 1px solid #fbbf24;
}
.btn-edit {
background: #3b82f6;
color: white;
}
.btn-edit:hover {
background: #2563eb;
}
.btn-delete {
background: #ef4444;
color: white;
}
.btn-delete:hover {
background: #dc2626;
}
.info-row { .info-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -495,7 +540,36 @@ import { Manga, Comment, MangaStatus } from '@library/shared-types';
font-size: 1.5rem; font-size: 1.5rem;
} }
.info-row {
.admin-actions {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: #fef3c7;
border-radius: 4px;
border: 1px solid #fbbf24;
}
.btn-edit {
background: #3b82f6;
color: white;
}
.btn-edit:hover {
background: #2563eb;
}
.btn-delete {
background: #ef4444;
color: white;
}
.btn-delete:hover {
background: #dc2626;
}
.info-row {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: 0.25rem; gap: 0.25rem;
@@ -521,6 +595,7 @@ export class MangaDetailComponent implements OnInit {
commentsLoading = signal(false); commentsLoading = signal(false);
error = signal<string | null>(null); error = signal<string | null>(null);
newCommentContent = ''; newCommentContent = '';
showEditForm = signal(false);
ngOnInit() { ngOnInit() {
const mangaId = this.route.snapshot.paramMap.get('id'); const mangaId = this.route.snapshot.paramMap.get('id');
@@ -630,4 +705,45 @@ export class MangaDetailComponent implements OnInit {
return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`; return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`;
} }
} }
}
toggleEditForm() {
this.showEditForm.update(value => !value);
}
saveEdit(data: UpdateMangaDto) {
const manga = this.manga();
if (!manga) return;
this.mangaService.updateManga(manga.id, data).subscribe({
next: (updatedManga) => {
this.manga.set(updatedManga);
this.showEditForm.set(false);
},
error: (err) => {
alert('Failed to update manga: ' + (err.error?.message || 'Unknown error'));
}
});
}
cancelEdit() {
this.showEditForm.set(false);
}
deleteManga() {
const manga = this.manga();
if (!manga) return;
if (!confirm(`Are you sure you want to delete "${manga.title}"? This action cannot be undone.`)) {
return;
}
this.mangaService.deleteManga(manga.id).subscribe({
next: () => {
this.router.navigate(['/manga']);
},
error: (err) => {
alert('Failed to delete manga: ' + (err.error?.message || 'Unknown error'));
}
});
}
}
@@ -1347,6 +1347,9 @@ export class MangaListComponent implements OnInit {
this.editTagInput = ''; this.editTagInput = '';
this.editLinkTitle = ''; this.editLinkTitle = '';
this.editLinkUrl = ''; this.editLinkUrl = '';
// Scroll to top so the form is visible
window.scrollTo({ top: 0, behavior: 'smooth' });
} }
cancelEdit() { cancelEdit() {
@@ -14,18 +14,28 @@ import { AuthService } from '../../services/auth.service';
import { SanitizeService } from '../../services/sanitize.service'; import { SanitizeService } from '../../services/sanitize.service';
import { CommentDisplayComponent } from '../comment-display/comment-display.component'; import { CommentDisplayComponent } from '../comment-display/comment-display.component';
import { LikeButtonComponent } from '../shared/like-button.component'; import { LikeButtonComponent } from '../shared/like-button.component';
import { Music, Comment, MusicStatus, MusicType } from '@library/shared-types'; import { MusicFormComponent } from '../shared/music-form.component';
import { Music, Comment, MusicStatus, MusicType, UpdateMusicDto } from '@library/shared-types';
@Component({ @Component({
selector: 'app-music-detail', selector: 'app-music-detail',
standalone: true, standalone: true,
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent], imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent, MusicFormComponent],
template: ` template: `
<div class="container"> <div class="container">
<div class="breadcrumb"> <div class="breadcrumb">
<a routerLink="/music" class="breadcrumb-link">← Back to Music</a> <a routerLink="/music" class="breadcrumb-link">← Back to Music</a>
</div> </div>
@if (showEditForm() && authService.user()?.isAdmin && music()) {
<app-music-form
mode="edit"
[music]="music()!"
(save)="saveEdit($event)"
(cancel)="cancelEdit()"
></app-music-form>
}
@if (loading()) { @if (loading()) {
<div class="loading">Loading music details...</div> <div class="loading">Loading music details...</div>
} @else if (error()) { } @else if (error()) {
@@ -54,6 +64,12 @@ import { Music, Comment, MusicStatus, MusicType } from '@library/shared-types';
</div> </div>
<p class="artist">by {{ music()!.artist }}</p> <p class="artist">by {{ music()!.artist }}</p>
@if (authService.user()?.isAdmin) {
<div class="admin-actions">
<button (click)="editMusic()" class="btn btn-edit">✏️ Edit</button>
<button (click)="deleteMusic()" class="btn btn-delete">🗑️ Delete</button>
</div>
}
@if (music()!.rating) { @if (music()!.rating) {
<div class="info-row"> <div class="info-row">
@@ -322,6 +338,35 @@ import { Music, Comment, MusicStatus, MusicType } from '@library/shared-types';
color: #4b5563; color: #4b5563;
} }
.admin-actions {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: #fef3c7;
border-radius: 4px;
border: 1px solid #fbbf24;
}
.btn-edit {
background: #3b82f6;
color: white;
}
.btn-edit:hover {
background: #2563eb;
}
.btn-delete {
background: #ef4444;
color: white;
}
.btn-delete:hover {
background: #dc2626;
}
.info-row { .info-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -521,7 +566,36 @@ import { Music, Comment, MusicStatus, MusicType } from '@library/shared-types';
font-size: 1.5rem; font-size: 1.5rem;
} }
.info-row {
.admin-actions {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: #fef3c7;
border-radius: 4px;
border: 1px solid #fbbf24;
}
.btn-edit {
background: #3b82f6;
color: white;
}
.btn-edit:hover {
background: #2563eb;
}
.btn-delete {
background: #ef4444;
color: white;
}
.btn-delete:hover {
background: #dc2626;
}
.info-row {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: 0.25rem; gap: 0.25rem;
@@ -547,6 +621,7 @@ export class MusicDetailComponent implements OnInit {
commentsLoading = signal(false); commentsLoading = signal(false);
error = signal<string | null>(null); error = signal<string | null>(null);
newCommentContent = ''; newCommentContent = '';
showEditForm = signal(false);
ngOnInit() { ngOnInit() {
const musicId = this.route.snapshot.paramMap.get('id'); const musicId = this.route.snapshot.paramMap.get('id');
@@ -664,4 +739,45 @@ export class MusicDetailComponent implements OnInit {
return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`; return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`;
} }
} }
}
editMusic() {
this.showEditForm.set(true);
}
cancelEdit() {
this.showEditForm.set(false);
}
saveEdit(data: UpdateMusicDto) {
const music = this.music();
if (!music) return;
this.musicService.updateMusic(music.id, data).subscribe({
next: (updatedMusic) => {
this.music.set(updatedMusic);
this.showEditForm.set(false);
},
error: (err) => {
alert('Failed to update music: ' + (err.error?.message || 'Unknown error'));
}
});
}
deleteMusic() {
const music = this.music();
if (!music) return;
if (!confirm(`Are you sure you want to delete "${music.title}"? This action cannot be undone.`)) {
return;
}
this.musicService.deleteMusic(music.id).subscribe({
next: () => {
this.router.navigate(['/music']);
},
error: (err) => {
alert('Failed to delete music: ' + (err.error?.message || 'Unknown error'));
}
});
}
}
@@ -1571,6 +1571,9 @@ export class MusicListComponent implements OnInit {
this.editTagInput = ''; this.editTagInput = '';
this.editLinkTitle = ''; this.editLinkTitle = '';
this.editLinkUrl = ''; this.editLinkUrl = '';
// Scroll to top so the form is visible
window.scrollTo({ top: 0, behavior: 'smooth' });
} }
cancelEdit() { cancelEdit() {
@@ -0,0 +1,405 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan, Hikari
*/
import { Component, Input, Output, EventEmitter, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Art, CreateArtDto, UpdateArtDto, Link } from '@library/shared-types';
@Component({
selector: 'app-art-form',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<form (ngSubmit)="onSubmit()" class="art-form">
<h3>{{ mode === 'add' ? 'Add New Art' : 'Edit Art' }}</h3>
<div class="form-group">
<label for="title">Title</label>
<input
type="text"
id="title"
[(ngModel)]="formData.title"
name="title"
required
placeholder="Enter artwork title"
>
</div>
<div class="form-group">
<label for="artist">Artist</label>
<input
type="text"
id="artist"
[(ngModel)]="formData.artist"
name="artist"
required
placeholder="Enter artist name"
>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea
id="description"
[(ngModel)]="formData.description"
name="description"
rows="3"
placeholder="Description of the artwork..."
></textarea>
</div>
<div class="form-group">
<label for="imageUrl">Image (max 500KB)</label>
<input
type="file"
id="imageUrl"
name="imageUrl"
accept="image/*"
(change)="onImageSelected($event)"
>
@if (imagePreview()) {
<div class="image-preview">
<img [src]="imagePreview()" alt="Artwork preview">
<button type="button" (click)="clearImage()" 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 formData.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
<button type="button" (click)="removeTag(i)" class="tag-remove">×</button>
</span>
}
<input
type="text"
[(ngModel)]="tagInput"
name="tagInput"
placeholder="Add a tag and press Enter"
(keydown.enter)="addTag(); $event.preventDefault()"
>
</div>
</div>
<div class="form-group" aria-label="External Links">
<div class="links-list">
@for (link of formData.links; track link.url; let i = $index) {
<div class="link-item">
<span>{{ link.title }}: {{ link.url }}</span>
<button type="button" (click)="removeLink(i)" class="btn btn-danger btn-sm">×</button>
</div>
}
</div>
<div class="link-add-form">
<input
type="text"
[(ngModel)]="linkTitle"
name="linkTitle"
placeholder="Link title (e.g., Artist Portfolio)"
>
<input
type="url"
[(ngModel)]="linkUrl"
name="linkUrl"
placeholder="https://..."
>
<button type="button" (click)="addLink()" class="btn btn-secondary btn-sm">Add Link</button>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ mode === 'add' ? 'Add Art' : 'Save Changes' }}</button>
<button type="button" (click)="onCancel()" class="btn btn-secondary">Cancel</button>
</div>
</form>
`,
styles: [`
.art-form {
background: white;
padding: 1.5rem;
border-radius: 8px;
border: 1px solid #e5e7eb;
margin-bottom: 2rem;
}
.art-form h3 {
margin: 0 0 1.5rem 0;
color: #1f2937;
font-size: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #374151;
}
.form-group input[type="text"],
.form-group input[type="url"],
.form-group textarea {
width: 100%;
padding: 0.625rem;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 1rem;
font-family: inherit;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: #ff6b6b;
}
.tags-input-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.625rem;
border: 1px solid #d1d5db;
border-radius: 4px;
min-height: 44px;
}
.tags-input-container input {
flex: 1;
border: none;
outline: none;
font-size: 1rem;
min-width: 150px;
}
.tag {
background: #ff6b6b;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.tag-remove {
background: none;
border: none;
color: white;
cursor: pointer;
font-size: 1.25rem;
line-height: 1;
padding: 0;
}
.links-list {
margin-bottom: 0.75rem;
}
.link-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
background: #f3f4f6;
border-radius: 4px;
margin-bottom: 0.5rem;
}
.link-add-form {
display: grid;
grid-template-columns: 1fr 2fr auto;
gap: 0.5rem;
}
.image-preview {
margin-top: 0.75rem;
display: flex;
align-items: center;
gap: 1rem;
}
.image-preview img {
max-width: 200px;
max-height: 200px;
border-radius: 4px;
border: 1px solid #e5e7eb;
}
.error-text {
color: #dc2626;
font-size: 0.875rem;
display: block;
margin-top: 0.5rem;
}
.form-actions {
display: flex;
gap: 0.75rem;
margin-top: 1.5rem;
}
.btn {
padding: 0.625rem 1.25rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
transition: all 0.3s;
font-weight: 500;
}
.btn:hover {
opacity: 0.9;
}
.btn-primary {
background: #ff6b6b;
color: white;
}
.btn-secondary {
background: #6b7280;
color: white;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
}
`]
})
export class ArtFormComponent implements OnInit {
@Input() mode: 'add' | 'edit' = 'add';
@Input() art?: Art;
@Input() initialData?: Partial<CreateArtDto>;
@Output() save = new EventEmitter<CreateArtDto | UpdateArtDto>();
@Output() cancel = new EventEmitter<void>();
formData: Partial<CreateArtDto | UpdateArtDto> = {
tags: [],
links: []
};
tagInput = '';
linkTitle = '';
linkUrl = '';
imagePreview = signal<string | null>(null);
imageError = signal<string | null>(null);
ngOnInit() {
if (this.mode === 'edit' && this.art) {
this.formData = {
title: this.art.title,
artist: this.art.artist,
description: this.art.description,
imageUrl: this.art.imageUrl,
tags: [...(this.art.tags || [])],
links: [...(this.art.links || [])]
};
this.imagePreview.set(this.art.imageUrl || null);
} else {
this.formData = {
tags: [],
links: [],
...this.initialData
};
if (this.initialData?.imageUrl) {
this.imagePreview.set(this.initialData.imageUrl);
}
}
}
addTag() {
if (this.tagInput.trim() && !this.formData.tags?.includes(this.tagInput.trim())) {
this.formData.tags = [...(this.formData.tags || []), this.tagInput.trim()];
this.tagInput = '';
}
}
removeTag(index: number) {
this.formData.tags = this.formData.tags?.filter((_, i) => i !== index) || [];
}
addLink() {
if (this.linkTitle.trim() && this.linkUrl.trim()) {
const newLink: Link = {
title: this.linkTitle.trim(),
url: this.linkUrl.trim()
};
this.formData.links = [...(this.formData.links || []), newLink];
this.linkTitle = '';
this.linkUrl = '';
}
}
removeLink(index: number) {
this.formData.links = this.formData.links?.filter((_, i) => i !== index) || [];
}
onImageSelected(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
if (file.size > 500000) {
this.imageError.set('Image must be under 500KB');
input.value = '';
return;
}
this.imageError.set(null);
const reader = new FileReader();
reader.onload = () => {
const base64String = reader.result as string;
this.formData.imageUrl = base64String;
this.imagePreview.set(base64String);
};
reader.readAsDataURL(file);
}
clearImage() {
this.formData.imageUrl = undefined;
this.imagePreview.set(null);
}
onSubmit() {
if (!this.formData.title || !this.formData.artist || !this.formData.imageUrl) {
return;
}
const data: CreateArtDto | UpdateArtDto = {
...this.formData as CreateArtDto | UpdateArtDto
};
this.save.emit(data);
}
onCancel() {
this.cancel.emit();
}
}
@@ -0,0 +1,543 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan, Hikari
*/
import { Component, Input, Output, EventEmitter, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Book, BookStatus, CreateBookDto, UpdateBookDto, Link } from '@library/shared-types';
@Component({
selector: 'app-book-form',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<form (ngSubmit)="onSubmit()" class="book-form">
<h3>{{ mode === 'add' ? 'Add New Book' : 'Edit Book' }}</h3>
<div class="form-group">
<label for="title">Title</label>
<input
type="text"
id="title"
[(ngModel)]="formData.title"
name="title"
required
placeholder="Enter book title"
>
</div>
<div class="form-group">
<label for="author">Author</label>
<input
type="text"
id="author"
[(ngModel)]="formData.author"
name="author"
required
placeholder="Enter author name"
>
</div>
<div class="form-group">
<label for="isbn">ISBN (optional)</label>
<input
type="text"
id="isbn"
[(ngModel)]="formData.isbn"
name="isbn"
placeholder="Enter ISBN"
>
</div>
<div class="form-group">
<label for="status">Status</label>
<select id="status" [(ngModel)]="formData.status" name="status" required>
<option [value]="BookStatus.reading">Currently Reading</option>
<option [value]="BookStatus.finished">Finished</option>
<option [value]="BookStatus.toRead">To Read</option>
<option [value]="BookStatus.retired">Retired</option>
</select>
</div>
<div class="form-group">
<label for="dateStarted">Date Started</label>
<input
type="date"
id="dateStarted"
[(ngModel)]="formData.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="dateFinished">Date Finished</label>
<input
type="date"
id="dateFinished"
[(ngModel)]="formData.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group">
<label for="rating">Rating (1-10)</label>
<input
type="number"
id="rating"
[(ngModel)]="formData.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)]="timeHours"
name="timeHours"
min="0"
placeholder="0"
(ngModelChange)="updateTimeSpent()"
>
</div>
<div class="form-group">
<label for="timeMinutes">Time Spent (Minutes)</label>
<input
type="number"
id="timeMinutes"
[(ngModel)]="timeMinutes"
name="timeMinutes"
min="0"
max="59"
placeholder="0"
(ngModelChange)="updateTimeSpent()"
>
</div>
</div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea
id="notes"
[(ngModel)]="formData.notes"
name="notes"
rows="3"
placeholder="Any thoughts about the book..."
></textarea>
</div>
<div class="form-group">
<label for="series">Series (optional)</label>
<input
type="text"
id="series"
[(ngModel)]="formData.series"
name="series"
placeholder="e.g., Harry Potter"
>
</div>
<div class="form-group">
<label for="seriesOrder">Series Order (optional)</label>
<input
type="number"
id="seriesOrder"
[(ngModel)]="formData.seriesOrder"
name="seriesOrder"
min="1"
placeholder="Order in series"
>
</div>
<div class="form-group">
<label for="coverImage">Cover Image (max 500KB)</label>
<input
type="file"
id="coverImage"
name="coverImage"
accept="image/*"
(change)="onImageSelected($event)"
>
@if (imagePreview()) {
<div class="image-preview">
<img [src]="imagePreview()" alt="Cover preview">
<button type="button" (click)="clearImage()" 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 formData.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
<button type="button" (click)="removeTag(i)" class="tag-remove">×</button>
</span>
}
<input
type="text"
[(ngModel)]="tagInput"
name="tagInput"
placeholder="Add a tag and press Enter"
(keydown.enter)="addTag(); $event.preventDefault()"
>
</div>
</div>
<div class="form-group" aria-label="External Links">
<div class="links-list">
@for (link of formData.links; track link.url; let i = $index) {
<div class="link-item">
<span>{{ link.title }}: {{ link.url }}</span>
<button type="button" (click)="removeLink(i)" class="btn btn-danger btn-sm">×</button>
</div>
}
</div>
<div class="link-add-form">
<input
type="text"
[(ngModel)]="linkTitle"
name="linkTitle"
placeholder="Link title (e.g., Goodreads)"
>
<input
type="url"
[(ngModel)]="linkUrl"
name="linkUrl"
placeholder="https://..."
>
<button type="button" (click)="addLink()" class="btn btn-secondary btn-sm">Add Link</button>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ mode === 'add' ? 'Add Book' : 'Save Changes' }}</button>
<button type="button" (click)="onCancel()" class="btn btn-secondary">Cancel</button>
</div>
</form>
`,
styles: [`
.book-form {
background: white;
padding: 1.5rem;
border-radius: 8px;
border: 1px solid #e5e7eb;
margin-bottom: 2rem;
}
.book-form h3 {
margin: 0 0 1.5rem 0;
color: #1f2937;
font-size: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #374151;
}
.form-group input[type="text"],
.form-group input[type="number"],
.form-group input[type="date"],
.form-group input[type="url"],
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.625rem;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 1rem;
font-family: inherit;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: #ff6b6b;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.tags-input-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.625rem;
border: 1px solid #d1d5db;
border-radius: 4px;
min-height: 44px;
}
.tags-input-container input {
flex: 1;
border: none;
outline: none;
font-size: 1rem;
min-width: 150px;
}
.tag {
background: #ff6b6b;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.tag-remove {
background: none;
border: none;
color: white;
cursor: pointer;
font-size: 1.25rem;
line-height: 1;
padding: 0;
}
.links-list {
margin-bottom: 0.75rem;
}
.link-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
background: #f3f4f6;
border-radius: 4px;
margin-bottom: 0.5rem;
}
.link-add-form {
display: grid;
grid-template-columns: 1fr 2fr auto;
gap: 0.5rem;
}
.image-preview {
margin-top: 0.75rem;
display: flex;
align-items: center;
gap: 1rem;
}
.image-preview img {
max-width: 200px;
max-height: 200px;
border-radius: 4px;
border: 1px solid #e5e7eb;
}
.error-text {
color: #dc2626;
font-size: 0.875rem;
display: block;
margin-top: 0.5rem;
}
.form-actions {
display: flex;
gap: 0.75rem;
margin-top: 1.5rem;
}
.btn {
padding: 0.625rem 1.25rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
transition: all 0.3s;
font-weight: 500;
}
.btn:hover {
opacity: 0.9;
}
.btn-primary {
background: #ff6b6b;
color: white;
}
.btn-secondary {
background: #6b7280;
color: white;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
}
`]
})
export class BookFormComponent implements OnInit {
@Input() mode: 'add' | 'edit' = 'add';
@Input() book?: Book;
@Input() initialData?: Partial<CreateBookDto>;
@Output() save = new EventEmitter<CreateBookDto | UpdateBookDto>();
@Output() cancel = new EventEmitter<void>();
BookStatus = BookStatus;
formData: Partial<CreateBookDto | UpdateBookDto> = {
tags: [],
links: []
};
timeHours = 0;
timeMinutes = 0;
tagInput = '';
linkTitle = '';
linkUrl = '';
imagePreview = signal<string | null>(null);
imageError = signal<string | null>(null);
ngOnInit() {
if (this.mode === 'edit' && this.book) {
this.formData = {
title: this.book.title,
author: this.book.author,
isbn: this.book.isbn,
status: this.book.status,
dateStarted: this.book.dateStarted,
dateFinished: this.book.dateFinished,
rating: this.book.rating,
notes: this.book.notes,
coverImage: this.book.coverImage,
tags: [...(this.book.tags || [])],
links: [...(this.book.links || [])],
series: this.book.series,
seriesOrder: this.book.seriesOrder,
timeSpent: this.book.timeSpent
};
if (this.book.timeSpent) {
this.timeHours = Math.floor(this.book.timeSpent / 60);
this.timeMinutes = this.book.timeSpent % 60;
}
this.imagePreview.set(this.book.coverImage || null);
} else {
this.formData = {
status: BookStatus.toRead,
tags: [],
links: [],
...this.initialData
};
if (this.initialData?.coverImage) {
this.imagePreview.set(this.initialData.coverImage);
}
}
}
updateTimeSpent() {
this.formData.timeSpent = (this.timeHours * 60) + this.timeMinutes;
}
addTag() {
if (this.tagInput.trim() && !this.formData.tags?.includes(this.tagInput.trim())) {
this.formData.tags = [...(this.formData.tags || []), this.tagInput.trim()];
this.tagInput = '';
}
}
removeTag(index: number) {
this.formData.tags = this.formData.tags?.filter((_, i) => i !== index) || [];
}
addLink() {
if (this.linkTitle.trim() && this.linkUrl.trim()) {
const newLink: Link = {
title: this.linkTitle.trim(),
url: this.linkUrl.trim()
};
this.formData.links = [...(this.formData.links || []), newLink];
this.linkTitle = '';
this.linkUrl = '';
}
}
removeLink(index: number) {
this.formData.links = this.formData.links?.filter((_, i) => i !== index) || [];
}
onImageSelected(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
if (file.size > 500000) {
this.imageError.set('Image must be under 500KB');
input.value = '';
return;
}
this.imageError.set(null);
const reader = new FileReader();
reader.onload = () => {
const base64String = reader.result as string;
this.formData.coverImage = base64String;
this.imagePreview.set(base64String);
};
reader.readAsDataURL(file);
}
clearImage() {
this.formData.coverImage = undefined;
this.imagePreview.set(null);
}
onSubmit() {
if (!this.formData.title || !this.formData.author || !this.formData.status) {
return;
}
const data: CreateBookDto | UpdateBookDto = {
...this.formData as CreateBookDto | UpdateBookDto,
dateStarted: this.formData.dateStarted ? new Date(this.formData.dateStarted) : undefined,
dateFinished: this.formData.dateFinished ? new Date(this.formData.dateFinished) : undefined
};
this.save.emit(data);
}
onCancel() {
this.cancel.emit();
}
}
@@ -0,0 +1,531 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan, Hikari
*/
import { Component, Input, Output, EventEmitter, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Game, GameStatus, CreateGameDto, UpdateGameDto, Link } from '@library/shared-types';
@Component({
selector: 'app-game-form',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<form (ngSubmit)="onSubmit()" class="game-form">
<h3>{{ mode === 'add' ? 'Add New Game' : 'Edit Game' }}</h3>
<div class="form-group">
<label for="title">Title</label>
<input
type="text"
id="title"
[(ngModel)]="formData.title"
name="title"
required
placeholder="Enter game title"
>
</div>
<div class="form-group">
<label for="platform">Platform</label>
<input
type="text"
id="platform"
[(ngModel)]="formData.platform"
name="platform"
placeholder="PC, PS5, Xbox, Switch, etc."
>
</div>
<div class="form-group">
<label for="status">Status</label>
<select id="status" [(ngModel)]="formData.status" name="status" required>
<option [value]="GameStatus.playing">Currently Playing</option>
<option [value]="GameStatus.completed">Completed</option>
<option [value]="GameStatus.backlog">In Backlog</option>
<option [value]="GameStatus.retired">Retired</option>
</select>
</div>
<div class="form-group">
<label for="dateStarted">Date Started</label>
<input
type="date"
id="dateStarted"
[(ngModel)]="formData.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="dateFinished">Date Finished</label>
<input
type="date"
id="dateFinished"
[(ngModel)]="formData.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group">
<label for="rating">Rating (1-10)</label>
<input
type="number"
id="rating"
[(ngModel)]="formData.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)]="timeHours"
name="timeHours"
min="0"
placeholder="0"
(ngModelChange)="updateTimeSpent()"
>
</div>
<div class="form-group">
<label for="timeMinutes">Time Spent (Minutes)</label>
<input
type="number"
id="timeMinutes"
[(ngModel)]="timeMinutes"
name="timeMinutes"
min="0"
max="59"
placeholder="0"
(ngModelChange)="updateTimeSpent()"
>
</div>
</div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea
id="notes"
[(ngModel)]="formData.notes"
name="notes"
rows="3"
placeholder="Any thoughts about the game..."
></textarea>
</div>
<div class="form-group">
<label for="series">Series (optional)</label>
<input
type="text"
id="series"
[(ngModel)]="formData.series"
name="series"
placeholder="e.g., The Legend of Zelda"
>
</div>
<div class="form-group">
<label for="seriesOrder">Series Order (optional)</label>
<input
type="number"
id="seriesOrder"
[(ngModel)]="formData.seriesOrder"
name="seriesOrder"
min="1"
placeholder="Order in series"
>
</div>
<div class="form-group">
<label for="coverImage">Box Art (max 500KB)</label>
<input
type="file"
id="coverImage"
name="coverImage"
accept="image/*"
(change)="onImageSelected($event)"
>
@if (imagePreview()) {
<div class="image-preview">
<img [src]="imagePreview()" alt="Box art preview">
<button type="button" (click)="clearImage()" 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 formData.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
<button type="button" (click)="removeTag(i)" class="tag-remove">×</button>
</span>
}
<input
type="text"
[(ngModel)]="tagInput"
name="tagInput"
placeholder="Add a tag and press Enter"
(keydown.enter)="addTag(); $event.preventDefault()"
>
</div>
</div>
<div class="form-group" aria-label="External Links">
<div class="links-list">
@for (link of formData.links; track link.url; let i = $index) {
<div class="link-item">
<span>{{ link.title }}: {{ link.url }}</span>
<button type="button" (click)="removeLink(i)" class="btn btn-danger btn-sm">×</button>
</div>
}
</div>
<div class="link-add-form">
<input
type="text"
[(ngModel)]="linkTitle"
name="linkTitle"
placeholder="Link title (e.g., Steam)"
>
<input
type="url"
[(ngModel)]="linkUrl"
name="linkUrl"
placeholder="https://..."
>
<button type="button" (click)="addLink()" class="btn btn-secondary btn-sm">Add Link</button>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ mode === 'add' ? 'Add Game' : 'Save Changes' }}</button>
<button type="button" (click)="onCancel()" class="btn btn-secondary">Cancel</button>
</div>
</form>
`,
styles: [`
.game-form {
background: white;
padding: 1.5rem;
border-radius: 8px;
border: 1px solid #e5e7eb;
margin-bottom: 2rem;
}
.game-form h3 {
margin: 0 0 1.5rem 0;
color: #1f2937;
font-size: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #374151;
}
.form-group input[type="text"],
.form-group input[type="number"],
.form-group input[type="date"],
.form-group input[type="url"],
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.625rem;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 1rem;
font-family: inherit;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: #ff6b6b;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.tags-input-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.625rem;
border: 1px solid #d1d5db;
border-radius: 4px;
min-height: 44px;
}
.tags-input-container input {
flex: 1;
border: none;
outline: none;
font-size: 1rem;
min-width: 150px;
}
.tag {
background: #ff6b6b;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.tag-remove {
background: none;
border: none;
color: white;
cursor: pointer;
font-size: 1.25rem;
line-height: 1;
padding: 0;
}
.links-list {
margin-bottom: 0.75rem;
}
.link-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
background: #f3f4f6;
border-radius: 4px;
margin-bottom: 0.5rem;
}
.link-add-form {
display: grid;
grid-template-columns: 1fr 2fr auto;
gap: 0.5rem;
}
.image-preview {
margin-top: 0.75rem;
display: flex;
align-items: center;
gap: 1rem;
}
.image-preview img {
max-width: 200px;
max-height: 200px;
border-radius: 4px;
border: 1px solid #e5e7eb;
}
.error-text {
color: #dc2626;
font-size: 0.875rem;
display: block;
margin-top: 0.5rem;
}
.form-actions {
display: flex;
gap: 0.75rem;
margin-top: 1.5rem;
}
.btn {
padding: 0.625rem 1.25rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
transition: all 0.3s;
font-weight: 500;
}
.btn:hover {
opacity: 0.9;
}
.btn-primary {
background: #ff6b6b;
color: white;
}
.btn-secondary {
background: #6b7280;
color: white;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
}
`]
})
export class GameFormComponent implements OnInit {
@Input() mode: 'add' | 'edit' = 'add';
@Input() game?: Game;
@Input() initialData?: Partial<CreateGameDto>;
@Output() save = new EventEmitter<CreateGameDto | UpdateGameDto>();
@Output() cancel = new EventEmitter<void>();
GameStatus = GameStatus;
formData: Partial<CreateGameDto | UpdateGameDto> = {
tags: [],
links: []
};
timeHours = 0;
timeMinutes = 0;
tagInput = '';
linkTitle = '';
linkUrl = '';
imagePreview = signal<string | null>(null);
imageError = signal<string | null>(null);
ngOnInit() {
if (this.mode === 'edit' && this.game) {
this.formData = {
title: this.game.title,
platform: this.game.platform,
status: this.game.status,
dateStarted: this.game.dateStarted,
dateFinished: this.game.dateFinished,
rating: this.game.rating,
notes: this.game.notes,
coverImage: this.game.coverImage,
tags: [...(this.game.tags || [])],
links: [...(this.game.links || [])],
series: this.game.series,
seriesOrder: this.game.seriesOrder,
timeSpent: this.game.timeSpent
};
if (this.game.timeSpent) {
this.timeHours = Math.floor(this.game.timeSpent / 60);
this.timeMinutes = this.game.timeSpent % 60;
}
this.imagePreview.set(this.game.coverImage || null);
} else {
// Add mode - use initialData if provided, otherwise defaults
this.formData = {
status: GameStatus.backlog,
tags: [],
links: [],
...this.initialData
};
if (this.initialData?.coverImage) {
this.imagePreview.set(this.initialData.coverImage);
}
}
}
updateTimeSpent() {
this.formData.timeSpent = (this.timeHours * 60) + this.timeMinutes;
}
addTag() {
if (this.tagInput.trim() && !this.formData.tags?.includes(this.tagInput.trim())) {
this.formData.tags = [...(this.formData.tags || []), this.tagInput.trim()];
this.tagInput = '';
}
}
removeTag(index: number) {
this.formData.tags = this.formData.tags?.filter((_, i) => i !== index) || [];
}
addLink() {
if (this.linkTitle.trim() && this.linkUrl.trim()) {
const newLink: Link = {
title: this.linkTitle.trim(),
url: this.linkUrl.trim()
};
this.formData.links = [...(this.formData.links || []), newLink];
this.linkTitle = '';
this.linkUrl = '';
}
}
removeLink(index: number) {
this.formData.links = this.formData.links?.filter((_, i) => i !== index) || [];
}
onImageSelected(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
if (file.size > 500000) {
this.imageError.set('Image must be under 500KB');
input.value = '';
return;
}
this.imageError.set(null);
const reader = new FileReader();
reader.onload = () => {
const base64String = reader.result as string;
this.formData.coverImage = base64String;
this.imagePreview.set(base64String);
};
reader.readAsDataURL(file);
}
clearImage() {
this.formData.coverImage = undefined;
this.imagePreview.set(null);
}
onSubmit() {
if (!this.formData.title || !this.formData.status) {
return;
}
const data: CreateGameDto | UpdateGameDto = {
...this.formData as CreateGameDto | UpdateGameDto,
dateStarted: this.formData.dateStarted ? new Date(this.formData.dateStarted) : undefined,
dateFinished: this.formData.dateFinished ? new Date(this.formData.dateFinished) : undefined
};
this.save.emit(data);
}
onCancel() {
this.cancel.emit();
}
}
@@ -0,0 +1,506 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan, Hikari
*/
import { Component, Input, Output, EventEmitter, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Link } from '@library/shared-types';
@Component({
selector: 'app-manga-form',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<form (ngSubmit)="onSubmit()" class="manga-form">
<h3>{{ mode === 'add' ? 'Add New Manga' : 'Edit Manga' }}</h3>
<div class="form-group">
<label for="title">Title</label>
<input
type="text"
id="title"
[(ngModel)]="formData.title"
name="title"
required
placeholder="Enter manga title"
>
</div>
<div class="form-group">
<label for="author">Author</label>
<input
type="text"
id="author"
[(ngModel)]="formData.author"
name="author"
required
placeholder="Enter author name"
>
</div>
<div class="form-group">
<label for="status">Status</label>
<select id="status" [(ngModel)]="formData.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)]="formData.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="dateFinished">Date Finished</label>
<input
type="date"
id="dateFinished"
[(ngModel)]="formData.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group">
<label for="rating">Rating (1-10)</label>
<input
type="number"
id="rating"
[(ngModel)]="formData.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)]="timeHours"
name="timeHours"
min="0"
placeholder="0"
(ngModelChange)="updateTimeSpent()"
>
</div>
<div class="form-group">
<label for="timeMinutes">Time Spent (Minutes)</label>
<input
type="number"
id="timeMinutes"
[(ngModel)]="timeMinutes"
name="timeMinutes"
min="0"
max="59"
placeholder="0"
(ngModelChange)="updateTimeSpent()"
>
</div>
</div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea
id="notes"
[(ngModel)]="formData.notes"
name="notes"
rows="3"
placeholder="Any thoughts about the manga..."
></textarea>
</div>
<div class="form-group">
<label for="coverImage">Cover Image (max 500KB)</label>
<input
type="file"
id="coverImage"
name="coverImage"
accept="image/*"
(change)="onImageSelected($event)"
>
@if (imagePreview()) {
<div class="image-preview">
<img [src]="imagePreview()" alt="Cover preview">
<button type="button" (click)="clearImage()" 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 formData.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
<button type="button" (click)="removeTag(i)" class="tag-remove">×</button>
</span>
}
<input
type="text"
[(ngModel)]="tagInput"
name="tagInput"
placeholder="Add a tag and press Enter"
(keydown.enter)="addTag(); $event.preventDefault()"
>
</div>
</div>
<div class="form-group" aria-label="External Links">
<div class="links-list">
@for (link of formData.links; track link.url; let i = $index) {
<div class="link-item">
<span>{{ link.title }}: {{ link.url }}</span>
<button type="button" (click)="removeLink(i)" class="btn btn-danger btn-sm">×</button>
</div>
}
</div>
<div class="link-add-form">
<input
type="text"
[(ngModel)]="linkTitle"
name="linkTitle"
placeholder="Link title (e.g., MyAnimeList)"
>
<input
type="url"
[(ngModel)]="linkUrl"
name="linkUrl"
placeholder="https://..."
>
<button type="button" (click)="addLink()" class="btn btn-secondary btn-sm">Add Link</button>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ mode === 'add' ? 'Add Manga' : 'Save Changes' }}</button>
<button type="button" (click)="onCancel()" class="btn btn-secondary">Cancel</button>
</div>
</form>
`,
styles: [`
.manga-form {
background: white;
padding: 1.5rem;
border-radius: 8px;
border: 1px solid #e5e7eb;
margin-bottom: 2rem;
}
.manga-form h3 {
margin: 0 0 1.5rem 0;
color: #1f2937;
font-size: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #374151;
}
.form-group input[type="text"],
.form-group input[type="number"],
.form-group input[type="date"],
.form-group input[type="url"],
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.625rem;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 1rem;
font-family: inherit;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: #ff6b6b;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.tags-input-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.625rem;
border: 1px solid #d1d5db;
border-radius: 4px;
min-height: 44px;
}
.tags-input-container input {
flex: 1;
border: none;
outline: none;
font-size: 1rem;
min-width: 150px;
}
.tag {
background: #ff6b6b;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.tag-remove {
background: none;
border: none;
color: white;
cursor: pointer;
font-size: 1.25rem;
line-height: 1;
padding: 0;
}
.links-list {
margin-bottom: 0.75rem;
}
.link-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
background: #f3f4f6;
border-radius: 4px;
margin-bottom: 0.5rem;
}
.link-add-form {
display: grid;
grid-template-columns: 1fr 2fr auto;
gap: 0.5rem;
}
.image-preview {
margin-top: 0.75rem;
display: flex;
align-items: center;
gap: 1rem;
}
.image-preview img {
max-width: 200px;
max-height: 200px;
border-radius: 4px;
border: 1px solid #e5e7eb;
}
.error-text {
color: #dc2626;
font-size: 0.875rem;
display: block;
margin-top: 0.5rem;
}
.form-actions {
display: flex;
gap: 0.75rem;
margin-top: 1.5rem;
}
.btn {
padding: 0.625rem 1.25rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
transition: all 0.3s;
font-weight: 500;
}
.btn:hover {
opacity: 0.9;
}
.btn-primary {
background: #ff6b6b;
color: white;
}
.btn-secondary {
background: #6b7280;
color: white;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
}
`]
})
export class MangaFormComponent implements OnInit {
@Input() mode: 'add' | 'edit' = 'add';
@Input() manga?: Manga;
@Input() initialData?: Partial<CreateMangaDto>;
@Output() save = new EventEmitter<CreateMangaDto | UpdateMangaDto>();
@Output() cancel = new EventEmitter<void>();
MangaStatus = MangaStatus;
formData: Partial<CreateMangaDto | UpdateMangaDto> = {
tags: [],
links: []
};
timeHours = 0;
timeMinutes = 0;
tagInput = '';
linkTitle = '';
linkUrl = '';
imagePreview = signal<string | null>(null);
imageError = signal<string | null>(null);
ngOnInit() {
if (this.mode === 'edit' && this.manga) {
this.formData = {
title: this.manga.title,
author: this.manga.author,
status: this.manga.status,
dateStarted: this.manga.dateStarted,
dateFinished: this.manga.dateFinished,
rating: this.manga.rating,
notes: this.manga.notes,
coverImage: this.manga.coverImage,
tags: [...(this.manga.tags || [])],
links: [...(this.manga.links || [])],
timeSpent: this.manga.timeSpent
};
if (this.manga.timeSpent) {
this.timeHours = Math.floor(this.manga.timeSpent / 60);
this.timeMinutes = this.manga.timeSpent % 60;
}
this.imagePreview.set(this.manga.coverImage || null);
} else {
this.formData = {
status: MangaStatus.wantToRead,
tags: [],
links: [],
...this.initialData
};
if (this.initialData?.coverImage) {
this.imagePreview.set(this.initialData.coverImage);
}
}
}
updateTimeSpent() {
this.formData.timeSpent = (this.timeHours * 60) + this.timeMinutes;
}
addTag() {
if (this.tagInput.trim() && !this.formData.tags?.includes(this.tagInput.trim())) {
this.formData.tags = [...(this.formData.tags || []), this.tagInput.trim()];
this.tagInput = '';
}
}
removeTag(index: number) {
this.formData.tags = this.formData.tags?.filter((_, i) => i !== index) || [];
}
addLink() {
if (this.linkTitle.trim() && this.linkUrl.trim()) {
const newLink: Link = {
title: this.linkTitle.trim(),
url: this.linkUrl.trim()
};
this.formData.links = [...(this.formData.links || []), newLink];
this.linkTitle = '';
this.linkUrl = '';
}
}
removeLink(index: number) {
this.formData.links = this.formData.links?.filter((_, i) => i !== index) || [];
}
onImageSelected(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
if (file.size > 500000) {
this.imageError.set('Image must be under 500KB');
input.value = '';
return;
}
this.imageError.set(null);
const reader = new FileReader();
reader.onload = () => {
const base64String = reader.result as string;
this.formData.coverImage = base64String;
this.imagePreview.set(base64String);
};
reader.readAsDataURL(file);
}
clearImage() {
this.formData.coverImage = undefined;
this.imagePreview.set(null);
}
onSubmit() {
if (!this.formData.title || !this.formData.author || !this.formData.status) {
return;
}
const data: CreateMangaDto | UpdateMangaDto = {
...this.formData as CreateMangaDto | UpdateMangaDto,
dateStarted: this.formData.dateStarted ? new Date(this.formData.dateStarted) : undefined,
dateFinished: this.formData.dateFinished ? new Date(this.formData.dateFinished) : undefined
};
this.save.emit(data);
}
onCancel() {
this.cancel.emit();
}
}
@@ -0,0 +1,518 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan, Hikari
*/
import { Component, Input, Output, EventEmitter, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Link } from '@library/shared-types';
@Component({
selector: 'app-music-form',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<form (ngSubmit)="onSubmit()" class="music-form">
<h3>{{ mode === 'add' ? 'Add New Music' : 'Edit Music' }}</h3>
<div class="form-group">
<label for="title">Title</label>
<input
type="text"
id="title"
[(ngModel)]="formData.title"
name="title"
required
placeholder="Enter album/single/EP title"
>
</div>
<div class="form-group">
<label for="artist">Artist</label>
<input
type="text"
id="artist"
[(ngModel)]="formData.artist"
name="artist"
required
placeholder="Enter artist name"
>
</div>
<div class="form-group">
<label for="type">Type</label>
<select id="type" [(ngModel)]="formData.type" name="type" required>
<option [value]="MusicType.album">Album</option>
<option [value]="MusicType.single">Single</option>
<option [value]="MusicType.ep">EP</option>
</select>
</div>
<div class="form-group">
<label for="status">Status</label>
<select id="status" [(ngModel)]="formData.status" name="status" required>
<option [value]="MusicStatus.listening">Currently Listening</option>
<option [value]="MusicStatus.completed">Completed</option>
<option [value]="MusicStatus.wantToListen">Want to Listen</option>
<option [value]="MusicStatus.retired">Retired</option>
</select>
</div>
<div class="form-group">
<label for="dateStarted">Date Started</label>
<input
type="date"
id="dateStarted"
[(ngModel)]="formData.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="dateFinished">Date Finished</label>
<input
type="date"
id="dateFinished"
[(ngModel)]="formData.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group">
<label for="rating">Rating (1-10)</label>
<input
type="number"
id="rating"
[(ngModel)]="formData.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)]="timeHours"
name="timeHours"
min="0"
placeholder="0"
(ngModelChange)="updateTimeSpent()"
>
</div>
<div class="form-group">
<label for="timeMinutes">Time Spent (Minutes)</label>
<input
type="number"
id="timeMinutes"
[(ngModel)]="timeMinutes"
name="timeMinutes"
min="0"
max="59"
placeholder="0"
(ngModelChange)="updateTimeSpent()"
>
</div>
</div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea
id="notes"
[(ngModel)]="formData.notes"
name="notes"
rows="3"
placeholder="Any thoughts about the music..."
></textarea>
</div>
<div class="form-group">
<label for="coverArt">Cover Art (max 500KB)</label>
<input
type="file"
id="coverArt"
name="coverArt"
accept="image/*"
(change)="onImageSelected($event)"
>
@if (imagePreview()) {
<div class="image-preview">
<img [src]="imagePreview()" alt="Cover preview">
<button type="button" (click)="clearImage()" 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 formData.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
<button type="button" (click)="removeTag(i)" class="tag-remove">×</button>
</span>
}
<input
type="text"
[(ngModel)]="tagInput"
name="tagInput"
placeholder="Add a tag and press Enter"
(keydown.enter)="addTag(); $event.preventDefault()"
>
</div>
</div>
<div class="form-group" aria-label="External Links">
<div class="links-list">
@for (link of formData.links; track link.url; let i = $index) {
<div class="link-item">
<span>{{ link.title }}: {{ link.url }}</span>
<button type="button" (click)="removeLink(i)" class="btn btn-danger btn-sm">×</button>
</div>
}
</div>
<div class="link-add-form">
<input
type="text"
[(ngModel)]="linkTitle"
name="linkTitle"
placeholder="Link title (e.g., Spotify)"
>
<input
type="url"
[(ngModel)]="linkUrl"
name="linkUrl"
placeholder="https://..."
>
<button type="button" (click)="addLink()" class="btn btn-secondary btn-sm">Add Link</button>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ mode === 'add' ? 'Add Music' : 'Save Changes' }}</button>
<button type="button" (click)="onCancel()" class="btn btn-secondary">Cancel</button>
</div>
</form>
`,
styles: [`
.music-form {
background: white;
padding: 1.5rem;
border-radius: 8px;
border: 1px solid #e5e7eb;
margin-bottom: 2rem;
}
.music-form h3 {
margin: 0 0 1.5rem 0;
color: #1f2937;
font-size: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #374151;
}
.form-group input[type="text"],
.form-group input[type="number"],
.form-group input[type="date"],
.form-group input[type="url"],
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.625rem;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 1rem;
font-family: inherit;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: #ff6b6b;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.tags-input-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.625rem;
border: 1px solid #d1d5db;
border-radius: 4px;
min-height: 44px;
}
.tags-input-container input {
flex: 1;
border: none;
outline: none;
font-size: 1rem;
min-width: 150px;
}
.tag {
background: #ff6b6b;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.tag-remove {
background: none;
border: none;
color: white;
cursor: pointer;
font-size: 1.25rem;
line-height: 1;
padding: 0;
}
.links-list {
margin-bottom: 0.75rem;
}
.link-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
background: #f3f4f6;
border-radius: 4px;
margin-bottom: 0.5rem;
}
.link-add-form {
display: grid;
grid-template-columns: 1fr 2fr auto;
gap: 0.5rem;
}
.image-preview {
margin-top: 0.75rem;
display: flex;
align-items: center;
gap: 1rem;
}
.image-preview img {
max-width: 200px;
max-height: 200px;
border-radius: 4px;
border: 1px solid #e5e7eb;
}
.error-text {
color: #dc2626;
font-size: 0.875rem;
display: block;
margin-top: 0.5rem;
}
.form-actions {
display: flex;
gap: 0.75rem;
margin-top: 1.5rem;
}
.btn {
padding: 0.625rem 1.25rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
transition: all 0.3s;
font-weight: 500;
}
.btn:hover {
opacity: 0.9;
}
.btn-primary {
background: #ff6b6b;
color: white;
}
.btn-secondary {
background: #6b7280;
color: white;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
}
`]
})
export class MusicFormComponent implements OnInit {
@Input() mode: 'add' | 'edit' = 'add';
@Input() music?: Music;
@Input() initialData?: Partial<CreateMusicDto>;
@Output() save = new EventEmitter<CreateMusicDto | UpdateMusicDto>();
@Output() cancel = new EventEmitter<void>();
MusicStatus = MusicStatus;
MusicType = MusicType;
formData: Partial<CreateMusicDto | UpdateMusicDto> = {
tags: [],
links: []
};
timeHours = 0;
timeMinutes = 0;
tagInput = '';
linkTitle = '';
linkUrl = '';
imagePreview = signal<string | null>(null);
imageError = signal<string | null>(null);
ngOnInit() {
if (this.mode === 'edit' && this.music) {
this.formData = {
title: this.music.title,
artist: this.music.artist,
type: this.music.type,
status: this.music.status,
dateStarted: this.music.dateStarted,
dateFinished: this.music.dateFinished,
rating: this.music.rating,
notes: this.music.notes,
coverArt: this.music.coverArt,
tags: [...(this.music.tags || [])],
links: [...(this.music.links || [])],
timeSpent: this.music.timeSpent
};
if (this.music.timeSpent) {
this.timeHours = Math.floor(this.music.timeSpent / 60);
this.timeMinutes = this.music.timeSpent % 60;
}
this.imagePreview.set(this.music.coverArt || null);
} else {
this.formData = {
type: MusicType.album,
status: MusicStatus.wantToListen,
tags: [],
links: [],
...this.initialData
};
if (this.initialData?.coverArt) {
this.imagePreview.set(this.initialData.coverArt);
}
}
}
updateTimeSpent() {
this.formData.timeSpent = (this.timeHours * 60) + this.timeMinutes;
}
addTag() {
if (this.tagInput.trim() && !this.formData.tags?.includes(this.tagInput.trim())) {
this.formData.tags = [...(this.formData.tags || []), this.tagInput.trim()];
this.tagInput = '';
}
}
removeTag(index: number) {
this.formData.tags = this.formData.tags?.filter((_, i) => i !== index) || [];
}
addLink() {
if (this.linkTitle.trim() && this.linkUrl.trim()) {
const newLink: Link = {
title: this.linkTitle.trim(),
url: this.linkUrl.trim()
};
this.formData.links = [...(this.formData.links || []), newLink];
this.linkTitle = '';
this.linkUrl = '';
}
}
removeLink(index: number) {
this.formData.links = this.formData.links?.filter((_, i) => i !== index) || [];
}
onImageSelected(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
if (file.size > 500000) {
this.imageError.set('Image must be under 500KB');
input.value = '';
return;
}
this.imageError.set(null);
const reader = new FileReader();
reader.onload = () => {
const base64String = reader.result as string;
this.formData.coverArt = base64String;
this.imagePreview.set(base64String);
};
reader.readAsDataURL(file);
}
clearImage() {
this.formData.coverArt = undefined;
this.imagePreview.set(null);
}
onSubmit() {
if (!this.formData.title || !this.formData.artist || !this.formData.type || !this.formData.status) {
return;
}
const data: CreateMusicDto | UpdateMusicDto = {
...this.formData as CreateMusicDto | UpdateMusicDto,
dateStarted: this.formData.dateStarted ? new Date(this.formData.dateStarted) : undefined,
dateFinished: this.formData.dateFinished ? new Date(this.formData.dateFinished) : undefined
};
this.save.emit(data);
}
onCancel() {
this.cancel.emit();
}
}
@@ -0,0 +1,506 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan, Hikari
*/
import { Component, Input, Output, EventEmitter, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Link } from '@library/shared-types';
@Component({
selector: 'app-show-form',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<form (ngSubmit)="onSubmit()" class="show-form">
<h3>{{ mode === 'add' ? 'Add New Show' : 'Edit Show' }}</h3>
<div class="form-group">
<label for="title">Title</label>
<input
type="text"
id="title"
[(ngModel)]="formData.title"
name="title"
required
placeholder="Enter show title"
>
</div>
<div class="form-group">
<label for="type">Type</label>
<select id="type" [(ngModel)]="formData.type" name="type" required>
<option [value]="ShowType.tvSeries">TV Series</option>
<option [value]="ShowType.anime">Anime</option>
<option [value]="ShowType.film">Film</option>
<option [value]="ShowType.documentary">Documentary</option>
</select>
</div>
<div class="form-group">
<label for="status">Status</label>
<select id="status" [(ngModel)]="formData.status" name="status" required>
<option [value]="ShowStatus.watching">Currently Watching</option>
<option [value]="ShowStatus.completed">Completed</option>
<option [value]="ShowStatus.wantToWatch">Want to Watch</option>
<option [value]="ShowStatus.retired">Retired</option>
</select>
</div>
<div class="form-group">
<label for="dateStarted">Date Started</label>
<input
type="date"
id="dateStarted"
[(ngModel)]="formData.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="dateFinished">Date Finished</label>
<input
type="date"
id="dateFinished"
[(ngModel)]="formData.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group">
<label for="rating">Rating (1-10)</label>
<input
type="number"
id="rating"
[(ngModel)]="formData.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)]="timeHours"
name="timeHours"
min="0"
placeholder="0"
(ngModelChange)="updateTimeSpent()"
>
</div>
<div class="form-group">
<label for="timeMinutes">Time Spent (Minutes)</label>
<input
type="number"
id="timeMinutes"
[(ngModel)]="timeMinutes"
name="timeMinutes"
min="0"
max="59"
placeholder="0"
(ngModelChange)="updateTimeSpent()"
>
</div>
</div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea
id="notes"
[(ngModel)]="formData.notes"
name="notes"
rows="3"
placeholder="Any thoughts about the show..."
></textarea>
</div>
<div class="form-group">
<label for="coverImage">Cover Image (max 500KB)</label>
<input
type="file"
id="coverImage"
name="coverImage"
accept="image/*"
(change)="onImageSelected($event)"
>
@if (imagePreview()) {
<div class="image-preview">
<img [src]="imagePreview()" alt="Cover preview">
<button type="button" (click)="clearImage()" 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 formData.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
<button type="button" (click)="removeTag(i)" class="tag-remove">×</button>
</span>
}
<input
type="text"
[(ngModel)]="tagInput"
name="tagInput"
placeholder="Add a tag and press Enter"
(keydown.enter)="addTag(); $event.preventDefault()"
>
</div>
</div>
<div class="form-group" aria-label="External Links">
<div class="links-list">
@for (link of formData.links; track link.url; let i = $index) {
<div class="link-item">
<span>{{ link.title }}: {{ link.url }}</span>
<button type="button" (click)="removeLink(i)" class="btn btn-danger btn-sm">×</button>
</div>
}
</div>
<div class="link-add-form">
<input
type="text"
[(ngModel)]="linkTitle"
name="linkTitle"
placeholder="Link title (e.g., IMDB)"
>
<input
type="url"
[(ngModel)]="linkUrl"
name="linkUrl"
placeholder="https://..."
>
<button type="button" (click)="addLink()" class="btn btn-secondary btn-sm">Add Link</button>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ mode === 'add' ? 'Add Show' : 'Save Changes' }}</button>
<button type="button" (click)="onCancel()" class="btn btn-secondary">Cancel</button>
</div>
</form>
`,
styles: [`
.show-form {
background: white;
padding: 1.5rem;
border-radius: 8px;
border: 1px solid #e5e7eb;
margin-bottom: 2rem;
}
.show-form h3 {
margin: 0 0 1.5rem 0;
color: #1f2937;
font-size: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #374151;
}
.form-group input[type="text"],
.form-group input[type="number"],
.form-group input[type="date"],
.form-group input[type="url"],
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.625rem;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 1rem;
font-family: inherit;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: #ff6b6b;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.tags-input-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.625rem;
border: 1px solid #d1d5db;
border-radius: 4px;
min-height: 44px;
}
.tags-input-container input {
flex: 1;
border: none;
outline: none;
font-size: 1rem;
min-width: 150px;
}
.tag {
background: #ff6b6b;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.tag-remove {
background: none;
border: none;
color: white;
cursor: pointer;
font-size: 1.25rem;
line-height: 1;
padding: 0;
}
.links-list {
margin-bottom: 0.75rem;
}
.link-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
background: #f3f4f6;
border-radius: 4px;
margin-bottom: 0.5rem;
}
.link-add-form {
display: grid;
grid-template-columns: 1fr 2fr auto;
gap: 0.5rem;
}
.image-preview {
margin-top: 0.75rem;
display: flex;
align-items: center;
gap: 1rem;
}
.image-preview img {
max-width: 200px;
max-height: 200px;
border-radius: 4px;
border: 1px solid #e5e7eb;
}
.error-text {
color: #dc2626;
font-size: 0.875rem;
display: block;
margin-top: 0.5rem;
}
.form-actions {
display: flex;
gap: 0.75rem;
margin-top: 1.5rem;
}
.btn {
padding: 0.625rem 1.25rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
transition: all 0.3s;
font-weight: 500;
}
.btn:hover {
opacity: 0.9;
}
.btn-primary {
background: #ff6b6b;
color: white;
}
.btn-secondary {
background: #6b7280;
color: white;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
}
`]
})
export class ShowFormComponent implements OnInit {
@Input() mode: 'add' | 'edit' = 'add';
@Input() show?: Show;
@Input() initialData?: Partial<CreateShowDto>;
@Output() save = new EventEmitter<CreateShowDto | UpdateShowDto>();
@Output() cancel = new EventEmitter<void>();
ShowStatus = ShowStatus;
ShowType = ShowType;
formData: Partial<CreateShowDto | UpdateShowDto> = {
tags: [],
links: []
};
timeHours = 0;
timeMinutes = 0;
tagInput = '';
linkTitle = '';
linkUrl = '';
imagePreview = signal<string | null>(null);
imageError = signal<string | null>(null);
ngOnInit() {
if (this.mode === 'edit' && this.show) {
this.formData = {
title: this.show.title,
type: this.show.type,
status: this.show.status,
dateStarted: this.show.dateStarted,
dateFinished: this.show.dateFinished,
rating: this.show.rating,
notes: this.show.notes,
coverImage: this.show.coverImage,
tags: [...(this.show.tags || [])],
links: [...(this.show.links || [])],
timeSpent: this.show.timeSpent
};
if (this.show.timeSpent) {
this.timeHours = Math.floor(this.show.timeSpent / 60);
this.timeMinutes = this.show.timeSpent % 60;
}
this.imagePreview.set(this.show.coverImage || null);
} else {
this.formData = {
type: ShowType.tvSeries,
status: ShowStatus.wantToWatch,
tags: [],
links: [],
...this.initialData
};
if (this.initialData?.coverImage) {
this.imagePreview.set(this.initialData.coverImage);
}
}
}
updateTimeSpent() {
this.formData.timeSpent = (this.timeHours * 60) + this.timeMinutes;
}
addTag() {
if (this.tagInput.trim() && !this.formData.tags?.includes(this.tagInput.trim())) {
this.formData.tags = [...(this.formData.tags || []), this.tagInput.trim()];
this.tagInput = '';
}
}
removeTag(index: number) {
this.formData.tags = this.formData.tags?.filter((_, i) => i !== index) || [];
}
addLink() {
if (this.linkTitle.trim() && this.linkUrl.trim()) {
const newLink: Link = {
title: this.linkTitle.trim(),
url: this.linkUrl.trim()
};
this.formData.links = [...(this.formData.links || []), newLink];
this.linkTitle = '';
this.linkUrl = '';
}
}
removeLink(index: number) {
this.formData.links = this.formData.links?.filter((_, i) => i !== index) || [];
}
onImageSelected(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
if (file.size > 500000) {
this.imageError.set('Image must be under 500KB');
input.value = '';
return;
}
this.imageError.set(null);
const reader = new FileReader();
reader.onload = () => {
const base64String = reader.result as string;
this.formData.coverImage = base64String;
this.imagePreview.set(base64String);
};
reader.readAsDataURL(file);
}
clearImage() {
this.formData.coverImage = undefined;
this.imagePreview.set(null);
}
onSubmit() {
if (!this.formData.title || !this.formData.type || !this.formData.status) {
return;
}
const data: CreateShowDto | UpdateShowDto = {
...this.formData as CreateShowDto | UpdateShowDto,
dateStarted: this.formData.dateStarted ? new Date(this.formData.dateStarted) : undefined,
dateFinished: this.formData.dateFinished ? new Date(this.formData.dateFinished) : undefined
};
this.save.emit(data);
}
onCancel() {
this.cancel.emit();
}
}
@@ -14,18 +14,28 @@ import { AuthService } from '../../services/auth.service';
import { SanitizeService } from '../../services/sanitize.service'; import { SanitizeService } from '../../services/sanitize.service';
import { CommentDisplayComponent } from '../comment-display/comment-display.component'; import { CommentDisplayComponent } from '../comment-display/comment-display.component';
import { LikeButtonComponent } from '../shared/like-button.component'; import { LikeButtonComponent } from '../shared/like-button.component';
import { Show, Comment, ShowStatus, ShowType } from '@library/shared-types'; import { ShowFormComponent } from '../shared/show-form.component';
import { Show, Comment, ShowStatus, ShowType, UpdateShowDto } from '@library/shared-types';
@Component({ @Component({
selector: 'app-show-detail', selector: 'app-show-detail',
standalone: true, standalone: true,
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent], imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent, ShowFormComponent],
template: ` template: `
<div class="container"> <div class="container">
<div class="breadcrumb"> <div class="breadcrumb">
<a routerLink="/shows" class="breadcrumb-link">← Back to Shows</a> <a routerLink="/shows" class="breadcrumb-link">← Back to Shows</a>
</div> </div>
@if (showEditForm() && authService.user()?.isAdmin && show()) {
<app-show-form
mode="edit"
[show]="show()!"
(save)="saveEdit($event)"
(cancel)="cancelEdit()"
></app-show-form>
}
@if (loading()) { @if (loading()) {
<div class="loading">Loading show details...</div> <div class="loading">Loading show details...</div>
} @else if (error()) { } @else if (error()) {
@@ -52,6 +62,12 @@ import { Show, Comment, ShowStatus, ShowType } from '@library/shared-types';
{{ getStatusLabel(show()!.status) }} {{ getStatusLabel(show()!.status) }}
</span> </span>
</div> </div>
@if (authService.user()?.isAdmin) {
<div class="admin-actions">
<button (click)="toggleEditForm()" class="btn btn-edit">✏️ {{ showEditForm() ? 'Cancel Edit' : 'Edit' }}</button>
<button (click)="deleteShow()" class="btn btn-delete">🗑️ Delete</button>
</div>
}
@if (show()!.rating) { @if (show()!.rating) {
<div class="info-row"> <div class="info-row">
@@ -318,6 +334,35 @@ import { Show, Comment, ShowStatus, ShowType } from '@library/shared-types';
color: #4b5563; color: #4b5563;
} }
.admin-actions {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: #fef3c7;
border-radius: 4px;
border: 1px solid #fbbf24;
}
.btn-edit {
background: #3b82f6;
color: white;
}
.btn-edit:hover {
background: #2563eb;
}
.btn-delete {
background: #ef4444;
color: white;
}
.btn-delete:hover {
background: #dc2626;
}
.info-row { .info-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -517,7 +562,36 @@ import { Show, Comment, ShowStatus, ShowType } from '@library/shared-types';
font-size: 1.5rem; font-size: 1.5rem;
} }
.info-row {
.admin-actions {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: #fef3c7;
border-radius: 4px;
border: 1px solid #fbbf24;
}
.btn-edit {
background: #3b82f6;
color: white;
}
.btn-edit:hover {
background: #2563eb;
}
.btn-delete {
background: #ef4444;
color: white;
}
.btn-delete:hover {
background: #dc2626;
}
.info-row {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: 0.25rem; gap: 0.25rem;
@@ -543,6 +617,7 @@ export class ShowDetailComponent implements OnInit {
commentsLoading = signal(false); commentsLoading = signal(false);
error = signal<string | null>(null); error = signal<string | null>(null);
newCommentContent = ''; newCommentContent = '';
showEditForm = signal(false);
ngOnInit() { ngOnInit() {
const showId = this.route.snapshot.paramMap.get('id'); const showId = this.route.snapshot.paramMap.get('id');
@@ -661,4 +736,45 @@ export class ShowDetailComponent implements OnInit {
return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`; return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`;
} }
} }
}
toggleEditForm() {
this.showEditForm.update(value => !value);
}
saveEdit(data: UpdateShowDto) {
const show = this.show();
if (!show) return;
this.showsService.updateShow(show.id, data).subscribe({
next: (updatedShow) => {
this.show.set(updatedShow);
this.showEditForm.set(false);
},
error: (err) => {
alert('Failed to update show: ' + (err.error?.message || 'Unknown error'));
}
});
}
cancelEdit() {
this.showEditForm.set(false);
}
deleteShow() {
const show = this.show();
if (!show) return;
if (!confirm(`Are you sure you want to delete "${show.title}"? This action cannot be undone.`)) {
return;
}
this.showsService.deleteShow(show.id).subscribe({
next: () => {
this.router.navigate(['/shows']);
},
error: (err) => {
alert('Failed to delete show: ' + (err.error?.message || 'Unknown error'));
}
});
}
}
@@ -1368,6 +1368,9 @@ export class ShowsListComponent implements OnInit {
this.editTagInput = ''; this.editTagInput = '';
this.editLinkTitle = ''; this.editLinkTitle = '';
this.editLinkUrl = ''; this.editLinkUrl = '';
// Scroll to top so the form is visible
window.scrollTo({ top: 0, behavior: 'smooth' });
} }
cancelEdit() { cancelEdit() {