diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8bfdfe6 --- /dev/null +++ b/CLAUDE.md @@ -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 " --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 -- +op run --env-file=prod.env -- +``` + +## 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 diff --git a/api/src/app/plugins/rate-limit.ts b/api/src/app/plugins/rate-limit.ts index 8343a14..bafdfbf 100644 --- a/api/src/app/plugins/rate-limit.ts +++ b/api/src/app/plugins/rate-limit.ts @@ -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({ diff --git a/api/src/app/plugins/static.ts b/api/src/app/plugins/static.ts index 08f5ec5..06dbd98 100644 --- a/api/src/app/plugins/static.ts +++ b/api/src/app/plugins/static.ts @@ -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" }); diff --git a/api/src/app/routes/auth/index.ts b/api/src/app/routes/auth/index.ts index f13d76e..c679da4 100644 --- a/api/src/app/routes/auth/index.ts +++ b/api/src/app/routes/auth/index.ts @@ -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, { diff --git a/api/src/app/routes/games/index.ts b/api/src/app/routes/games/index.ts index eef98a0..8516e92 100644 --- a/api/src/app/routes/games/index.ts +++ b/api/src/app/routes/games/index.ts @@ -41,22 +41,29 @@ 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) => { - const game = await gameService.createGame(request.body); - await AuditService.logFromRequest(request, { - action: AuditAction.entryCreate, - category: AuditCategory.content, - resourceType: "game", - resourceId: game.id, - details: `Created game: ${game.title}`, - }); - return game; + async (request, reply) => { + try { + const game = await gameService.createGame(request.body); + await AuditService.logFromRequest(request, { + action: AuditAction.entryCreate, + category: AuditCategory.content, + resourceType: "game", + resourceId: game.id, + details: `Created game: ${game.title}`, + }); + return game; + } catch (error) { + if (error instanceof Error) { + return reply.code(400).send({ error: error.message }); + } + throw error; + } } ); @@ -64,26 +71,33 @@ 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) => { - const { id } = request.params; - const game = await gameService.updateGame(id, request.body); - if (game) { - await AuditService.logFromRequest(request, { - action: AuditAction.entryUpdate, - category: AuditCategory.content, - resourceType: "game", - resourceId: id, - details: `Updated game: ${game.title}`, - }); + async (request, reply) => { + try { + const { id } = request.params; + const game = await gameService.updateGame(id, request.body); + if (game) { + await AuditService.logFromRequest(request, { + action: AuditAction.entryUpdate, + category: AuditCategory.content, + resourceType: "game", + resourceId: id, + details: `Updated game: ${game.title}`, + }); + } + return game; + } catch (error) { + if (error instanceof Error) { + return reply.code(400).send({ error: error.message }); + } + throw error; } - return game; } ); diff --git a/api/src/app/services/game.service.ts b/api/src/app/services/game.service.ts index bc15287..25b82a6 100644 --- a/api/src/app/services/game.service.ts +++ b/api/src/app/services/game.service.ts @@ -33,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)) { @@ -45,8 +42,14 @@ export class GameService { // Validate cover image URL if (data.coverImage) { if (data.coverImage.startsWith("data:")) { - const sizeInBytes = data.coverImage.length * 0.75; - if (sizeInBytes > MAX_LENGTHS.DATA_URL) { + // 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)) { diff --git a/api/src/app/utils/validation.ts b/api/src/app/utils/validation.ts index 05792b4..f69ebeb 100644 --- a/api/src/app/utils/validation.ts +++ b/api/src/app/utils/validation.ts @@ -6,9 +6,10 @@ /** * 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+/=]+$/.test(url); + return /^data:image\/(jpeg|png|gif|webp|svg\+xml);base64,[A-Za-z0-9+/=\s]+$/.test(url); } /** diff --git a/api/src/main.ts b/api/src/main.ts index 3270acd..e94b868 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -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. diff --git a/apps/frontend/public/assets/default-cover.jpg b/apps/frontend/public/assets/default-cover.jpg new file mode 100755 index 0000000..14ae84c Binary files /dev/null and b/apps/frontend/public/assets/default-cover.jpg differ diff --git a/apps/frontend/src/app/components/admin/admin-suggestions.component.ts b/apps/frontend/src/app/components/admin/admin-suggestions.component.ts index a0198ed..0940756 100644 --- a/apps/frontend/src/app/components/admin/admin-suggestions.component.ts +++ b/apps/frontend/src/app/components/admin/admin-suggestions.component.ts @@ -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: `
@@ -231,158 +237,62 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared- @if (showEditModal()) { @@ -810,59 +720,174 @@ export class AdminSuggestionsComponent implements OnInit { return new Date(date).toLocaleDateString(); } + getGameInitialData(suggestion: Suggestion): Partial { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; diff --git a/apps/frontend/src/app/components/art/art-detail.component.ts b/apps/frontend/src/app/components/art/art-detail.component.ts index 8a9596a..8579ba7 100644 --- a/apps/frontend/src/app/components/art/art-detail.component.ts +++ b/apps/frontend/src/app/components/art/art-detail.component.ts @@ -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: `
+ @if (showEditForm() && authService.user()?.isAdmin && art()) { + + } + @if (loading()) {
Loading artwork details...
} @else if (error()) { @@ -36,11 +46,9 @@ import { Art, Comment } from '@library/shared-types';
} @else if (art()) {
- @if (art()!.imageUrl) { -
- -
- } +
+ +
@@ -48,6 +56,12 @@ import { Art, Comment } from '@library/shared-types';

by {{ art()!.artist }}

+ @if (authService.user()?.isAdmin) { +
+ + +
+ }
Added: @@ -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,7 +469,36 @@ import { Art, Comment } from '@library/shared-types'; font-size: 1.5rem; } - .info-row { + + .admin-actions { + display: flex; + gap: 0.75rem; + margin-bottom: 1.5rem; + padding: 1rem; + background: #fef3c7; + border-radius: 4px; + border: 1px solid #fbbf24; + } + + .btn-edit { + background: #3b82f6; + color: white; + } + + .btn-edit:hover { + background: #2563eb; + } + + .btn-delete { + background: #ef4444; + color: white; + } + + .btn-delete:hover { + background: #dc2626; + } + + .info-row { flex-direction: column; align-items: flex-start; gap: 0.25rem; @@ -452,6 +524,7 @@ export class ArtDetailComponent implements OnInit { commentsLoading = signal(false); error = signal(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')); + } + }); + } +} \ No newline at end of file diff --git a/apps/frontend/src/app/components/art/art-gallery.component.ts b/apps/frontend/src/app/components/art/art-gallery.component.ts index a02795b..0ee9947 100644 --- a/apps/frontend/src/app/components/art/art-gallery.component.ts +++ b/apps/frontend/src/app/components/art/art-gallery.component.ts @@ -395,7 +395,7 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
@@ -1121,6 +1121,9 @@ export class ArtGalleryComponent implements OnInit { this.editTagInput = ''; this.editLinkTitle = ''; this.editLinkUrl = ''; + + // Scroll to top so the form is visible + window.scrollTo({ top: 0, behavior: 'smooth' }); } cancelEdit() { diff --git a/apps/frontend/src/app/components/books/book-detail.component.ts b/apps/frontend/src/app/components/books/book-detail.component.ts index d86199d..f1ac82f 100644 --- a/apps/frontend/src/app/components/books/book-detail.component.ts +++ b/apps/frontend/src/app/components/books/book-detail.component.ts @@ -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: `
+ @if (showEditForm() && authService.user()?.isAdmin && book()) { + + } + @if (loading()) {
Loading book details...
} @else if (error()) { @@ -36,11 +46,9 @@ import { Book, Comment, BookStatus } from '@library/shared-types';
} @else if (book()) {
- @if (book()!.coverImage) { -
- -
- } +
+ +
@@ -51,6 +59,12 @@ import { Book, Comment, BookStatus } from '@library/shared-types';

by {{ book()!.author }}

+ @if (authService.user()?.isAdmin) { +
+ + +
+ } @if (book()!.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(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')); + } + }); + } +} \ No newline at end of file diff --git a/apps/frontend/src/app/components/books/books-list.component.ts b/apps/frontend/src/app/components/books/books-list.component.ts index 4fe2186..89111d6 100644 --- a/apps/frontend/src/app/components/books/books-list.component.ts +++ b/apps/frontend/src/app/components/books/books-list.component.ts @@ -643,11 +643,7 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti @for (book of paginatedBooks(); track book.id) {

- @if (book.coverImage) { - - } @else { -
📚
- } +

{{ book.title }}

@@ -1561,6 +1557,9 @@ export class BooksListComponent implements OnInit { this.editBookTimeHours = 0; this.editBookTimeMinutes = 0; } + + // Scroll to top so the form is visible + window.scrollTo({ top: 0, behavior: 'smooth' }); } cancelEdit() { diff --git a/apps/frontend/src/app/components/games/game-detail.component.ts b/apps/frontend/src/app/components/games/game-detail.component.ts index 0b7d43e..710f298 100644 --- a/apps/frontend/src/app/components/games/game-detail.component.ts +++ b/apps/frontend/src/app/components/games/game-detail.component.ts @@ -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: `
} @else if (game()) {
- @if (game()!.coverImage) { -
- -
- } +
+ +
@@ -50,6 +49,22 @@ import { Game, Comment, GameStatus } from '@library/shared-types';
+ @if (authService.user()?.isAdmin) { +
+ + +
+ } + + @if (showEditForm() && authService.user()?.isAdmin && game()) { + + } + @if (game()!.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(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')); + } + }); + } } diff --git a/apps/frontend/src/app/components/games/games-list.component.ts b/apps/frontend/src/app/components/games/games-list.component.ts index ce4ee91..8f97031 100644 --- a/apps/frontend/src/app/components/games/games-list.component.ts +++ b/apps/frontend/src/app/components/games/games-list.component.ts @@ -625,9 +625,7 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti @for (game of paginatedGames(); track game.id) {

- @if (game.coverImage) { - - } +

{{ game.title }}

@@ -1434,6 +1432,9 @@ export class GamesListComponent implements OnInit { this.editTagInput = ''; this.editLinkTitle = ''; this.editLinkUrl = ''; + + // Scroll to top so the form is visible + window.scrollTo({ top: 0, behavior: 'smooth' }); } cancelEdit() { diff --git a/apps/frontend/src/app/components/manga/manga-detail.component.ts b/apps/frontend/src/app/components/manga/manga-detail.component.ts index c26a960..eaa82f7 100644 --- a/apps/frontend/src/app/components/manga/manga-detail.component.ts +++ b/apps/frontend/src/app/components/manga/manga-detail.component.ts @@ -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: `
+ @if (showEditForm() && authService.user()?.isAdmin && manga()) { + + } + @if (loading()) {
Loading manga details...
} @else if (error()) { @@ -36,11 +46,9 @@ import { Manga, Comment, MangaStatus } from '@library/shared-types';
} @else if (manga()) {
- @if (manga()!.coverImage) { -
- -
- } +
+ +
@@ -51,6 +59,12 @@ import { Manga, Comment, MangaStatus } from '@library/shared-types';

by {{ manga()!.author }}

+ @if (authService.user()?.isAdmin) { +
+ + +
+ } @if (manga()!.rating) {
@@ -296,6 +310,35 @@ import { Manga, Comment, MangaStatus } from '@library/shared-types'; color: #4b5563; } + + .admin-actions { + display: flex; + gap: 0.75rem; + margin-bottom: 1.5rem; + padding: 1rem; + background: #fef3c7; + border-radius: 4px; + border: 1px solid #fbbf24; + } + + .btn-edit { + background: #3b82f6; + color: white; + } + + .btn-edit:hover { + background: #2563eb; + } + + .btn-delete { + background: #ef4444; + color: white; + } + + .btn-delete:hover { + background: #dc2626; + } + .info-row { display: flex; align-items: center; @@ -495,7 +538,36 @@ import { Manga, Comment, MangaStatus } from '@library/shared-types'; font-size: 1.5rem; } - .info-row { + + .admin-actions { + display: flex; + gap: 0.75rem; + margin-bottom: 1.5rem; + padding: 1rem; + background: #fef3c7; + border-radius: 4px; + border: 1px solid #fbbf24; + } + + .btn-edit { + background: #3b82f6; + color: white; + } + + .btn-edit:hover { + background: #2563eb; + } + + .btn-delete { + background: #ef4444; + color: white; + } + + .btn-delete:hover { + background: #dc2626; + } + + .info-row { flex-direction: column; align-items: flex-start; gap: 0.25rem; @@ -521,6 +593,7 @@ export class MangaDetailComponent implements OnInit { commentsLoading = signal(false); error = signal(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')); + } + }); + } +} \ No newline at end of file diff --git a/apps/frontend/src/app/components/manga/manga-list.component.ts b/apps/frontend/src/app/components/manga/manga-list.component.ts index 707958e..68f2c7f 100644 --- a/apps/frontend/src/app/components/manga/manga-list.component.ts +++ b/apps/frontend/src/app/components/manga/manga-list.component.ts @@ -562,9 +562,7 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion @for (manga of paginatedManga(); track manga.id) {
- @if (manga.coverImage) { - - } +

{{ manga.title }}

@@ -1347,6 +1345,9 @@ export class MangaListComponent implements OnInit { this.editTagInput = ''; this.editLinkTitle = ''; this.editLinkUrl = ''; + + // Scroll to top so the form is visible + window.scrollTo({ top: 0, behavior: 'smooth' }); } cancelEdit() { diff --git a/apps/frontend/src/app/components/music/music-detail.component.ts b/apps/frontend/src/app/components/music/music-detail.component.ts index 8e933a3..80e0a40 100644 --- a/apps/frontend/src/app/components/music/music-detail.component.ts +++ b/apps/frontend/src/app/components/music/music-detail.component.ts @@ -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: `
+ @if (showEditForm() && authService.user()?.isAdmin && music()) { + + } + @if (loading()) {
Loading music details...
} @else if (error()) { @@ -36,11 +46,9 @@ import { Music, Comment, MusicStatus, MusicType } from '@library/shared-types';
} @else if (music()) {
- @if (music()!.coverArt) { -
- -
- } +
+ +
@@ -54,6 +62,12 @@ import { Music, Comment, MusicStatus, MusicType } from '@library/shared-types';

by {{ music()!.artist }}

+ @if (authService.user()?.isAdmin) { +
+ + +
+ } @if (music()!.rating) {
@@ -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,7 +564,36 @@ import { Music, Comment, MusicStatus, MusicType } from '@library/shared-types'; font-size: 1.5rem; } - .info-row { + + .admin-actions { + display: flex; + gap: 0.75rem; + margin-bottom: 1.5rem; + padding: 1rem; + background: #fef3c7; + border-radius: 4px; + border: 1px solid #fbbf24; + } + + .btn-edit { + background: #3b82f6; + color: white; + } + + .btn-edit:hover { + background: #2563eb; + } + + .btn-delete { + background: #ef4444; + color: white; + } + + .btn-delete:hover { + background: #dc2626; + } + + .info-row { flex-direction: column; align-items: flex-start; gap: 0.25rem; @@ -547,6 +619,7 @@ export class MusicDetailComponent implements OnInit { commentsLoading = signal(false); error = signal(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')); + } + }); + } +} \ No newline at end of file diff --git a/apps/frontend/src/app/components/music/music-list.component.ts b/apps/frontend/src/app/components/music/music-list.component.ts index 3767364..4b56693 100644 --- a/apps/frontend/src/app/components/music/music-list.component.ts +++ b/apps/frontend/src/app/components/music/music-list.component.ts @@ -624,17 +624,7 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment, @for (music of paginatedMusic(); track music.id) {
- @if (music.coverArt) { - - } @else { -
- @switch (music.type) { - @case (MusicType.album) { 💿 } - @case (MusicType.single) { 🎵 } - @case (MusicType.ep) { 🎶 } - } -
- } +

{{ music.title }}

@@ -1571,6 +1561,9 @@ export class MusicListComponent implements OnInit { this.editTagInput = ''; this.editLinkTitle = ''; this.editLinkUrl = ''; + + // Scroll to top so the form is visible + window.scrollTo({ top: 0, behavior: 'smooth' }); } cancelEdit() { diff --git a/apps/frontend/src/app/components/shared/art-form.component.ts b/apps/frontend/src/app/components/shared/art-form.component.ts new file mode 100644 index 0000000..eaf00f4 --- /dev/null +++ b/apps/frontend/src/app/components/shared/art-form.component.ts @@ -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: ` +
+

{{ mode === 'add' ? 'Add New Art' : 'Edit Art' }}

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + @if (imagePreview()) { +
+ Artwork preview + +
+ } + @if (imageError()) { + {{ imageError() }} + } +
+ +
+
+ @for (tag of formData.tags; track tag; let i = $index) { + + {{ tag }} + + + } + +
+
+ +
+ + +
+ +
+ + +
+
+ `, + 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; + @Output() formSubmit = new EventEmitter(); + @Output() formCancel = new EventEmitter(); + + formData: Partial = { + tags: [], + links: [] + }; + + tagInput = ''; + linkTitle = ''; + linkUrl = ''; + imagePreview = signal(null); + imageError = signal(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(); + } +} diff --git a/apps/frontend/src/app/components/shared/book-form.component.ts b/apps/frontend/src/app/components/shared/book-form.component.ts new file mode 100644 index 0000000..d423288 --- /dev/null +++ b/apps/frontend/src/app/components/shared/book-form.component.ts @@ -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: ` +
+

{{ mode === 'add' ? 'Add New Book' : 'Edit Book' }}

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + @if (imagePreview()) { +
+ Cover preview + +
+ } + @if (imageError()) { + {{ imageError() }} + } +
+ +
+
+ @for (tag of formData.tags; track tag; let i = $index) { + + {{ tag }} + + + } + +
+
+ +
+ + +
+ +
+ + +
+
+ `, + 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; + @Output() formSubmit = new EventEmitter(); + @Output() formCancel = new EventEmitter(); + + BookStatus = BookStatus; + + formData: Partial = { + tags: [], + links: [] + }; + + timeHours = 0; + timeMinutes = 0; + tagInput = ''; + linkTitle = ''; + linkUrl = ''; + imagePreview = signal(null); + imageError = signal(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(); + } +} diff --git a/apps/frontend/src/app/components/shared/game-form.component.ts b/apps/frontend/src/app/components/shared/game-form.component.ts new file mode 100644 index 0000000..8d8a277 --- /dev/null +++ b/apps/frontend/src/app/components/shared/game-form.component.ts @@ -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: ` +
+

{{ mode === 'add' ? 'Add New Game' : 'Edit Game' }}

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + @if (imagePreview()) { +
+ Box art preview + +
+ } + @if (imageError()) { + {{ imageError() }} + } +
+ +
+
+ @for (tag of formData.tags; track tag; let i = $index) { + + {{ tag }} + + + } + +
+
+ +
+ + +
+ +
+ + +
+
+ `, + 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; + @Output() formSubmit = new EventEmitter(); + @Output() formCancel = new EventEmitter(); + + GameStatus = GameStatus; + + formData: Partial = { + tags: [], + links: [] + }; + + timeHours = 0; + timeMinutes = 0; + tagInput = ''; + linkTitle = ''; + linkUrl = ''; + imagePreview = signal(null); + imageError = signal(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(); + } +} diff --git a/apps/frontend/src/app/components/shared/manga-form.component.ts b/apps/frontend/src/app/components/shared/manga-form.component.ts new file mode 100644 index 0000000..d444590 --- /dev/null +++ b/apps/frontend/src/app/components/shared/manga-form.component.ts @@ -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: ` +
+

{{ mode === 'add' ? 'Add New Manga' : 'Edit Manga' }}

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + + @if (imagePreview()) { +
+ Cover preview + +
+ } + @if (imageError()) { + {{ imageError() }} + } +
+ +
+
+ @for (tag of formData.tags; track tag; let i = $index) { + + {{ tag }} + + + } + +
+
+ +
+ + +
+ +
+ + +
+
+ `, + 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; + @Output() formSubmit = new EventEmitter(); + @Output() formCancel = new EventEmitter(); + + MangaStatus = MangaStatus; + + formData: Partial = { + tags: [], + links: [] + }; + + timeHours = 0; + timeMinutes = 0; + tagInput = ''; + linkTitle = ''; + linkUrl = ''; + imagePreview = signal(null); + imageError = signal(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(); + } +} diff --git a/apps/frontend/src/app/components/shared/music-form.component.ts b/apps/frontend/src/app/components/shared/music-form.component.ts new file mode 100644 index 0000000..b7378b4 --- /dev/null +++ b/apps/frontend/src/app/components/shared/music-form.component.ts @@ -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: ` +
+

{{ mode === 'add' ? 'Add New Music' : 'Edit Music' }}

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + + @if (imagePreview()) { +
+ Cover preview + +
+ } + @if (imageError()) { + {{ imageError() }} + } +
+ +
+
+ @for (tag of formData.tags; track tag; let i = $index) { + + {{ tag }} + + + } + +
+
+ +
+ + +
+ +
+ + +
+
+ `, + 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; + @Output() formSubmit = new EventEmitter(); + @Output() formCancel = new EventEmitter(); + + MusicStatus = MusicStatus; + MusicType = MusicType; + + formData: Partial = { + tags: [], + links: [] + }; + + timeHours = 0; + timeMinutes = 0; + tagInput = ''; + linkTitle = ''; + linkUrl = ''; + imagePreview = signal(null); + imageError = signal(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(); + } +} diff --git a/apps/frontend/src/app/components/shared/show-form.component.ts b/apps/frontend/src/app/components/shared/show-form.component.ts new file mode 100644 index 0000000..744a2d9 --- /dev/null +++ b/apps/frontend/src/app/components/shared/show-form.component.ts @@ -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: ` +
+

{{ mode === 'add' ? 'Add New Show' : 'Edit Show' }}

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + + @if (imagePreview()) { +
+ Cover preview + +
+ } + @if (imageError()) { + {{ imageError() }} + } +
+ +
+
+ @for (tag of formData.tags; track tag; let i = $index) { + + {{ tag }} + + + } + +
+
+ +
+ + +
+ +
+ + +
+
+ `, + 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; + @Output() formSubmit = new EventEmitter(); + @Output() formCancel = new EventEmitter(); + + ShowStatus = ShowStatus; + ShowType = ShowType; + + formData: Partial = { + tags: [], + links: [] + }; + + timeHours = 0; + timeMinutes = 0; + tagInput = ''; + linkTitle = ''; + linkUrl = ''; + imagePreview = signal(null); + imageError = signal(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(); + } +} diff --git a/apps/frontend/src/app/components/shows/show-detail.component.ts b/apps/frontend/src/app/components/shows/show-detail.component.ts index c657443..8b72812 100644 --- a/apps/frontend/src/app/components/shows/show-detail.component.ts +++ b/apps/frontend/src/app/components/shows/show-detail.component.ts @@ -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: `
+ @if (showEditForm() && authService.user()?.isAdmin && show()) { + + } + @if (loading()) {
Loading show details...
} @else if (error()) { @@ -36,11 +46,9 @@ import { Show, Comment, ShowStatus, ShowType } from '@library/shared-types';
} @else if (show()) {
- @if (show()!.coverImage) { -
- -
- } +
+ +
@@ -52,6 +60,12 @@ import { Show, Comment, ShowStatus, ShowType } from '@library/shared-types'; {{ getStatusLabel(show()!.status) }}
+ @if (authService.user()?.isAdmin) { +
+ + +
+ } @if (show()!.rating) {
@@ -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,7 +560,36 @@ import { Show, Comment, ShowStatus, ShowType } from '@library/shared-types'; font-size: 1.5rem; } - .info-row { + + .admin-actions { + display: flex; + gap: 0.75rem; + margin-bottom: 1.5rem; + padding: 1rem; + background: #fef3c7; + border-radius: 4px; + border: 1px solid #fbbf24; + } + + .btn-edit { + background: #3b82f6; + color: white; + } + + .btn-edit:hover { + background: #2563eb; + } + + .btn-delete { + background: #ef4444; + color: white; + } + + .btn-delete:hover { + background: #dc2626; + } + + .info-row { flex-direction: column; align-items: flex-start; gap: 0.25rem; @@ -543,6 +615,7 @@ export class ShowDetailComponent implements OnInit { commentsLoading = signal(false); error = signal(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')); + } + }); + } +} \ No newline at end of file diff --git a/apps/frontend/src/app/components/shows/shows-list.component.ts b/apps/frontend/src/app/components/shows/shows-list.component.ts index a6970d9..361a28a 100644 --- a/apps/frontend/src/app/components/shows/shows-list.component.ts +++ b/apps/frontend/src/app/components/shows/shows-list.component.ts @@ -556,9 +556,7 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg @for (show of paginatedShows(); track show.id) {
- @if (show.coverImage) { - - } +

{{ show.title }}

@@ -1368,6 +1366,9 @@ export class ShowsListComponent implements OnInit { this.editTagInput = ''; this.editLinkTitle = ''; this.editLinkUrl = ''; + + // Scroll to top so the form is visible + window.scrollTo({ top: 0, behavior: 'smooth' }); } cancelEdit() { diff --git a/dev.env b/dev.env index d0fc8c4..0d34dec 100644 --- a/dev.env +++ b/dev.env @@ -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" diff --git a/prod.env b/prod.env index 6e20d68..47ed1f8 100644 --- a/prod.env +++ b/prod.env @@ -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"