feat: base64 uploads, reusable forms, Discord roles, and UX improvements (#66)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m20s
Node.js CI / CI (push) Successful in 1m24s

## 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>
This commit was merged in pull request #66.
This commit is contained in:
2026-02-20 20:32:52 -08:00
committed by Naomi Carrigan
parent 208c11d153
commit 983b78b0e9
30 changed files with 4359 additions and 328 deletions
@@ -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() formSubmit = new EventEmitter<CreateMangaDto | UpdateMangaDto>();
@Output() formCancel = 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.formSubmit.emit(data);
}
onCancel() {
this.formCancel.emit();
}
}