generated from nhcarrigan/template
feat: base64 uploads, reusable forms, Discord roles, and UX improvements (#66)
## 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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user