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
@@ -14,18 +14,28 @@ import { AuthService } from '../../services/auth.service';
import { SanitizeService } from '../../services/sanitize.service';
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
import { LikeButtonComponent } from '../shared/like-button.component';
import { Manga, Comment, MangaStatus } from '@library/shared-types';
import { MangaFormComponent } from '../shared/manga-form.component';
import { Manga, Comment, MangaStatus, UpdateMangaDto } from '@library/shared-types';
@Component({
selector: 'app-manga-detail',
standalone: true,
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent],
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent, MangaFormComponent],
template: `
<div class="container">
<div class="breadcrumb">
<a routerLink="/manga" class="breadcrumb-link">← Back to Manga</a>
</div>
@if (showEditForm() && authService.user()?.isAdmin && manga()) {
<app-manga-form
mode="edit"
[manga]="manga()!"
(formSubmit)="saveEdit($event)"
(formCancel)="cancelEdit()"
></app-manga-form>
}
@if (loading()) {
<div class="loading">Loading manga details...</div>
} @else if (error()) {
@@ -36,11 +46,9 @@ import { Manga, Comment, MangaStatus } from '@library/shared-types';
</div>
} @else if (manga()) {
<div class="manga-detail-card">
@if (manga()!.coverImage) {
<div class="manga-cover-section">
<img [src]="manga()!.coverImage" [alt]="manga()!.title" class="manga-cover-large">
</div>
}
<div class="manga-cover-section">
<img [src]="manga()!.coverImage || '/assets/default-cover.jpg'" [alt]="manga()!.title" class="manga-cover-large">
</div>
<div class="manga-content">
<div class="manga-header">
@@ -51,6 +59,12 @@ import { Manga, Comment, MangaStatus } from '@library/shared-types';
</div>
<p class="author">by {{ manga()!.author }}</p>
@if (authService.user()?.isAdmin) {
<div class="admin-actions">
<button (click)="toggleEditForm()" class="btn btn-edit">✏️ {{ showEditForm() ? 'Cancel Edit' : 'Edit' }}</button>
<button (click)="deleteManga()" class="btn btn-delete">🗑️ Delete</button>
</div>
}
@if (manga()!.rating) {
<div class="info-row">
@@ -296,6 +310,35 @@ import { Manga, Comment, MangaStatus } from '@library/shared-types';
color: #4b5563;
}
.admin-actions {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: #fef3c7;
border-radius: 4px;
border: 1px solid #fbbf24;
}
.btn-edit {
background: #3b82f6;
color: white;
}
.btn-edit:hover {
background: #2563eb;
}
.btn-delete {
background: #ef4444;
color: white;
}
.btn-delete:hover {
background: #dc2626;
}
.info-row {
display: flex;
align-items: center;
@@ -495,7 +538,36 @@ import { Manga, Comment, MangaStatus } from '@library/shared-types';
font-size: 1.5rem;
}
.info-row {
.admin-actions {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: #fef3c7;
border-radius: 4px;
border: 1px solid #fbbf24;
}
.btn-edit {
background: #3b82f6;
color: white;
}
.btn-edit:hover {
background: #2563eb;
}
.btn-delete {
background: #ef4444;
color: white;
}
.btn-delete:hover {
background: #dc2626;
}
.info-row {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
@@ -521,6 +593,7 @@ export class MangaDetailComponent implements OnInit {
commentsLoading = signal(false);
error = signal<string | null>(null);
newCommentContent = '';
showEditForm = signal(false);
ngOnInit() {
const mangaId = this.route.snapshot.paramMap.get('id');
@@ -630,4 +703,45 @@ export class MangaDetailComponent implements OnInit {
return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`;
}
}
}
toggleEditForm() {
this.showEditForm.update(value => !value);
}
saveEdit(data: UpdateMangaDto) {
const manga = this.manga();
if (!manga) return;
this.mangaService.updateManga(manga.id, data).subscribe({
next: (updatedManga) => {
this.manga.set(updatedManga);
this.showEditForm.set(false);
},
error: (err) => {
alert('Failed to update manga: ' + (err.error?.message || 'Unknown error'));
}
});
}
cancelEdit() {
this.showEditForm.set(false);
}
deleteManga() {
const manga = this.manga();
if (!manga) return;
if (!confirm(`Are you sure you want to delete "${manga.title}"? This action cannot be undone.`)) {
return;
}
this.mangaService.deleteManga(manga.id).subscribe({
next: () => {
this.router.navigate(['/manga']);
},
error: (err) => {
alert('Failed to delete manga: ' + (err.error?.message || 'Unknown error'));
}
});
}
}
@@ -562,9 +562,7 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
@for (manga of paginatedManga(); track manga.id) {
<div class="manga-card" [class.completed]="manga.status === MangaStatus.completed">
<a [routerLink]="['/manga', manga.id]" class="card-link">
@if (manga.coverImage) {
<img [src]="manga.coverImage" [alt]="manga.title" class="manga-cover">
}
<img [src]="manga.coverImage || '/assets/default-cover.jpg'" [alt]="manga.title" class="manga-cover">
<div class="manga-info">
<h3>{{ manga.title }}</h3>
@@ -1347,6 +1345,9 @@ export class MangaListComponent implements OnInit {
this.editTagInput = '';
this.editLinkTitle = '';
this.editLinkUrl = '';
// Scroll to top so the form is visible
window.scrollTo({ top: 0, behavior: 'smooth' });
}
cancelEdit() {