Files
library/apps/frontend/src/app/components/shared/game-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

532 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 { 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();
}
}