Compare commits

..

6 Commits

Author SHA1 Message Date
hikari e6b6131134 fix: allow static assets to be served correctly
Fixed the not-found handler to exclude /assets routes, allowing static assets
like images to be served with correct MIME types instead of being caught by
the SPA fallback handler.

Changes:
- Updated setNotFoundHandler to check for /assets prefix
- Assets are now served directly by fastify-static with correct content-type
- Only non-API, non-asset routes fall through to index.html

This fixes the default cover image not displaying because it was being served
as text/html instead of image/jpeg.

Co-Authored-By: Naomi Carrigan <commits@nhcarrigan.com>
2026-02-20 19:45:37 -08:00
hikari d74a342f63 feat: add default cover image for all media types
Added a beautiful default cover image featuring Naomi reading in her cozy
library, which will be displayed whenever a media item doesn't have a cover
image provided.

Changes:
- Added default-cover.jpg to frontend public/assets directory
- Updated all list components (games, books, music, shows, manga, art) to
  always display an image using the default as fallback
- Updated all detail components to always show cover section with default
  fallback
- Removed conditional rendering of cover images - now always visible
- Properly handles different property names (coverImage, coverArt, imageUrl)

Benefits:
- Consistent visual appearance across all media types
- No more empty spaces where cover images would be
- Better UX with uniform card layouts
- Beautiful placeholder that matches the library theme

Co-Authored-By: Naomi Carrigan <commits@nhcarrigan.com>
2026-02-20 19:40:46 -08:00
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
hikari f40f917bc5 feat: implement tiered rate limiting with admin bypass
Update rate limiting to be more lenient for authenticated users and bypass limits entirely for admin users:

- Unauthenticated users: 100 requests/minute (original limit)
- Authenticated users: 500 requests/minute (5x increase)
- Admin users: No rate limits (completely bypassed via allowList)

This allows the admin to interact with the library without restrictions whilst still protecting against abuse from unauthenticated users. Authenticated users get a much more generous limit for better user experience.

Uses @fastify/rate-limit's allowList and dynamic max options to implement the tiered system.
2026-02-20 18:35:21 -08:00
hikari 1fd2086afe docs: add comprehensive CLAUDE.md project documentation
Add detailed project documentation for AI assistants covering:
- Git commit guidelines and user permissions
- Project structure and technology stack
- Database schema with all models
- Authentication flow with Discord OAuth
- Image upload handling (base64 and regular URLs)
- Development workflow and scripts
- CI/CD pipeline configuration
- Configuration standards (TypeScript, ESLint, Jest)
- Secrets management with 1Password CLI
- Security features and API route structure
- Code style conventions and common gotchas
- Deployment instructions

This documentation will help AI assistants understand the project architecture, follow established patterns, and work effectively within the codebase.
2026-02-20 18:32:05 -08:00
hikari 3668a67a62 fix: resolve base64 image upload issues
This resolves issue #65 by addressing multiple problems that were preventing base64-encoded cover images from being uploaded:

1. Increased Fastify body limit from 1MB to 10MB to accommodate base64-encoded images
2. Removed duplicate coverImage string length validation that was blocking base64 data URLs
3. Fixed base64 size calculation to properly extract and measure just the base64 data portion
4. Updated validateDataUrl regex to allow whitespace in base64 strings
5. Added proper error handling with 400 status codes and helpful error messages instead of 500 errors

Users can now successfully upload cover images as base64 data URLs up to 5MB (decoded size) and will receive clear validation error messages if uploads fail.

Closes #65
2026-02-20 18:28:24 -08:00
54 changed files with 492 additions and 881 deletions
+2 -2
View File
@@ -22,8 +22,8 @@ export async function app(fastify: FastifyInstance, opts: AppOptions) {
});
}
// Log unauthorized access attempts (exclude /api/auth/me as 401s there are expected during token refresh)
if ((error.statusCode === 401 || error.statusCode === 403) && request.url !== '/api/auth/me') {
// Log unauthorized access attempts
if (error.statusCode === 401 || error.statusCode === 403) {
await AuditService.log({
action: AuditAction.unauthorizedAccess,
category: AuditCategory.security,
+2 -2
View File
@@ -14,11 +14,11 @@ const helmetPlugin: FastifyPluginAsync = async (app) => {
directives: {
defaultSrc: ["'self'"],
// Angular uses inline styles for component encapsulation, so we need to allow them
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
scriptSrc: ["'self'"],
connectSrc: ["'self'", process.env.FRONTEND_URL ?? "http://localhost:4200"],
fontSrc: ["'self'", "data:", "https://fonts.gstatic.com"],
fontSrc: ["'self'", "data:"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
-47
View File
@@ -102,53 +102,6 @@ const authRoutes: FastifyPluginAsync = async (app) => {
request
);
// Assign library member role if user is in Discord server but doesn't have it
const libraryRoleId = process.env.LIBRARY_ROLE_ID;
if (inDiscord && guildId && libraryRoleId) {
try {
const memberResponse = await fetch(
`https://discord.com/api/users/@me/guilds/${guildId}/member`,
{
headers: {
Authorization: `Bearer ${tokenResult.token.access_token}`,
},
}
);
if (memberResponse.ok) {
const memberData = await memberResponse.json() as { roles: string[] };
const hasLibraryRole = memberData.roles.includes(libraryRoleId);
if (!hasLibraryRole) {
const botToken = process.env.DISCORD_BOT_TOKEN;
if (botToken) {
const assignRoleResponse = await fetch(
`https://discord.com/api/v10/guilds/${guildId}/members/${userData.id}/roles/${libraryRoleId}`,
{
method: "PUT",
headers: {
Authorization: `Bot ${botToken}`,
"Content-Type": "application/json",
},
}
);
if (assignRoleResponse.ok || assignRoleResponse.status === 204) {
app.log.info(`Assigned library role to user ${user.username} (${user.id})`);
} else {
app.log.error(
`Failed to assign library role to user ${user.username}: ${assignRoleResponse.status}`
);
}
}
}
}
} catch (error) {
// Don't fail the login if role assignment fails
app.log.error({ err: error }, "Error assigning library role");
}
}
// Set signed cookies and redirect to frontend
reply
.setCookie("auth-token", accessToken, {
+5 -5
View File
@@ -36,6 +36,10 @@ export class BookService {
if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) {
throw new Error(`Notes must be ${MAX_LENGTHS.NOTES} characters or less.`);
}
if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) {
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
}
// Validate rating
if (!validateRating(data.rating)) {
throw new Error("Rating must be an integer between 0 and 10.");
@@ -43,11 +47,7 @@ export class BookService {
if (data.coverImage) {
if (data.coverImage.startsWith("data:")) {
const base64Data = data.coverImage.split(",")[1];
if (!base64Data) {
throw new Error("Invalid image data URL format.");
}
const sizeInBytes = base64Data.length * 0.75;
const sizeInBytes = data.coverImage.length * 0.75;
if (sizeInBytes > MAX_LENGTHS.DATA_URL) {
throw new Error("Cover image must be under 5MB.");
}
+5 -5
View File
@@ -33,6 +33,10 @@ export class MangaService {
if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) {
throw new Error(`Notes must be ${MAX_LENGTHS.NOTES} characters or less.`);
}
if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) {
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
}
// Validate rating
if (!validateRating(data.rating)) {
throw new Error("Rating must be an integer between 0 and 10.");
@@ -41,11 +45,7 @@ export class MangaService {
// Validate cover image URL
if (data.coverImage) {
if (data.coverImage.startsWith("data:")) {
const base64Data = data.coverImage.split(",")[1];
if (!base64Data) {
throw new Error("Invalid image data URL format.");
}
const sizeInBytes = base64Data.length * 0.75;
const sizeInBytes = data.coverImage.length * 0.75;
if (sizeInBytes > MAX_LENGTHS.DATA_URL) {
throw new Error("Cover image must be under 5MB.");
}
+5 -5
View File
@@ -33,6 +33,10 @@ export class MusicService {
if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) {
throw new Error(`Notes must be ${MAX_LENGTHS.NOTES} characters or less.`);
}
if (!validateStringLength(data.coverArt, MAX_LENGTHS.URL)) {
throw new Error(`Cover art URL must be ${MAX_LENGTHS.URL} characters or less.`);
}
// Validate rating
if (data.rating !== undefined && !validateRating(data.rating)) {
throw new Error("Rating must be an integer between 0 and 10.");
@@ -41,11 +45,7 @@ export class MusicService {
// Validate cover art URL
if (data.coverArt) {
if (data.coverArt.startsWith("data:")) {
const base64Data = data.coverArt.split(",")[1];
if (!base64Data) {
throw new Error("Invalid image data URL format.");
}
const sizeInBytes = base64Data.length * 0.75;
const sizeInBytes = data.coverArt.length * 0.75;
if (sizeInBytes > MAX_LENGTHS.DATA_URL) {
throw new Error("Cover image must be under 5MB.");
}
+5 -5
View File
@@ -30,6 +30,10 @@ export class ShowService {
if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) {
throw new Error(`Notes must be ${MAX_LENGTHS.NOTES} characters or less.`);
}
if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) {
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
}
// Validate rating
if (!validateRating(data.rating)) {
throw new Error("Rating must be an integer between 0 and 10.");
@@ -38,11 +42,7 @@ export class ShowService {
// Validate cover image URL
if (data.coverImage) {
if (data.coverImage.startsWith("data:")) {
const base64Data = data.coverImage.split(",")[1];
if (!base64Data) {
throw new Error("Invalid image data URL format.");
}
const sizeInBytes = base64Data.length * 0.75;
const sizeInBytes = data.coverImage.length * 0.75;
if (sizeInBytes > MAX_LENGTHS.DATA_URL) {
throw new Error("Cover image must be under 5MB.");
}
-5
View File
@@ -25,11 +25,6 @@
},
"configurations": {
"production": {
"optimization": {
"styles": {
"inlineCritical": false
}
},
"budgets": [
{
"type": "initial",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 381 KiB

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 381 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -244,8 +244,8 @@ import { Suggestion, SuggestionStatus, SuggestionEntity, CreateGameDto, UpdateGa
<app-game-form
mode="add"
[initialData]="getGameInitialData(editingSuggestion()!)"
(formSubmit)="saveGameFromSuggestion($event)"
(formCancel)="closeEditModal()"
(save)="saveGameFromSuggestion($event)"
(cancel)="closeEditModal()"
></app-game-form>
} @else if (editingSuggestion()!.entityType === SuggestionEntity.book) {
<h3>Review & Edit Book Before Accepting</h3>
@@ -253,8 +253,8 @@ import { Suggestion, SuggestionStatus, SuggestionEntity, CreateGameDto, UpdateGa
<app-book-form
mode="add"
[initialData]="getBookInitialData(editingSuggestion()!)"
(formSubmit)="saveBookFromSuggestion($event)"
(formCancel)="closeEditModal()"
(save)="saveBookFromSuggestion($event)"
(cancel)="closeEditModal()"
></app-book-form>
} @else if (editingSuggestion()!.entityType === SuggestionEntity.music) {
<h3>Review & Edit Music Before Accepting</h3>
@@ -262,8 +262,8 @@ import { Suggestion, SuggestionStatus, SuggestionEntity, CreateGameDto, UpdateGa
<app-music-form
mode="add"
[initialData]="getMusicInitialData(editingSuggestion()!)"
(formSubmit)="saveMusicFromSuggestion($event)"
(formCancel)="closeEditModal()"
(save)="saveMusicFromSuggestion($event)"
(cancel)="closeEditModal()"
></app-music-form>
} @else if (editingSuggestion()!.entityType === SuggestionEntity.show) {
<h3>Review & Edit Show Before Accepting</h3>
@@ -271,8 +271,8 @@ import { Suggestion, SuggestionStatus, SuggestionEntity, CreateGameDto, UpdateGa
<app-show-form
mode="add"
[initialData]="getShowInitialData(editingSuggestion()!)"
(formSubmit)="saveShowFromSuggestion($event)"
(formCancel)="closeEditModal()"
(save)="saveShowFromSuggestion($event)"
(cancel)="closeEditModal()"
></app-show-form>
} @else if (editingSuggestion()!.entityType === SuggestionEntity.manga) {
<h3>Review & Edit Manga Before Accepting</h3>
@@ -280,8 +280,8 @@ import { Suggestion, SuggestionStatus, SuggestionEntity, CreateGameDto, UpdateGa
<app-manga-form
mode="add"
[initialData]="getMangaInitialData(editingSuggestion()!)"
(formSubmit)="saveMangaFromSuggestion($event)"
(formCancel)="closeEditModal()"
(save)="saveMangaFromSuggestion($event)"
(cancel)="closeEditModal()"
></app-manga-form>
} @else if (editingSuggestion()!.entityType === SuggestionEntity.art) {
<h3>Review & Edit Art Before Accepting</h3>
@@ -289,8 +289,8 @@ import { Suggestion, SuggestionStatus, SuggestionEntity, CreateGameDto, UpdateGa
<app-art-form
mode="add"
[initialData]="getArtInitialData(editingSuggestion()!)"
(formSubmit)="saveArtFromSuggestion($event)"
(formCancel)="closeEditModal()"
(save)="saveArtFromSuggestion($event)"
(cancel)="closeEditModal()"
></app-art-form>
}
}
@@ -31,8 +31,8 @@ import { Art, Comment, UpdateArtDto } from '@library/shared-types';
<app-art-form
mode="edit"
[art]="art()!"
(formSubmit)="saveEdit($event)"
(formCancel)="cancelEdit()"
(save)="saveEdit($event)"
(cancel)="cancelEdit()"
></app-art-form>
}
@@ -23,10 +23,6 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
imports: [CommonModule, FormsModule, RouterModule, PaginationComponent, LikeButtonComponent],
template: `
<div class="container">
<div class="page-hero">
<img src="/assets/avatars/art-avatar.jpg" alt="Art avatar" class="page-avatar" />
</div>
<div class="header-section">
<h2>Art Gallery</h2>
<p class="subtitle">Artwork of Naomi</p>
@@ -462,26 +458,6 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
padding: 2rem;
}
.page-hero {
text-align: center;
margin-bottom: 2rem;
}
.page-avatar {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
border: 3px solid #fdcb6e;
box-shadow: 0 4px 12px rgba(253, 203, 110, 0.3);
transition: all 0.3s;
}
.page-avatar:hover {
transform: scale(1.05);
box-shadow: 0 8px 16px rgba(253, 203, 110, 0.5);
}
.header-section {
display: flex;
flex-direction: column;
@@ -31,8 +31,8 @@ import { Book, Comment, BookStatus, UpdateBookDto } from '@library/shared-types'
<app-book-form
mode="edit"
[book]="book()!"
(formSubmit)="saveEdit($event)"
(formCancel)="cancelEdit()"
(save)="saveEdit($event)"
(cancel)="cancelEdit()"
></app-book-form>
}
@@ -23,10 +23,6 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
imports: [CommonModule, FormsModule, RouterLink, PaginationComponent, LikeButtonComponent],
template: `
<div class="container">
<div class="page-hero">
<img src="/assets/avatars/books-avatar.jpg" alt="Books avatar" class="page-avatar" />
</div>
<div class="header-section">
<h2>My Book Collection</h2>
@if (authService.isAdmin()) {
@@ -705,26 +701,6 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
padding: 2rem;
}
.page-hero {
text-align: center;
margin-bottom: 2rem;
}
.page-avatar {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
border: 3px solid #8b6f47;
box-shadow: 0 4px 12px rgba(139, 111, 71, 0.3);
transition: all 0.3s;
}
.page-avatar:hover {
transform: scale(1.05);
box-shadow: 0 8px 16px rgba(139, 111, 71, 0.5);
}
.header-section {
display: flex;
justify-content: space-between;
@@ -60,8 +60,8 @@ import { Game, Comment, GameStatus, UpdateGameDto } from '@library/shared-types'
<app-game-form
mode="edit"
[game]="game()!"
(formSubmit)="saveEdit($event)"
(formCancel)="cancelEdit()"
(save)="saveEdit($event)"
(cancel)="cancelEdit()"
></app-game-form>
}
@@ -23,10 +23,6 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
imports: [CommonModule, FormsModule, RouterModule, PaginationComponent, LikeButtonComponent],
template: `
<div class="container">
<div class="page-hero">
<img src="/assets/avatars/games-avatar.jpg" alt="Gaming avatar" class="page-avatar" />
</div>
<div class="header-section">
<h2>My Game Collection</h2>
@if (authService.isAdmin()) {
@@ -688,26 +684,6 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
padding: 2rem;
}
.page-hero {
text-align: center;
margin-bottom: 2rem;
}
.page-avatar {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
border: 3px solid #ff6b6b;
box-shadow: 0 4px 12px rgba(255, 107, 107, 0.3);
transition: all 0.3s;
}
.page-avatar:hover {
transform: scale(1.05);
box-shadow: 0 8px 16px rgba(255, 107, 107, 0.5);
}
.header-section {
display: flex;
justify-content: space-between;
@@ -18,7 +18,7 @@ import { ApiService } from '../../services/api.service';
<header class="header">
<nav class="navbar" aria-label="Main navigation">
<div class="nav-brand">
<img src="/assets/nav-icon.jpg" alt="" class="brand-icon" role="presentation" />
<img src="/assets/icons/icon-72x72.png" alt="" class="brand-icon" role="presentation" />
<h1><a routerLink="/">Naomi's Library</a></h1>
@if (version()) {
<span class="version" aria-label="Version {{ version() }}">v{{ version() }}</span>
@@ -23,7 +23,6 @@ import { Game, GameStatus, Book, BookStatus, Music, MusicType, Manga, MangaStatu
<div class="container">
<div class="hero">
<h1>Welcome to Naomi's Library</h1>
<img src="/assets/nav-icon.jpg" alt="Naomi's avatar" class="hero-avatar" />
<p class="tagline">A personal collection of games, books, music, manga, shows, and art</p>
</div>
@@ -191,28 +190,10 @@ import { Game, GameStatus, Book, BookStatus, Music, MusicType, Manga, MangaStatu
.hero h1 {
font-size: 2.5rem;
margin-bottom: 1rem;
margin-bottom: 0.5rem;
color: var(--witch-purple);
}
.hero-avatar {
width: 150px;
height: 150px;
border-radius: 50%;
object-fit: cover;
border: 4px solid var(--witch-lavender);
box-shadow: 0 4px 12px var(--witch-shadow);
margin: 1rem auto;
display: block;
transition: all 0.3s;
}
.hero-avatar:hover {
transform: scale(1.05);
box-shadow: 0 8px 16px rgba(157, 78, 221, 0.5);
border-color: var(--witch-rose);
}
.tagline {
font-size: 1.2rem;
color: var(--witch-plum);
@@ -31,8 +31,8 @@ import { Manga, Comment, MangaStatus, UpdateMangaDto } from '@library/shared-typ
<app-manga-form
mode="edit"
[manga]="manga()!"
(formSubmit)="saveEdit($event)"
(formCancel)="cancelEdit()"
(save)="saveEdit($event)"
(cancel)="cancelEdit()"
></app-manga-form>
}
@@ -23,10 +23,6 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
imports: [CommonModule, FormsModule, RouterLink, PaginationComponent, LikeButtonComponent],
template: `
<div class="container">
<div class="page-hero">
<img src="/assets/avatars/manga-avatar.jpg" alt="Manga avatar" class="page-avatar" />
</div>
<div class="header-section">
<h2>My Manga Collection</h2>
@if (authService.isAdmin()) {
@@ -623,26 +619,6 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
padding: 2rem;
}
.page-hero {
text-align: center;
margin-bottom: 2rem;
}
.page-avatar {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
border: 3px solid #00b894;
box-shadow: 0 4px 12px rgba(0, 184, 148, 0.3);
transition: all 0.3s;
}
.page-avatar:hover {
transform: scale(1.05);
box-shadow: 0 8px 16px rgba(0, 184, 148, 0.5);
}
.header-section {
display: flex;
justify-content: space-between;
@@ -31,8 +31,8 @@ import { Music, Comment, MusicStatus, MusicType, UpdateMusicDto } from '@library
<app-music-form
mode="edit"
[music]="music()!"
(formSubmit)="saveEdit($event)"
(formCancel)="cancelEdit()"
(save)="saveEdit($event)"
(cancel)="cancelEdit()"
></app-music-form>
}
@@ -23,10 +23,6 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
imports: [CommonModule, FormsModule, RouterLink, PaginationComponent, LikeButtonComponent],
template: `
<div class="container">
<div class="page-hero">
<img src="/assets/avatars/music-avatar.jpg" alt="Music avatar" class="page-avatar" />
</div>
<div class="header-section">
<h2>My Music Collection</h2>
@if (authService.isAdmin()) {
@@ -693,26 +689,6 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
padding: 2rem;
}
.page-hero {
text-align: center;
margin-bottom: 2rem;
}
.page-avatar {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
border: 3px solid #74b9ff;
box-shadow: 0 4px 12px rgba(116, 185, 255, 0.3);
transition: all 0.3s;
}
.page-avatar:hover {
transform: scale(1.05);
box-shadow: 0 8px 16px rgba(116, 185, 255, 0.5);
}
.header-section {
display: flex;
justify-content: space-between;
@@ -290,8 +290,8 @@ export class ArtFormComponent implements OnInit {
@Input() mode: 'add' | 'edit' = 'add';
@Input() art?: Art;
@Input() initialData?: Partial<CreateArtDto>;
@Output() formSubmit = new EventEmitter<CreateArtDto | UpdateArtDto>();
@Output() formCancel = new EventEmitter<void>();
@Output() save = new EventEmitter<CreateArtDto | UpdateArtDto>();
@Output() cancel = new EventEmitter<void>();
formData: Partial<CreateArtDto | UpdateArtDto> = {
tags: [],
@@ -396,10 +396,10 @@ export class ArtFormComponent implements OnInit {
...this.formData as CreateArtDto | UpdateArtDto
};
this.formSubmit.emit(data);
this.save.emit(data);
}
onCancel() {
this.formCancel.emit();
this.cancel.emit();
}
}
@@ -404,8 +404,8 @@ export class BookFormComponent implements OnInit {
@Input() mode: 'add' | 'edit' = 'add';
@Input() book?: Book;
@Input() initialData?: Partial<CreateBookDto>;
@Output() formSubmit = new EventEmitter<CreateBookDto | UpdateBookDto>();
@Output() formCancel = new EventEmitter<void>();
@Output() save = new EventEmitter<CreateBookDto | UpdateBookDto>();
@Output() cancel = new EventEmitter<void>();
BookStatus = BookStatus;
@@ -534,10 +534,10 @@ export class BookFormComponent implements OnInit {
dateFinished: this.formData.dateFinished ? new Date(this.formData.dateFinished) : undefined
};
this.formSubmit.emit(data);
this.save.emit(data);
}
onCancel() {
this.formCancel.emit();
this.cancel.emit();
}
}
@@ -392,8 +392,8 @@ export class GameFormComponent implements OnInit {
@Input() mode: 'add' | 'edit' = 'add';
@Input() game?: Game;
@Input() initialData?: Partial<CreateGameDto>;
@Output() formSubmit = new EventEmitter<CreateGameDto | UpdateGameDto>();
@Output() formCancel = new EventEmitter<void>();
@Output() save = new EventEmitter<CreateGameDto | UpdateGameDto>();
@Output() cancel = new EventEmitter<void>();
GameStatus = GameStatus;
@@ -522,10 +522,10 @@ export class GameFormComponent implements OnInit {
dateFinished: this.formData.dateFinished ? new Date(this.formData.dateFinished) : undefined
};
this.formSubmit.emit(data);
this.save.emit(data);
}
onCancel() {
this.formCancel.emit();
this.cancel.emit();
}
}
@@ -370,8 +370,8 @@ 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>();
@Output() save = new EventEmitter<CreateMangaDto | UpdateMangaDto>();
@Output() cancel = new EventEmitter<void>();
MangaStatus = MangaStatus;
@@ -497,10 +497,10 @@ export class MangaFormComponent implements OnInit {
dateFinished: this.formData.dateFinished ? new Date(this.formData.dateFinished) : undefined
};
this.formSubmit.emit(data);
this.save.emit(data);
}
onCancel() {
this.formCancel.emit();
this.cancel.emit();
}
}
@@ -379,8 +379,8 @@ export class MusicFormComponent implements OnInit {
@Input() mode: 'add' | 'edit' = 'add';
@Input() music?: Music;
@Input() initialData?: Partial<CreateMusicDto>;
@Output() formSubmit = new EventEmitter<CreateMusicDto | UpdateMusicDto>();
@Output() formCancel = new EventEmitter<void>();
@Output() save = new EventEmitter<CreateMusicDto | UpdateMusicDto>();
@Output() cancel = new EventEmitter<void>();
MusicStatus = MusicStatus;
MusicType = MusicType;
@@ -509,10 +509,10 @@ export class MusicFormComponent implements OnInit {
dateFinished: this.formData.dateFinished ? new Date(this.formData.dateFinished) : undefined
};
this.formSubmit.emit(data);
this.save.emit(data);
}
onCancel() {
this.formCancel.emit();
this.cancel.emit();
}
}
@@ -368,8 +368,8 @@ 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>();
@Output() save = new EventEmitter<CreateShowDto | UpdateShowDto>();
@Output() cancel = new EventEmitter<void>();
ShowStatus = ShowStatus;
ShowType = ShowType;
@@ -497,10 +497,10 @@ export class ShowFormComponent implements OnInit {
dateFinished: this.formData.dateFinished ? new Date(this.formData.dateFinished) : undefined
};
this.formSubmit.emit(data);
this.save.emit(data);
}
onCancel() {
this.formCancel.emit();
this.cancel.emit();
}
}
@@ -31,8 +31,8 @@ import { Show, Comment, ShowStatus, ShowType, UpdateShowDto } from '@library/sha
<app-show-form
mode="edit"
[show]="show()!"
(formSubmit)="saveEdit($event)"
(formCancel)="cancelEdit()"
(save)="saveEdit($event)"
(cancel)="cancelEdit()"
></app-show-form>
}
@@ -23,10 +23,6 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
imports: [CommonModule, RouterLink, FormsModule, PaginationComponent, LikeButtonComponent],
template: `
<div class="container">
<div class="page-hero">
<img src="/assets/avatars/shows-avatar.jpg" alt="Shows avatar" class="page-avatar" />
</div>
<div class="header-section">
<h2>My Shows &amp; Films</h2>
@if (authService.isAdmin()) {
@@ -619,26 +615,6 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
padding: 2rem;
}
.page-hero {
text-align: center;
margin-bottom: 2rem;
}
.page-avatar {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
border: 3px solid #e84393;
box-shadow: 0 4px 12px rgba(232, 67, 147, 0.3);
transition: all 0.3s;
}
.page-avatar:hover {
transform: scale(1.05);
box-shadow: 0 8px 16px rgba(232, 67, 147, 0.5);
}
.header-section {
display: flex;
justify-content: space-between;
@@ -12,11 +12,6 @@ export class GlobalErrorHandler implements ErrorHandler {
private toast = inject(ToastService);
handleError(error: Error): void {
if (error.name === 'ChunkLoadError' || error.message.includes('Loading chunk')) {
window.location.reload();
return;
}
console.error('Global error caught:', error);
// Show user-friendly error message
-3
View File
@@ -8,9 +8,6 @@
<meta name="description" content="Naomi's curated collection of games, books, music, shows, manga, and art. Browse, engage, and suggest new additions!" />
<meta name="theme-color" content="#9d4edd" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Creepster&family=Griffy&family=Henny+Penny&family=Kalam:wght@300;400;700&display=swap" rel="stylesheet" />
<script defer src="https://analytics.nhcarrigan.com/js/pa-YUXAn1vhhRttySUAw_LMN.js"></script>
<script>
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
+1 -22
View File
@@ -38,7 +38,7 @@
body {
margin: 0;
padding: 0;
font-family: 'Kalam', cursive;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
font-size: 14pt;
line-height: 1.6;
color: var(--foreground);
@@ -75,30 +75,10 @@ body::after {
pointer-events: none;
}
@keyframes wiggle {
0%, 100% { transform: rotate(-2deg); }
50% { transform: rotate(2deg); }
}
h1, h2, h3, h4, h5, h6 {
margin-top: 0;
line-height: 1.2;
color: var(--witch-purple);
font-family: 'Griffy', cursive;
}
h1 {
animation: wiggle 4s ease-in-out infinite;
display: inline-block;
}
.witchy-accent,
.spooky-title {
font-family: 'Creepster', cursive;
}
.mystical-text {
font-family: 'Henny Penny', cursive;
}
a {
@@ -141,7 +121,6 @@ select:focus {
// Button base styles
button {
font-family: inherit;
transition: all 0.3s;
}
-2
View File
@@ -16,8 +16,6 @@ DISCORD_GUILD_ID="op://Environment Variables - Naomi/Library/discord server id"
SPONSOR_ROLE_ID="op://Environment Variables - Naomi/Library/sponsor role id"
MOD_ROLE_ID="op://Environment Variables - Naomi/Library/mod role id"
STAFF_ROLE_ID="op://Environment Variables - Naomi/Library/staff role id"
DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Library/discord bot token"
LIBRARY_ROLE_ID="op://Environment Variables - Naomi/Library/library role id"
# Application URL
BASE_URL="op://Environment Variables - Naomi/Library/localhost url"
+8 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@library/source",
"version": "1.1.1",
"version": "1.0.0",
"license": "MIT",
"scripts": {
"dev": "nx run-many --target=build --all && NODE_ENV=production op run --env-file=dev.env -- node dist/api/main.js",
@@ -31,7 +31,7 @@
"@fastify/csrf-protection": "7.1.0",
"@fastify/helmet": "13.0.2",
"@fastify/jwt": "10.0.0",
"@fastify/oauth2": "8.2.0",
"@fastify/oauth2": "8.1.2",
"@fastify/rate-limit": "10.3.0",
"@fastify/sensible": "6.0.4",
"@fastify/static": "9.0.0",
@@ -44,8 +44,8 @@
"dompurify": "3.3.1",
"fastify": "5.7.3",
"fastify-plugin": "5.0.1",
"jsdom": "28.1.0",
"marked": "17.0.3",
"jsdom": "28.0.0",
"marked": "17.0.1",
"rxjs": "7.8.2"
},
"devDependencies": {
@@ -70,13 +70,13 @@
"@schematics/angular": "21.1.2",
"@swc-node/register": "1.9.2",
"@swc/core": "1.5.29",
"@swc/helpers": "0.5.19",
"@swc/helpers": "0.5.18",
"@types/dompurify": "3.2.0",
"@types/jest": "30.0.0",
"@types/jsdom": "27.0.0",
"@types/jsonwebtoken": "9.0.10",
"@types/node": "20.19.9",
"@typescript-eslint/utils": "8.56.0",
"@typescript-eslint/utils": "8.54.0",
"angular-eslint": "21.1.0",
"cypress": "15.9.0",
"esbuild": "0.19.12",
@@ -87,10 +87,10 @@
"jest-util": "30.2.0",
"nx": "22.4.4",
"prisma": "6.19.2",
"ts-jest": "29.4.9",
"ts-jest": "29.4.6",
"ts-node": "10.9.1",
"tslib": "2.8.1",
"typescript": "5.9.3",
"typescript-eslint": "8.56.0"
"typescript-eslint": "8.54.0"
}
}
+409 -550
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -16,8 +16,6 @@ DISCORD_GUILD_ID="op://Environment Variables - Naomi/Library/discord server id"
SPONSOR_ROLE_ID="op://Environment Variables - Naomi/Library/sponsor role id"
MOD_ROLE_ID="op://Environment Variables - Naomi/Library/mod role id"
STAFF_ROLE_ID="op://Environment Variables - Naomi/Library/staff role id"
DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Library/discord bot token"
LIBRARY_ROLE_ID="op://Environment Variables - Naomi/Library/library role id"
# Application URL
BASE_URL="op://Environment Variables - Naomi/Library/base url"