Compare commits

..

3 Commits

Author SHA1 Message Date
naomi fbfc24ba0d release: v1.1.0
Node.js CI / CI (push) Failing after 1m25s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m35s
2026-02-20 20:35:15 -08:00
hikari 983b78b0e9 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>
2026-02-20 20:32:52 -08:00
naomi 208c11d153 fix: handle base64 uploads correctly (#64)
Node.js CI / CI (push) Successful in 1m28s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m31s
### Explanation

_No response_

### Issue

_No response_

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #64
2026-02-20 16:53:48 -08:00
35 changed files with 4455 additions and 337 deletions
+299
View File
@@ -0,0 +1,299 @@
# Library Project - Claude Instructions
This is a personal media library tracking application built with an Nx monorepo structure. It tracks games, books, music, art, shows, and manga with user profiles, comments, achievements, and social features.
## Git Commit Guidelines
**CRITICAL**: When working on this project:
- **Always commit as Hikari** using: `--author="Hikari <hikari@nhcarrigan.com>" --no-gpg-sign`
- **Always ask permission before creating commits** - Never commit without explicit user approval
- **Never modify git config** - Always use CLI flags for author information
## User Permissions
This is **Naomi's personal library**:
- **Admin (Naomi only)** - Can create, update, and delete all media items (games, books, music, art, shows, manga)
- **Regular users** - Can only:
- Like items
- Comment on items (with markdown support)
- Submit suggestions for new items (requires admin approval)
- Customise their own profile
All CRUD operations on media items are **admin-only**. Regular users interact through the social features (likes, comments, suggestions).
## Project Structure
This is an **Nx monorepo** containing:
- **`api/`** - Fastify backend API with Prisma ORM
- **`apps/frontend/`** - Angular frontend application
- **`shared-types/`** - Shared TypeScript types between frontend and backend
- **`libs/`** - Shared libraries
## Technology Stack
### Backend (api/)
- **Runtime**: Node.js v24
- **Framework**: Fastify 5.x with plugins:
- `@fastify/autoload` - Auto-loads routes and plugins
- `@fastify/jwt` - JWT authentication
- `@fastify/oauth2` - Discord OAuth integration
- `@fastify/helmet` - Security headers
- `@fastify/cors` - CORS configuration
- `@fastify/csrf-protection` - CSRF protection
- `@fastify/rate-limit` - Rate limiting
- `@fastify/cookie` - Cookie handling
- `@fastify/static` - Static file serving
- **Database**: MongoDB with Prisma ORM
- **Logging**: `@nhcarrigan/logger` (custom logger package)
- **Markdown**: `marked` for parsing, `dompurify` + `jsdom` for sanitisation
### Frontend (apps/frontend/)
- **Framework**: Angular 21.x
- **Icons**: FontAwesome (solid + brands)
- **Styling**: Angular's built-in styling system
### Development Tools
- **Package Manager**: pnpm v10
- **Monorepo**: Nx 22.x
- **Linting**: ESLint 9 (flat config) with `@nhcarrigan/eslint-config`
- **Testing**: Jest 30.x
- **Build**: esbuild (backend), Angular CLI (frontend)
- **Secrets**: 1Password CLI (`op run`)
## Database Schema
The Prisma schema (`api/prisma/schema.prisma`) defines these models:
- **Game** - Video game tracking with platform, status, rating, cover image, tags, series
- **Book** - Book tracking with author, ISBN, status, rating, cover image, tags, series
- **Music** - Music tracking (album/single/EP) with artist, status, rating, cover art, tags
- **Art** - Art collection with artist, description, image, tags
- **Show** - TV/anime/film tracking with type, status, rating, cover image, tags
- **Manga** - Manga tracking with author, status, rating, cover image, tags
- **User** - User profiles with Discord auth, slug, bio, badges, achievements, social links
- **Comment** - Comments on any media type with markdown support
- **AuditLog** - Security and action logging
- **Suggestion** - User-submitted content suggestions
- **Like** - User likes on media items
- **RefreshToken** - JWT refresh token storage
- **ProfileReport** - User profile reporting system
- **CommentReport** - Comment reporting system
- **UserAchievement** - Achievement tracking with progress
All models use MongoDB ObjectIds and include timestamps (`createdAt`, `updatedAt`).
## Authentication Flow
The application uses **Discord OAuth** for authentication:
1. User clicks "Login with Discord"
2. OAuth flow redirects to Discord for authorisation
3. Discord redirects back with authorisation code
4. Backend exchanges code for Discord user info
5. Backend creates/updates User record
6. Backend issues JWT access token (15m expiry) and refresh token (7d expiry)
7. Frontend stores tokens in cookies
8. Access token in `Authorization` header for API requests
9. Refresh token used to obtain new access tokens
See `api/AUTH_FLOW.md` for detailed authentication implementation.
## Image Upload Handling
The application supports **two methods** for cover images:
1. **Regular URLs** - Standard image URLs (max 2048 characters)
2. **Base64 Data URLs** - Inline base64-encoded images (max 5MB decoded size)
### Base64 Upload Constraints
- **Fastify body limit**: 10MB (accommodates base64 overhead)
- **Validation**: Data URLs must match format `data:image/(jpeg|png|gif|webp|svg+xml);base64,[base64data]`
- **Size calculation**: Base64 data is extracted and decoded size calculated as `base64Data.length * 0.75`
- **Size limit**: Decoded image must be under 5MB
### Implementation Notes
- Base64 size check happens AFTER format validation
- Regular URL length check (2048 chars) does NOT apply to data URLs
- Validation logic is in `api/src/app/services/*.service.ts` files
- Base64 validation helpers in `api/src/app/utils/validation.ts`
## Development Workflow
### Local Development
```bash
pnpm dev # Builds all projects and starts API with dev.env secrets
```
The `dev` script:
1. Runs `nx run-many --target=build --all` to build all projects
2. Uses `op run --env-file=dev.env` to inject secrets from 1Password
3. Starts the API with `NODE_ENV=production node dist/api/main.js`
4. API serves the frontend as static files from `dist/apps/frontend/browser`
### Separate Frontend/Backend Development
```bash
pnpm start:frontend:dev # nx serve frontend (port 4200)
pnpm start:api:dev # nx serve api (port 3000, watch mode)
```
### Building for Production
```bash
pnpm build # Generates Prisma client, then builds all projects
```
### Database Management
```bash
pnpm db:gen # Generate Prisma client
pnpm db:push # Push schema changes to MongoDB (uses prod.env secrets)
```
### Linting & Testing
```bash
pnpm lint # Lints all projects with ESLint
pnpm test # Runs all tests with Jest
```
## CI/CD Pipeline
The project uses **Gitea Actions** (`.gitea/workflows/ci.yml`):
1. **Dependency Pin Check** - Ensures all dependencies are pinned (no `^` or `~`)
2. **Install Dependencies** - `pnpm install`
3. **Lint** - `pnpm run lint`
4. **Build** - `pnpm run build`
5. **Test** - `pnpm run test`
CI runs on:
- Pushes to `main` branch
- Pull requests to `main` branch
## Configuration Standards
### TypeScript
- Uses `@nhcarrigan/typescript-config` as base
- Separate configs for app code (`tsconfig.app.json`) and tests (`tsconfig.spec.json`)
- Output directory: `dist/` for builds
### ESLint
- Uses `@nhcarrigan/eslint-config` (ESLint 9 flat config)
- **No Prettier** - all style rules handled by ESLint
- Configured in `eslint.config.mjs`
- Angular-specific rules enabled for frontend
### Testing
- Jest 30.x with `ts-jest` for TypeScript support
- Separate jest configs per project (e.g., `api/jest.config.cts`)
- Cypress for e2e testing (frontend-e2e project)
## Secrets Management
### Environment Files
- **`dev.env`** - Development secrets (1Password vault references)
- **`prod.env`** - Production secrets (1Password vault references)
These files contain **ONLY** 1Password references (e.g., `op://vault/item/field`), NOT actual secrets. They are safe to commit.
### Non-Secret Configuration
Configuration that isn't sensitive (URLs, intervals, feature flags) should be in code (e.g., constants files), NOT in `.env` files.
### Running with Secrets
Always use 1Password CLI:
```bash
op run --env-file=dev.env -- <command>
op run --env-file=prod.env -- <command>
```
## Security Features
The application implements multiple security layers:
- **Helmet** - Security headers (CSP, HSTS, etc.)
- **CORS** - Configured origin restrictions
- **CSRF Protection** - Token-based CSRF validation
- **Rate Limiting** - Request rate limits per IP
- **JWT** - Short-lived access tokens with refresh token rotation
- **Audit Logging** - All security events logged to AuditLog model
- **Content Sanitisation** - Markdown comments sanitised with DOMPurify
- **Input Validation** - All inputs validated before database operations
## API Routes Structure
Routes are auto-loaded via `@fastify/autoload` from `api/src/app/routes/`:
- Each route file exports Fastify route handlers
- Routes follow REST conventions (GET, POST, PUT, DELETE)
- All routes require JWT authentication (except auth endpoints)
- Route handlers call service functions for business logic
Example structure:
- `api/src/app/routes/games/index.ts` - Game CRUD endpoints
- `api/src/app/services/game.service.ts` - Game business logic
## Code Style Conventions
### General
- Use **clear, descriptive variable/function names**
- Prefer **self-documenting code** over comments
- Only use comments to explain **reasoning** when intent isn't clear
- Use **British English** spelling (colour, organise, whilst)
### TypeScript
- Prefer `interface` over `type` for object shapes
- Use `const` assertions where appropriate
- Enable strict mode (inherited from `@nhcarrigan/typescript-config`)
### Error Handling
- Service functions throw `Error` instances with descriptive messages
- Route handlers wrap service calls in try-catch blocks
- Return 400 for validation errors with error message
- Return 500 for unexpected errors (message hidden in production)
- All errors logged to AuditLog for security events
### Validation
- Validation utilities in `api/src/app/utils/validation.ts`
- Constants for max lengths in `shared-types/src/lib/constants.ts`
- Validate early in service functions before database operations
## Common Gotchas
### Base64 Image Uploads
- **Body limit must be 10MB** (`api/src/main.ts`) to accommodate base64 overhead
- Validate data URL format BEFORE size check
- Extract base64 portion (after comma) for size calculation
- Don't apply regular URL length validation to data URLs
### Prisma Client Generation
- Must run `pnpm db:gen` before building if schema changed
- The build script automatically runs this
- Prisma client is generated to `api/generated/`
### Nx Cache
- Nx caches build outputs for faster rebuilds
- Clear cache with `nx reset` if experiencing issues
- Cache stored in `.nx/cache/`
### MongoDB ObjectIds
- All IDs are MongoDB ObjectIds (not UUIDs or auto-increment integers)
- Use `@db.ObjectId` in Prisma schema
- Use `String` type in TypeScript for ObjectIds
## Deployment
The application is deployed in production mode:
```bash
pnpm build # Build all projects
pnpm start # Start with prod.env secrets
```
Production start command:
```bash
NODE_ENV=production op run --env-file=prod.env -- node dist/api/main.js
```
The API serves the built frontend as static files, so only the API process needs to run in production.
## Important Notes
- This is a **personal project** for Naomi's media tracking
- Uses **Discord OAuth** for authentication (no other auth methods)
- **MongoDB** is the only supported database (Prisma schema is MongoDB-specific)
- All **timestamps** are stored as UTC in the database
- **Achievements system** tracks user activity and unlocks (see UserAchievement model)
- **Comments support markdown** with sanitisation
- **User profiles** support custom slugs, bios, and social links
- **Reporting system** for profiles and comments with moderation workflow
+20 -1
View File
@@ -12,8 +12,27 @@ import { AuditAction, AuditCategory } from "@library/shared-types";
const rateLimitPlugin: FastifyPluginAsync = async (app) => {
await app.register(fastifyRateLimit, {
max: 100,
max: async (request) => {
// Try to get user from JWT
try {
await request.jwtVerify();
// Authenticated users get higher limits
return 500;
} catch {
// Unauthenticated users get lower limits
return 100;
}
},
timeWindow: "1 minute",
allowList: async (request) => {
// Bypass rate limiting entirely for admin users
try {
await request.jwtVerify();
return request.user?.isAdmin === true;
} catch {
return false;
}
},
errorResponseBuilder: (request) => {
// Log rate limit exceeded event
AuditService.log({
+2 -2
View File
@@ -21,8 +21,8 @@ export default async function staticPlugin(app: FastifyInstance) {
// Catch-all route for Angular SPA routing (must be registered after API routes)
app.setNotFoundHandler((request, reply) => {
// Only catch routes that don't start with /api
if (!request.url.startsWith("/api")) {
// Only catch routes that don't start with /api or /assets
if (!request.url.startsWith("/api") && !request.url.startsWith("/assets")) {
reply.sendFile("index.html");
} else {
reply.code(404).send({ error: "Not Found" });
+47
View File
@@ -102,6 +102,53 @@ 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, {
+18 -4
View File
@@ -41,13 +41,14 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
);
// Create game (protected admin route)
app.post<{ Body: CreateGameDto; Reply: Game }>(
app.post<{ Body: CreateGameDto; Reply: Game | { error: string } }>(
"/",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request) => {
async (request, reply) => {
try {
const game = await gameService.createGame(request.body);
await AuditService.logFromRequest(request, {
action: AuditAction.entryCreate,
@@ -57,6 +58,12 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
details: `Created game: ${game.title}`,
});
return game;
} catch (error) {
if (error instanceof Error) {
return reply.code(400).send({ error: error.message });
}
throw error;
}
}
);
@@ -64,14 +71,15 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
app.put<{
Params: { id: string };
Body: UpdateGameDto;
Reply: Game | null;
Reply: Game | null | { error: string };
}>(
"/:id",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request) => {
async (request, reply) => {
try {
const { id } = request.params;
const game = await gameService.updateGame(id, request.body);
if (game) {
@@ -84,6 +92,12 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
});
}
return game;
} catch (error) {
if (error instanceof Error) {
return reply.code(400).send({ error: error.message });
}
throw error;
}
}
);
+17 -2
View File
@@ -10,6 +10,7 @@ import {
validateUrl,
validateRating,
validateStringLength,
validateDataUrl,
MAX_LENGTHS,
} from "../utils/validation";
@@ -44,10 +45,24 @@ export class BookService {
throw new Error("Rating must be an integer between 0 and 10.");
}
// Validate cover image URL
if (data.coverImage && !validateUrl(data.coverImage)) {
if (data.coverImage) {
if (data.coverImage.startsWith("data:")) {
const sizeInBytes = data.coverImage.length * 0.75;
if (sizeInBytes > MAX_LENGTHS.DATA_URL) {
throw new Error("Cover image must be under 5MB.");
}
if (!validateDataUrl(data.coverImage)) {
throw new Error("Invalid image data URL.");
}
} else {
if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) {
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
}
if (!validateUrl(data.coverImage)) {
throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
}
}
}
// Validate tags
if (data.tags) {
+23 -4
View File
@@ -10,6 +10,7 @@ import {
validateUrl,
validateRating,
validateStringLength,
validateDataUrl,
MAX_LENGTHS,
} from "../utils/validation";
@@ -32,9 +33,6 @@ export class GameService {
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)) {
@@ -42,9 +40,30 @@ export class GameService {
}
// Validate cover image URL
if (data.coverImage && !validateUrl(data.coverImage)) {
if (data.coverImage) {
if (data.coverImage.startsWith("data:")) {
// Extract just the base64 data (after the comma)
const base64Data = data.coverImage.split(",")[1];
if (!base64Data) {
throw new Error("Invalid image data URL format.");
}
// Calculate decoded size: base64 is ~4/3 larger than original, so multiply by 0.75
const decodedSizeInBytes = base64Data.length * 0.75;
if (decodedSizeInBytes > MAX_LENGTHS.DATA_URL) {
throw new Error("Cover image must be under 5MB.");
}
if (!validateDataUrl(data.coverImage)) {
throw new Error("Invalid image data URL.");
}
} else {
if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) {
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
}
if (!validateUrl(data.coverImage)) {
throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
}
}
}
// Validate tags
if (data.tags) {
+17 -1
View File
@@ -10,6 +10,7 @@ import {
validateUrl,
validateRating,
validateStringLength,
validateDataUrl,
MAX_LENGTHS,
} from "../utils/validation";
@@ -42,9 +43,24 @@ export class MangaService {
}
// Validate cover image URL
if (data.coverImage && !validateUrl(data.coverImage)) {
if (data.coverImage) {
if (data.coverImage.startsWith("data:")) {
const sizeInBytes = data.coverImage.length * 0.75;
if (sizeInBytes > MAX_LENGTHS.DATA_URL) {
throw new Error("Cover image must be under 5MB.");
}
if (!validateDataUrl(data.coverImage)) {
throw new Error("Invalid image data URL.");
}
} else {
if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) {
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
}
if (!validateUrl(data.coverImage)) {
throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
}
}
}
// Validate tags
if (data.tags) {
+18 -2
View File
@@ -10,6 +10,7 @@ import {
validateUrl,
validateRating,
validateStringLength,
validateDataUrl,
MAX_LENGTHS,
} from "../utils/validation";
@@ -42,8 +43,23 @@ export class MusicService {
}
// Validate cover art URL
if (data.coverArt && !validateUrl(data.coverArt)) {
throw new Error("Invalid cover art URL. Only http and https URLs are allowed.");
if (data.coverArt) {
if (data.coverArt.startsWith("data:")) {
const sizeInBytes = data.coverArt.length * 0.75;
if (sizeInBytes > MAX_LENGTHS.DATA_URL) {
throw new Error("Cover image must be under 5MB.");
}
if (!validateDataUrl(data.coverArt)) {
throw new Error("Invalid image data URL.");
}
} else {
if (!validateStringLength(data.coverArt, MAX_LENGTHS.URL)) {
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
}
if (!validateUrl(data.coverArt)) {
throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
}
}
}
// Validate tags
+17 -1
View File
@@ -10,6 +10,7 @@ import {
validateUrl,
validateRating,
validateStringLength,
validateDataUrl,
MAX_LENGTHS,
} from "../utils/validation";
@@ -39,9 +40,24 @@ export class ShowService {
}
// Validate cover image URL
if (data.coverImage && !validateUrl(data.coverImage)) {
if (data.coverImage) {
if (data.coverImage.startsWith("data:")) {
const sizeInBytes = data.coverImage.length * 0.75;
if (sizeInBytes > MAX_LENGTHS.DATA_URL) {
throw new Error("Cover image must be under 5MB.");
}
if (!validateDataUrl(data.coverImage)) {
throw new Error("Invalid image data URL.");
}
} else {
if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) {
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
}
if (!validateUrl(data.coverImage)) {
throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
}
}
}
// Validate tags
if (data.tags) {
+9
View File
@@ -4,6 +4,14 @@
* @author Naomi Carrigan
*/
/**
* Validates that a URL is a proper base64 data string.
* Allows whitespace in the base64 data which may occur during transmission.
*/
export function validateDataUrl(url: string): boolean {
return /^data:image\/(jpeg|png|gif|webp|svg\+xml);base64,[A-Za-z0-9+/=\s]+$/.test(url);
}
/**
* Validates that a URL is safe and points to an allowed protocol.
* Prevents javascript:, data:, vbscript:, and file: URLs.
@@ -83,4 +91,5 @@ export const MAX_LENGTHS = {
NOTES: 5000,
TAGS: 50, // per tag
ISBN: 50,
DATA_URL: 5 * 1024 * 1024, // 5MB in bytes (not chars)
} as const;
+1 -1
View File
@@ -40,7 +40,7 @@ process.on('SIGINT', () => {
// Instantiate Fastify with some config
const server = Fastify({
logger: true,
bodyLimit: 1048576, // 1MB max body size
bodyLimit: 10485760, // 10MB max body size (to accommodate base64-encoded images)
});
// Register your application as a normal plugin.
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 MiB

@@ -10,12 +10,18 @@ import { FormsModule } from '@angular/forms';
import { SuggestionService } from '../../services/suggestion.service';
import { AuthService } from '../../services/auth.service';
import { PaginationComponent } from '../shared/pagination.component';
import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-types';
import { GameFormComponent } from '../shared/game-form.component';
import { BookFormComponent } from '../shared/book-form.component';
import { MusicFormComponent } from '../shared/music-form.component';
import { ShowFormComponent } from '../shared/show-form.component';
import { MangaFormComponent } from '../shared/manga-form.component';
import { ArtFormComponent } from '../shared/art-form.component';
import { Suggestion, SuggestionStatus, SuggestionEntity, CreateGameDto, UpdateGameDto, GameStatus, CreateBookDto, UpdateBookDto, BookStatus, CreateMusicDto, UpdateMusicDto, MusicStatus, MusicType, CreateShowDto, UpdateShowDto, ShowStatus, ShowType, CreateMangaDto, UpdateMangaDto, MangaStatus, CreateArtDto, UpdateArtDto } from '@library/shared-types';
@Component({
selector: 'app-admin-suggestions',
standalone: true,
imports: [CommonModule, FormsModule, PaginationComponent],
imports: [CommonModule, FormsModule, PaginationComponent, GameFormComponent, BookFormComponent, MusicFormComponent, ShowFormComponent, MangaFormComponent, ArtFormComponent],
template: `
<div class="container">
<div class="header-section">
@@ -231,158 +237,62 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
@if (showEditModal()) {
<div class="modal-overlay" (click)="closeEditModal()" (keyup.escape)="closeEditModal()" tabindex="0" role="button">
<div class="modal edit-modal" (click)="$event.stopPropagation()" (keyup)="$event.stopPropagation()" tabindex="-1">
<h3>Review & Edit Before Accepting</h3>
<p>Review and edit the details before adding to your collection.</p>
@if (editingSuggestion()) {
<form (ngSubmit)="confirmAcceptWithEdits()">
<div class="form-group">
<label for="edit-title">Title</label>
<input
type="text"
id="edit-title"
[(ngModel)]="editedData.title"
name="title"
required
>
</div>
@switch (editingSuggestion()!.entityType) {
@case (SuggestionEntity.book) {
<div class="form-group">
<label for="edit-author">Author</label>
<input
type="text"
id="edit-author"
[(ngModel)]="editedData.author"
name="author"
required
>
</div>
<div class="form-group">
<label for="edit-isbn">ISBN</label>
<input
type="text"
id="edit-isbn"
[(ngModel)]="editedData.isbn"
name="isbn"
>
</div>
@if (editingSuggestion()!.entityType === SuggestionEntity.game) {
<h3>Review & Edit Game Before Accepting</h3>
<p>Review and edit all the details before adding to your collection.</p>
<app-game-form
mode="add"
[initialData]="getGameInitialData(editingSuggestion()!)"
(formSubmit)="saveGameFromSuggestion($event)"
(formCancel)="closeEditModal()"
></app-game-form>
} @else if (editingSuggestion()!.entityType === SuggestionEntity.book) {
<h3>Review & Edit Book Before Accepting</h3>
<p>Review and edit all the details before adding to your collection.</p>
<app-book-form
mode="add"
[initialData]="getBookInitialData(editingSuggestion()!)"
(formSubmit)="saveBookFromSuggestion($event)"
(formCancel)="closeEditModal()"
></app-book-form>
} @else if (editingSuggestion()!.entityType === SuggestionEntity.music) {
<h3>Review & Edit Music Before Accepting</h3>
<p>Review and edit all the details before adding to your collection.</p>
<app-music-form
mode="add"
[initialData]="getMusicInitialData(editingSuggestion()!)"
(formSubmit)="saveMusicFromSuggestion($event)"
(formCancel)="closeEditModal()"
></app-music-form>
} @else if (editingSuggestion()!.entityType === SuggestionEntity.show) {
<h3>Review & Edit Show Before Accepting</h3>
<p>Review and edit all the details before adding to your collection.</p>
<app-show-form
mode="add"
[initialData]="getShowInitialData(editingSuggestion()!)"
(formSubmit)="saveShowFromSuggestion($event)"
(formCancel)="closeEditModal()"
></app-show-form>
} @else if (editingSuggestion()!.entityType === SuggestionEntity.manga) {
<h3>Review & Edit Manga Before Accepting</h3>
<p>Review and edit all the details before adding to your collection.</p>
<app-manga-form
mode="add"
[initialData]="getMangaInitialData(editingSuggestion()!)"
(formSubmit)="saveMangaFromSuggestion($event)"
(formCancel)="closeEditModal()"
></app-manga-form>
} @else if (editingSuggestion()!.entityType === SuggestionEntity.art) {
<h3>Review & Edit Art Before Accepting</h3>
<p>Review and edit all the details before adding to your collection.</p>
<app-art-form
mode="add"
[initialData]="getArtInitialData(editingSuggestion()!)"
(formSubmit)="saveArtFromSuggestion($event)"
(formCancel)="closeEditModal()"
></app-art-form>
}
@case (SuggestionEntity.game) {
<div class="form-group">
<label for="edit-platform">Platform</label>
<input
type="text"
id="edit-platform"
[(ngModel)]="editedData.platform"
name="platform"
>
</div>
}
@case (SuggestionEntity.music) {
<div class="form-group">
<label for="edit-artist">Artist</label>
<input
type="text"
id="edit-artist"
[(ngModel)]="editedData.artist"
name="artist"
required
>
</div>
<div class="form-group">
<label for="edit-type">Type</label>
<select id="edit-type" [(ngModel)]="editedData.type" name="type" required>
<option value="ALBUM">Album</option>
<option value="SINGLE">Single</option>
<option value="EP">EP</option>
</select>
</div>
}
@case (SuggestionEntity.art) {
<div class="form-group">
<label for="edit-artist">Artist</label>
<input
type="text"
id="edit-artist"
[(ngModel)]="editedData.artist"
name="artist"
required
>
</div>
<div class="form-group">
<label for="edit-description">Description</label>
<textarea
id="edit-description"
[(ngModel)]="editedData.description"
name="description"
rows="3"
></textarea>
</div>
<div class="form-group">
<label for="edit-imageUrl">Image URL</label>
<input
type="text"
id="edit-imageUrl"
[(ngModel)]="editedData.imageUrl"
name="imageUrl"
required
>
</div>
}
@case (SuggestionEntity.show) {
<div class="form-group">
<label for="edit-type">Type</label>
<select id="edit-type" [(ngModel)]="editedData.type" name="type" required>
<option value="TV_SERIES">TV Series</option>
<option value="ANIME">Anime</option>
<option value="FILM">Film</option>
<option value="DOCUMENTARY">Documentary</option>
</select>
</div>
}
@case (SuggestionEntity.manga) {
<div class="form-group">
<label for="edit-author">Author</label>
<input
type="text"
id="edit-author"
[(ngModel)]="editedData.author"
name="author"
required
>
</div>
}
}
<div class="form-group">
<label for="edit-notes">Notes</label>
<textarea
id="edit-notes"
[(ngModel)]="editedData.notes"
name="notes"
rows="3"
></textarea>
</div>
@if (editingSuggestion()!.entityType !== SuggestionEntity.art) {
<div class="form-group">
<label for="edit-coverImage">Cover Image URL</label>
<input
type="text"
id="edit-coverImage"
[(ngModel)]="editedData.coverImage"
name="coverImage"
>
</div>
}
<div class="modal-actions">
<button type="submit" class="btn btn-accept">Accept with Edits</button>
<button type="button" (click)="closeEditModal()" class="btn btn-secondary">Cancel</button>
</div>
</form>
}
</div>
</div>
@@ -810,59 +720,174 @@ export class AdminSuggestionsComponent implements OnInit {
return new Date(date).toLocaleDateString();
}
getGameInitialData(suggestion: Suggestion): Partial<CreateGameDto> {
const gameData = suggestion.gameData as any;
return {
title: suggestion.title,
platform: gameData?.platform || undefined,
status: GameStatus.backlog, // Default to backlog for new suggestions
notes: gameData?.notes || undefined,
coverImage: gameData?.coverImage || undefined,
tags: [],
links: []
};
}
async saveGameFromSuggestion(data: CreateGameDto | UpdateGameDto) {
const suggestion = this.editingSuggestion();
if (!suggestion) return;
try {
// Treat it as CreateGameDto since we're creating a new item from a suggestion
await this.suggestionService.acceptSuggestionWithEdits(suggestion.id, data as CreateGameDto);
alert(`"${(data as CreateGameDto).title}" has been added to your collection!`);
this.closeEditModal();
this.loadSuggestions();
} catch (error) {
alert('Failed to accept suggestion. Please try again.');
}
}
getBookInitialData(suggestion: Suggestion): Partial<CreateBookDto> {
const bookData = suggestion.bookData as any;
return {
title: suggestion.title,
author: bookData?.author || '',
isbn: bookData?.isbn || undefined,
status: BookStatus.toRead,
notes: bookData?.notes || undefined,
coverImage: bookData?.coverImage || undefined,
tags: [],
links: []
};
}
async saveBookFromSuggestion(data: CreateBookDto | UpdateBookDto) {
const suggestion = this.editingSuggestion();
if (!suggestion) return;
try {
await this.suggestionService.acceptSuggestionWithEdits(suggestion.id, data as CreateBookDto);
alert(`"${(data as CreateBookDto).title}" has been added to your collection!`);
this.closeEditModal();
this.loadSuggestions();
} catch (error) {
alert('Failed to accept suggestion. Please try again.');
}
}
getMusicInitialData(suggestion: Suggestion): Partial<CreateMusicDto> {
const musicData = suggestion.musicData as any;
return {
title: suggestion.title,
artist: musicData?.artist || '',
type: musicData?.type || MusicType.album,
status: MusicStatus.wantToListen,
notes: musicData?.notes || undefined,
coverArt: musicData?.coverArt || undefined,
tags: [],
links: []
};
}
async saveMusicFromSuggestion(data: CreateMusicDto | UpdateMusicDto) {
const suggestion = this.editingSuggestion();
if (!suggestion) return;
try {
await this.suggestionService.acceptSuggestionWithEdits(suggestion.id, data as CreateMusicDto);
alert(`"${(data as CreateMusicDto).title}" has been added to your collection!`);
this.closeEditModal();
this.loadSuggestions();
} catch (error) {
alert('Failed to accept suggestion. Please try again.');
}
}
getShowInitialData(suggestion: Suggestion): Partial<CreateShowDto> {
const showData = suggestion.showData as any;
return {
title: suggestion.title,
type: showData?.type || ShowType.tvSeries,
status: ShowStatus.wantToWatch,
notes: showData?.notes || undefined,
coverImage: showData?.coverImage || undefined,
tags: [],
links: []
};
}
async saveShowFromSuggestion(data: CreateShowDto | UpdateShowDto) {
const suggestion = this.editingSuggestion();
if (!suggestion) return;
try {
await this.suggestionService.acceptSuggestionWithEdits(suggestion.id, data as CreateShowDto);
alert(`"${(data as CreateShowDto).title}" has been added to your collection!`);
this.closeEditModal();
this.loadSuggestions();
} catch (error) {
alert('Failed to accept suggestion. Please try again.');
}
}
getMangaInitialData(suggestion: Suggestion): Partial<CreateMangaDto> {
const mangaData = suggestion.mangaData as any;
return {
title: suggestion.title,
author: mangaData?.author || '',
status: MangaStatus.wantToRead,
notes: mangaData?.notes || undefined,
coverImage: mangaData?.coverImage || undefined,
tags: [],
links: []
};
}
async saveMangaFromSuggestion(data: CreateMangaDto | UpdateMangaDto) {
const suggestion = this.editingSuggestion();
if (!suggestion) return;
try {
await this.suggestionService.acceptSuggestionWithEdits(suggestion.id, data as CreateMangaDto);
alert(`"${(data as CreateMangaDto).title}" has been added to your collection!`);
this.closeEditModal();
this.loadSuggestions();
} catch (error) {
alert('Failed to accept suggestion. Please try again.');
}
}
getArtInitialData(suggestion: Suggestion): Partial<CreateArtDto> {
const artData = suggestion.artData as any;
return {
title: suggestion.title,
artist: artData?.artist || '',
description: artData?.description || undefined,
imageUrl: artData?.imageUrl || '',
tags: [],
links: []
};
}
async saveArtFromSuggestion(data: CreateArtDto | UpdateArtDto) {
const suggestion = this.editingSuggestion();
if (!suggestion) return;
try {
await this.suggestionService.acceptSuggestionWithEdits(suggestion.id, data as CreateArtDto);
alert(`"${(data as CreateArtDto).title}" has been added to your collection!`);
this.closeEditModal();
this.loadSuggestions();
} catch (error) {
alert('Failed to accept suggestion. Please try again.');
}
}
async acceptSuggestion(suggestion: Suggestion) {
this.editingSuggestion.set(suggestion);
// Pre-populate the edit form with suggestion data
this.editedData = {
title: suggestion.title,
notes: '',
coverImage: '',
coverArt: ''
};
// Add entity-specific data
switch (suggestion.entityType) {
case SuggestionEntity.book:
const bookData = suggestion.bookData as any;
this.editedData.author = bookData?.author || '';
this.editedData.isbn = bookData?.isbn || '';
this.editedData.notes = bookData?.notes || '';
this.editedData.coverImage = bookData?.coverImage || '';
break;
case SuggestionEntity.game:
const gameData = suggestion.gameData as any;
this.editedData.platform = gameData?.platform || '';
this.editedData.notes = gameData?.notes || '';
this.editedData.coverImage = gameData?.coverImage || '';
break;
case SuggestionEntity.music:
const musicData = suggestion.musicData as any;
this.editedData.artist = musicData?.artist || '';
this.editedData.type = musicData?.type || 'ALBUM';
this.editedData.notes = musicData?.notes || '';
this.editedData.coverArt = musicData?.coverArt || '';
break;
case SuggestionEntity.art:
const artData = suggestion.artData as any;
this.editedData.artist = artData?.artist || '';
this.editedData.description = artData?.description || '';
this.editedData.imageUrl = artData?.imageUrl || '';
break;
case SuggestionEntity.show:
const showData = suggestion.showData as any;
this.editedData.type = showData?.type || 'TV_SERIES';
this.editedData.notes = showData?.notes || '';
this.editedData.coverImage = showData?.coverImage || '';
break;
case SuggestionEntity.manga:
const mangaData = suggestion.mangaData as any;
this.editedData.author = mangaData?.author || '';
this.editedData.notes = mangaData?.notes || '';
this.editedData.coverImage = mangaData?.coverImage || '';
break;
}
// For all entity types, we'll use the form components which have their own initialization logic
this.showEditModal.set(true);
}
@@ -884,20 +909,6 @@ export class AdminSuggestionsComponent implements OnInit {
this.editedData = {};
}
async confirmAcceptWithEdits() {
const suggestion = this.editingSuggestion();
if (!suggestion) return;
try {
await this.suggestionService.acceptSuggestionWithEdits(suggestion.id, this.editedData);
alert(`"${this.editedData.title}" has been added to your collection with your edits!`);
this.closeEditModal();
this.loadSuggestions();
} catch (error) {
alert('Failed to accept suggestion. Please try again.');
}
}
async confirmDecline() {
const suggestion = this.decliningsuggestion();
if (!suggestion) return;
@@ -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 { Art, Comment } from '@library/shared-types';
import { ArtFormComponent } from '../shared/art-form.component';
import { Art, Comment, UpdateArtDto } from '@library/shared-types';
@Component({
selector: 'app-art-detail',
standalone: true,
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent],
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent, ArtFormComponent],
template: `
<div class="container">
<div class="breadcrumb">
<a routerLink="/art" class="breadcrumb-link">← Back to Art</a>
</div>
@if (showEditForm() && authService.user()?.isAdmin && art()) {
<app-art-form
mode="edit"
[art]="art()!"
(formSubmit)="saveEdit($event)"
(formCancel)="cancelEdit()"
></app-art-form>
}
@if (loading()) {
<div class="loading">Loading artwork details...</div>
} @else if (error()) {
@@ -36,11 +46,9 @@ import { Art, Comment } from '@library/shared-types';
</div>
} @else if (art()) {
<div class="art-detail-card">
@if (art()!.imageUrl) {
<div class="art-image-section">
<img [src]="art()!.imageUrl" [alt]="art()!.description || art()!.title" class="art-image-large">
<img [src]="art()!.imageUrl || '/assets/default-cover.jpg'" [alt]="art()!.description || art()!.title" class="art-image-large">
</div>
}
<div class="art-content">
<div class="art-header">
@@ -48,6 +56,12 @@ import { Art, Comment } from '@library/shared-types';
</div>
<p class="artist">by {{ art()!.artist }}</p>
@if (authService.user()?.isAdmin) {
<div class="admin-actions">
<button (click)="toggleEditForm()" class="btn btn-edit">✏️ {{ showEditForm() ? 'Cancel Edit' : 'Edit' }}</button>
<button (click)="deleteArt()" class="btn btn-delete">🗑️ Delete</button>
</div>
}
<div class="info-row">
<span class="info-label">Added:</span>
@@ -232,6 +246,35 @@ import { Art, Comment } from '@library/shared-types';
margin: 0 0 1rem 0;
}
.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;
@@ -426,6 +469,35 @@ import { Art, Comment } from '@library/shared-types';
font-size: 1.5rem;
}
.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;
@@ -452,6 +524,7 @@ export class ArtDetailComponent implements OnInit {
commentsLoading = signal(false);
error = signal<string | null>(null);
newCommentContent = '';
showEditForm = signal(false);
ngOnInit() {
const artId = this.route.snapshot.paramMap.get('id');
@@ -539,4 +612,45 @@ export class ArtDetailComponent implements OnInit {
day: 'numeric'
});
}
toggleEditForm() {
this.showEditForm.update(value => !value);
}
saveEdit(data: UpdateArtDto) {
const art = this.art();
if (!art) return;
this.artService.updateArt(art.id, data).subscribe({
next: (updatedArt) => {
this.art.set(updatedArt);
this.showEditForm.set(false);
},
error: (err) => {
alert('Failed to update art: ' + (err.error?.message || 'Unknown error'));
}
});
}
cancelEdit() {
this.showEditForm.set(false);
}
deleteArt() {
const art = this.art();
if (!art) return;
if (!confirm(`Are you sure you want to delete "${art.title}"? This action cannot be undone.`)) {
return;
}
this.artService.deleteArt(art.id).subscribe({
next: () => {
this.router.navigate(['/art']);
},
error: (err) => {
alert('Failed to delete art: ' + (err.error?.message || 'Unknown error'));
}
});
}
}
@@ -395,7 +395,7 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
<a [routerLink]="['/art', art.id]" class="card-link">
<div class="art-image-container">
<img
[src]="art.imageUrl"
[src]="art.imageUrl || '/assets/default-cover.jpg'"
[alt]="art.description || art.title"
class="art-image"
>
@@ -1121,6 +1121,9 @@ export class ArtGalleryComponent implements OnInit {
this.editTagInput = '';
this.editLinkTitle = '';
this.editLinkUrl = '';
// Scroll to top so the form is visible
window.scrollTo({ top: 0, behavior: 'smooth' });
}
cancelEdit() {
@@ -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 { Book, Comment, BookStatus } from '@library/shared-types';
import { BookFormComponent } from '../shared/book-form.component';
import { Book, Comment, BookStatus, UpdateBookDto } from '@library/shared-types';
@Component({
selector: 'app-book-detail',
standalone: true,
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent],
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent, BookFormComponent],
template: `
<div class="container">
<div class="breadcrumb">
<a routerLink="/books" class="breadcrumb-link">← Back to Books</a>
</div>
@if (showEditForm() && authService.user()?.isAdmin && book()) {
<app-book-form
mode="edit"
[book]="book()!"
(formSubmit)="saveEdit($event)"
(formCancel)="cancelEdit()"
></app-book-form>
}
@if (loading()) {
<div class="loading">Loading book details...</div>
} @else if (error()) {
@@ -36,11 +46,9 @@ import { Book, Comment, BookStatus } from '@library/shared-types';
</div>
} @else if (book()) {
<div class="book-detail-card">
@if (book()!.coverImage) {
<div class="book-cover-section">
<img [src]="book()!.coverImage" [alt]="book()!.title" class="book-cover-large">
<img [src]="book()!.coverImage || '/assets/default-cover.jpg'" [alt]="book()!.title" class="book-cover-large">
</div>
}
<div class="book-content">
<div class="book-header">
@@ -51,6 +59,12 @@ import { Book, Comment, BookStatus } from '@library/shared-types';
</div>
<p class="author">by {{ book()!.author }}</p>
@if (authService.user()?.isAdmin) {
<div class="admin-actions">
<button (click)="editBook()" class="btn btn-edit">✏️ Edit</button>
<button (click)="deleteBook()" class="btn btn-delete">🗑️ Delete</button>
</div>
}
@if (book()!.series) {
<p class="series">
@@ -309,6 +323,35 @@ import { Book, Comment, BookStatus } 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;
}
.series {
color: #8b6f47;
font-size: 1rem;
@@ -542,6 +585,7 @@ export class BookDetailComponent implements OnInit {
commentsLoading = signal(false);
error = signal<string | null>(null);
newCommentContent = '';
showEditForm = signal(false);
ngOnInit() {
const bookId = this.route.snapshot.paramMap.get('id');
@@ -651,4 +695,45 @@ export class BookDetailComponent implements OnInit {
return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`;
}
}
editBook() {
this.showEditForm.set(true);
}
cancelEdit() {
this.showEditForm.set(false);
}
saveEdit(data: UpdateBookDto) {
const book = this.book();
if (!book) return;
this.booksService.updateBook(book.id, data).subscribe({
next: (updatedBook) => {
this.book.set(updatedBook);
this.showEditForm.set(false);
},
error: (err) => {
alert('Failed to update book: ' + (err.error?.message || 'Unknown error'));
}
});
}
deleteBook() {
const book = this.book();
if (!book) return;
if (!confirm(`Are you sure you want to delete "${book.title}"? This action cannot be undone.`)) {
return;
}
this.booksService.deleteBook(book.id).subscribe({
next: () => {
this.router.navigate(['/books']);
},
error: (err) => {
alert('Failed to delete book: ' + (err.error?.message || 'Unknown error'));
}
});
}
}
@@ -643,11 +643,7 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
@for (book of paginatedBooks(); track book.id) {
<div class="book-card" [class.finished]="book.status === BookStatus.finished">
<a [routerLink]="['/books', book.id]" class="card-link">
@if (book.coverImage) {
<img [src]="book.coverImage" [alt]="book.title" class="book-cover">
} @else {
<div class="book-cover placeholder">📚</div>
}
<img [src]="book.coverImage || '/assets/default-cover.jpg'" [alt]="book.title" class="book-cover">
<div class="book-info">
<h3>{{ book.title }}</h3>
@@ -1561,6 +1557,9 @@ export class BooksListComponent implements OnInit {
this.editBookTimeHours = 0;
this.editBookTimeMinutes = 0;
}
// Scroll to top so the form is visible
window.scrollTo({ top: 0, behavior: 'smooth' });
}
cancelEdit() {
@@ -14,12 +14,13 @@ 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 { Game, Comment, GameStatus } from '@library/shared-types';
import { GameFormComponent } from '../shared/game-form.component';
import { Game, Comment, GameStatus, UpdateGameDto } from '@library/shared-types';
@Component({
selector: 'app-game-detail',
standalone: true,
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent],
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent, GameFormComponent],
template: `
<div class="container">
<div class="breadcrumb">
@@ -36,11 +37,9 @@ import { Game, Comment, GameStatus } from '@library/shared-types';
</div>
} @else if (game()) {
<div class="game-detail-card">
@if (game()!.coverImage) {
<div class="game-cover-section">
<img [src]="game()!.coverImage" [alt]="game()!.title" class="game-cover-large">
<img [src]="game()!.coverImage || '/assets/default-cover.jpg'" [alt]="game()!.title" class="game-cover-large">
</div>
}
<div class="game-content">
<div class="game-header">
@@ -50,6 +49,22 @@ import { Game, Comment, GameStatus } from '@library/shared-types';
</span>
</div>
@if (authService.user()?.isAdmin) {
<div class="admin-actions">
<button (click)="toggleEditForm()" class="btn btn-edit">✏️ {{ showEditForm() ? 'Cancel Edit' : 'Edit' }}</button>
<button (click)="deleteGame()" class="btn btn-delete">🗑️ Delete</button>
</div>
}
@if (showEditForm() && authService.user()?.isAdmin && game()) {
<app-game-form
mode="edit"
[game]="game()!"
(formSubmit)="saveEdit($event)"
(formCancel)="cancelEdit()"
></app-game-form>
}
@if (game()!.series) {
<p class="series">
📚 {{ game()!.series }}@if (game()!.seriesOrder) { #{{ game()!.seriesOrder }}}
@@ -300,6 +315,34 @@ import { Game, Comment, GameStatus } 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;
}
.series {
color: #8b6f47;
font-size: 1rem;
@@ -533,6 +576,7 @@ export class GameDetailComponent implements OnInit {
commentsLoading = signal(false);
error = signal<string | null>(null);
newCommentContent = '';
showEditForm = signal(false);
ngOnInit() {
const gameId = this.route.snapshot.paramMap.get('id');
@@ -642,4 +686,45 @@ export class GameDetailComponent implements OnInit {
return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`;
}
}
toggleEditForm() {
this.showEditForm.update(value => !value);
}
saveEdit(data: UpdateGameDto) {
const game = this.game();
if (!game) return;
this.gamesService.updateGame(game.id, data).subscribe({
next: (updatedGame) => {
this.game.set(updatedGame);
this.showEditForm.set(false);
},
error: (err) => {
alert('Failed to update game: ' + (err.error?.message || 'Unknown error'));
}
});
}
cancelEdit() {
this.showEditForm.set(false);
}
deleteGame() {
const game = this.game();
if (!game) return;
if (!confirm(`Are you sure you want to delete "${game.title}"? This action cannot be undone.`)) {
return;
}
this.gamesService.deleteGame(game.id).subscribe({
next: () => {
this.router.navigate(['/games']);
},
error: (err) => {
alert('Failed to delete game: ' + (err.error?.message || 'Unknown error'));
}
});
}
}
@@ -625,9 +625,7 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
@for (game of paginatedGames(); track game.id) {
<div class="game-card" [class.completed]="game.status === GameStatus.completed">
<a [routerLink]="['/games', game.id]" class="card-link">
@if (game.coverImage) {
<img [src]="game.coverImage" [alt]="game.title" class="game-cover">
}
<img [src]="game.coverImage || '/assets/default-cover.jpg'" [alt]="game.title" class="game-cover">
<div class="game-info">
<h3>{{ game.title }}</h3>
@@ -1434,6 +1432,9 @@ export class GamesListComponent implements OnInit {
this.editTagInput = '';
this.editLinkTitle = '';
this.editLinkUrl = '';
// Scroll to top so the form is visible
window.scrollTo({ top: 0, behavior: 'smooth' });
}
cancelEdit() {
@@ -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">
<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,6 +538,35 @@ import { Manga, Comment, MangaStatus } from '@library/shared-types';
font-size: 1.5rem;
}
.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;
@@ -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() {
@@ -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 { Music, Comment, MusicStatus, MusicType } from '@library/shared-types';
import { MusicFormComponent } from '../shared/music-form.component';
import { Music, Comment, MusicStatus, MusicType, UpdateMusicDto } from '@library/shared-types';
@Component({
selector: 'app-music-detail',
standalone: true,
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent],
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent, MusicFormComponent],
template: `
<div class="container">
<div class="breadcrumb">
<a routerLink="/music" class="breadcrumb-link">← Back to Music</a>
</div>
@if (showEditForm() && authService.user()?.isAdmin && music()) {
<app-music-form
mode="edit"
[music]="music()!"
(formSubmit)="saveEdit($event)"
(formCancel)="cancelEdit()"
></app-music-form>
}
@if (loading()) {
<div class="loading">Loading music details...</div>
} @else if (error()) {
@@ -36,11 +46,9 @@ import { Music, Comment, MusicStatus, MusicType } from '@library/shared-types';
</div>
} @else if (music()) {
<div class="music-detail-card">
@if (music()!.coverArt) {
<div class="music-cover-section">
<img [src]="music()!.coverArt" [alt]="music()!.title" class="music-cover-large">
<img [src]="music()!.coverArt || '/assets/default-cover.jpg'" [alt]="music()!.title" class="music-cover-large">
</div>
}
<div class="music-content">
<div class="music-header">
@@ -54,6 +62,12 @@ import { Music, Comment, MusicStatus, MusicType } from '@library/shared-types';
</div>
<p class="artist">by {{ music()!.artist }}</p>
@if (authService.user()?.isAdmin) {
<div class="admin-actions">
<button (click)="editMusic()" class="btn btn-edit">✏️ Edit</button>
<button (click)="deleteMusic()" class="btn btn-delete">🗑️ Delete</button>
</div>
}
@if (music()!.rating) {
<div class="info-row">
@@ -322,6 +336,35 @@ import { Music, Comment, MusicStatus, MusicType } 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;
@@ -521,6 +564,35 @@ import { Music, Comment, MusicStatus, MusicType } from '@library/shared-types';
font-size: 1.5rem;
}
.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;
@@ -547,6 +619,7 @@ export class MusicDetailComponent implements OnInit {
commentsLoading = signal(false);
error = signal<string | null>(null);
newCommentContent = '';
showEditForm = signal(false);
ngOnInit() {
const musicId = this.route.snapshot.paramMap.get('id');
@@ -664,4 +737,45 @@ export class MusicDetailComponent implements OnInit {
return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`;
}
}
editMusic() {
this.showEditForm.set(true);
}
cancelEdit() {
this.showEditForm.set(false);
}
saveEdit(data: UpdateMusicDto) {
const music = this.music();
if (!music) return;
this.musicService.updateMusic(music.id, data).subscribe({
next: (updatedMusic) => {
this.music.set(updatedMusic);
this.showEditForm.set(false);
},
error: (err) => {
alert('Failed to update music: ' + (err.error?.message || 'Unknown error'));
}
});
}
deleteMusic() {
const music = this.music();
if (!music) return;
if (!confirm(`Are you sure you want to delete "${music.title}"? This action cannot be undone.`)) {
return;
}
this.musicService.deleteMusic(music.id).subscribe({
next: () => {
this.router.navigate(['/music']);
},
error: (err) => {
alert('Failed to delete music: ' + (err.error?.message || 'Unknown error'));
}
});
}
}
@@ -624,17 +624,7 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
@for (music of paginatedMusic(); track music.id) {
<div class="music-card" [class.completed]="music.status === MusicStatus.completed">
<a [routerLink]="['/music', music.id]" class="card-link">
@if (music.coverArt) {
<img [src]="music.coverArt" [alt]="music.title" class="music-cover">
} @else {
<div class="music-cover placeholder">
@switch (music.type) {
@case (MusicType.album) { 💿 }
@case (MusicType.single) { 🎵 }
@case (MusicType.ep) { 🎶 }
}
</div>
}
<img [src]="music.coverArt || '/assets/default-cover.jpg'" [alt]="music.title" class="music-cover">
<div class="music-info">
<h3>{{ music.title }}</h3>
@@ -1571,6 +1561,9 @@ export class MusicListComponent implements OnInit {
this.editTagInput = '';
this.editLinkTitle = '';
this.editLinkUrl = '';
// Scroll to top so the form is visible
window.scrollTo({ top: 0, behavior: 'smooth' });
}
cancelEdit() {
@@ -0,0 +1,405 @@
/**
* @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 { Art, CreateArtDto, UpdateArtDto, Link } from '@library/shared-types';
@Component({
selector: 'app-art-form',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<form (ngSubmit)="onSubmit()" class="art-form">
<h3>{{ mode === 'add' ? 'Add New Art' : 'Edit Art' }}</h3>
<div class="form-group">
<label for="title">Title</label>
<input
type="text"
id="title"
[(ngModel)]="formData.title"
name="title"
required
placeholder="Enter artwork title"
>
</div>
<div class="form-group">
<label for="artist">Artist</label>
<input
type="text"
id="artist"
[(ngModel)]="formData.artist"
name="artist"
required
placeholder="Enter artist name"
>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea
id="description"
[(ngModel)]="formData.description"
name="description"
rows="3"
placeholder="Description of the artwork..."
></textarea>
</div>
<div class="form-group">
<label for="imageUrl">Image (max 500KB)</label>
<input
type="file"
id="imageUrl"
name="imageUrl"
accept="image/*"
(change)="onImageSelected($event)"
>
@if (imagePreview()) {
<div class="image-preview">
<img [src]="imagePreview()" alt="Artwork 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., Artist Portfolio)"
>
<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 Art' : 'Save Changes' }}</button>
<button type="button" (click)="onCancel()" class="btn btn-secondary">Cancel</button>
</div>
</form>
`,
styles: [`
.art-form {
background: white;
padding: 1.5rem;
border-radius: 8px;
border: 1px solid #e5e7eb;
margin-bottom: 2rem;
}
.art-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="url"],
.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 textarea:focus {
outline: none;
border-color: #ff6b6b;
}
.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 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>();
formData: Partial<CreateArtDto | UpdateArtDto> = {
tags: [],
links: []
};
tagInput = '';
linkTitle = '';
linkUrl = '';
imagePreview = signal<string | null>(null);
imageError = signal<string | null>(null);
ngOnInit() {
if (this.mode === 'edit' && this.art) {
this.formData = {
title: this.art.title,
artist: this.art.artist,
description: this.art.description,
imageUrl: this.art.imageUrl,
tags: [...(this.art.tags || [])],
links: [...(this.art.links || [])]
};
this.imagePreview.set(this.art.imageUrl || null);
} else {
this.formData = {
tags: [],
links: [],
...this.initialData
};
if (this.initialData?.imageUrl) {
this.imagePreview.set(this.initialData.imageUrl);
}
}
}
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.imageUrl = base64String;
this.imagePreview.set(base64String);
};
reader.readAsDataURL(file);
}
clearImage() {
this.formData.imageUrl = undefined;
this.imagePreview.set(null);
}
onSubmit() {
if (!this.formData.title || !this.formData.artist || !this.formData.imageUrl) {
return;
}
const data: CreateArtDto | UpdateArtDto = {
...this.formData as CreateArtDto | UpdateArtDto
};
this.formSubmit.emit(data);
}
onCancel() {
this.formCancel.emit();
}
}
@@ -0,0 +1,543 @@
/**
* @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 { Book, BookStatus, CreateBookDto, UpdateBookDto, Link } from '@library/shared-types';
@Component({
selector: 'app-book-form',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<form (ngSubmit)="onSubmit()" class="book-form">
<h3>{{ mode === 'add' ? 'Add New Book' : 'Edit Book' }}</h3>
<div class="form-group">
<label for="title">Title</label>
<input
type="text"
id="title"
[(ngModel)]="formData.title"
name="title"
required
placeholder="Enter book 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="isbn">ISBN (optional)</label>
<input
type="text"
id="isbn"
[(ngModel)]="formData.isbn"
name="isbn"
placeholder="Enter ISBN"
>
</div>
<div class="form-group">
<label for="status">Status</label>
<select id="status" [(ngModel)]="formData.status" name="status" required>
<option [value]="BookStatus.reading">Currently Reading</option>
<option [value]="BookStatus.finished">Finished</option>
<option [value]="BookStatus.toRead">To Read</option>
<option [value]="BookStatus.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 book..."
></textarea>
</div>
<div class="form-group">
<label for="series">Series (optional)</label>
<input
type="text"
id="series"
[(ngModel)]="formData.series"
name="series"
placeholder="e.g., Harry Potter"
>
</div>
<div class="form-group">
<label for="seriesOrder">Series Order (optional)</label>
<input
type="number"
id="seriesOrder"
[(ngModel)]="formData.seriesOrder"
name="seriesOrder"
min="1"
placeholder="Order in series"
>
</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., Goodreads)"
>
<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 Book' : 'Save Changes' }}</button>
<button type="button" (click)="onCancel()" class="btn btn-secondary">Cancel</button>
</div>
</form>
`,
styles: [`
.book-form {
background: white;
padding: 1.5rem;
border-radius: 8px;
border: 1px solid #e5e7eb;
margin-bottom: 2rem;
}
.book-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 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>();
BookStatus = BookStatus;
formData: Partial<CreateBookDto | UpdateBookDto> = {
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.book) {
this.formData = {
title: this.book.title,
author: this.book.author,
isbn: this.book.isbn,
status: this.book.status,
dateStarted: this.book.dateStarted,
dateFinished: this.book.dateFinished,
rating: this.book.rating,
notes: this.book.notes,
coverImage: this.book.coverImage,
tags: [...(this.book.tags || [])],
links: [...(this.book.links || [])],
series: this.book.series,
seriesOrder: this.book.seriesOrder,
timeSpent: this.book.timeSpent
};
if (this.book.timeSpent) {
this.timeHours = Math.floor(this.book.timeSpent / 60);
this.timeMinutes = this.book.timeSpent % 60;
}
this.imagePreview.set(this.book.coverImage || null);
} else {
this.formData = {
status: BookStatus.toRead,
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: CreateBookDto | UpdateBookDto = {
...this.formData as CreateBookDto | UpdateBookDto,
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();
}
}
@@ -0,0 +1,531 @@
/**
* @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 { Game, GameStatus, CreateGameDto, UpdateGameDto, Link } from '@library/shared-types';
@Component({
selector: 'app-game-form',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<form (ngSubmit)="onSubmit()" class="game-form">
<h3>{{ mode === 'add' ? 'Add New Game' : 'Edit Game' }}</h3>
<div class="form-group">
<label for="title">Title</label>
<input
type="text"
id="title"
[(ngModel)]="formData.title"
name="title"
required
placeholder="Enter game title"
>
</div>
<div class="form-group">
<label for="platform">Platform</label>
<input
type="text"
id="platform"
[(ngModel)]="formData.platform"
name="platform"
placeholder="PC, PS5, Xbox, Switch, etc."
>
</div>
<div class="form-group">
<label for="status">Status</label>
<select id="status" [(ngModel)]="formData.status" name="status" required>
<option [value]="GameStatus.playing">Currently Playing</option>
<option [value]="GameStatus.completed">Completed</option>
<option [value]="GameStatus.backlog">In Backlog</option>
<option [value]="GameStatus.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 game..."
></textarea>
</div>
<div class="form-group">
<label for="series">Series (optional)</label>
<input
type="text"
id="series"
[(ngModel)]="formData.series"
name="series"
placeholder="e.g., The Legend of Zelda"
>
</div>
<div class="form-group">
<label for="seriesOrder">Series Order (optional)</label>
<input
type="number"
id="seriesOrder"
[(ngModel)]="formData.seriesOrder"
name="seriesOrder"
min="1"
placeholder="Order in series"
>
</div>
<div class="form-group">
<label for="coverImage">Box Art (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="Box art 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., Steam)"
>
<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 Game' : 'Save Changes' }}</button>
<button type="button" (click)="onCancel()" class="btn btn-secondary">Cancel</button>
</div>
</form>
`,
styles: [`
.game-form {
background: white;
padding: 1.5rem;
border-radius: 8px;
border: 1px solid #e5e7eb;
margin-bottom: 2rem;
}
.game-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 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>();
GameStatus = GameStatus;
formData: Partial<CreateGameDto | UpdateGameDto> = {
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.game) {
this.formData = {
title: this.game.title,
platform: this.game.platform,
status: this.game.status,
dateStarted: this.game.dateStarted,
dateFinished: this.game.dateFinished,
rating: this.game.rating,
notes: this.game.notes,
coverImage: this.game.coverImage,
tags: [...(this.game.tags || [])],
links: [...(this.game.links || [])],
series: this.game.series,
seriesOrder: this.game.seriesOrder,
timeSpent: this.game.timeSpent
};
if (this.game.timeSpent) {
this.timeHours = Math.floor(this.game.timeSpent / 60);
this.timeMinutes = this.game.timeSpent % 60;
}
this.imagePreview.set(this.game.coverImage || null);
} else {
// Add mode - use initialData if provided, otherwise defaults
this.formData = {
status: GameStatus.backlog,
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.status) {
return;
}
const data: CreateGameDto | UpdateGameDto = {
...this.formData as CreateGameDto | UpdateGameDto,
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();
}
}
@@ -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();
}
}
@@ -0,0 +1,518 @@
/**
* @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 { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Link } from '@library/shared-types';
@Component({
selector: 'app-music-form',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<form (ngSubmit)="onSubmit()" class="music-form">
<h3>{{ mode === 'add' ? 'Add New Music' : 'Edit Music' }}</h3>
<div class="form-group">
<label for="title">Title</label>
<input
type="text"
id="title"
[(ngModel)]="formData.title"
name="title"
required
placeholder="Enter album/single/EP title"
>
</div>
<div class="form-group">
<label for="artist">Artist</label>
<input
type="text"
id="artist"
[(ngModel)]="formData.artist"
name="artist"
required
placeholder="Enter artist name"
>
</div>
<div class="form-group">
<label for="type">Type</label>
<select id="type" [(ngModel)]="formData.type" name="type" required>
<option [value]="MusicType.album">Album</option>
<option [value]="MusicType.single">Single</option>
<option [value]="MusicType.ep">EP</option>
</select>
</div>
<div class="form-group">
<label for="status">Status</label>
<select id="status" [(ngModel)]="formData.status" name="status" required>
<option [value]="MusicStatus.listening">Currently Listening</option>
<option [value]="MusicStatus.completed">Completed</option>
<option [value]="MusicStatus.wantToListen">Want to Listen</option>
<option [value]="MusicStatus.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 music..."
></textarea>
</div>
<div class="form-group">
<label for="coverArt">Cover Art (max 500KB)</label>
<input
type="file"
id="coverArt"
name="coverArt"
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., Spotify)"
>
<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 Music' : 'Save Changes' }}</button>
<button type="button" (click)="onCancel()" class="btn btn-secondary">Cancel</button>
</div>
</form>
`,
styles: [`
.music-form {
background: white;
padding: 1.5rem;
border-radius: 8px;
border: 1px solid #e5e7eb;
margin-bottom: 2rem;
}
.music-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 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>();
MusicStatus = MusicStatus;
MusicType = MusicType;
formData: Partial<CreateMusicDto | UpdateMusicDto> = {
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.music) {
this.formData = {
title: this.music.title,
artist: this.music.artist,
type: this.music.type,
status: this.music.status,
dateStarted: this.music.dateStarted,
dateFinished: this.music.dateFinished,
rating: this.music.rating,
notes: this.music.notes,
coverArt: this.music.coverArt,
tags: [...(this.music.tags || [])],
links: [...(this.music.links || [])],
timeSpent: this.music.timeSpent
};
if (this.music.timeSpent) {
this.timeHours = Math.floor(this.music.timeSpent / 60);
this.timeMinutes = this.music.timeSpent % 60;
}
this.imagePreview.set(this.music.coverArt || null);
} else {
this.formData = {
type: MusicType.album,
status: MusicStatus.wantToListen,
tags: [],
links: [],
...this.initialData
};
if (this.initialData?.coverArt) {
this.imagePreview.set(this.initialData.coverArt);
}
}
}
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.coverArt = base64String;
this.imagePreview.set(base64String);
};
reader.readAsDataURL(file);
}
clearImage() {
this.formData.coverArt = undefined;
this.imagePreview.set(null);
}
onSubmit() {
if (!this.formData.title || !this.formData.artist || !this.formData.type || !this.formData.status) {
return;
}
const data: CreateMusicDto | UpdateMusicDto = {
...this.formData as CreateMusicDto | UpdateMusicDto,
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();
}
}
@@ -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 { 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();
}
}
@@ -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 { Show, Comment, ShowStatus, ShowType } from '@library/shared-types';
import { ShowFormComponent } from '../shared/show-form.component';
import { Show, Comment, ShowStatus, ShowType, UpdateShowDto } from '@library/shared-types';
@Component({
selector: 'app-show-detail',
standalone: true,
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent],
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent, ShowFormComponent],
template: `
<div class="container">
<div class="breadcrumb">
<a routerLink="/shows" class="breadcrumb-link">← Back to Shows</a>
</div>
@if (showEditForm() && authService.user()?.isAdmin && show()) {
<app-show-form
mode="edit"
[show]="show()!"
(formSubmit)="saveEdit($event)"
(formCancel)="cancelEdit()"
></app-show-form>
}
@if (loading()) {
<div class="loading">Loading show details...</div>
} @else if (error()) {
@@ -36,11 +46,9 @@ import { Show, Comment, ShowStatus, ShowType } from '@library/shared-types';
</div>
} @else if (show()) {
<div class="show-detail-card">
@if (show()!.coverImage) {
<div class="show-poster-section">
<img [src]="show()!.coverImage" [alt]="show()!.title" class="show-poster-large">
<img [src]="show()!.coverImage || '/assets/default-cover.jpg'" [alt]="show()!.title" class="show-poster-large">
</div>
}
<div class="show-content">
<div class="show-header">
@@ -52,6 +60,12 @@ import { Show, Comment, ShowStatus, ShowType } from '@library/shared-types';
{{ getStatusLabel(show()!.status) }}
</span>
</div>
@if (authService.user()?.isAdmin) {
<div class="admin-actions">
<button (click)="toggleEditForm()" class="btn btn-edit">✏️ {{ showEditForm() ? 'Cancel Edit' : 'Edit' }}</button>
<button (click)="deleteShow()" class="btn btn-delete">🗑️ Delete</button>
</div>
}
@if (show()!.rating) {
<div class="info-row">
@@ -318,6 +332,35 @@ import { Show, Comment, ShowStatus, ShowType } 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;
@@ -517,6 +560,35 @@ import { Show, Comment, ShowStatus, ShowType } from '@library/shared-types';
font-size: 1.5rem;
}
.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;
@@ -543,6 +615,7 @@ export class ShowDetailComponent implements OnInit {
commentsLoading = signal(false);
error = signal<string | null>(null);
newCommentContent = '';
showEditForm = signal(false);
ngOnInit() {
const showId = this.route.snapshot.paramMap.get('id');
@@ -661,4 +734,45 @@ export class ShowDetailComponent implements OnInit {
return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`;
}
}
toggleEditForm() {
this.showEditForm.update(value => !value);
}
saveEdit(data: UpdateShowDto) {
const show = this.show();
if (!show) return;
this.showsService.updateShow(show.id, data).subscribe({
next: (updatedShow) => {
this.show.set(updatedShow);
this.showEditForm.set(false);
},
error: (err) => {
alert('Failed to update show: ' + (err.error?.message || 'Unknown error'));
}
});
}
cancelEdit() {
this.showEditForm.set(false);
}
deleteShow() {
const show = this.show();
if (!show) return;
if (!confirm(`Are you sure you want to delete "${show.title}"? This action cannot be undone.`)) {
return;
}
this.showsService.deleteShow(show.id).subscribe({
next: () => {
this.router.navigate(['/shows']);
},
error: (err) => {
alert('Failed to delete show: ' + (err.error?.message || 'Unknown error'));
}
});
}
}
@@ -556,9 +556,7 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
@for (show of paginatedShows(); track show.id) {
<div class="show-card" [class.completed]="show.status === ShowStatus.completed">
<a [routerLink]="['/shows', show.id]" class="card-link">
@if (show.coverImage) {
<img [src]="show.coverImage" [alt]="show.title" class="show-cover">
}
<img [src]="show.coverImage || '/assets/default-cover.jpg'" [alt]="show.title" class="show-cover">
<div class="show-info">
<h3>{{ show.title }}</h3>
@@ -1368,6 +1366,9 @@ export class ShowsListComponent implements OnInit {
this.editTagInput = '';
this.editLinkTitle = '';
this.editLinkUrl = '';
// Scroll to top so the form is visible
window.scrollTo({ top: 0, behavior: 'smooth' });
}
cancelEdit() {
+2
View File
@@ -16,6 +16,8 @@ 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"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@library/source",
"version": "1.0.0",
"version": "1.1.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",
+2
View File
@@ -16,6 +16,8 @@ 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"