generated from nhcarrigan/template
feat: base64 uploads, reusable forms, Discord roles, and UX improvements #66
@@ -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)"
|
||||||
|
(cancel)="closeEditModal()"
|
||||||
|
></app-show-form>
|
||||||
|
} @else if (editingSuggestion()!.entityType === SuggestionEntity.manga) {
|
||||||
|
<h3>Review & Edit Manga Before Accepting</h3>
|
||||||
|
<p>Review and edit all the details before adding to your collection.</p>
|
||||||
|
<app-manga-form
|
||||||
|
mode="add"
|
||||||
|
[initialData]="getMangaInitialData(editingSuggestion()!)"
|
||||||
|
(save)="saveMangaFromSuggestion($event)"
|
||||||
|
(cancel)="closeEditModal()"
|
||||||
|
></app-manga-form>
|
||||||
|
} @else if (editingSuggestion()!.entityType === SuggestionEntity.art) {
|
||||||
|
<h3>Review & Edit Art Before Accepting</h3>
|
||||||
|
<p>Review and edit all the details before adding to your collection.</p>
|
||||||
|
<app-art-form
|
||||||
|
mode="add"
|
||||||
|
[initialData]="getArtInitialData(editingSuggestion()!)"
|
||||||
|
(save)="saveArtFromSuggestion($event)"
|
||||||
|
(cancel)="closeEditModal()"
|
||||||
|
></app-art-form>
|
||||||
}
|
}
|
||||||
@case (SuggestionEntity.game) {
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="edit-platform">Platform</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="edit-platform"
|
|
||||||
[(ngModel)]="editedData.platform"
|
|
||||||
name="platform"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@case (SuggestionEntity.music) {
|
|
||||||
<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-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,6 +471,35 @@ import { Art, Comment } from '@library/shared-types';
|
|||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.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 {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -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,6 +540,35 @@ import { Manga, Comment, MangaStatus } from '@library/shared-types';
|
|||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.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 {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -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,6 +566,35 @@ import { Music, Comment, MusicStatus, MusicType } from '@library/shared-types';
|
|||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.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 {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -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,6 +562,35 @@ import { Show, Comment, ShowStatus, ShowType } from '@library/shared-types';
|
|||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.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 {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user