Files
library/apps/frontend/src/app/components/shared/music-form.component.ts
T
hikari b781034fce feat: add reusable form components and inline editing for all media types
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>
2026-02-20 19:29:05 -08:00

519 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan, 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();
}
}