diff --git a/apps/frontend/src/app/components/admin/admin-suggestions.component.ts b/apps/frontend/src/app/components/admin/admin-suggestions.component.ts index a0198ed..ac171ca 100644 --- a/apps/frontend/src/app/components/admin/admin-suggestions.component.ts +++ b/apps/frontend/src/app/components/admin/admin-suggestions.component.ts @@ -10,12 +10,18 @@ import { FormsModule } from '@angular/forms'; import { SuggestionService } from '../../services/suggestion.service'; import { AuthService } from '../../services/auth.service'; 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({ selector: 'app-admin-suggestions', standalone: true, - imports: [CommonModule, FormsModule, PaginationComponent], + imports: [CommonModule, FormsModule, PaginationComponent, GameFormComponent, BookFormComponent, MusicFormComponent, ShowFormComponent, MangaFormComponent, ArtFormComponent], template: `
@@ -231,158 +237,62 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared- @if (showEditModal()) { @@ -810,59 +720,174 @@ export class AdminSuggestionsComponent implements OnInit { return new Date(date).toLocaleDateString(); } + getGameInitialData(suggestion: Suggestion): Partial { + 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 { + 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 { + 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 { + 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 { + 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 { + 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) { this.editingSuggestion.set(suggestion); - // Pre-populate the edit form with suggestion data - 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; - } - + // For all entity types, we'll use the form components which have their own initialization logic this.showEditModal.set(true); } @@ -884,20 +909,6 @@ export class AdminSuggestionsComponent implements OnInit { 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() { const suggestion = this.decliningsuggestion(); if (!suggestion) return; diff --git a/apps/frontend/src/app/components/art/art-detail.component.ts b/apps/frontend/src/app/components/art/art-detail.component.ts index 8a9596a..66ab365 100644 --- a/apps/frontend/src/app/components/art/art-detail.component.ts +++ b/apps/frontend/src/app/components/art/art-detail.component.ts @@ -14,18 +14,28 @@ import { AuthService } from '../../services/auth.service'; import { SanitizeService } from '../../services/sanitize.service'; import { CommentDisplayComponent } from '../comment-display/comment-display.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({ selector: 'app-art-detail', standalone: true, - imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent], + imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent, ArtFormComponent], template: `
+ @if (showEditForm() && authService.user()?.isAdmin && art()) { + + } + @if (loading()) {
Loading artwork details...
} @else if (error()) { @@ -48,6 +58,12 @@ import { Art, Comment } from '@library/shared-types';

by {{ art()!.artist }}

+ @if (authService.user()?.isAdmin) { +
+ + +
+ }
Added: @@ -232,6 +248,35 @@ import { Art, Comment } from '@library/shared-types'; 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 { display: flex; align-items: center; @@ -426,7 +471,36 @@ import { Art, Comment } from '@library/shared-types'; 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; align-items: flex-start; gap: 0.25rem; @@ -452,6 +526,7 @@ export class ArtDetailComponent implements OnInit { commentsLoading = signal(false); error = signal(null); newCommentContent = ''; + showEditForm = signal(false); ngOnInit() { const artId = this.route.snapshot.paramMap.get('id'); @@ -539,4 +614,45 @@ export class ArtDetailComponent implements OnInit { 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')); + } + }); + } +} \ No newline at end of file diff --git a/apps/frontend/src/app/components/art/art-gallery.component.ts b/apps/frontend/src/app/components/art/art-gallery.component.ts index a02795b..62b3d72 100644 --- a/apps/frontend/src/app/components/art/art-gallery.component.ts +++ b/apps/frontend/src/app/components/art/art-gallery.component.ts @@ -1121,6 +1121,9 @@ export class ArtGalleryComponent implements OnInit { this.editTagInput = ''; this.editLinkTitle = ''; this.editLinkUrl = ''; + + // Scroll to top so the form is visible + window.scrollTo({ top: 0, behavior: 'smooth' }); } cancelEdit() { diff --git a/apps/frontend/src/app/components/books/book-detail.component.ts b/apps/frontend/src/app/components/books/book-detail.component.ts index d86199d..dfc7c74 100644 --- a/apps/frontend/src/app/components/books/book-detail.component.ts +++ b/apps/frontend/src/app/components/books/book-detail.component.ts @@ -14,18 +14,28 @@ import { AuthService } from '../../services/auth.service'; import { SanitizeService } from '../../services/sanitize.service'; import { CommentDisplayComponent } from '../comment-display/comment-display.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({ selector: 'app-book-detail', standalone: true, - imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent], + imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent, BookFormComponent], template: `
+ @if (showEditForm() && authService.user()?.isAdmin && book()) { + + } + @if (loading()) {
Loading book details...
} @else if (error()) { @@ -51,6 +61,12 @@ import { Book, Comment, BookStatus } from '@library/shared-types';

by {{ book()!.author }}

+ @if (authService.user()?.isAdmin) { +
+ + +
+ } @if (book()!.series) {

@@ -309,6 +325,35 @@ import { Book, Comment, BookStatus } from '@library/shared-types'; 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 { color: #8b6f47; font-size: 1rem; @@ -542,6 +587,7 @@ export class BookDetailComponent implements OnInit { commentsLoading = signal(false); error = signal(null); newCommentContent = ''; + showEditForm = signal(false); ngOnInit() { 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'}`; } } -} + + 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')); + } + }); + } +} \ No newline at end of file diff --git a/apps/frontend/src/app/components/books/books-list.component.ts b/apps/frontend/src/app/components/books/books-list.component.ts index 4fe2186..8574530 100644 --- a/apps/frontend/src/app/components/books/books-list.component.ts +++ b/apps/frontend/src/app/components/books/books-list.component.ts @@ -1561,6 +1561,9 @@ export class BooksListComponent implements OnInit { this.editBookTimeHours = 0; this.editBookTimeMinutes = 0; } + + // Scroll to top so the form is visible + window.scrollTo({ top: 0, behavior: 'smooth' }); } cancelEdit() { diff --git a/apps/frontend/src/app/components/games/game-detail.component.ts b/apps/frontend/src/app/components/games/game-detail.component.ts index 0b7d43e..45325b8 100644 --- a/apps/frontend/src/app/components/games/game-detail.component.ts +++ b/apps/frontend/src/app/components/games/game-detail.component.ts @@ -14,12 +14,13 @@ import { AuthService } from '../../services/auth.service'; import { SanitizeService } from '../../services/sanitize.service'; import { CommentDisplayComponent } from '../comment-display/comment-display.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({ selector: 'app-game-detail', standalone: true, - imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent], + imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent, GameFormComponent], template: `

+ @if (authService.user()?.isAdmin) { +
+ + +
+ } + + @if (showEditForm() && authService.user()?.isAdmin && game()) { + + } + @if (game()!.series) {

📚 {{ game()!.series }}@if (game()!.seriesOrder) { #{{ game()!.seriesOrder }}} @@ -300,6 +317,34 @@ import { Game, Comment, GameStatus } from '@library/shared-types'; 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 { color: #8b6f47; font-size: 1rem; @@ -533,6 +578,7 @@ export class GameDetailComponent implements OnInit { commentsLoading = signal(false); error = signal(null); newCommentContent = ''; + showEditForm = signal(false); ngOnInit() { 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'}`; } } + + 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')); + } + }); + } } diff --git a/apps/frontend/src/app/components/games/games-list.component.ts b/apps/frontend/src/app/components/games/games-list.component.ts index ce4ee91..988dfb2 100644 --- a/apps/frontend/src/app/components/games/games-list.component.ts +++ b/apps/frontend/src/app/components/games/games-list.component.ts @@ -1434,6 +1434,9 @@ export class GamesListComponent implements OnInit { this.editTagInput = ''; this.editLinkTitle = ''; this.editLinkUrl = ''; + + // Scroll to top so the form is visible + window.scrollTo({ top: 0, behavior: 'smooth' }); } cancelEdit() { diff --git a/apps/frontend/src/app/components/manga/manga-detail.component.ts b/apps/frontend/src/app/components/manga/manga-detail.component.ts index c26a960..c063d64 100644 --- a/apps/frontend/src/app/components/manga/manga-detail.component.ts +++ b/apps/frontend/src/app/components/manga/manga-detail.component.ts @@ -14,18 +14,28 @@ import { AuthService } from '../../services/auth.service'; import { SanitizeService } from '../../services/sanitize.service'; import { CommentDisplayComponent } from '../comment-display/comment-display.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({ selector: 'app-manga-detail', standalone: true, - imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent], + imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent, MangaFormComponent], template: `

+ @if (showEditForm() && authService.user()?.isAdmin && manga()) { + + } + @if (loading()) {
Loading manga details...
} @else if (error()) { @@ -51,6 +61,12 @@ import { Manga, Comment, MangaStatus } from '@library/shared-types';

by {{ manga()!.author }}

+ @if (authService.user()?.isAdmin) { +
+ + +
+ } @if (manga()!.rating) {
@@ -296,6 +312,35 @@ import { Manga, Comment, MangaStatus } from '@library/shared-types'; 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 { display: flex; align-items: center; @@ -495,7 +540,36 @@ import { Manga, Comment, MangaStatus } from '@library/shared-types'; 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; align-items: flex-start; gap: 0.25rem; @@ -521,6 +595,7 @@ export class MangaDetailComponent implements OnInit { commentsLoading = signal(false); error = signal(null); newCommentContent = ''; + showEditForm = signal(false); ngOnInit() { 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'}`; } } -} + + 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')); + } + }); + } +} \ No newline at end of file diff --git a/apps/frontend/src/app/components/manga/manga-list.component.ts b/apps/frontend/src/app/components/manga/manga-list.component.ts index 707958e..65565c7 100644 --- a/apps/frontend/src/app/components/manga/manga-list.component.ts +++ b/apps/frontend/src/app/components/manga/manga-list.component.ts @@ -1347,6 +1347,9 @@ export class MangaListComponent implements OnInit { this.editTagInput = ''; this.editLinkTitle = ''; this.editLinkUrl = ''; + + // Scroll to top so the form is visible + window.scrollTo({ top: 0, behavior: 'smooth' }); } cancelEdit() { diff --git a/apps/frontend/src/app/components/music/music-detail.component.ts b/apps/frontend/src/app/components/music/music-detail.component.ts index 8e933a3..e6d41de 100644 --- a/apps/frontend/src/app/components/music/music-detail.component.ts +++ b/apps/frontend/src/app/components/music/music-detail.component.ts @@ -14,18 +14,28 @@ import { AuthService } from '../../services/auth.service'; import { SanitizeService } from '../../services/sanitize.service'; import { CommentDisplayComponent } from '../comment-display/comment-display.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({ selector: 'app-music-detail', standalone: true, - imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent], + imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent, MusicFormComponent], template: `
+ @if (showEditForm() && authService.user()?.isAdmin && music()) { + + } + @if (loading()) {
Loading music details...
} @else if (error()) { @@ -54,6 +64,12 @@ import { Music, Comment, MusicStatus, MusicType } from '@library/shared-types';

by {{ music()!.artist }}

+ @if (authService.user()?.isAdmin) { +
+ + +
+ } @if (music()!.rating) {
@@ -322,6 +338,35 @@ import { Music, Comment, MusicStatus, MusicType } from '@library/shared-types'; 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 { display: flex; align-items: center; @@ -521,7 +566,36 @@ import { Music, Comment, MusicStatus, MusicType } from '@library/shared-types'; 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; align-items: flex-start; gap: 0.25rem; @@ -547,6 +621,7 @@ export class MusicDetailComponent implements OnInit { commentsLoading = signal(false); error = signal(null); newCommentContent = ''; + showEditForm = signal(false); ngOnInit() { 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'}`; } } -} + + 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')); + } + }); + } +} \ No newline at end of file diff --git a/apps/frontend/src/app/components/music/music-list.component.ts b/apps/frontend/src/app/components/music/music-list.component.ts index 3767364..4b84c2d 100644 --- a/apps/frontend/src/app/components/music/music-list.component.ts +++ b/apps/frontend/src/app/components/music/music-list.component.ts @@ -1571,6 +1571,9 @@ export class MusicListComponent implements OnInit { this.editTagInput = ''; this.editLinkTitle = ''; this.editLinkUrl = ''; + + // Scroll to top so the form is visible + window.scrollTo({ top: 0, behavior: 'smooth' }); } cancelEdit() { diff --git a/apps/frontend/src/app/components/shared/art-form.component.ts b/apps/frontend/src/app/components/shared/art-form.component.ts new file mode 100644 index 0000000..ebbed66 --- /dev/null +++ b/apps/frontend/src/app/components/shared/art-form.component.ts @@ -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: ` +
+

{{ mode === 'add' ? 'Add New Art' : 'Edit Art' }}

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + @if (imagePreview()) { +
+ Artwork preview + +
+ } + @if (imageError()) { + {{ imageError() }} + } +
+ +
+
+ @for (tag of formData.tags; track tag; let i = $index) { + + {{ tag }} + + + } + +
+
+ +
+ + +
+ +
+ + +
+
+ `, + 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; + @Output() save = new EventEmitter(); + @Output() cancel = new EventEmitter(); + + formData: Partial = { + tags: [], + links: [] + }; + + tagInput = ''; + linkTitle = ''; + linkUrl = ''; + imagePreview = signal(null); + imageError = signal(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(); + } +} diff --git a/apps/frontend/src/app/components/shared/book-form.component.ts b/apps/frontend/src/app/components/shared/book-form.component.ts new file mode 100644 index 0000000..eef0631 --- /dev/null +++ b/apps/frontend/src/app/components/shared/book-form.component.ts @@ -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: ` +
+

{{ mode === 'add' ? 'Add New Book' : 'Edit Book' }}

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + @if (imagePreview()) { +
+ Cover preview + +
+ } + @if (imageError()) { + {{ imageError() }} + } +
+ +
+
+ @for (tag of formData.tags; track tag; let i = $index) { + + {{ tag }} + + + } + +
+
+ +
+ + +
+ +
+ + +
+
+ `, + 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; + @Output() save = new EventEmitter(); + @Output() cancel = new EventEmitter(); + + BookStatus = BookStatus; + + formData: Partial = { + tags: [], + links: [] + }; + + timeHours = 0; + timeMinutes = 0; + tagInput = ''; + linkTitle = ''; + linkUrl = ''; + imagePreview = signal(null); + imageError = signal(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(); + } +} diff --git a/apps/frontend/src/app/components/shared/game-form.component.ts b/apps/frontend/src/app/components/shared/game-form.component.ts new file mode 100644 index 0000000..cd25184 --- /dev/null +++ b/apps/frontend/src/app/components/shared/game-form.component.ts @@ -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: ` +
+

{{ mode === 'add' ? 'Add New Game' : 'Edit Game' }}

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + @if (imagePreview()) { +
+ Box art preview + +
+ } + @if (imageError()) { + {{ imageError() }} + } +
+ +
+
+ @for (tag of formData.tags; track tag; let i = $index) { + + {{ tag }} + + + } + +
+
+ +
+ + +
+ +
+ + +
+
+ `, + 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; + @Output() save = new EventEmitter(); + @Output() cancel = new EventEmitter(); + + GameStatus = GameStatus; + + formData: Partial = { + tags: [], + links: [] + }; + + timeHours = 0; + timeMinutes = 0; + tagInput = ''; + linkTitle = ''; + linkUrl = ''; + imagePreview = signal(null); + imageError = signal(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(); + } +} diff --git a/apps/frontend/src/app/components/shared/manga-form.component.ts b/apps/frontend/src/app/components/shared/manga-form.component.ts new file mode 100644 index 0000000..7d35919 --- /dev/null +++ b/apps/frontend/src/app/components/shared/manga-form.component.ts @@ -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: ` +
+

{{ mode === 'add' ? 'Add New Manga' : 'Edit Manga' }}

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + + @if (imagePreview()) { +
+ Cover preview + +
+ } + @if (imageError()) { + {{ imageError() }} + } +
+ +
+
+ @for (tag of formData.tags; track tag; let i = $index) { + + {{ tag }} + + + } + +
+
+ +
+ + +
+ +
+ + +
+
+ `, + 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; + @Output() save = new EventEmitter(); + @Output() cancel = new EventEmitter(); + + MangaStatus = MangaStatus; + + formData: Partial = { + tags: [], + links: [] + }; + + timeHours = 0; + timeMinutes = 0; + tagInput = ''; + linkTitle = ''; + linkUrl = ''; + imagePreview = signal(null); + imageError = signal(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(); + } +} diff --git a/apps/frontend/src/app/components/shared/music-form.component.ts b/apps/frontend/src/app/components/shared/music-form.component.ts new file mode 100644 index 0000000..554b4ca --- /dev/null +++ b/apps/frontend/src/app/components/shared/music-form.component.ts @@ -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: ` +
+

{{ mode === 'add' ? 'Add New Music' : 'Edit Music' }}

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + + @if (imagePreview()) { +
+ Cover preview + +
+ } + @if (imageError()) { + {{ imageError() }} + } +
+ +
+
+ @for (tag of formData.tags; track tag; let i = $index) { + + {{ tag }} + + + } + +
+
+ +
+ + +
+ +
+ + +
+
+ `, + 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; + @Output() save = new EventEmitter(); + @Output() cancel = new EventEmitter(); + + MusicStatus = MusicStatus; + MusicType = MusicType; + + formData: Partial = { + tags: [], + links: [] + }; + + timeHours = 0; + timeMinutes = 0; + tagInput = ''; + linkTitle = ''; + linkUrl = ''; + imagePreview = signal(null); + imageError = signal(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(); + } +} diff --git a/apps/frontend/src/app/components/shared/show-form.component.ts b/apps/frontend/src/app/components/shared/show-form.component.ts new file mode 100644 index 0000000..e9bbeb9 --- /dev/null +++ b/apps/frontend/src/app/components/shared/show-form.component.ts @@ -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: ` +
+

{{ mode === 'add' ? 'Add New Show' : 'Edit Show' }}

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + + @if (imagePreview()) { +
+ Cover preview + +
+ } + @if (imageError()) { + {{ imageError() }} + } +
+ +
+
+ @for (tag of formData.tags; track tag; let i = $index) { + + {{ tag }} + + + } + +
+
+ +
+ + +
+ +
+ + +
+
+ `, + 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; + @Output() save = new EventEmitter(); + @Output() cancel = new EventEmitter(); + + ShowStatus = ShowStatus; + ShowType = ShowType; + + formData: Partial = { + tags: [], + links: [] + }; + + timeHours = 0; + timeMinutes = 0; + tagInput = ''; + linkTitle = ''; + linkUrl = ''; + imagePreview = signal(null); + imageError = signal(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(); + } +} diff --git a/apps/frontend/src/app/components/shows/show-detail.component.ts b/apps/frontend/src/app/components/shows/show-detail.component.ts index c657443..51727cf 100644 --- a/apps/frontend/src/app/components/shows/show-detail.component.ts +++ b/apps/frontend/src/app/components/shows/show-detail.component.ts @@ -14,18 +14,28 @@ import { AuthService } from '../../services/auth.service'; import { SanitizeService } from '../../services/sanitize.service'; import { CommentDisplayComponent } from '../comment-display/comment-display.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({ selector: 'app-show-detail', standalone: true, - imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent], + imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent, ShowFormComponent], template: `
+ @if (showEditForm() && authService.user()?.isAdmin && show()) { + + } + @if (loading()) {
Loading show details...
} @else if (error()) { @@ -52,6 +62,12 @@ import { Show, Comment, ShowStatus, ShowType } from '@library/shared-types'; {{ getStatusLabel(show()!.status) }}
+ @if (authService.user()?.isAdmin) { +
+ + +
+ } @if (show()!.rating) {
@@ -318,6 +334,35 @@ import { Show, Comment, ShowStatus, ShowType } from '@library/shared-types'; 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 { display: flex; align-items: center; @@ -517,7 +562,36 @@ import { Show, Comment, ShowStatus, ShowType } from '@library/shared-types'; 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; align-items: flex-start; gap: 0.25rem; @@ -543,6 +617,7 @@ export class ShowDetailComponent implements OnInit { commentsLoading = signal(false); error = signal(null); newCommentContent = ''; + showEditForm = signal(false); ngOnInit() { 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'}`; } } -} + + 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')); + } + }); + } +} \ No newline at end of file diff --git a/apps/frontend/src/app/components/shows/shows-list.component.ts b/apps/frontend/src/app/components/shows/shows-list.component.ts index a6970d9..14ae090 100644 --- a/apps/frontend/src/app/components/shows/shows-list.component.ts +++ b/apps/frontend/src/app/components/shows/shows-list.component.ts @@ -1368,6 +1368,9 @@ export class ShowsListComponent implements OnInit { this.editTagInput = ''; this.editLinkTitle = ''; this.editLinkUrl = ''; + + // Scroll to top so the form is visible + window.scrollTo({ top: 0, behavior: 'smooth' }); } cancelEdit() {