Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 037993804d | |||
| 7d8c6bf21c | |||
| c769c81207 | |||
| 6dd0ec7db0 | |||
| b821ab7a6e | |||
| d3e91bfcb1 | |||
| 8f51c75f0a | |||
| 163738867b | |||
| 84fee6afcb | |||
| 0cc515971a | |||
| e3fae2f8bb | |||
| 2f9e623af6 | |||
| 1af0e0d7de | |||
| 2dc553ee1c | |||
| 21af80181f | |||
|
3fecd548a4
|
|||
| 6d5b0581a5 | |||
| d7cd3ccd99 | |||
| ff0ae73fa7 | |||
|
fbfc24ba0d
|
|||
| 983b78b0e9 | |||
| 208c11d153 | |||
| b245f1984e | |||
| 6545f46ba6 | |||
| 8215fda5ff |
@@ -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
|
||||
@@ -22,8 +22,8 @@ export async function app(fastify: FastifyInstance, opts: AppOptions) {
|
||||
});
|
||||
}
|
||||
|
||||
// Log unauthorized access attempts
|
||||
if (error.statusCode === 401 || error.statusCode === 403) {
|
||||
// Log unauthorized access attempts (exclude /api/auth/me as 401s there are expected during token refresh)
|
||||
if ((error.statusCode === 401 || error.statusCode === 403) && request.url !== '/api/auth/me') {
|
||||
await AuditService.log({
|
||||
action: AuditAction.unauthorizedAccess,
|
||||
category: AuditCategory.security,
|
||||
|
||||
@@ -14,11 +14,11 @@ const helmetPlugin: FastifyPluginAsync = async (app) => {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
// Angular uses inline styles for component encapsulation, so we need to allow them
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
|
||||
imgSrc: ["'self'", "data:", "https:"],
|
||||
scriptSrc: ["'self'"],
|
||||
connectSrc: ["'self'", process.env.FRONTEND_URL ?? "http://localhost:4200"],
|
||||
fontSrc: ["'self'", "data:"],
|
||||
fontSrc: ["'self'", "data:", "https://fonts.gstatic.com"],
|
||||
objectSrc: ["'none'"],
|
||||
baseUri: ["'self'"],
|
||||
formAction: ["'self'"],
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
validateUrl,
|
||||
validateRating,
|
||||
validateStringLength,
|
||||
validateDataUrl,
|
||||
MAX_LENGTHS,
|
||||
} from "../utils/validation";
|
||||
|
||||
@@ -35,19 +36,33 @@ export class BookService {
|
||||
if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) {
|
||||
throw new Error(`Notes must be ${MAX_LENGTHS.NOTES} characters or less.`);
|
||||
}
|
||||
if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) {
|
||||
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
|
||||
}
|
||||
|
||||
// Validate rating
|
||||
if (!validateRating(data.rating)) {
|
||||
throw new Error("Rating must be an integer between 0 and 10.");
|
||||
}
|
||||
|
||||
// Validate cover image URL
|
||||
if (data.coverImage && !validateUrl(data.coverImage)) {
|
||||
if (data.coverImage) {
|
||||
if (data.coverImage.startsWith("data:")) {
|
||||
const base64Data = data.coverImage.split(",")[1];
|
||||
if (!base64Data) {
|
||||
throw new Error("Invalid image data URL format.");
|
||||
}
|
||||
const sizeInBytes = base64Data.length * 0.75;
|
||||
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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
validateUrl,
|
||||
validateRating,
|
||||
validateStringLength,
|
||||
validateDataUrl,
|
||||
MAX_LENGTHS,
|
||||
} from "../utils/validation";
|
||||
|
||||
@@ -32,19 +33,34 @@ export class MangaService {
|
||||
if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) {
|
||||
throw new Error(`Notes must be ${MAX_LENGTHS.NOTES} characters or less.`);
|
||||
}
|
||||
if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) {
|
||||
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
|
||||
}
|
||||
|
||||
// Validate rating
|
||||
if (!validateRating(data.rating)) {
|
||||
throw new Error("Rating must be an integer between 0 and 10.");
|
||||
}
|
||||
|
||||
// Validate cover image URL
|
||||
if (data.coverImage && !validateUrl(data.coverImage)) {
|
||||
if (data.coverImage) {
|
||||
if (data.coverImage.startsWith("data:")) {
|
||||
const base64Data = data.coverImage.split(",")[1];
|
||||
if (!base64Data) {
|
||||
throw new Error("Invalid image data URL format.");
|
||||
}
|
||||
const sizeInBytes = base64Data.length * 0.75;
|
||||
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) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
validateUrl,
|
||||
validateRating,
|
||||
validateStringLength,
|
||||
validateDataUrl,
|
||||
MAX_LENGTHS,
|
||||
} from "../utils/validation";
|
||||
|
||||
@@ -32,18 +33,33 @@ export class MusicService {
|
||||
if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) {
|
||||
throw new Error(`Notes must be ${MAX_LENGTHS.NOTES} characters or less.`);
|
||||
}
|
||||
if (!validateStringLength(data.coverArt, MAX_LENGTHS.URL)) {
|
||||
throw new Error(`Cover art URL must be ${MAX_LENGTHS.URL} characters or less.`);
|
||||
}
|
||||
|
||||
// Validate rating
|
||||
if (data.rating !== undefined && !validateRating(data.rating)) {
|
||||
throw new Error("Rating must be an integer between 0 and 10.");
|
||||
}
|
||||
|
||||
// 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 base64Data = data.coverArt.split(",")[1];
|
||||
if (!base64Data) {
|
||||
throw new Error("Invalid image data URL format.");
|
||||
}
|
||||
const sizeInBytes = base64Data.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
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
validateUrl,
|
||||
validateRating,
|
||||
validateStringLength,
|
||||
validateDataUrl,
|
||||
MAX_LENGTHS,
|
||||
} from "../utils/validation";
|
||||
|
||||
@@ -29,19 +30,34 @@ export class ShowService {
|
||||
if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) {
|
||||
throw new Error(`Notes must be ${MAX_LENGTHS.NOTES} characters or less.`);
|
||||
}
|
||||
if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) {
|
||||
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
|
||||
}
|
||||
|
||||
// Validate rating
|
||||
if (!validateRating(data.rating)) {
|
||||
throw new Error("Rating must be an integer between 0 and 10.");
|
||||
}
|
||||
|
||||
// Validate cover image URL
|
||||
if (data.coverImage && !validateUrl(data.coverImage)) {
|
||||
if (data.coverImage) {
|
||||
if (data.coverImage.startsWith("data:")) {
|
||||
const base64Data = data.coverImage.split(",")[1];
|
||||
if (!base64Data) {
|
||||
throw new Error("Invalid image data URL format.");
|
||||
}
|
||||
const sizeInBytes = base64Data.length * 0.75;
|
||||
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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -25,6 +25,11 @@
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"optimization": {
|
||||
"styles": {
|
||||
"inlineCritical": false
|
||||
}
|
||||
},
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
|
||||
|
After Width: | Height: | Size: 8.3 MiB |
|
After Width: | Height: | Size: 8.5 MiB |
|
After Width: | Height: | Size: 8.3 MiB |
|
After Width: | Height: | Size: 9.1 MiB |
|
After Width: | Height: | Size: 8.1 MiB |
|
After Width: | Height: | Size: 8.2 MiB |
|
After Width: | Height: | Size: 8.3 MiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 233 KiB After Width: | Height: | Size: 229 KiB |
|
Before Width: | Height: | Size: 392 KiB After Width: | Height: | Size: 381 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 381 KiB |
|
After Width: | Height: | Size: 8.6 MiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
@@ -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'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,10 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
|
||||
imports: [CommonModule, FormsModule, RouterModule, PaginationComponent, LikeButtonComponent],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="page-hero">
|
||||
<img src="/assets/avatars/art-avatar.jpg" alt="Art avatar" class="page-avatar" />
|
||||
</div>
|
||||
|
||||
<div class="header-section">
|
||||
<h2>Art Gallery</h2>
|
||||
<p class="subtitle">Artwork of Naomi</p>
|
||||
@@ -395,7 +399,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"
|
||||
>
|
||||
@@ -458,6 +462,26 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.page-hero {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 3px solid #fdcb6e;
|
||||
box-shadow: 0 4px 12px rgba(253, 203, 110, 0.3);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.page-avatar:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 8px 16px rgba(253, 203, 110, 0.5);
|
||||
}
|
||||
|
||||
.header-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1121,6 +1145,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'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,10 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
imports: [CommonModule, FormsModule, RouterLink, PaginationComponent, LikeButtonComponent],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="page-hero">
|
||||
<img src="/assets/avatars/books-avatar.jpg" alt="Books avatar" class="page-avatar" />
|
||||
</div>
|
||||
|
||||
<div class="header-section">
|
||||
<h2>My Book Collection</h2>
|
||||
@if (authService.isAdmin()) {
|
||||
@@ -643,11 +647,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>
|
||||
@@ -705,6 +705,26 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.page-hero {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 3px solid #8b6f47;
|
||||
box-shadow: 0 4px 12px rgba(139, 111, 71, 0.3);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.page-avatar:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 8px 16px rgba(139, 111, 71, 0.5);
|
||||
}
|
||||
|
||||
.header-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -1561,6 +1581,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'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
imports: [CommonModule, FormsModule, RouterModule, PaginationComponent, LikeButtonComponent],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="page-hero">
|
||||
<img src="/assets/avatars/games-avatar.jpg" alt="Gaming avatar" class="page-avatar" />
|
||||
</div>
|
||||
|
||||
<div class="header-section">
|
||||
<h2>My Game Collection</h2>
|
||||
@if (authService.isAdmin()) {
|
||||
@@ -625,9 +629,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>
|
||||
@@ -686,6 +688,26 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.page-hero {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 3px solid #ff6b6b;
|
||||
box-shadow: 0 4px 12px rgba(255, 107, 107, 0.3);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.page-avatar:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 8px 16px rgba(255, 107, 107, 0.5);
|
||||
}
|
||||
|
||||
.header-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -1434,6 +1456,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() {
|
||||
|
||||
@@ -18,7 +18,7 @@ import { ApiService } from '../../services/api.service';
|
||||
<header class="header">
|
||||
<nav class="navbar" aria-label="Main navigation">
|
||||
<div class="nav-brand">
|
||||
<img src="/assets/icons/icon-72x72.png" alt="" class="brand-icon" role="presentation" />
|
||||
<img src="/assets/nav-icon.jpg" alt="" class="brand-icon" role="presentation" />
|
||||
<h1><a routerLink="/">Naomi's Library</a></h1>
|
||||
@if (version()) {
|
||||
<span class="version" aria-label="Version {{ version() }}">v{{ version() }}</span>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Game, GameStatus, Book, BookStatus, Music, MusicType, Manga, MangaStatu
|
||||
<div class="container">
|
||||
<div class="hero">
|
||||
<h1>Welcome to Naomi's Library</h1>
|
||||
<img src="/assets/nav-icon.jpg" alt="Naomi's avatar" class="hero-avatar" />
|
||||
<p class="tagline">A personal collection of games, books, music, manga, shows, and art</p>
|
||||
</div>
|
||||
|
||||
@@ -190,10 +191,28 @@ import { Game, GameStatus, Book, BookStatus, Music, MusicType, Manga, MangaStatu
|
||||
|
||||
.hero h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--witch-purple);
|
||||
}
|
||||
|
||||
.hero-avatar {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 4px solid var(--witch-lavender);
|
||||
box-shadow: 0 4px 12px var(--witch-shadow);
|
||||
margin: 1rem auto;
|
||||
display: block;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.hero-avatar:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 8px 16px rgba(157, 78, 221, 0.5);
|
||||
border-color: var(--witch-rose);
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-size: 1.2rem;
|
||||
color: var(--witch-plum);
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,10 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
||||
imports: [CommonModule, FormsModule, RouterLink, PaginationComponent, LikeButtonComponent],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="page-hero">
|
||||
<img src="/assets/avatars/manga-avatar.jpg" alt="Manga avatar" class="page-avatar" />
|
||||
</div>
|
||||
|
||||
<div class="header-section">
|
||||
<h2>My Manga Collection</h2>
|
||||
@if (authService.isAdmin()) {
|
||||
@@ -562,9 +566,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>
|
||||
@@ -621,6 +623,26 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.page-hero {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 3px solid #00b894;
|
||||
box-shadow: 0 4px 12px rgba(0, 184, 148, 0.3);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.page-avatar:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 8px 16px rgba(0, 184, 148, 0.5);
|
||||
}
|
||||
|
||||
.header-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -1347,6 +1369,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'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,10 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
||||
imports: [CommonModule, FormsModule, RouterLink, PaginationComponent, LikeButtonComponent],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="page-hero">
|
||||
<img src="/assets/avatars/music-avatar.jpg" alt="Music avatar" class="page-avatar" />
|
||||
</div>
|
||||
|
||||
<div class="header-section">
|
||||
<h2>My Music Collection</h2>
|
||||
@if (authService.isAdmin()) {
|
||||
@@ -624,17 +628,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>
|
||||
@@ -699,6 +693,26 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.page-hero {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 3px solid #74b9ff;
|
||||
box-shadow: 0 4px 12px rgba(116, 185, 255, 0.3);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.page-avatar:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 8px 16px rgba(116, 185, 255, 0.5);
|
||||
}
|
||||
|
||||
.header-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -1571,6 +1585,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'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,10 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
||||
imports: [CommonModule, RouterLink, FormsModule, PaginationComponent, LikeButtonComponent],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="page-hero">
|
||||
<img src="/assets/avatars/shows-avatar.jpg" alt="Shows avatar" class="page-avatar" />
|
||||
</div>
|
||||
|
||||
<div class="header-section">
|
||||
<h2>My Shows & Films</h2>
|
||||
@if (authService.isAdmin()) {
|
||||
@@ -556,9 +560,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>
|
||||
@@ -617,6 +619,26 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.page-hero {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 3px solid #e84393;
|
||||
box-shadow: 0 4px 12px rgba(232, 67, 147, 0.3);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.page-avatar:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 8px 16px rgba(232, 67, 147, 0.5);
|
||||
}
|
||||
|
||||
.header-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -1368,6 +1390,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() {
|
||||
|
||||
@@ -12,6 +12,11 @@ export class GlobalErrorHandler implements ErrorHandler {
|
||||
private toast = inject(ToastService);
|
||||
|
||||
handleError(error: Error): void {
|
||||
if (error.name === 'ChunkLoadError' || error.message.includes('Loading chunk')) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Global error caught:', error);
|
||||
|
||||
// Show user-friendly error message
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<meta name="description" content="Naomi's curated collection of games, books, music, shows, manga, and art. Browse, engage, and suggest new additions!" />
|
||||
<meta name="theme-color" content="#9d4edd" />
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Creepster&family=Griffy&family=Henny+Penny&family=Kalam:wght@300;400;700&display=swap" rel="stylesheet" />
|
||||
<script defer src="https://analytics.nhcarrigan.com/js/pa-YUXAn1vhhRttySUAw_LMN.js"></script>
|
||||
<script>
|
||||
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
font-family: 'Kalam', cursive;
|
||||
font-size: 14pt;
|
||||
line-height: 1.6;
|
||||
color: var(--foreground);
|
||||
@@ -75,10 +75,30 @@ body::after {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes wiggle {
|
||||
0%, 100% { transform: rotate(-2deg); }
|
||||
50% { transform: rotate(2deg); }
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin-top: 0;
|
||||
line-height: 1.2;
|
||||
color: var(--witch-purple);
|
||||
font-family: 'Griffy', cursive;
|
||||
}
|
||||
|
||||
h1 {
|
||||
animation: wiggle 4s ease-in-out infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.witchy-accent,
|
||||
.spooky-title {
|
||||
font-family: 'Creepster', cursive;
|
||||
}
|
||||
|
||||
.mystical-text {
|
||||
font-family: 'Henny Penny', cursive;
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -121,6 +141,7 @@ select:focus {
|
||||
|
||||
// Button base styles
|
||||
button {
|
||||
font-family: inherit;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@library/source",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.1",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "nx run-many --target=build --all && NODE_ENV=production op run --env-file=dev.env -- node dist/api/main.js",
|
||||
@@ -31,7 +31,7 @@
|
||||
"@fastify/csrf-protection": "7.1.0",
|
||||
"@fastify/helmet": "13.0.2",
|
||||
"@fastify/jwt": "10.0.0",
|
||||
"@fastify/oauth2": "8.1.2",
|
||||
"@fastify/oauth2": "8.2.0",
|
||||
"@fastify/rate-limit": "10.3.0",
|
||||
"@fastify/sensible": "6.0.4",
|
||||
"@fastify/static": "9.0.0",
|
||||
@@ -44,8 +44,8 @@
|
||||
"dompurify": "3.3.1",
|
||||
"fastify": "5.7.3",
|
||||
"fastify-plugin": "5.0.1",
|
||||
"jsdom": "28.0.0",
|
||||
"marked": "17.0.1",
|
||||
"jsdom": "28.1.0",
|
||||
"marked": "17.0.3",
|
||||
"rxjs": "7.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -70,13 +70,13 @@
|
||||
"@schematics/angular": "21.1.2",
|
||||
"@swc-node/register": "1.9.2",
|
||||
"@swc/core": "1.5.29",
|
||||
"@swc/helpers": "0.5.18",
|
||||
"@swc/helpers": "0.5.19",
|
||||
"@types/dompurify": "3.2.0",
|
||||
"@types/jest": "30.0.0",
|
||||
"@types/jsdom": "27.0.0",
|
||||
"@types/jsonwebtoken": "9.0.10",
|
||||
"@types/node": "20.19.9",
|
||||
"@typescript-eslint/utils": "8.54.0",
|
||||
"@typescript-eslint/utils": "8.56.0",
|
||||
"angular-eslint": "21.1.0",
|
||||
"cypress": "15.9.0",
|
||||
"esbuild": "0.19.12",
|
||||
@@ -91,6 +91,6 @@
|
||||
"ts-node": "10.9.1",
|
||||
"tslib": "2.8.1",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.54.0"
|
||||
"typescript-eslint": "8.56.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||