generated from nhcarrigan/template
983b78b0e9
## Summary This PR includes multiple feature additions and fixes to improve the library application: ### 🎨 Base64 Image Upload Support - Fixed Fastify body limit (1MB → 10MB) to accommodate base64-encoded images - Corrected base64 size calculation and validation logic - Improved error handling with proper 400 status codes and helpful messages - Removed duplicate validation that was blocking uploads - Users can now upload cover images up to 5MB (decoded size) ### 📝 Reusable Form Components - Created 6 form components: `GameForm`, `BookForm`, `MusicForm`, `ShowForm`, `MangaForm`, `ArtForm` - All forms support both 'add' and 'edit' modes with pre-population - Integrated inline editing into all detail views (edit/delete buttons) - Enhanced admin suggestions workflow with full forms instead of basic modals - Added scroll-to-top when clicking edit in list views for better UX ### 🖼️ Default Cover Image - Added beautiful library reading image as default cover for all media types - Fixed static asset serving to use correct MIME types - Updated all 12 components (6 list views + 6 detail views) to always show images ### 🔒 Tiered Rate Limiting - Unauthenticated users: 100 requests/minute - Authenticated users: 500 requests/minute (5x more lenient) - Admin users: No rate limits (complete bypass via allowList) ### 🎮 Discord Integration - Auto-assign library member role to users in NHCarrigan Discord server - Checks server membership on every login - Only assigns role if user is in server and doesn't have it yet - Graceful error handling without blocking login - Similar pattern to badge refresh flow ### 📚 Documentation - Added comprehensive CLAUDE.md with: - Project structure and tech stack - Development workflow and commands - Database schema documentation - Authentication flow details - Security features - Code style conventions - Common gotchas and solutions ## Test Plan - [x] Base64 image uploads work for cover images up to 5MB - [x] Helpful error messages appear for validation failures - [x] Edit/delete buttons appear on all detail views for admin users - [x] Inline edit forms display and save correctly - [x] Admin suggestions workflow uses full forms for all media types - [x] Scroll-to-top works when editing from list views - [x] Default cover image displays when no cover is provided - [x] Static assets serve with correct MIME types - [x] Rate limiting works correctly for different user types - [x] Discord role assignment works on login - [x] All builds pass without errors - [x] No TypeScript errors ## Related Issues Closes #65 - Base64 image upload issue ✨ This pull request was created with help from Hikari~ 🌸 Reviewed-on: #66 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
507 lines
13 KiB
TypeScript
507 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 { 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() formSubmit = new EventEmitter<CreateShowDto | UpdateShowDto>();
|
||
@Output() formCancel = 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.formSubmit.emit(data);
|
||
}
|
||
|
||
onCancel() {
|
||
this.formCancel.emit();
|
||
}
|
||
}
|