generated from nhcarrigan/template
b781034fce
Created reusable form components for all media types (Game, Book, Music, Show, Manga, Art) to provide a consistent editing experience across the application. Changes: - Created 6 new form components in shared folder for all media types - Each form component supports both 'add' and 'edit' modes - Forms include all fields: title, author/artist/platform, status, dates, rating, time spent, notes, cover images, tags, and links - Added inline editing to all detail views using form components - Detail views now show edit form inline instead of navigating away - Integrated form components into admin suggestions workflow - Admins can now review and edit all suggestion details before accepting - Added scroll-to-top functionality when clicking edit in all list views - Ensures edit form is visible when opened from paginated lists Benefits: - Consistent UX across all media types - Better editing experience with inline forms - Improved admin suggestion workflow with full editing capabilities - No route navigation for edits (better for SEO and UX) - All forms follow the same styling and interaction patterns Co-Authored-By: Naomi Carrigan <commits@nhcarrigan.com>
519 lines
13 KiB
TypeScript
519 lines
13 KiB
TypeScript
/**
|
||
* @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();
|
||
}
|
||
}
|