Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f0ef13c0a | |||
| 7d8c6bf21c | |||
| c769c81207 | |||
| b821ab7a6e | |||
| d3e91bfcb1 | |||
| 8f51c75f0a | |||
| 163738867b | |||
| 84fee6afcb | |||
| 0cc515971a | |||
| e3fae2f8bb | |||
| 2f9e623af6 | |||
| 1af0e0d7de | |||
| 2dc553ee1c | |||
| 21af80181f | |||
|
3fecd548a4
|
|||
| 6d5b0581a5 | |||
| d7cd3ccd99 | |||
| ff0ae73fa7 | |||
|
fbfc24ba0d
|
|||
| 983b78b0e9 | |||
| 208c11d153 | |||
| b245f1984e | |||
| 6545f46ba6 | |||
| 8215fda5ff |
@@ -0,0 +1,299 @@
|
||||
# Library Project - Claude Instructions
|
||||
|
||||
This is a personal media library tracking application built with an Nx monorepo structure. It tracks games, books, music, art, shows, and manga with user profiles, comments, achievements, and social features.
|
||||
|
||||
## Git Commit Guidelines
|
||||
|
||||
**CRITICAL**: When working on this project:
|
||||
- **Always commit as Hikari** using: `--author="Hikari <hikari@nhcarrigan.com>" --no-gpg-sign`
|
||||
- **Always ask permission before creating commits** - Never commit without explicit user approval
|
||||
- **Never modify git config** - Always use CLI flags for author information
|
||||
|
||||
## User Permissions
|
||||
|
||||
This is **Naomi's personal library**:
|
||||
- **Admin (Naomi only)** - Can create, update, and delete all media items (games, books, music, art, shows, manga)
|
||||
- **Regular users** - Can only:
|
||||
- Like items
|
||||
- Comment on items (with markdown support)
|
||||
- Submit suggestions for new items (requires admin approval)
|
||||
- Customise their own profile
|
||||
|
||||
All CRUD operations on media items are **admin-only**. Regular users interact through the social features (likes, comments, suggestions).
|
||||
|
||||
## Project Structure
|
||||
|
||||
This is an **Nx monorepo** containing:
|
||||
- **`api/`** - Fastify backend API with Prisma ORM
|
||||
- **`apps/frontend/`** - Angular frontend application
|
||||
- **`shared-types/`** - Shared TypeScript types between frontend and backend
|
||||
- **`libs/`** - Shared libraries
|
||||
|
||||
## Technology Stack
|
||||
|
||||
### Backend (api/)
|
||||
- **Runtime**: Node.js v24
|
||||
- **Framework**: Fastify 5.x with plugins:
|
||||
- `@fastify/autoload` - Auto-loads routes and plugins
|
||||
- `@fastify/jwt` - JWT authentication
|
||||
- `@fastify/oauth2` - Discord OAuth integration
|
||||
- `@fastify/helmet` - Security headers
|
||||
- `@fastify/cors` - CORS configuration
|
||||
- `@fastify/csrf-protection` - CSRF protection
|
||||
- `@fastify/rate-limit` - Rate limiting
|
||||
- `@fastify/cookie` - Cookie handling
|
||||
- `@fastify/static` - Static file serving
|
||||
- **Database**: MongoDB with Prisma ORM
|
||||
- **Logging**: `@nhcarrigan/logger` (custom logger package)
|
||||
- **Markdown**: `marked` for parsing, `dompurify` + `jsdom` for sanitisation
|
||||
|
||||
### Frontend (apps/frontend/)
|
||||
- **Framework**: Angular 21.x
|
||||
- **Icons**: FontAwesome (solid + brands)
|
||||
- **Styling**: Angular's built-in styling system
|
||||
|
||||
### Development Tools
|
||||
- **Package Manager**: pnpm v10
|
||||
- **Monorepo**: Nx 22.x
|
||||
- **Linting**: ESLint 9 (flat config) with `@nhcarrigan/eslint-config`
|
||||
- **Testing**: Jest 30.x
|
||||
- **Build**: esbuild (backend), Angular CLI (frontend)
|
||||
- **Secrets**: 1Password CLI (`op run`)
|
||||
|
||||
## Database Schema
|
||||
|
||||
The Prisma schema (`api/prisma/schema.prisma`) defines these models:
|
||||
- **Game** - Video game tracking with platform, status, rating, cover image, tags, series
|
||||
- **Book** - Book tracking with author, ISBN, status, rating, cover image, tags, series
|
||||
- **Music** - Music tracking (album/single/EP) with artist, status, rating, cover art, tags
|
||||
- **Art** - Art collection with artist, description, image, tags
|
||||
- **Show** - TV/anime/film tracking with type, status, rating, cover image, tags
|
||||
- **Manga** - Manga tracking with author, status, rating, cover image, tags
|
||||
- **User** - User profiles with Discord auth, slug, bio, badges, achievements, social links
|
||||
- **Comment** - Comments on any media type with markdown support
|
||||
- **AuditLog** - Security and action logging
|
||||
- **Suggestion** - User-submitted content suggestions
|
||||
- **Like** - User likes on media items
|
||||
- **RefreshToken** - JWT refresh token storage
|
||||
- **ProfileReport** - User profile reporting system
|
||||
- **CommentReport** - Comment reporting system
|
||||
- **UserAchievement** - Achievement tracking with progress
|
||||
|
||||
All models use MongoDB ObjectIds and include timestamps (`createdAt`, `updatedAt`).
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
The application uses **Discord OAuth** for authentication:
|
||||
1. User clicks "Login with Discord"
|
||||
2. OAuth flow redirects to Discord for authorisation
|
||||
3. Discord redirects back with authorisation code
|
||||
4. Backend exchanges code for Discord user info
|
||||
5. Backend creates/updates User record
|
||||
6. Backend issues JWT access token (15m expiry) and refresh token (7d expiry)
|
||||
7. Frontend stores tokens in cookies
|
||||
8. Access token in `Authorization` header for API requests
|
||||
9. Refresh token used to obtain new access tokens
|
||||
|
||||
See `api/AUTH_FLOW.md` for detailed authentication implementation.
|
||||
|
||||
## Image Upload Handling
|
||||
|
||||
The application supports **two methods** for cover images:
|
||||
1. **Regular URLs** - Standard image URLs (max 2048 characters)
|
||||
2. **Base64 Data URLs** - Inline base64-encoded images (max 5MB decoded size)
|
||||
|
||||
### Base64 Upload Constraints
|
||||
- **Fastify body limit**: 10MB (accommodates base64 overhead)
|
||||
- **Validation**: Data URLs must match format `data:image/(jpeg|png|gif|webp|svg+xml);base64,[base64data]`
|
||||
- **Size calculation**: Base64 data is extracted and decoded size calculated as `base64Data.length * 0.75`
|
||||
- **Size limit**: Decoded image must be under 5MB
|
||||
|
||||
### Implementation Notes
|
||||
- Base64 size check happens AFTER format validation
|
||||
- Regular URL length check (2048 chars) does NOT apply to data URLs
|
||||
- Validation logic is in `api/src/app/services/*.service.ts` files
|
||||
- Base64 validation helpers in `api/src/app/utils/validation.ts`
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Local Development
|
||||
```bash
|
||||
pnpm dev # Builds all projects and starts API with dev.env secrets
|
||||
```
|
||||
|
||||
The `dev` script:
|
||||
1. Runs `nx run-many --target=build --all` to build all projects
|
||||
2. Uses `op run --env-file=dev.env` to inject secrets from 1Password
|
||||
3. Starts the API with `NODE_ENV=production node dist/api/main.js`
|
||||
4. API serves the frontend as static files from `dist/apps/frontend/browser`
|
||||
|
||||
### Separate Frontend/Backend Development
|
||||
```bash
|
||||
pnpm start:frontend:dev # nx serve frontend (port 4200)
|
||||
pnpm start:api:dev # nx serve api (port 3000, watch mode)
|
||||
```
|
||||
|
||||
### Building for Production
|
||||
```bash
|
||||
pnpm build # Generates Prisma client, then builds all projects
|
||||
```
|
||||
|
||||
### Database Management
|
||||
```bash
|
||||
pnpm db:gen # Generate Prisma client
|
||||
pnpm db:push # Push schema changes to MongoDB (uses prod.env secrets)
|
||||
```
|
||||
|
||||
### Linting & Testing
|
||||
```bash
|
||||
pnpm lint # Lints all projects with ESLint
|
||||
pnpm test # Runs all tests with Jest
|
||||
```
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
The project uses **Gitea Actions** (`.gitea/workflows/ci.yml`):
|
||||
|
||||
1. **Dependency Pin Check** - Ensures all dependencies are pinned (no `^` or `~`)
|
||||
2. **Install Dependencies** - `pnpm install`
|
||||
3. **Lint** - `pnpm run lint`
|
||||
4. **Build** - `pnpm run build`
|
||||
5. **Test** - `pnpm run test`
|
||||
|
||||
CI runs on:
|
||||
- Pushes to `main` branch
|
||||
- Pull requests to `main` branch
|
||||
|
||||
## Configuration Standards
|
||||
|
||||
### TypeScript
|
||||
- Uses `@nhcarrigan/typescript-config` as base
|
||||
- Separate configs for app code (`tsconfig.app.json`) and tests (`tsconfig.spec.json`)
|
||||
- Output directory: `dist/` for builds
|
||||
|
||||
### ESLint
|
||||
- Uses `@nhcarrigan/eslint-config` (ESLint 9 flat config)
|
||||
- **No Prettier** - all style rules handled by ESLint
|
||||
- Configured in `eslint.config.mjs`
|
||||
- Angular-specific rules enabled for frontend
|
||||
|
||||
### Testing
|
||||
- Jest 30.x with `ts-jest` for TypeScript support
|
||||
- Separate jest configs per project (e.g., `api/jest.config.cts`)
|
||||
- Cypress for e2e testing (frontend-e2e project)
|
||||
|
||||
## Secrets Management
|
||||
|
||||
### Environment Files
|
||||
- **`dev.env`** - Development secrets (1Password vault references)
|
||||
- **`prod.env`** - Production secrets (1Password vault references)
|
||||
|
||||
These files contain **ONLY** 1Password references (e.g., `op://vault/item/field`), NOT actual secrets. They are safe to commit.
|
||||
|
||||
### Non-Secret Configuration
|
||||
Configuration that isn't sensitive (URLs, intervals, feature flags) should be in code (e.g., constants files), NOT in `.env` files.
|
||||
|
||||
### Running with Secrets
|
||||
Always use 1Password CLI:
|
||||
```bash
|
||||
op run --env-file=dev.env -- <command>
|
||||
op run --env-file=prod.env -- <command>
|
||||
```
|
||||
|
||||
## Security Features
|
||||
|
||||
The application implements multiple security layers:
|
||||
- **Helmet** - Security headers (CSP, HSTS, etc.)
|
||||
- **CORS** - Configured origin restrictions
|
||||
- **CSRF Protection** - Token-based CSRF validation
|
||||
- **Rate Limiting** - Request rate limits per IP
|
||||
- **JWT** - Short-lived access tokens with refresh token rotation
|
||||
- **Audit Logging** - All security events logged to AuditLog model
|
||||
- **Content Sanitisation** - Markdown comments sanitised with DOMPurify
|
||||
- **Input Validation** - All inputs validated before database operations
|
||||
|
||||
## API Routes Structure
|
||||
|
||||
Routes are auto-loaded via `@fastify/autoload` from `api/src/app/routes/`:
|
||||
- Each route file exports Fastify route handlers
|
||||
- Routes follow REST conventions (GET, POST, PUT, DELETE)
|
||||
- All routes require JWT authentication (except auth endpoints)
|
||||
- Route handlers call service functions for business logic
|
||||
|
||||
Example structure:
|
||||
- `api/src/app/routes/games/index.ts` - Game CRUD endpoints
|
||||
- `api/src/app/services/game.service.ts` - Game business logic
|
||||
|
||||
## Code Style Conventions
|
||||
|
||||
### General
|
||||
- Use **clear, descriptive variable/function names**
|
||||
- Prefer **self-documenting code** over comments
|
||||
- Only use comments to explain **reasoning** when intent isn't clear
|
||||
- Use **British English** spelling (colour, organise, whilst)
|
||||
|
||||
### TypeScript
|
||||
- Prefer `interface` over `type` for object shapes
|
||||
- Use `const` assertions where appropriate
|
||||
- Enable strict mode (inherited from `@nhcarrigan/typescript-config`)
|
||||
|
||||
### Error Handling
|
||||
- Service functions throw `Error` instances with descriptive messages
|
||||
- Route handlers wrap service calls in try-catch blocks
|
||||
- Return 400 for validation errors with error message
|
||||
- Return 500 for unexpected errors (message hidden in production)
|
||||
- All errors logged to AuditLog for security events
|
||||
|
||||
### Validation
|
||||
- Validation utilities in `api/src/app/utils/validation.ts`
|
||||
- Constants for max lengths in `shared-types/src/lib/constants.ts`
|
||||
- Validate early in service functions before database operations
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
### Base64 Image Uploads
|
||||
- **Body limit must be 10MB** (`api/src/main.ts`) to accommodate base64 overhead
|
||||
- Validate data URL format BEFORE size check
|
||||
- Extract base64 portion (after comma) for size calculation
|
||||
- Don't apply regular URL length validation to data URLs
|
||||
|
||||
### Prisma Client Generation
|
||||
- Must run `pnpm db:gen` before building if schema changed
|
||||
- The build script automatically runs this
|
||||
- Prisma client is generated to `api/generated/`
|
||||
|
||||
### Nx Cache
|
||||
- Nx caches build outputs for faster rebuilds
|
||||
- Clear cache with `nx reset` if experiencing issues
|
||||
- Cache stored in `.nx/cache/`
|
||||
|
||||
### MongoDB ObjectIds
|
||||
- All IDs are MongoDB ObjectIds (not UUIDs or auto-increment integers)
|
||||
- Use `@db.ObjectId` in Prisma schema
|
||||
- Use `String` type in TypeScript for ObjectIds
|
||||
|
||||
## Deployment
|
||||
|
||||
The application is deployed in production mode:
|
||||
```bash
|
||||
pnpm build # Build all projects
|
||||
pnpm start # Start with prod.env secrets
|
||||
```
|
||||
|
||||
Production start command:
|
||||
```bash
|
||||
NODE_ENV=production op run --env-file=prod.env -- node dist/api/main.js
|
||||
```
|
||||
|
||||
The API serves the built frontend as static files, so only the API process needs to run in production.
|
||||
|
||||
## Important Notes
|
||||
|
||||
- This is a **personal project** for Naomi's media tracking
|
||||
- Uses **Discord OAuth** for authentication (no other auth methods)
|
||||
- **MongoDB** is the only supported database (Prisma schema is MongoDB-specific)
|
||||
- All **timestamps** are stored as UTC in the database
|
||||
- **Achievements system** tracks user activity and unlocks (see UserAchievement model)
|
||||
- **Comments support markdown** with sanitisation
|
||||
- **User profiles** support custom slugs, bios, and social links
|
||||
- **Reporting system** for profiles and comments with moderation workflow
|
||||
@@ -22,8 +22,8 @@ export async function app(fastify: FastifyInstance, opts: AppOptions) {
|
||||
});
|
||||
}
|
||||
|
||||
// Log unauthorized access attempts
|
||||
if (error.statusCode === 401 || error.statusCode === 403) {
|
||||
// Log unauthorized access attempts (exclude /api/auth/me as 401s there are expected during token refresh)
|
||||
if ((error.statusCode === 401 || error.statusCode === 403) && request.url !== '/api/auth/me') {
|
||||
await AuditService.log({
|
||||
action: AuditAction.unauthorizedAccess,
|
||||
category: AuditCategory.security,
|
||||
|
||||
@@ -14,11 +14,11 @@ const helmetPlugin: FastifyPluginAsync = async (app) => {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
// Angular uses inline styles for component encapsulation, so we need to allow them
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
|
||||
imgSrc: ["'self'", "data:", "https:"],
|
||||
scriptSrc: ["'self'"],
|
||||
connectSrc: ["'self'", process.env.FRONTEND_URL ?? "http://localhost:4200"],
|
||||
fontSrc: ["'self'", "data:"],
|
||||
fontSrc: ["'self'", "data:", "https://fonts.gstatic.com"],
|
||||
objectSrc: ["'none'"],
|
||||
baseUri: ["'self'"],
|
||||
formAction: ["'self'"],
|
||||
|
||||
@@ -12,8 +12,27 @@ import { AuditAction, AuditCategory } from "@library/shared-types";
|
||||
|
||||
const rateLimitPlugin: FastifyPluginAsync = async (app) => {
|
||||
await app.register(fastifyRateLimit, {
|
||||
max: 100,
|
||||
max: async (request) => {
|
||||
// Try to get user from JWT
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
// Authenticated users get higher limits
|
||||
return 500;
|
||||
} catch {
|
||||
// Unauthenticated users get lower limits
|
||||
return 100;
|
||||
}
|
||||
},
|
||||
timeWindow: "1 minute",
|
||||
allowList: async (request) => {
|
||||
// Bypass rate limiting entirely for admin users
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
return request.user?.isAdmin === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
errorResponseBuilder: (request) => {
|
||||
// Log rate limit exceeded event
|
||||
AuditService.log({
|
||||
|
||||
@@ -21,8 +21,8 @@ export default async function staticPlugin(app: FastifyInstance) {
|
||||
|
||||
// Catch-all route for Angular SPA routing (must be registered after API routes)
|
||||
app.setNotFoundHandler((request, reply) => {
|
||||
// Only catch routes that don't start with /api
|
||||
if (!request.url.startsWith("/api")) {
|
||||
// Only catch routes that don't start with /api or /assets
|
||||
if (!request.url.startsWith("/api") && !request.url.startsWith("/assets")) {
|
||||
reply.sendFile("index.html");
|
||||
} else {
|
||||
reply.code(404).send({ error: "Not Found" });
|
||||
|
||||
@@ -102,6 +102,53 @@ const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
request
|
||||
);
|
||||
|
||||
// Assign library member role if user is in Discord server but doesn't have it
|
||||
const libraryRoleId = process.env.LIBRARY_ROLE_ID;
|
||||
if (inDiscord && guildId && libraryRoleId) {
|
||||
try {
|
||||
const memberResponse = await fetch(
|
||||
`https://discord.com/api/users/@me/guilds/${guildId}/member`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenResult.token.access_token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (memberResponse.ok) {
|
||||
const memberData = await memberResponse.json() as { roles: string[] };
|
||||
const hasLibraryRole = memberData.roles.includes(libraryRoleId);
|
||||
|
||||
if (!hasLibraryRole) {
|
||||
const botToken = process.env.DISCORD_BOT_TOKEN;
|
||||
if (botToken) {
|
||||
const assignRoleResponse = await fetch(
|
||||
`https://discord.com/api/v10/guilds/${guildId}/members/${userData.id}/roles/${libraryRoleId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bot ${botToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (assignRoleResponse.ok || assignRoleResponse.status === 204) {
|
||||
app.log.info(`Assigned library role to user ${user.username} (${user.id})`);
|
||||
} else {
|
||||
app.log.error(
|
||||
`Failed to assign library role to user ${user.username}: ${assignRoleResponse.status}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Don't fail the login if role assignment fails
|
||||
app.log.error({ err: error }, "Error assigning library role");
|
||||
}
|
||||
}
|
||||
|
||||
// Set signed cookies and redirect to frontend
|
||||
reply
|
||||
.setCookie("auth-token", accessToken, {
|
||||
|
||||
@@ -41,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;
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
validateUrl,
|
||||
validateRating,
|
||||
validateStringLength,
|
||||
validateDataUrl,
|
||||
MAX_LENGTHS,
|
||||
} from "../utils/validation";
|
||||
|
||||
@@ -35,18 +36,32 @@ export class BookService {
|
||||
if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) {
|
||||
throw new Error(`Notes must be ${MAX_LENGTHS.NOTES} characters or less.`);
|
||||
}
|
||||
if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) {
|
||||
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
|
||||
}
|
||||
|
||||
// Validate rating
|
||||
if (!validateRating(data.rating)) {
|
||||
throw new Error("Rating must be an integer between 0 and 10.");
|
||||
}
|
||||
|
||||
// Validate cover image URL
|
||||
if (data.coverImage && !validateUrl(data.coverImage)) {
|
||||
throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
|
||||
if (data.coverImage) {
|
||||
if (data.coverImage.startsWith("data:")) {
|
||||
const base64Data = data.coverImage.split(",")[1];
|
||||
if (!base64Data) {
|
||||
throw new Error("Invalid image data URL format.");
|
||||
}
|
||||
const sizeInBytes = base64Data.length * 0.75;
|
||||
if (sizeInBytes > MAX_LENGTHS.DATA_URL) {
|
||||
throw new Error("Cover image must be under 5MB.");
|
||||
}
|
||||
if (!validateDataUrl(data.coverImage)) {
|
||||
throw new Error("Invalid image data URL.");
|
||||
}
|
||||
} else {
|
||||
if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) {
|
||||
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
|
||||
}
|
||||
if (!validateUrl(data.coverImage)) {
|
||||
throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate tags
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
validateUrl,
|
||||
validateRating,
|
||||
validateStringLength,
|
||||
validateDataUrl,
|
||||
MAX_LENGTHS,
|
||||
} from "../utils/validation";
|
||||
|
||||
@@ -32,9 +33,6 @@ export class GameService {
|
||||
if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) {
|
||||
throw new Error(`Notes must be ${MAX_LENGTHS.NOTES} characters or less.`);
|
||||
}
|
||||
if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) {
|
||||
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
|
||||
}
|
||||
|
||||
// Validate rating
|
||||
if (!validateRating(data.rating)) {
|
||||
@@ -42,8 +40,29 @@ export class GameService {
|
||||
}
|
||||
|
||||
// Validate cover image URL
|
||||
if (data.coverImage && !validateUrl(data.coverImage)) {
|
||||
throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
|
||||
if (data.coverImage) {
|
||||
if (data.coverImage.startsWith("data:")) {
|
||||
// Extract just the base64 data (after the comma)
|
||||
const base64Data = data.coverImage.split(",")[1];
|
||||
if (!base64Data) {
|
||||
throw new Error("Invalid image data URL format.");
|
||||
}
|
||||
// Calculate decoded size: base64 is ~4/3 larger than original, so multiply by 0.75
|
||||
const decodedSizeInBytes = base64Data.length * 0.75;
|
||||
if (decodedSizeInBytes > MAX_LENGTHS.DATA_URL) {
|
||||
throw new Error("Cover image must be under 5MB.");
|
||||
}
|
||||
if (!validateDataUrl(data.coverImage)) {
|
||||
throw new Error("Invalid image data URL.");
|
||||
}
|
||||
} else {
|
||||
if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) {
|
||||
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
|
||||
}
|
||||
if (!validateUrl(data.coverImage)) {
|
||||
throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate tags
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
validateUrl,
|
||||
validateRating,
|
||||
validateStringLength,
|
||||
validateDataUrl,
|
||||
MAX_LENGTHS,
|
||||
} from "../utils/validation";
|
||||
|
||||
@@ -32,18 +33,33 @@ export class MangaService {
|
||||
if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) {
|
||||
throw new Error(`Notes must be ${MAX_LENGTHS.NOTES} characters or less.`);
|
||||
}
|
||||
if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) {
|
||||
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
|
||||
}
|
||||
|
||||
// Validate rating
|
||||
if (!validateRating(data.rating)) {
|
||||
throw new Error("Rating must be an integer between 0 and 10.");
|
||||
}
|
||||
|
||||
// Validate cover image URL
|
||||
if (data.coverImage && !validateUrl(data.coverImage)) {
|
||||
throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
|
||||
if (data.coverImage) {
|
||||
if (data.coverImage.startsWith("data:")) {
|
||||
const base64Data = data.coverImage.split(",")[1];
|
||||
if (!base64Data) {
|
||||
throw new Error("Invalid image data URL format.");
|
||||
}
|
||||
const sizeInBytes = base64Data.length * 0.75;
|
||||
if (sizeInBytes > MAX_LENGTHS.DATA_URL) {
|
||||
throw new Error("Cover image must be under 5MB.");
|
||||
}
|
||||
if (!validateDataUrl(data.coverImage)) {
|
||||
throw new Error("Invalid image data URL.");
|
||||
}
|
||||
} else {
|
||||
if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) {
|
||||
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
|
||||
}
|
||||
if (!validateUrl(data.coverImage)) {
|
||||
throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate tags
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
validateUrl,
|
||||
validateRating,
|
||||
validateStringLength,
|
||||
validateDataUrl,
|
||||
MAX_LENGTHS,
|
||||
} from "../utils/validation";
|
||||
|
||||
@@ -32,18 +33,33 @@ export class MusicService {
|
||||
if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) {
|
||||
throw new Error(`Notes must be ${MAX_LENGTHS.NOTES} characters or less.`);
|
||||
}
|
||||
if (!validateStringLength(data.coverArt, MAX_LENGTHS.URL)) {
|
||||
throw new Error(`Cover art URL must be ${MAX_LENGTHS.URL} characters or less.`);
|
||||
}
|
||||
|
||||
// Validate rating
|
||||
if (data.rating !== undefined && !validateRating(data.rating)) {
|
||||
throw new Error("Rating must be an integer between 0 and 10.");
|
||||
}
|
||||
|
||||
// Validate cover art URL
|
||||
if (data.coverArt && !validateUrl(data.coverArt)) {
|
||||
throw new Error("Invalid cover art URL. Only http and https URLs are allowed.");
|
||||
if (data.coverArt) {
|
||||
if (data.coverArt.startsWith("data:")) {
|
||||
const base64Data = data.coverArt.split(",")[1];
|
||||
if (!base64Data) {
|
||||
throw new Error("Invalid image data URL format.");
|
||||
}
|
||||
const sizeInBytes = base64Data.length * 0.75;
|
||||
if (sizeInBytes > MAX_LENGTHS.DATA_URL) {
|
||||
throw new Error("Cover image must be under 5MB.");
|
||||
}
|
||||
if (!validateDataUrl(data.coverArt)) {
|
||||
throw new Error("Invalid image data URL.");
|
||||
}
|
||||
} else {
|
||||
if (!validateStringLength(data.coverArt, MAX_LENGTHS.URL)) {
|
||||
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
|
||||
}
|
||||
if (!validateUrl(data.coverArt)) {
|
||||
throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate tags
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
validateUrl,
|
||||
validateRating,
|
||||
validateStringLength,
|
||||
validateDataUrl,
|
||||
MAX_LENGTHS,
|
||||
} from "../utils/validation";
|
||||
|
||||
@@ -29,18 +30,33 @@ export class ShowService {
|
||||
if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) {
|
||||
throw new Error(`Notes must be ${MAX_LENGTHS.NOTES} characters or less.`);
|
||||
}
|
||||
if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) {
|
||||
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
|
||||
}
|
||||
|
||||
// Validate rating
|
||||
if (!validateRating(data.rating)) {
|
||||
throw new Error("Rating must be an integer between 0 and 10.");
|
||||
}
|
||||
|
||||
// Validate cover image URL
|
||||
if (data.coverImage && !validateUrl(data.coverImage)) {
|
||||
throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
|
||||
if (data.coverImage) {
|
||||
if (data.coverImage.startsWith("data:")) {
|
||||
const base64Data = data.coverImage.split(",")[1];
|
||||
if (!base64Data) {
|
||||
throw new Error("Invalid image data URL format.");
|
||||
}
|
||||
const sizeInBytes = base64Data.length * 0.75;
|
||||
if (sizeInBytes > MAX_LENGTHS.DATA_URL) {
|
||||
throw new Error("Cover image must be under 5MB.");
|
||||
}
|
||||
if (!validateDataUrl(data.coverImage)) {
|
||||
throw new Error("Invalid image data URL.");
|
||||
}
|
||||
} else {
|
||||
if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) {
|
||||
throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`);
|
||||
}
|
||||
if (!validateUrl(data.coverImage)) {
|
||||
throw new Error("Invalid cover image URL. Only http and https URLs are allowed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate tags
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
/**
|
||||
* Validates that a URL is a proper base64 data string.
|
||||
* Allows whitespace in the base64 data which may occur during transmission.
|
||||
*/
|
||||
export function validateDataUrl(url: string): boolean {
|
||||
return /^data:image\/(jpeg|png|gif|webp|svg\+xml);base64,[A-Za-z0-9+/=\s]+$/.test(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a URL is safe and points to an allowed protocol.
|
||||
* Prevents javascript:, data:, vbscript:, and file: URLs.
|
||||
@@ -83,4 +91,5 @@ export const MAX_LENGTHS = {
|
||||
NOTES: 5000,
|
||||
TAGS: 50, // per tag
|
||||
ISBN: 50,
|
||||
DATA_URL: 5 * 1024 * 1024, // 5MB in bytes (not chars)
|
||||
} as const;
|
||||
|
||||
@@ -40,7 +40,7 @@ process.on('SIGINT', () => {
|
||||
// Instantiate Fastify with some config
|
||||
const server = Fastify({
|
||||
logger: true,
|
||||
bodyLimit: 1048576, // 1MB max body size
|
||||
bodyLimit: 10485760, // 10MB max body size (to accommodate base64-encoded images)
|
||||
});
|
||||
|
||||
// Register your application as a normal plugin.
|
||||
|
||||
@@ -25,6 +25,11 @@
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"optimization": {
|
||||
"styles": {
|
||||
"inlineCritical": false
|
||||
}
|
||||
},
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
|
||||
|
After Width: | Height: | Size: 8.3 MiB |
|
After Width: | Height: | Size: 8.5 MiB |
|
After Width: | Height: | Size: 8.3 MiB |
|
After Width: | Height: | Size: 9.1 MiB |
|
After Width: | Height: | Size: 8.1 MiB |
|
After Width: | Height: | Size: 8.2 MiB |
|
After Width: | Height: | Size: 8.3 MiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 233 KiB After Width: | Height: | Size: 229 KiB |
|
Before Width: | Height: | Size: 392 KiB After Width: | Height: | Size: 381 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 381 KiB |
|
After Width: | Height: | Size: 8.6 MiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
@@ -10,12 +10,18 @@ import { FormsModule } from '@angular/forms';
|
||||
import { SuggestionService } from '../../services/suggestion.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { PaginationComponent } from '../shared/pagination.component';
|
||||
import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-types';
|
||||
import { GameFormComponent } from '../shared/game-form.component';
|
||||
import { BookFormComponent } from '../shared/book-form.component';
|
||||
import { MusicFormComponent } from '../shared/music-form.component';
|
||||
import { ShowFormComponent } from '../shared/show-form.component';
|
||||
import { MangaFormComponent } from '../shared/manga-form.component';
|
||||
import { ArtFormComponent } from '../shared/art-form.component';
|
||||
import { Suggestion, SuggestionStatus, SuggestionEntity, CreateGameDto, UpdateGameDto, GameStatus, CreateBookDto, UpdateBookDto, BookStatus, CreateMusicDto, UpdateMusicDto, MusicStatus, MusicType, CreateShowDto, UpdateShowDto, ShowStatus, ShowType, CreateMangaDto, UpdateMangaDto, MangaStatus, CreateArtDto, UpdateArtDto } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-suggestions',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, PaginationComponent],
|
||||
imports: [CommonModule, FormsModule, PaginationComponent, GameFormComponent, BookFormComponent, MusicFormComponent, ShowFormComponent, MangaFormComponent, ArtFormComponent],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="header-section">
|
||||
@@ -231,158 +237,62 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
|
||||
@if (showEditModal()) {
|
||||
<div class="modal-overlay" (click)="closeEditModal()" (keyup.escape)="closeEditModal()" tabindex="0" role="button">
|
||||
<div class="modal edit-modal" (click)="$event.stopPropagation()" (keyup)="$event.stopPropagation()" tabindex="-1">
|
||||
<h3>Review & Edit Before Accepting</h3>
|
||||
<p>Review and edit the details before adding to your collection.</p>
|
||||
|
||||
@if (editingSuggestion()) {
|
||||
<form (ngSubmit)="confirmAcceptWithEdits()">
|
||||
<div class="form-group">
|
||||
<label for="edit-title">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
id="edit-title"
|
||||
[(ngModel)]="editedData.title"
|
||||
name="title"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
@switch (editingSuggestion()!.entityType) {
|
||||
@case (SuggestionEntity.book) {
|
||||
<div class="form-group">
|
||||
<label for="edit-author">Author</label>
|
||||
<input
|
||||
type="text"
|
||||
id="edit-author"
|
||||
[(ngModel)]="editedData.author"
|
||||
name="author"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-isbn">ISBN</label>
|
||||
<input
|
||||
type="text"
|
||||
id="edit-isbn"
|
||||
[(ngModel)]="editedData.isbn"
|
||||
name="isbn"
|
||||
>
|
||||
</div>
|
||||
}
|
||||
@case (SuggestionEntity.game) {
|
||||
<div class="form-group">
|
||||
<label for="edit-platform">Platform</label>
|
||||
<input
|
||||
type="text"
|
||||
id="edit-platform"
|
||||
[(ngModel)]="editedData.platform"
|
||||
name="platform"
|
||||
>
|
||||
</div>
|
||||
}
|
||||
@case (SuggestionEntity.music) {
|
||||
<div class="form-group">
|
||||
<label for="edit-artist">Artist</label>
|
||||
<input
|
||||
type="text"
|
||||
id="edit-artist"
|
||||
[(ngModel)]="editedData.artist"
|
||||
name="artist"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-type">Type</label>
|
||||
<select id="edit-type" [(ngModel)]="editedData.type" name="type" required>
|
||||
<option value="ALBUM">Album</option>
|
||||
<option value="SINGLE">Single</option>
|
||||
<option value="EP">EP</option>
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
@case (SuggestionEntity.art) {
|
||||
<div class="form-group">
|
||||
<label for="edit-artist">Artist</label>
|
||||
<input
|
||||
type="text"
|
||||
id="edit-artist"
|
||||
[(ngModel)]="editedData.artist"
|
||||
name="artist"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-description">Description</label>
|
||||
<textarea
|
||||
id="edit-description"
|
||||
[(ngModel)]="editedData.description"
|
||||
name="description"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-imageUrl">Image URL</label>
|
||||
<input
|
||||
type="text"
|
||||
id="edit-imageUrl"
|
||||
[(ngModel)]="editedData.imageUrl"
|
||||
name="imageUrl"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
}
|
||||
@case (SuggestionEntity.show) {
|
||||
<div class="form-group">
|
||||
<label for="edit-type">Type</label>
|
||||
<select id="edit-type" [(ngModel)]="editedData.type" name="type" required>
|
||||
<option value="TV_SERIES">TV Series</option>
|
||||
<option value="ANIME">Anime</option>
|
||||
<option value="FILM">Film</option>
|
||||
<option value="DOCUMENTARY">Documentary</option>
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
@case (SuggestionEntity.manga) {
|
||||
<div class="form-group">
|
||||
<label for="edit-author">Author</label>
|
||||
<input
|
||||
type="text"
|
||||
id="edit-author"
|
||||
[(ngModel)]="editedData.author"
|
||||
name="author"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-notes">Notes</label>
|
||||
<textarea
|
||||
id="edit-notes"
|
||||
[(ngModel)]="editedData.notes"
|
||||
name="notes"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
@if (editingSuggestion()!.entityType !== SuggestionEntity.art) {
|
||||
<div class="form-group">
|
||||
<label for="edit-coverImage">Cover Image URL</label>
|
||||
<input
|
||||
type="text"
|
||||
id="edit-coverImage"
|
||||
[(ngModel)]="editedData.coverImage"
|
||||
name="coverImage"
|
||||
>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="submit" class="btn btn-accept">Accept with Edits</button>
|
||||
<button type="button" (click)="closeEditModal()" class="btn btn-secondary">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
@if (editingSuggestion()!.entityType === SuggestionEntity.game) {
|
||||
<h3>Review & Edit Game Before Accepting</h3>
|
||||
<p>Review and edit all the details before adding to your collection.</p>
|
||||
<app-game-form
|
||||
mode="add"
|
||||
[initialData]="getGameInitialData(editingSuggestion()!)"
|
||||
(formSubmit)="saveGameFromSuggestion($event)"
|
||||
(formCancel)="closeEditModal()"
|
||||
></app-game-form>
|
||||
} @else if (editingSuggestion()!.entityType === SuggestionEntity.book) {
|
||||
<h3>Review & Edit Book Before Accepting</h3>
|
||||
<p>Review and edit all the details before adding to your collection.</p>
|
||||
<app-book-form
|
||||
mode="add"
|
||||
[initialData]="getBookInitialData(editingSuggestion()!)"
|
||||
(formSubmit)="saveBookFromSuggestion($event)"
|
||||
(formCancel)="closeEditModal()"
|
||||
></app-book-form>
|
||||
} @else if (editingSuggestion()!.entityType === SuggestionEntity.music) {
|
||||
<h3>Review & Edit Music Before Accepting</h3>
|
||||
<p>Review and edit all the details before adding to your collection.</p>
|
||||
<app-music-form
|
||||
mode="add"
|
||||
[initialData]="getMusicInitialData(editingSuggestion()!)"
|
||||
(formSubmit)="saveMusicFromSuggestion($event)"
|
||||
(formCancel)="closeEditModal()"
|
||||
></app-music-form>
|
||||
} @else if (editingSuggestion()!.entityType === SuggestionEntity.show) {
|
||||
<h3>Review & Edit Show Before Accepting</h3>
|
||||
<p>Review and edit all the details before adding to your collection.</p>
|
||||
<app-show-form
|
||||
mode="add"
|
||||
[initialData]="getShowInitialData(editingSuggestion()!)"
|
||||
(formSubmit)="saveShowFromSuggestion($event)"
|
||||
(formCancel)="closeEditModal()"
|
||||
></app-show-form>
|
||||
} @else if (editingSuggestion()!.entityType === SuggestionEntity.manga) {
|
||||
<h3>Review & Edit Manga Before Accepting</h3>
|
||||
<p>Review and edit all the details before adding to your collection.</p>
|
||||
<app-manga-form
|
||||
mode="add"
|
||||
[initialData]="getMangaInitialData(editingSuggestion()!)"
|
||||
(formSubmit)="saveMangaFromSuggestion($event)"
|
||||
(formCancel)="closeEditModal()"
|
||||
></app-manga-form>
|
||||
} @else if (editingSuggestion()!.entityType === SuggestionEntity.art) {
|
||||
<h3>Review & Edit Art Before Accepting</h3>
|
||||
<p>Review and edit all the details before adding to your collection.</p>
|
||||
<app-art-form
|
||||
mode="add"
|
||||
[initialData]="getArtInitialData(editingSuggestion()!)"
|
||||
(formSubmit)="saveArtFromSuggestion($event)"
|
||||
(formCancel)="closeEditModal()"
|
||||
></app-art-form>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -810,59 +720,174 @@ export class AdminSuggestionsComponent implements OnInit {
|
||||
return new Date(date).toLocaleDateString();
|
||||
}
|
||||
|
||||
getGameInitialData(suggestion: Suggestion): Partial<CreateGameDto> {
|
||||
const gameData = suggestion.gameData as any;
|
||||
return {
|
||||
title: suggestion.title,
|
||||
platform: gameData?.platform || undefined,
|
||||
status: GameStatus.backlog, // Default to backlog for new suggestions
|
||||
notes: gameData?.notes || undefined,
|
||||
coverImage: gameData?.coverImage || undefined,
|
||||
tags: [],
|
||||
links: []
|
||||
};
|
||||
}
|
||||
|
||||
async saveGameFromSuggestion(data: CreateGameDto | UpdateGameDto) {
|
||||
const suggestion = this.editingSuggestion();
|
||||
if (!suggestion) return;
|
||||
|
||||
try {
|
||||
// Treat it as CreateGameDto since we're creating a new item from a suggestion
|
||||
await this.suggestionService.acceptSuggestionWithEdits(suggestion.id, data as CreateGameDto);
|
||||
alert(`"${(data as CreateGameDto).title}" has been added to your collection!`);
|
||||
this.closeEditModal();
|
||||
this.loadSuggestions();
|
||||
} catch (error) {
|
||||
alert('Failed to accept suggestion. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
getBookInitialData(suggestion: Suggestion): Partial<CreateBookDto> {
|
||||
const bookData = suggestion.bookData as any;
|
||||
return {
|
||||
title: suggestion.title,
|
||||
author: bookData?.author || '',
|
||||
isbn: bookData?.isbn || undefined,
|
||||
status: BookStatus.toRead,
|
||||
notes: bookData?.notes || undefined,
|
||||
coverImage: bookData?.coverImage || undefined,
|
||||
tags: [],
|
||||
links: []
|
||||
};
|
||||
}
|
||||
|
||||
async saveBookFromSuggestion(data: CreateBookDto | UpdateBookDto) {
|
||||
const suggestion = this.editingSuggestion();
|
||||
if (!suggestion) return;
|
||||
|
||||
try {
|
||||
await this.suggestionService.acceptSuggestionWithEdits(suggestion.id, data as CreateBookDto);
|
||||
alert(`"${(data as CreateBookDto).title}" has been added to your collection!`);
|
||||
this.closeEditModal();
|
||||
this.loadSuggestions();
|
||||
} catch (error) {
|
||||
alert('Failed to accept suggestion. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
getMusicInitialData(suggestion: Suggestion): Partial<CreateMusicDto> {
|
||||
const musicData = suggestion.musicData as any;
|
||||
return {
|
||||
title: suggestion.title,
|
||||
artist: musicData?.artist || '',
|
||||
type: musicData?.type || MusicType.album,
|
||||
status: MusicStatus.wantToListen,
|
||||
notes: musicData?.notes || undefined,
|
||||
coverArt: musicData?.coverArt || undefined,
|
||||
tags: [],
|
||||
links: []
|
||||
};
|
||||
}
|
||||
|
||||
async saveMusicFromSuggestion(data: CreateMusicDto | UpdateMusicDto) {
|
||||
const suggestion = this.editingSuggestion();
|
||||
if (!suggestion) return;
|
||||
|
||||
try {
|
||||
await this.suggestionService.acceptSuggestionWithEdits(suggestion.id, data as CreateMusicDto);
|
||||
alert(`"${(data as CreateMusicDto).title}" has been added to your collection!`);
|
||||
this.closeEditModal();
|
||||
this.loadSuggestions();
|
||||
} catch (error) {
|
||||
alert('Failed to accept suggestion. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
getShowInitialData(suggestion: Suggestion): Partial<CreateShowDto> {
|
||||
const showData = suggestion.showData as any;
|
||||
return {
|
||||
title: suggestion.title,
|
||||
type: showData?.type || ShowType.tvSeries,
|
||||
status: ShowStatus.wantToWatch,
|
||||
notes: showData?.notes || undefined,
|
||||
coverImage: showData?.coverImage || undefined,
|
||||
tags: [],
|
||||
links: []
|
||||
};
|
||||
}
|
||||
|
||||
async saveShowFromSuggestion(data: CreateShowDto | UpdateShowDto) {
|
||||
const suggestion = this.editingSuggestion();
|
||||
if (!suggestion) return;
|
||||
|
||||
try {
|
||||
await this.suggestionService.acceptSuggestionWithEdits(suggestion.id, data as CreateShowDto);
|
||||
alert(`"${(data as CreateShowDto).title}" has been added to your collection!`);
|
||||
this.closeEditModal();
|
||||
this.loadSuggestions();
|
||||
} catch (error) {
|
||||
alert('Failed to accept suggestion. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
getMangaInitialData(suggestion: Suggestion): Partial<CreateMangaDto> {
|
||||
const mangaData = suggestion.mangaData as any;
|
||||
return {
|
||||
title: suggestion.title,
|
||||
author: mangaData?.author || '',
|
||||
status: MangaStatus.wantToRead,
|
||||
notes: mangaData?.notes || undefined,
|
||||
coverImage: mangaData?.coverImage || undefined,
|
||||
tags: [],
|
||||
links: []
|
||||
};
|
||||
}
|
||||
|
||||
async saveMangaFromSuggestion(data: CreateMangaDto | UpdateMangaDto) {
|
||||
const suggestion = this.editingSuggestion();
|
||||
if (!suggestion) return;
|
||||
|
||||
try {
|
||||
await this.suggestionService.acceptSuggestionWithEdits(suggestion.id, data as CreateMangaDto);
|
||||
alert(`"${(data as CreateMangaDto).title}" has been added to your collection!`);
|
||||
this.closeEditModal();
|
||||
this.loadSuggestions();
|
||||
} catch (error) {
|
||||
alert('Failed to accept suggestion. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
getArtInitialData(suggestion: Suggestion): Partial<CreateArtDto> {
|
||||
const artData = suggestion.artData as any;
|
||||
return {
|
||||
title: suggestion.title,
|
||||
artist: artData?.artist || '',
|
||||
description: artData?.description || undefined,
|
||||
imageUrl: artData?.imageUrl || '',
|
||||
tags: [],
|
||||
links: []
|
||||
};
|
||||
}
|
||||
|
||||
async saveArtFromSuggestion(data: CreateArtDto | UpdateArtDto) {
|
||||
const suggestion = this.editingSuggestion();
|
||||
if (!suggestion) return;
|
||||
|
||||
try {
|
||||
await this.suggestionService.acceptSuggestionWithEdits(suggestion.id, data as CreateArtDto);
|
||||
alert(`"${(data as CreateArtDto).title}" has been added to your collection!`);
|
||||
this.closeEditModal();
|
||||
this.loadSuggestions();
|
||||
} catch (error) {
|
||||
alert('Failed to accept suggestion. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
async acceptSuggestion(suggestion: Suggestion) {
|
||||
this.editingSuggestion.set(suggestion);
|
||||
|
||||
// Pre-populate the edit form with suggestion data
|
||||
this.editedData = {
|
||||
title: suggestion.title,
|
||||
notes: '',
|
||||
coverImage: '',
|
||||
coverArt: ''
|
||||
};
|
||||
|
||||
// Add entity-specific data
|
||||
switch (suggestion.entityType) {
|
||||
case SuggestionEntity.book:
|
||||
const bookData = suggestion.bookData as any;
|
||||
this.editedData.author = bookData?.author || '';
|
||||
this.editedData.isbn = bookData?.isbn || '';
|
||||
this.editedData.notes = bookData?.notes || '';
|
||||
this.editedData.coverImage = bookData?.coverImage || '';
|
||||
break;
|
||||
case SuggestionEntity.game:
|
||||
const gameData = suggestion.gameData as any;
|
||||
this.editedData.platform = gameData?.platform || '';
|
||||
this.editedData.notes = gameData?.notes || '';
|
||||
this.editedData.coverImage = gameData?.coverImage || '';
|
||||
break;
|
||||
case SuggestionEntity.music:
|
||||
const musicData = suggestion.musicData as any;
|
||||
this.editedData.artist = musicData?.artist || '';
|
||||
this.editedData.type = musicData?.type || 'ALBUM';
|
||||
this.editedData.notes = musicData?.notes || '';
|
||||
this.editedData.coverArt = musicData?.coverArt || '';
|
||||
break;
|
||||
case SuggestionEntity.art:
|
||||
const artData = suggestion.artData as any;
|
||||
this.editedData.artist = artData?.artist || '';
|
||||
this.editedData.description = artData?.description || '';
|
||||
this.editedData.imageUrl = artData?.imageUrl || '';
|
||||
break;
|
||||
case SuggestionEntity.show:
|
||||
const showData = suggestion.showData as any;
|
||||
this.editedData.type = showData?.type || 'TV_SERIES';
|
||||
this.editedData.notes = showData?.notes || '';
|
||||
this.editedData.coverImage = showData?.coverImage || '';
|
||||
break;
|
||||
case SuggestionEntity.manga:
|
||||
const mangaData = suggestion.mangaData as any;
|
||||
this.editedData.author = mangaData?.author || '';
|
||||
this.editedData.notes = mangaData?.notes || '';
|
||||
this.editedData.coverImage = mangaData?.coverImage || '';
|
||||
break;
|
||||
}
|
||||
|
||||
// For all entity types, we'll use the form components which have their own initialization logic
|
||||
this.showEditModal.set(true);
|
||||
}
|
||||
|
||||
@@ -884,20 +909,6 @@ export class AdminSuggestionsComponent implements OnInit {
|
||||
this.editedData = {};
|
||||
}
|
||||
|
||||
async confirmAcceptWithEdits() {
|
||||
const suggestion = this.editingSuggestion();
|
||||
if (!suggestion) return;
|
||||
|
||||
try {
|
||||
await this.suggestionService.acceptSuggestionWithEdits(suggestion.id, this.editedData);
|
||||
alert(`"${this.editedData.title}" has been added to your collection with your edits!`);
|
||||
this.closeEditModal();
|
||||
this.loadSuggestions();
|
||||
} catch (error) {
|
||||
alert('Failed to accept suggestion. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
async confirmDecline() {
|
||||
const suggestion = this.decliningsuggestion();
|
||||
if (!suggestion) return;
|
||||
|
||||
@@ -14,18 +14,28 @@ import { AuthService } from '../../services/auth.service';
|
||||
import { SanitizeService } from '../../services/sanitize.service';
|
||||
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
|
||||
import { LikeButtonComponent } from '../shared/like-button.component';
|
||||
import { Art, Comment } from '@library/shared-types';
|
||||
import { ArtFormComponent } from '../shared/art-form.component';
|
||||
import { Art, Comment, UpdateArtDto } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-art-detail',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent],
|
||||
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent, ArtFormComponent],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="breadcrumb">
|
||||
<a routerLink="/art" class="breadcrumb-link">← Back to Art</a>
|
||||
</div>
|
||||
|
||||
@if (showEditForm() && authService.user()?.isAdmin && art()) {
|
||||
<app-art-form
|
||||
mode="edit"
|
||||
[art]="art()!"
|
||||
(formSubmit)="saveEdit($event)"
|
||||
(formCancel)="cancelEdit()"
|
||||
></app-art-form>
|
||||
}
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading">Loading artwork details...</div>
|
||||
} @else if (error()) {
|
||||
@@ -36,11 +46,9 @@ import { Art, Comment } from '@library/shared-types';
|
||||
</div>
|
||||
} @else if (art()) {
|
||||
<div class="art-detail-card">
|
||||
@if (art()!.imageUrl) {
|
||||
<div class="art-image-section">
|
||||
<img [src]="art()!.imageUrl" [alt]="art()!.description || art()!.title" class="art-image-large">
|
||||
</div>
|
||||
}
|
||||
<div class="art-image-section">
|
||||
<img [src]="art()!.imageUrl || '/assets/default-cover.jpg'" [alt]="art()!.description || art()!.title" class="art-image-large">
|
||||
</div>
|
||||
|
||||
<div class="art-content">
|
||||
<div class="art-header">
|
||||
@@ -48,6 +56,12 @@ import { Art, Comment } from '@library/shared-types';
|
||||
</div>
|
||||
|
||||
<p class="artist">by {{ art()!.artist }}</p>
|
||||
@if (authService.user()?.isAdmin) {
|
||||
<div class="admin-actions">
|
||||
<button (click)="toggleEditForm()" class="btn btn-edit">✏️ {{ showEditForm() ? 'Cancel Edit' : 'Edit' }}</button>
|
||||
<button (click)="deleteArt()" class="btn btn-delete">🗑️ Delete</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="info-row">
|
||||
<span class="info-label">Added:</span>
|
||||
@@ -232,6 +246,35 @@ import { Art, Comment } from '@library/shared-types';
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
|
||||
.admin-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: #fef3c7;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #fbbf24;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-edit:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -426,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<string | null>(null);
|
||||
newCommentContent = '';
|
||||
showEditForm = signal(false);
|
||||
|
||||
ngOnInit() {
|
||||
const artId = this.route.snapshot.paramMap.get('id');
|
||||
@@ -539,4 +612,45 @@ export class ArtDetailComponent implements OnInit {
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toggleEditForm() {
|
||||
this.showEditForm.update(value => !value);
|
||||
}
|
||||
|
||||
saveEdit(data: UpdateArtDto) {
|
||||
const art = this.art();
|
||||
if (!art) return;
|
||||
|
||||
this.artService.updateArt(art.id, data).subscribe({
|
||||
next: (updatedArt) => {
|
||||
this.art.set(updatedArt);
|
||||
this.showEditForm.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
alert('Failed to update art: ' + (err.error?.message || 'Unknown error'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cancelEdit() {
|
||||
this.showEditForm.set(false);
|
||||
}
|
||||
|
||||
deleteArt() {
|
||||
const art = this.art();
|
||||
if (!art) return;
|
||||
|
||||
if (!confirm(`Are you sure you want to delete "${art.title}"? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.artService.deleteArt(art.id).subscribe({
|
||||
next: () => {
|
||||
this.router.navigate(['/art']);
|
||||
},
|
||||
error: (err) => {
|
||||
alert('Failed to delete art: ' + (err.error?.message || 'Unknown error'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,10 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
|
||||
imports: [CommonModule, FormsModule, RouterModule, PaginationComponent, LikeButtonComponent],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="page-hero">
|
||||
<img src="/assets/avatars/art-avatar.jpg" alt="Art avatar" class="page-avatar" />
|
||||
</div>
|
||||
|
||||
<div class="header-section">
|
||||
<h2>Art Gallery</h2>
|
||||
<p class="subtitle">Artwork of Naomi</p>
|
||||
@@ -395,7 +399,7 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
|
||||
<a [routerLink]="['/art', art.id]" class="card-link">
|
||||
<div class="art-image-container">
|
||||
<img
|
||||
[src]="art.imageUrl"
|
||||
[src]="art.imageUrl || '/assets/default-cover.jpg'"
|
||||
[alt]="art.description || art.title"
|
||||
class="art-image"
|
||||
>
|
||||
@@ -458,6 +462,26 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.page-hero {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 3px solid #fdcb6e;
|
||||
box-shadow: 0 4px 12px rgba(253, 203, 110, 0.3);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.page-avatar:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 8px 16px rgba(253, 203, 110, 0.5);
|
||||
}
|
||||
|
||||
.header-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1121,6 +1145,9 @@ export class ArtGalleryComponent implements OnInit {
|
||||
this.editTagInput = '';
|
||||
this.editLinkTitle = '';
|
||||
this.editLinkUrl = '';
|
||||
|
||||
// Scroll to top so the form is visible
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
cancelEdit() {
|
||||
|
||||
@@ -14,18 +14,28 @@ import { AuthService } from '../../services/auth.service';
|
||||
import { SanitizeService } from '../../services/sanitize.service';
|
||||
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
|
||||
import { LikeButtonComponent } from '../shared/like-button.component';
|
||||
import { Book, Comment, BookStatus } from '@library/shared-types';
|
||||
import { BookFormComponent } from '../shared/book-form.component';
|
||||
import { Book, Comment, BookStatus, UpdateBookDto } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-book-detail',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent],
|
||||
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent, BookFormComponent],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="breadcrumb">
|
||||
<a routerLink="/books" class="breadcrumb-link">← Back to Books</a>
|
||||
</div>
|
||||
|
||||
@if (showEditForm() && authService.user()?.isAdmin && book()) {
|
||||
<app-book-form
|
||||
mode="edit"
|
||||
[book]="book()!"
|
||||
(formSubmit)="saveEdit($event)"
|
||||
(formCancel)="cancelEdit()"
|
||||
></app-book-form>
|
||||
}
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading">Loading book details...</div>
|
||||
} @else if (error()) {
|
||||
@@ -36,11 +46,9 @@ import { Book, Comment, BookStatus } from '@library/shared-types';
|
||||
</div>
|
||||
} @else if (book()) {
|
||||
<div class="book-detail-card">
|
||||
@if (book()!.coverImage) {
|
||||
<div class="book-cover-section">
|
||||
<img [src]="book()!.coverImage" [alt]="book()!.title" class="book-cover-large">
|
||||
</div>
|
||||
}
|
||||
<div class="book-cover-section">
|
||||
<img [src]="book()!.coverImage || '/assets/default-cover.jpg'" [alt]="book()!.title" class="book-cover-large">
|
||||
</div>
|
||||
|
||||
<div class="book-content">
|
||||
<div class="book-header">
|
||||
@@ -51,6 +59,12 @@ import { Book, Comment, BookStatus } from '@library/shared-types';
|
||||
</div>
|
||||
|
||||
<p class="author">by {{ book()!.author }}</p>
|
||||
@if (authService.user()?.isAdmin) {
|
||||
<div class="admin-actions">
|
||||
<button (click)="editBook()" class="btn btn-edit">✏️ Edit</button>
|
||||
<button (click)="deleteBook()" class="btn btn-delete">🗑️ Delete</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (book()!.series) {
|
||||
<p class="series">
|
||||
@@ -309,6 +323,35 @@ import { Book, Comment, BookStatus } from '@library/shared-types';
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
|
||||
.admin-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: #fef3c7;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #fbbf24;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-edit:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.series {
|
||||
color: #8b6f47;
|
||||
font-size: 1rem;
|
||||
@@ -542,6 +585,7 @@ export class BookDetailComponent implements OnInit {
|
||||
commentsLoading = signal(false);
|
||||
error = signal<string | null>(null);
|
||||
newCommentContent = '';
|
||||
showEditForm = signal(false);
|
||||
|
||||
ngOnInit() {
|
||||
const bookId = this.route.snapshot.paramMap.get('id');
|
||||
@@ -651,4 +695,45 @@ export class BookDetailComponent implements OnInit {
|
||||
return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
editBook() {
|
||||
this.showEditForm.set(true);
|
||||
}
|
||||
|
||||
cancelEdit() {
|
||||
this.showEditForm.set(false);
|
||||
}
|
||||
|
||||
saveEdit(data: UpdateBookDto) {
|
||||
const book = this.book();
|
||||
if (!book) return;
|
||||
|
||||
this.booksService.updateBook(book.id, data).subscribe({
|
||||
next: (updatedBook) => {
|
||||
this.book.set(updatedBook);
|
||||
this.showEditForm.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
alert('Failed to update book: ' + (err.error?.message || 'Unknown error'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deleteBook() {
|
||||
const book = this.book();
|
||||
if (!book) return;
|
||||
|
||||
if (!confirm(`Are you sure you want to delete "${book.title}"? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.booksService.deleteBook(book.id).subscribe({
|
||||
next: () => {
|
||||
this.router.navigate(['/books']);
|
||||
},
|
||||
error: (err) => {
|
||||
alert('Failed to delete book: ' + (err.error?.message || 'Unknown error'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,10 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
imports: [CommonModule, FormsModule, RouterLink, PaginationComponent, LikeButtonComponent],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="page-hero">
|
||||
<img src="/assets/avatars/books-avatar.jpg" alt="Books avatar" class="page-avatar" />
|
||||
</div>
|
||||
|
||||
<div class="header-section">
|
||||
<h2>My Book Collection</h2>
|
||||
@if (authService.isAdmin()) {
|
||||
@@ -643,11 +647,7 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
@for (book of paginatedBooks(); track book.id) {
|
||||
<div class="book-card" [class.finished]="book.status === BookStatus.finished">
|
||||
<a [routerLink]="['/books', book.id]" class="card-link">
|
||||
@if (book.coverImage) {
|
||||
<img [src]="book.coverImage" [alt]="book.title" class="book-cover">
|
||||
} @else {
|
||||
<div class="book-cover placeholder">📚</div>
|
||||
}
|
||||
<img [src]="book.coverImage || '/assets/default-cover.jpg'" [alt]="book.title" class="book-cover">
|
||||
|
||||
<div class="book-info">
|
||||
<h3>{{ book.title }}</h3>
|
||||
@@ -705,6 +705,26 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.page-hero {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 3px solid #8b6f47;
|
||||
box-shadow: 0 4px 12px rgba(139, 111, 71, 0.3);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.page-avatar:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 8px 16px rgba(139, 111, 71, 0.5);
|
||||
}
|
||||
|
||||
.header-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -1561,6 +1581,9 @@ export class BooksListComponent implements OnInit {
|
||||
this.editBookTimeHours = 0;
|
||||
this.editBookTimeMinutes = 0;
|
||||
}
|
||||
|
||||
// Scroll to top so the form is visible
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
cancelEdit() {
|
||||
|
||||
@@ -14,12 +14,13 @@ import { AuthService } from '../../services/auth.service';
|
||||
import { SanitizeService } from '../../services/sanitize.service';
|
||||
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
|
||||
import { LikeButtonComponent } from '../shared/like-button.component';
|
||||
import { Game, Comment, GameStatus } from '@library/shared-types';
|
||||
import { GameFormComponent } from '../shared/game-form.component';
|
||||
import { Game, Comment, GameStatus, UpdateGameDto } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-game-detail',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent],
|
||||
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent, GameFormComponent],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="breadcrumb">
|
||||
@@ -36,11 +37,9 @@ import { Game, Comment, GameStatus } from '@library/shared-types';
|
||||
</div>
|
||||
} @else if (game()) {
|
||||
<div class="game-detail-card">
|
||||
@if (game()!.coverImage) {
|
||||
<div class="game-cover-section">
|
||||
<img [src]="game()!.coverImage" [alt]="game()!.title" class="game-cover-large">
|
||||
</div>
|
||||
}
|
||||
<div class="game-cover-section">
|
||||
<img [src]="game()!.coverImage || '/assets/default-cover.jpg'" [alt]="game()!.title" class="game-cover-large">
|
||||
</div>
|
||||
|
||||
<div class="game-content">
|
||||
<div class="game-header">
|
||||
@@ -50,6 +49,22 @@ import { Game, Comment, GameStatus } from '@library/shared-types';
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (authService.user()?.isAdmin) {
|
||||
<div class="admin-actions">
|
||||
<button (click)="toggleEditForm()" class="btn btn-edit">✏️ {{ showEditForm() ? 'Cancel Edit' : 'Edit' }}</button>
|
||||
<button (click)="deleteGame()" class="btn btn-delete">🗑️ Delete</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showEditForm() && authService.user()?.isAdmin && game()) {
|
||||
<app-game-form
|
||||
mode="edit"
|
||||
[game]="game()!"
|
||||
(formSubmit)="saveEdit($event)"
|
||||
(formCancel)="cancelEdit()"
|
||||
></app-game-form>
|
||||
}
|
||||
|
||||
@if (game()!.series) {
|
||||
<p class="series">
|
||||
📚 {{ game()!.series }}@if (game()!.seriesOrder) { #{{ game()!.seriesOrder }}}
|
||||
@@ -300,6 +315,34 @@ import { Game, Comment, GameStatus } from '@library/shared-types';
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.admin-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: #fef3c7;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #fbbf24;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-edit:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.series {
|
||||
color: #8b6f47;
|
||||
font-size: 1rem;
|
||||
@@ -533,6 +576,7 @@ export class GameDetailComponent implements OnInit {
|
||||
commentsLoading = signal(false);
|
||||
error = signal<string | null>(null);
|
||||
newCommentContent = '';
|
||||
showEditForm = signal(false);
|
||||
|
||||
ngOnInit() {
|
||||
const gameId = this.route.snapshot.paramMap.get('id');
|
||||
@@ -642,4 +686,45 @@ export class GameDetailComponent implements OnInit {
|
||||
return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`;
|
||||
}
|
||||
}
|
||||
|
||||
toggleEditForm() {
|
||||
this.showEditForm.update(value => !value);
|
||||
}
|
||||
|
||||
saveEdit(data: UpdateGameDto) {
|
||||
const game = this.game();
|
||||
if (!game) return;
|
||||
|
||||
this.gamesService.updateGame(game.id, data).subscribe({
|
||||
next: (updatedGame) => {
|
||||
this.game.set(updatedGame);
|
||||
this.showEditForm.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
alert('Failed to update game: ' + (err.error?.message || 'Unknown error'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cancelEdit() {
|
||||
this.showEditForm.set(false);
|
||||
}
|
||||
|
||||
deleteGame() {
|
||||
const game = this.game();
|
||||
if (!game) return;
|
||||
|
||||
if (!confirm(`Are you sure you want to delete "${game.title}"? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.gamesService.deleteGame(game.id).subscribe({
|
||||
next: () => {
|
||||
this.router.navigate(['/games']);
|
||||
},
|
||||
error: (err) => {
|
||||
alert('Failed to delete game: ' + (err.error?.message || 'Unknown error'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
imports: [CommonModule, FormsModule, RouterModule, PaginationComponent, LikeButtonComponent],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="page-hero">
|
||||
<img src="/assets/avatars/games-avatar.jpg" alt="Gaming avatar" class="page-avatar" />
|
||||
</div>
|
||||
|
||||
<div class="header-section">
|
||||
<h2>My Game Collection</h2>
|
||||
@if (authService.isAdmin()) {
|
||||
@@ -625,9 +629,7 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
@for (game of paginatedGames(); track game.id) {
|
||||
<div class="game-card" [class.completed]="game.status === GameStatus.completed">
|
||||
<a [routerLink]="['/games', game.id]" class="card-link">
|
||||
@if (game.coverImage) {
|
||||
<img [src]="game.coverImage" [alt]="game.title" class="game-cover">
|
||||
}
|
||||
<img [src]="game.coverImage || '/assets/default-cover.jpg'" [alt]="game.title" class="game-cover">
|
||||
|
||||
<div class="game-info">
|
||||
<h3>{{ game.title }}</h3>
|
||||
@@ -686,6 +688,26 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.page-hero {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 3px solid #ff6b6b;
|
||||
box-shadow: 0 4px 12px rgba(255, 107, 107, 0.3);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.page-avatar:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 8px 16px rgba(255, 107, 107, 0.5);
|
||||
}
|
||||
|
||||
.header-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -1434,6 +1456,9 @@ export class GamesListComponent implements OnInit {
|
||||
this.editTagInput = '';
|
||||
this.editLinkTitle = '';
|
||||
this.editLinkUrl = '';
|
||||
|
||||
// Scroll to top so the form is visible
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
cancelEdit() {
|
||||
|
||||
@@ -18,7 +18,7 @@ import { ApiService } from '../../services/api.service';
|
||||
<header class="header">
|
||||
<nav class="navbar" aria-label="Main navigation">
|
||||
<div class="nav-brand">
|
||||
<img src="/assets/icons/icon-72x72.png" alt="" class="brand-icon" role="presentation" />
|
||||
<img src="/assets/nav-icon.jpg" alt="" class="brand-icon" role="presentation" />
|
||||
<h1><a routerLink="/">Naomi's Library</a></h1>
|
||||
@if (version()) {
|
||||
<span class="version" aria-label="Version {{ version() }}">v{{ version() }}</span>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Game, GameStatus, Book, BookStatus, Music, MusicType, Manga, MangaStatu
|
||||
<div class="container">
|
||||
<div class="hero">
|
||||
<h1>Welcome to Naomi's Library</h1>
|
||||
<img src="/assets/nav-icon.jpg" alt="Naomi's avatar" class="hero-avatar" />
|
||||
<p class="tagline">A personal collection of games, books, music, manga, shows, and art</p>
|
||||
</div>
|
||||
|
||||
@@ -190,10 +191,28 @@ import { Game, GameStatus, Book, BookStatus, Music, MusicType, Manga, MangaStatu
|
||||
|
||||
.hero h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--witch-purple);
|
||||
}
|
||||
|
||||
.hero-avatar {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 4px solid var(--witch-lavender);
|
||||
box-shadow: 0 4px 12px var(--witch-shadow);
|
||||
margin: 1rem auto;
|
||||
display: block;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.hero-avatar:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 8px 16px rgba(157, 78, 221, 0.5);
|
||||
border-color: var(--witch-rose);
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-size: 1.2rem;
|
||||
color: var(--witch-plum);
|
||||
|
||||
@@ -14,18 +14,28 @@ import { AuthService } from '../../services/auth.service';
|
||||
import { SanitizeService } from '../../services/sanitize.service';
|
||||
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
|
||||
import { LikeButtonComponent } from '../shared/like-button.component';
|
||||
import { Manga, Comment, MangaStatus } from '@library/shared-types';
|
||||
import { MangaFormComponent } from '../shared/manga-form.component';
|
||||
import { Manga, Comment, MangaStatus, UpdateMangaDto } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-manga-detail',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent],
|
||||
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent, MangaFormComponent],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="breadcrumb">
|
||||
<a routerLink="/manga" class="breadcrumb-link">← Back to Manga</a>
|
||||
</div>
|
||||
|
||||
@if (showEditForm() && authService.user()?.isAdmin && manga()) {
|
||||
<app-manga-form
|
||||
mode="edit"
|
||||
[manga]="manga()!"
|
||||
(formSubmit)="saveEdit($event)"
|
||||
(formCancel)="cancelEdit()"
|
||||
></app-manga-form>
|
||||
}
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading">Loading manga details...</div>
|
||||
} @else if (error()) {
|
||||
@@ -36,11 +46,9 @@ import { Manga, Comment, MangaStatus } from '@library/shared-types';
|
||||
</div>
|
||||
} @else if (manga()) {
|
||||
<div class="manga-detail-card">
|
||||
@if (manga()!.coverImage) {
|
||||
<div class="manga-cover-section">
|
||||
<img [src]="manga()!.coverImage" [alt]="manga()!.title" class="manga-cover-large">
|
||||
</div>
|
||||
}
|
||||
<div class="manga-cover-section">
|
||||
<img [src]="manga()!.coverImage || '/assets/default-cover.jpg'" [alt]="manga()!.title" class="manga-cover-large">
|
||||
</div>
|
||||
|
||||
<div class="manga-content">
|
||||
<div class="manga-header">
|
||||
@@ -51,6 +59,12 @@ import { Manga, Comment, MangaStatus } from '@library/shared-types';
|
||||
</div>
|
||||
|
||||
<p class="author">by {{ manga()!.author }}</p>
|
||||
@if (authService.user()?.isAdmin) {
|
||||
<div class="admin-actions">
|
||||
<button (click)="toggleEditForm()" class="btn btn-edit">✏️ {{ showEditForm() ? 'Cancel Edit' : 'Edit' }}</button>
|
||||
<button (click)="deleteManga()" class="btn btn-delete">🗑️ Delete</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (manga()!.rating) {
|
||||
<div class="info-row">
|
||||
@@ -296,6 +310,35 @@ import { Manga, Comment, MangaStatus } from '@library/shared-types';
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
|
||||
.admin-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: #fef3c7;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #fbbf24;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-edit:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -495,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<string | null>(null);
|
||||
newCommentContent = '';
|
||||
showEditForm = signal(false);
|
||||
|
||||
ngOnInit() {
|
||||
const mangaId = this.route.snapshot.paramMap.get('id');
|
||||
@@ -630,4 +703,45 @@ export class MangaDetailComponent implements OnInit {
|
||||
return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toggleEditForm() {
|
||||
this.showEditForm.update(value => !value);
|
||||
}
|
||||
|
||||
saveEdit(data: UpdateMangaDto) {
|
||||
const manga = this.manga();
|
||||
if (!manga) return;
|
||||
|
||||
this.mangaService.updateManga(manga.id, data).subscribe({
|
||||
next: (updatedManga) => {
|
||||
this.manga.set(updatedManga);
|
||||
this.showEditForm.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
alert('Failed to update manga: ' + (err.error?.message || 'Unknown error'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cancelEdit() {
|
||||
this.showEditForm.set(false);
|
||||
}
|
||||
|
||||
deleteManga() {
|
||||
const manga = this.manga();
|
||||
if (!manga) return;
|
||||
|
||||
if (!confirm(`Are you sure you want to delete "${manga.title}"? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.mangaService.deleteManga(manga.id).subscribe({
|
||||
next: () => {
|
||||
this.router.navigate(['/manga']);
|
||||
},
|
||||
error: (err) => {
|
||||
alert('Failed to delete manga: ' + (err.error?.message || 'Unknown error'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,10 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
||||
imports: [CommonModule, FormsModule, RouterLink, PaginationComponent, LikeButtonComponent],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="page-hero">
|
||||
<img src="/assets/avatars/manga-avatar.jpg" alt="Manga avatar" class="page-avatar" />
|
||||
</div>
|
||||
|
||||
<div class="header-section">
|
||||
<h2>My Manga Collection</h2>
|
||||
@if (authService.isAdmin()) {
|
||||
@@ -562,9 +566,7 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
||||
@for (manga of paginatedManga(); track manga.id) {
|
||||
<div class="manga-card" [class.completed]="manga.status === MangaStatus.completed">
|
||||
<a [routerLink]="['/manga', manga.id]" class="card-link">
|
||||
@if (manga.coverImage) {
|
||||
<img [src]="manga.coverImage" [alt]="manga.title" class="manga-cover">
|
||||
}
|
||||
<img [src]="manga.coverImage || '/assets/default-cover.jpg'" [alt]="manga.title" class="manga-cover">
|
||||
|
||||
<div class="manga-info">
|
||||
<h3>{{ manga.title }}</h3>
|
||||
@@ -621,6 +623,26 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.page-hero {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 3px solid #00b894;
|
||||
box-shadow: 0 4px 12px rgba(0, 184, 148, 0.3);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.page-avatar:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 8px 16px rgba(0, 184, 148, 0.5);
|
||||
}
|
||||
|
||||
.header-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -1347,6 +1369,9 @@ export class MangaListComponent implements OnInit {
|
||||
this.editTagInput = '';
|
||||
this.editLinkTitle = '';
|
||||
this.editLinkUrl = '';
|
||||
|
||||
// Scroll to top so the form is visible
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
cancelEdit() {
|
||||
|
||||
@@ -14,18 +14,28 @@ import { AuthService } from '../../services/auth.service';
|
||||
import { SanitizeService } from '../../services/sanitize.service';
|
||||
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
|
||||
import { LikeButtonComponent } from '../shared/like-button.component';
|
||||
import { Music, Comment, MusicStatus, MusicType } from '@library/shared-types';
|
||||
import { MusicFormComponent } from '../shared/music-form.component';
|
||||
import { Music, Comment, MusicStatus, MusicType, UpdateMusicDto } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-music-detail',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent],
|
||||
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent, MusicFormComponent],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="breadcrumb">
|
||||
<a routerLink="/music" class="breadcrumb-link">← Back to Music</a>
|
||||
</div>
|
||||
|
||||
@if (showEditForm() && authService.user()?.isAdmin && music()) {
|
||||
<app-music-form
|
||||
mode="edit"
|
||||
[music]="music()!"
|
||||
(formSubmit)="saveEdit($event)"
|
||||
(formCancel)="cancelEdit()"
|
||||
></app-music-form>
|
||||
}
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading">Loading music details...</div>
|
||||
} @else if (error()) {
|
||||
@@ -36,11 +46,9 @@ import { Music, Comment, MusicStatus, MusicType } from '@library/shared-types';
|
||||
</div>
|
||||
} @else if (music()) {
|
||||
<div class="music-detail-card">
|
||||
@if (music()!.coverArt) {
|
||||
<div class="music-cover-section">
|
||||
<img [src]="music()!.coverArt" [alt]="music()!.title" class="music-cover-large">
|
||||
</div>
|
||||
}
|
||||
<div class="music-cover-section">
|
||||
<img [src]="music()!.coverArt || '/assets/default-cover.jpg'" [alt]="music()!.title" class="music-cover-large">
|
||||
</div>
|
||||
|
||||
<div class="music-content">
|
||||
<div class="music-header">
|
||||
@@ -54,6 +62,12 @@ import { Music, Comment, MusicStatus, MusicType } from '@library/shared-types';
|
||||
</div>
|
||||
|
||||
<p class="artist">by {{ music()!.artist }}</p>
|
||||
@if (authService.user()?.isAdmin) {
|
||||
<div class="admin-actions">
|
||||
<button (click)="editMusic()" class="btn btn-edit">✏️ Edit</button>
|
||||
<button (click)="deleteMusic()" class="btn btn-delete">🗑️ Delete</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (music()!.rating) {
|
||||
<div class="info-row">
|
||||
@@ -322,6 +336,35 @@ import { Music, Comment, MusicStatus, MusicType } from '@library/shared-types';
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
|
||||
.admin-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: #fef3c7;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #fbbf24;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-edit:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -521,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<string | null>(null);
|
||||
newCommentContent = '';
|
||||
showEditForm = signal(false);
|
||||
|
||||
ngOnInit() {
|
||||
const musicId = this.route.snapshot.paramMap.get('id');
|
||||
@@ -664,4 +737,45 @@ export class MusicDetailComponent implements OnInit {
|
||||
return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
editMusic() {
|
||||
this.showEditForm.set(true);
|
||||
}
|
||||
|
||||
cancelEdit() {
|
||||
this.showEditForm.set(false);
|
||||
}
|
||||
|
||||
saveEdit(data: UpdateMusicDto) {
|
||||
const music = this.music();
|
||||
if (!music) return;
|
||||
|
||||
this.musicService.updateMusic(music.id, data).subscribe({
|
||||
next: (updatedMusic) => {
|
||||
this.music.set(updatedMusic);
|
||||
this.showEditForm.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
alert('Failed to update music: ' + (err.error?.message || 'Unknown error'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deleteMusic() {
|
||||
const music = this.music();
|
||||
if (!music) return;
|
||||
|
||||
if (!confirm(`Are you sure you want to delete "${music.title}"? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.musicService.deleteMusic(music.id).subscribe({
|
||||
next: () => {
|
||||
this.router.navigate(['/music']);
|
||||
},
|
||||
error: (err) => {
|
||||
alert('Failed to delete music: ' + (err.error?.message || 'Unknown error'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,10 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
||||
imports: [CommonModule, FormsModule, RouterLink, PaginationComponent, LikeButtonComponent],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="page-hero">
|
||||
<img src="/assets/avatars/music-avatar.jpg" alt="Music avatar" class="page-avatar" />
|
||||
</div>
|
||||
|
||||
<div class="header-section">
|
||||
<h2>My Music Collection</h2>
|
||||
@if (authService.isAdmin()) {
|
||||
@@ -624,17 +628,7 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
||||
@for (music of paginatedMusic(); track music.id) {
|
||||
<div class="music-card" [class.completed]="music.status === MusicStatus.completed">
|
||||
<a [routerLink]="['/music', music.id]" class="card-link">
|
||||
@if (music.coverArt) {
|
||||
<img [src]="music.coverArt" [alt]="music.title" class="music-cover">
|
||||
} @else {
|
||||
<div class="music-cover placeholder">
|
||||
@switch (music.type) {
|
||||
@case (MusicType.album) { 💿 }
|
||||
@case (MusicType.single) { 🎵 }
|
||||
@case (MusicType.ep) { 🎶 }
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<img [src]="music.coverArt || '/assets/default-cover.jpg'" [alt]="music.title" class="music-cover">
|
||||
|
||||
<div class="music-info">
|
||||
<h3>{{ music.title }}</h3>
|
||||
@@ -699,6 +693,26 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.page-hero {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 3px solid #74b9ff;
|
||||
box-shadow: 0 4px 12px rgba(116, 185, 255, 0.3);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.page-avatar:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 8px 16px rgba(116, 185, 255, 0.5);
|
||||
}
|
||||
|
||||
.header-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -1571,6 +1585,9 @@ export class MusicListComponent implements OnInit {
|
||||
this.editTagInput = '';
|
||||
this.editLinkTitle = '';
|
||||
this.editLinkUrl = '';
|
||||
|
||||
// Scroll to top so the form is visible
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
cancelEdit() {
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan, Hikari
|
||||
*/
|
||||
|
||||
import { Component, Input, Output, EventEmitter, OnInit, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Art, CreateArtDto, UpdateArtDto, Link } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-art-form',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<form (ngSubmit)="onSubmit()" class="art-form">
|
||||
<h3>{{ mode === 'add' ? 'Add New Art' : 'Edit Art' }}</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="title">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
[(ngModel)]="formData.title"
|
||||
name="title"
|
||||
required
|
||||
placeholder="Enter artwork title"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="artist">Artist</label>
|
||||
<input
|
||||
type="text"
|
||||
id="artist"
|
||||
[(ngModel)]="formData.artist"
|
||||
name="artist"
|
||||
required
|
||||
placeholder="Enter artist name"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea
|
||||
id="description"
|
||||
[(ngModel)]="formData.description"
|
||||
name="description"
|
||||
rows="3"
|
||||
placeholder="Description of the artwork..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="imageUrl">Image (max 500KB)</label>
|
||||
<input
|
||||
type="file"
|
||||
id="imageUrl"
|
||||
name="imageUrl"
|
||||
accept="image/*"
|
||||
(change)="onImageSelected($event)"
|
||||
>
|
||||
@if (imagePreview()) {
|
||||
<div class="image-preview">
|
||||
<img [src]="imagePreview()" alt="Artwork preview">
|
||||
<button type="button" (click)="clearImage()" class="btn btn-danger btn-sm">Remove</button>
|
||||
</div>
|
||||
}
|
||||
@if (imageError()) {
|
||||
<span class="error-text">{{ imageError() }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="tags-input-container" aria-label="Tags">
|
||||
@for (tag of formData.tags; track tag; let i = $index) {
|
||||
<span class="tag">
|
||||
{{ tag }}
|
||||
<button type="button" (click)="removeTag(i)" class="tag-remove">×</button>
|
||||
</span>
|
||||
}
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="tagInput"
|
||||
name="tagInput"
|
||||
placeholder="Add a tag and press Enter"
|
||||
(keydown.enter)="addTag(); $event.preventDefault()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" aria-label="External Links">
|
||||
<div class="links-list">
|
||||
@for (link of formData.links; track link.url; let i = $index) {
|
||||
<div class="link-item">
|
||||
<span>{{ link.title }}: {{ link.url }}</span>
|
||||
<button type="button" (click)="removeLink(i)" class="btn btn-danger btn-sm">×</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="link-add-form">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="linkTitle"
|
||||
name="linkTitle"
|
||||
placeholder="Link title (e.g., Artist Portfolio)"
|
||||
>
|
||||
<input
|
||||
type="url"
|
||||
[(ngModel)]="linkUrl"
|
||||
name="linkUrl"
|
||||
placeholder="https://..."
|
||||
>
|
||||
<button type="button" (click)="addLink()" class="btn btn-secondary btn-sm">Add Link</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ mode === 'add' ? 'Add Art' : 'Save Changes' }}</button>
|
||||
<button type="button" (click)="onCancel()" class="btn btn-secondary">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
`,
|
||||
styles: [`
|
||||
.art-form {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.art-form h3 {
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: #1f2937;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="url"],
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.625rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #ff6b6b;
|
||||
}
|
||||
|
||||
.tags-input-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.tags-input-container input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 1rem;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: #ff6b6b;
|
||||
color: white;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tag-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.links-list {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.link-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
background: #f3f4f6;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.link-add-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr auto;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
margin-top: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.image-preview img {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #dc2626;
|
||||
font-size: 0.875rem;
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.625rem 1.25rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #ff6b6b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6b7280;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class ArtFormComponent implements OnInit {
|
||||
@Input() mode: 'add' | 'edit' = 'add';
|
||||
@Input() art?: Art;
|
||||
@Input() initialData?: Partial<CreateArtDto>;
|
||||
@Output() formSubmit = new EventEmitter<CreateArtDto | UpdateArtDto>();
|
||||
@Output() formCancel = new EventEmitter<void>();
|
||||
|
||||
formData: Partial<CreateArtDto | UpdateArtDto> = {
|
||||
tags: [],
|
||||
links: []
|
||||
};
|
||||
|
||||
tagInput = '';
|
||||
linkTitle = '';
|
||||
linkUrl = '';
|
||||
imagePreview = signal<string | null>(null);
|
||||
imageError = signal<string | null>(null);
|
||||
|
||||
ngOnInit() {
|
||||
if (this.mode === 'edit' && this.art) {
|
||||
this.formData = {
|
||||
title: this.art.title,
|
||||
artist: this.art.artist,
|
||||
description: this.art.description,
|
||||
imageUrl: this.art.imageUrl,
|
||||
tags: [...(this.art.tags || [])],
|
||||
links: [...(this.art.links || [])]
|
||||
};
|
||||
|
||||
this.imagePreview.set(this.art.imageUrl || null);
|
||||
} else {
|
||||
this.formData = {
|
||||
tags: [],
|
||||
links: [],
|
||||
...this.initialData
|
||||
};
|
||||
|
||||
if (this.initialData?.imageUrl) {
|
||||
this.imagePreview.set(this.initialData.imageUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addTag() {
|
||||
if (this.tagInput.trim() && !this.formData.tags?.includes(this.tagInput.trim())) {
|
||||
this.formData.tags = [...(this.formData.tags || []), this.tagInput.trim()];
|
||||
this.tagInput = '';
|
||||
}
|
||||
}
|
||||
|
||||
removeTag(index: number) {
|
||||
this.formData.tags = this.formData.tags?.filter((_, i) => i !== index) || [];
|
||||
}
|
||||
|
||||
addLink() {
|
||||
if (this.linkTitle.trim() && this.linkUrl.trim()) {
|
||||
const newLink: Link = {
|
||||
title: this.linkTitle.trim(),
|
||||
url: this.linkUrl.trim()
|
||||
};
|
||||
this.formData.links = [...(this.formData.links || []), newLink];
|
||||
this.linkTitle = '';
|
||||
this.linkUrl = '';
|
||||
}
|
||||
}
|
||||
|
||||
removeLink(index: number) {
|
||||
this.formData.links = this.formData.links?.filter((_, i) => i !== index) || [];
|
||||
}
|
||||
|
||||
onImageSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 500000) {
|
||||
this.imageError.set('Image must be under 500KB');
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
this.imageError.set(null);
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
const base64String = reader.result as string;
|
||||
this.formData.imageUrl = base64String;
|
||||
this.imagePreview.set(base64String);
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
clearImage() {
|
||||
this.formData.imageUrl = undefined;
|
||||
this.imagePreview.set(null);
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
if (!this.formData.title || !this.formData.artist || !this.formData.imageUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data: CreateArtDto | UpdateArtDto = {
|
||||
...this.formData as CreateArtDto | UpdateArtDto
|
||||
};
|
||||
|
||||
this.formSubmit.emit(data);
|
||||
}
|
||||
|
||||
onCancel() {
|
||||
this.formCancel.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan, Hikari
|
||||
*/
|
||||
|
||||
import { Component, Input, Output, EventEmitter, OnInit, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Book, BookStatus, CreateBookDto, UpdateBookDto, Link } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-book-form',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<form (ngSubmit)="onSubmit()" class="book-form">
|
||||
<h3>{{ mode === 'add' ? 'Add New Book' : 'Edit Book' }}</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="title">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
[(ngModel)]="formData.title"
|
||||
name="title"
|
||||
required
|
||||
placeholder="Enter book title"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="author">Author</label>
|
||||
<input
|
||||
type="text"
|
||||
id="author"
|
||||
[(ngModel)]="formData.author"
|
||||
name="author"
|
||||
required
|
||||
placeholder="Enter author name"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="isbn">ISBN (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="isbn"
|
||||
[(ngModel)]="formData.isbn"
|
||||
name="isbn"
|
||||
placeholder="Enter ISBN"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="status">Status</label>
|
||||
<select id="status" [(ngModel)]="formData.status" name="status" required>
|
||||
<option [value]="BookStatus.reading">Currently Reading</option>
|
||||
<option [value]="BookStatus.finished">Finished</option>
|
||||
<option [value]="BookStatus.toRead">To Read</option>
|
||||
<option [value]="BookStatus.retired">Retired</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="dateStarted">Date Started</label>
|
||||
<input
|
||||
type="date"
|
||||
id="dateStarted"
|
||||
[(ngModel)]="formData.dateStarted"
|
||||
name="dateStarted"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="dateFinished">Date Finished</label>
|
||||
<input
|
||||
type="date"
|
||||
id="dateFinished"
|
||||
[(ngModel)]="formData.dateFinished"
|
||||
name="dateFinished"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="rating">Rating (1-10)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="rating"
|
||||
[(ngModel)]="formData.rating"
|
||||
name="rating"
|
||||
min="1"
|
||||
max="10"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeHours"
|
||||
[(ngModel)]="timeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeMinutes"
|
||||
[(ngModel)]="timeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
[(ngModel)]="formData.notes"
|
||||
name="notes"
|
||||
rows="3"
|
||||
placeholder="Any thoughts about the book..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="series">Series (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="series"
|
||||
[(ngModel)]="formData.series"
|
||||
name="series"
|
||||
placeholder="e.g., Harry Potter"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="seriesOrder">Series Order (optional)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="seriesOrder"
|
||||
[(ngModel)]="formData.seriesOrder"
|
||||
name="seriesOrder"
|
||||
min="1"
|
||||
placeholder="Order in series"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="coverImage">Cover Image (max 500KB)</label>
|
||||
<input
|
||||
type="file"
|
||||
id="coverImage"
|
||||
name="coverImage"
|
||||
accept="image/*"
|
||||
(change)="onImageSelected($event)"
|
||||
>
|
||||
@if (imagePreview()) {
|
||||
<div class="image-preview">
|
||||
<img [src]="imagePreview()" alt="Cover preview">
|
||||
<button type="button" (click)="clearImage()" class="btn btn-danger btn-sm">Remove</button>
|
||||
</div>
|
||||
}
|
||||
@if (imageError()) {
|
||||
<span class="error-text">{{ imageError() }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="tags-input-container" aria-label="Tags">
|
||||
@for (tag of formData.tags; track tag; let i = $index) {
|
||||
<span class="tag">
|
||||
{{ tag }}
|
||||
<button type="button" (click)="removeTag(i)" class="tag-remove">×</button>
|
||||
</span>
|
||||
}
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="tagInput"
|
||||
name="tagInput"
|
||||
placeholder="Add a tag and press Enter"
|
||||
(keydown.enter)="addTag(); $event.preventDefault()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" aria-label="External Links">
|
||||
<div class="links-list">
|
||||
@for (link of formData.links; track link.url; let i = $index) {
|
||||
<div class="link-item">
|
||||
<span>{{ link.title }}: {{ link.url }}</span>
|
||||
<button type="button" (click)="removeLink(i)" class="btn btn-danger btn-sm">×</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="link-add-form">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="linkTitle"
|
||||
name="linkTitle"
|
||||
placeholder="Link title (e.g., Goodreads)"
|
||||
>
|
||||
<input
|
||||
type="url"
|
||||
[(ngModel)]="linkUrl"
|
||||
name="linkUrl"
|
||||
placeholder="https://..."
|
||||
>
|
||||
<button type="button" (click)="addLink()" class="btn btn-secondary btn-sm">Add Link</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ mode === 'add' ? 'Add Book' : 'Save Changes' }}</button>
|
||||
<button type="button" (click)="onCancel()" class="btn btn-secondary">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
`,
|
||||
styles: [`
|
||||
.book-form {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.book-form h3 {
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: #1f2937;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="number"],
|
||||
.form-group input[type="date"],
|
||||
.form-group input[type="url"],
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.625rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #ff6b6b;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.tags-input-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.tags-input-container input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 1rem;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: #ff6b6b;
|
||||
color: white;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tag-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.links-list {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.link-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
background: #f3f4f6;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.link-add-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr auto;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
margin-top: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.image-preview img {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #dc2626;
|
||||
font-size: 0.875rem;
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.625rem 1.25rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #ff6b6b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6b7280;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class BookFormComponent implements OnInit {
|
||||
@Input() mode: 'add' | 'edit' = 'add';
|
||||
@Input() book?: Book;
|
||||
@Input() initialData?: Partial<CreateBookDto>;
|
||||
@Output() formSubmit = new EventEmitter<CreateBookDto | UpdateBookDto>();
|
||||
@Output() formCancel = new EventEmitter<void>();
|
||||
|
||||
BookStatus = BookStatus;
|
||||
|
||||
formData: Partial<CreateBookDto | UpdateBookDto> = {
|
||||
tags: [],
|
||||
links: []
|
||||
};
|
||||
|
||||
timeHours = 0;
|
||||
timeMinutes = 0;
|
||||
tagInput = '';
|
||||
linkTitle = '';
|
||||
linkUrl = '';
|
||||
imagePreview = signal<string | null>(null);
|
||||
imageError = signal<string | null>(null);
|
||||
|
||||
ngOnInit() {
|
||||
if (this.mode === 'edit' && this.book) {
|
||||
this.formData = {
|
||||
title: this.book.title,
|
||||
author: this.book.author,
|
||||
isbn: this.book.isbn,
|
||||
status: this.book.status,
|
||||
dateStarted: this.book.dateStarted,
|
||||
dateFinished: this.book.dateFinished,
|
||||
rating: this.book.rating,
|
||||
notes: this.book.notes,
|
||||
coverImage: this.book.coverImage,
|
||||
tags: [...(this.book.tags || [])],
|
||||
links: [...(this.book.links || [])],
|
||||
series: this.book.series,
|
||||
seriesOrder: this.book.seriesOrder,
|
||||
timeSpent: this.book.timeSpent
|
||||
};
|
||||
|
||||
if (this.book.timeSpent) {
|
||||
this.timeHours = Math.floor(this.book.timeSpent / 60);
|
||||
this.timeMinutes = this.book.timeSpent % 60;
|
||||
}
|
||||
|
||||
this.imagePreview.set(this.book.coverImage || null);
|
||||
} else {
|
||||
this.formData = {
|
||||
status: BookStatus.toRead,
|
||||
tags: [],
|
||||
links: [],
|
||||
...this.initialData
|
||||
};
|
||||
|
||||
if (this.initialData?.coverImage) {
|
||||
this.imagePreview.set(this.initialData.coverImage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateTimeSpent() {
|
||||
this.formData.timeSpent = (this.timeHours * 60) + this.timeMinutes;
|
||||
}
|
||||
|
||||
addTag() {
|
||||
if (this.tagInput.trim() && !this.formData.tags?.includes(this.tagInput.trim())) {
|
||||
this.formData.tags = [...(this.formData.tags || []), this.tagInput.trim()];
|
||||
this.tagInput = '';
|
||||
}
|
||||
}
|
||||
|
||||
removeTag(index: number) {
|
||||
this.formData.tags = this.formData.tags?.filter((_, i) => i !== index) || [];
|
||||
}
|
||||
|
||||
addLink() {
|
||||
if (this.linkTitle.trim() && this.linkUrl.trim()) {
|
||||
const newLink: Link = {
|
||||
title: this.linkTitle.trim(),
|
||||
url: this.linkUrl.trim()
|
||||
};
|
||||
this.formData.links = [...(this.formData.links || []), newLink];
|
||||
this.linkTitle = '';
|
||||
this.linkUrl = '';
|
||||
}
|
||||
}
|
||||
|
||||
removeLink(index: number) {
|
||||
this.formData.links = this.formData.links?.filter((_, i) => i !== index) || [];
|
||||
}
|
||||
|
||||
onImageSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 500000) {
|
||||
this.imageError.set('Image must be under 500KB');
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
this.imageError.set(null);
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
const base64String = reader.result as string;
|
||||
this.formData.coverImage = base64String;
|
||||
this.imagePreview.set(base64String);
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
clearImage() {
|
||||
this.formData.coverImage = undefined;
|
||||
this.imagePreview.set(null);
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
if (!this.formData.title || !this.formData.author || !this.formData.status) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data: CreateBookDto | UpdateBookDto = {
|
||||
...this.formData as CreateBookDto | UpdateBookDto,
|
||||
dateStarted: this.formData.dateStarted ? new Date(this.formData.dateStarted) : undefined,
|
||||
dateFinished: this.formData.dateFinished ? new Date(this.formData.dateFinished) : undefined
|
||||
};
|
||||
|
||||
this.formSubmit.emit(data);
|
||||
}
|
||||
|
||||
onCancel() {
|
||||
this.formCancel.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan, Hikari
|
||||
*/
|
||||
|
||||
import { Component, Input, Output, EventEmitter, OnInit, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Game, GameStatus, CreateGameDto, UpdateGameDto, Link } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-game-form',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<form (ngSubmit)="onSubmit()" class="game-form">
|
||||
<h3>{{ mode === 'add' ? 'Add New Game' : 'Edit Game' }}</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="title">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
[(ngModel)]="formData.title"
|
||||
name="title"
|
||||
required
|
||||
placeholder="Enter game title"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="platform">Platform</label>
|
||||
<input
|
||||
type="text"
|
||||
id="platform"
|
||||
[(ngModel)]="formData.platform"
|
||||
name="platform"
|
||||
placeholder="PC, PS5, Xbox, Switch, etc."
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="status">Status</label>
|
||||
<select id="status" [(ngModel)]="formData.status" name="status" required>
|
||||
<option [value]="GameStatus.playing">Currently Playing</option>
|
||||
<option [value]="GameStatus.completed">Completed</option>
|
||||
<option [value]="GameStatus.backlog">In Backlog</option>
|
||||
<option [value]="GameStatus.retired">Retired</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="dateStarted">Date Started</label>
|
||||
<input
|
||||
type="date"
|
||||
id="dateStarted"
|
||||
[(ngModel)]="formData.dateStarted"
|
||||
name="dateStarted"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="dateFinished">Date Finished</label>
|
||||
<input
|
||||
type="date"
|
||||
id="dateFinished"
|
||||
[(ngModel)]="formData.dateFinished"
|
||||
name="dateFinished"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="rating">Rating (1-10)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="rating"
|
||||
[(ngModel)]="formData.rating"
|
||||
name="rating"
|
||||
min="1"
|
||||
max="10"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeHours"
|
||||
[(ngModel)]="timeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeMinutes"
|
||||
[(ngModel)]="timeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
[(ngModel)]="formData.notes"
|
||||
name="notes"
|
||||
rows="3"
|
||||
placeholder="Any thoughts about the game..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="series">Series (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="series"
|
||||
[(ngModel)]="formData.series"
|
||||
name="series"
|
||||
placeholder="e.g., The Legend of Zelda"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="seriesOrder">Series Order (optional)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="seriesOrder"
|
||||
[(ngModel)]="formData.seriesOrder"
|
||||
name="seriesOrder"
|
||||
min="1"
|
||||
placeholder="Order in series"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="coverImage">Box Art (max 500KB)</label>
|
||||
<input
|
||||
type="file"
|
||||
id="coverImage"
|
||||
name="coverImage"
|
||||
accept="image/*"
|
||||
(change)="onImageSelected($event)"
|
||||
>
|
||||
@if (imagePreview()) {
|
||||
<div class="image-preview">
|
||||
<img [src]="imagePreview()" alt="Box art preview">
|
||||
<button type="button" (click)="clearImage()" class="btn btn-danger btn-sm">Remove</button>
|
||||
</div>
|
||||
}
|
||||
@if (imageError()) {
|
||||
<span class="error-text">{{ imageError() }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="tags-input-container" aria-label="Tags">
|
||||
@for (tag of formData.tags; track tag; let i = $index) {
|
||||
<span class="tag">
|
||||
{{ tag }}
|
||||
<button type="button" (click)="removeTag(i)" class="tag-remove">×</button>
|
||||
</span>
|
||||
}
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="tagInput"
|
||||
name="tagInput"
|
||||
placeholder="Add a tag and press Enter"
|
||||
(keydown.enter)="addTag(); $event.preventDefault()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" aria-label="External Links">
|
||||
<div class="links-list">
|
||||
@for (link of formData.links; track link.url; let i = $index) {
|
||||
<div class="link-item">
|
||||
<span>{{ link.title }}: {{ link.url }}</span>
|
||||
<button type="button" (click)="removeLink(i)" class="btn btn-danger btn-sm">×</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="link-add-form">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="linkTitle"
|
||||
name="linkTitle"
|
||||
placeholder="Link title (e.g., Steam)"
|
||||
>
|
||||
<input
|
||||
type="url"
|
||||
[(ngModel)]="linkUrl"
|
||||
name="linkUrl"
|
||||
placeholder="https://..."
|
||||
>
|
||||
<button type="button" (click)="addLink()" class="btn btn-secondary btn-sm">Add Link</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ mode === 'add' ? 'Add Game' : 'Save Changes' }}</button>
|
||||
<button type="button" (click)="onCancel()" class="btn btn-secondary">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
`,
|
||||
styles: [`
|
||||
.game-form {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.game-form h3 {
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: #1f2937;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="number"],
|
||||
.form-group input[type="date"],
|
||||
.form-group input[type="url"],
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.625rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #ff6b6b;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.tags-input-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.tags-input-container input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 1rem;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: #ff6b6b;
|
||||
color: white;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tag-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.links-list {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.link-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
background: #f3f4f6;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.link-add-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr auto;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
margin-top: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.image-preview img {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #dc2626;
|
||||
font-size: 0.875rem;
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.625rem 1.25rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #ff6b6b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6b7280;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class GameFormComponent implements OnInit {
|
||||
@Input() mode: 'add' | 'edit' = 'add';
|
||||
@Input() game?: Game;
|
||||
@Input() initialData?: Partial<CreateGameDto>;
|
||||
@Output() formSubmit = new EventEmitter<CreateGameDto | UpdateGameDto>();
|
||||
@Output() formCancel = new EventEmitter<void>();
|
||||
|
||||
GameStatus = GameStatus;
|
||||
|
||||
formData: Partial<CreateGameDto | UpdateGameDto> = {
|
||||
tags: [],
|
||||
links: []
|
||||
};
|
||||
|
||||
timeHours = 0;
|
||||
timeMinutes = 0;
|
||||
tagInput = '';
|
||||
linkTitle = '';
|
||||
linkUrl = '';
|
||||
imagePreview = signal<string | null>(null);
|
||||
imageError = signal<string | null>(null);
|
||||
|
||||
ngOnInit() {
|
||||
if (this.mode === 'edit' && this.game) {
|
||||
this.formData = {
|
||||
title: this.game.title,
|
||||
platform: this.game.platform,
|
||||
status: this.game.status,
|
||||
dateStarted: this.game.dateStarted,
|
||||
dateFinished: this.game.dateFinished,
|
||||
rating: this.game.rating,
|
||||
notes: this.game.notes,
|
||||
coverImage: this.game.coverImage,
|
||||
tags: [...(this.game.tags || [])],
|
||||
links: [...(this.game.links || [])],
|
||||
series: this.game.series,
|
||||
seriesOrder: this.game.seriesOrder,
|
||||
timeSpent: this.game.timeSpent
|
||||
};
|
||||
|
||||
if (this.game.timeSpent) {
|
||||
this.timeHours = Math.floor(this.game.timeSpent / 60);
|
||||
this.timeMinutes = this.game.timeSpent % 60;
|
||||
}
|
||||
|
||||
this.imagePreview.set(this.game.coverImage || null);
|
||||
} else {
|
||||
// Add mode - use initialData if provided, otherwise defaults
|
||||
this.formData = {
|
||||
status: GameStatus.backlog,
|
||||
tags: [],
|
||||
links: [],
|
||||
...this.initialData
|
||||
};
|
||||
|
||||
if (this.initialData?.coverImage) {
|
||||
this.imagePreview.set(this.initialData.coverImage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateTimeSpent() {
|
||||
this.formData.timeSpent = (this.timeHours * 60) + this.timeMinutes;
|
||||
}
|
||||
|
||||
addTag() {
|
||||
if (this.tagInput.trim() && !this.formData.tags?.includes(this.tagInput.trim())) {
|
||||
this.formData.tags = [...(this.formData.tags || []), this.tagInput.trim()];
|
||||
this.tagInput = '';
|
||||
}
|
||||
}
|
||||
|
||||
removeTag(index: number) {
|
||||
this.formData.tags = this.formData.tags?.filter((_, i) => i !== index) || [];
|
||||
}
|
||||
|
||||
addLink() {
|
||||
if (this.linkTitle.trim() && this.linkUrl.trim()) {
|
||||
const newLink: Link = {
|
||||
title: this.linkTitle.trim(),
|
||||
url: this.linkUrl.trim()
|
||||
};
|
||||
this.formData.links = [...(this.formData.links || []), newLink];
|
||||
this.linkTitle = '';
|
||||
this.linkUrl = '';
|
||||
}
|
||||
}
|
||||
|
||||
removeLink(index: number) {
|
||||
this.formData.links = this.formData.links?.filter((_, i) => i !== index) || [];
|
||||
}
|
||||
|
||||
onImageSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 500000) {
|
||||
this.imageError.set('Image must be under 500KB');
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
this.imageError.set(null);
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
const base64String = reader.result as string;
|
||||
this.formData.coverImage = base64String;
|
||||
this.imagePreview.set(base64String);
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
clearImage() {
|
||||
this.formData.coverImage = undefined;
|
||||
this.imagePreview.set(null);
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
if (!this.formData.title || !this.formData.status) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data: CreateGameDto | UpdateGameDto = {
|
||||
...this.formData as CreateGameDto | UpdateGameDto,
|
||||
dateStarted: this.formData.dateStarted ? new Date(this.formData.dateStarted) : undefined,
|
||||
dateFinished: this.formData.dateFinished ? new Date(this.formData.dateFinished) : undefined
|
||||
};
|
||||
|
||||
this.formSubmit.emit(data);
|
||||
}
|
||||
|
||||
onCancel() {
|
||||
this.formCancel.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan, Hikari
|
||||
*/
|
||||
|
||||
import { Component, Input, Output, EventEmitter, OnInit, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Link } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-manga-form',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<form (ngSubmit)="onSubmit()" class="manga-form">
|
||||
<h3>{{ mode === 'add' ? 'Add New Manga' : 'Edit Manga' }}</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="title">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
[(ngModel)]="formData.title"
|
||||
name="title"
|
||||
required
|
||||
placeholder="Enter manga title"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="author">Author</label>
|
||||
<input
|
||||
type="text"
|
||||
id="author"
|
||||
[(ngModel)]="formData.author"
|
||||
name="author"
|
||||
required
|
||||
placeholder="Enter author name"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="status">Status</label>
|
||||
<select id="status" [(ngModel)]="formData.status" name="status" required>
|
||||
<option [value]="MangaStatus.reading">Currently Reading</option>
|
||||
<option [value]="MangaStatus.completed">Completed</option>
|
||||
<option [value]="MangaStatus.wantToRead">Want to Read</option>
|
||||
<option [value]="MangaStatus.retired">Retired</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="dateStarted">Date Started</label>
|
||||
<input
|
||||
type="date"
|
||||
id="dateStarted"
|
||||
[(ngModel)]="formData.dateStarted"
|
||||
name="dateStarted"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="dateFinished">Date Finished</label>
|
||||
<input
|
||||
type="date"
|
||||
id="dateFinished"
|
||||
[(ngModel)]="formData.dateFinished"
|
||||
name="dateFinished"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="rating">Rating (1-10)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="rating"
|
||||
[(ngModel)]="formData.rating"
|
||||
name="rating"
|
||||
min="1"
|
||||
max="10"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeHours"
|
||||
[(ngModel)]="timeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeMinutes"
|
||||
[(ngModel)]="timeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
[(ngModel)]="formData.notes"
|
||||
name="notes"
|
||||
rows="3"
|
||||
placeholder="Any thoughts about the manga..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="coverImage">Cover Image (max 500KB)</label>
|
||||
<input
|
||||
type="file"
|
||||
id="coverImage"
|
||||
name="coverImage"
|
||||
accept="image/*"
|
||||
(change)="onImageSelected($event)"
|
||||
>
|
||||
@if (imagePreview()) {
|
||||
<div class="image-preview">
|
||||
<img [src]="imagePreview()" alt="Cover preview">
|
||||
<button type="button" (click)="clearImage()" class="btn btn-danger btn-sm">Remove</button>
|
||||
</div>
|
||||
}
|
||||
@if (imageError()) {
|
||||
<span class="error-text">{{ imageError() }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="tags-input-container" aria-label="Tags">
|
||||
@for (tag of formData.tags; track tag; let i = $index) {
|
||||
<span class="tag">
|
||||
{{ tag }}
|
||||
<button type="button" (click)="removeTag(i)" class="tag-remove">×</button>
|
||||
</span>
|
||||
}
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="tagInput"
|
||||
name="tagInput"
|
||||
placeholder="Add a tag and press Enter"
|
||||
(keydown.enter)="addTag(); $event.preventDefault()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" aria-label="External Links">
|
||||
<div class="links-list">
|
||||
@for (link of formData.links; track link.url; let i = $index) {
|
||||
<div class="link-item">
|
||||
<span>{{ link.title }}: {{ link.url }}</span>
|
||||
<button type="button" (click)="removeLink(i)" class="btn btn-danger btn-sm">×</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="link-add-form">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="linkTitle"
|
||||
name="linkTitle"
|
||||
placeholder="Link title (e.g., MyAnimeList)"
|
||||
>
|
||||
<input
|
||||
type="url"
|
||||
[(ngModel)]="linkUrl"
|
||||
name="linkUrl"
|
||||
placeholder="https://..."
|
||||
>
|
||||
<button type="button" (click)="addLink()" class="btn btn-secondary btn-sm">Add Link</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ mode === 'add' ? 'Add Manga' : 'Save Changes' }}</button>
|
||||
<button type="button" (click)="onCancel()" class="btn btn-secondary">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
`,
|
||||
styles: [`
|
||||
.manga-form {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.manga-form h3 {
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: #1f2937;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="number"],
|
||||
.form-group input[type="date"],
|
||||
.form-group input[type="url"],
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.625rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #ff6b6b;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.tags-input-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.tags-input-container input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 1rem;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: #ff6b6b;
|
||||
color: white;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tag-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.links-list {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.link-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
background: #f3f4f6;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.link-add-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr auto;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
margin-top: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.image-preview img {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #dc2626;
|
||||
font-size: 0.875rem;
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.625rem 1.25rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #ff6b6b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6b7280;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class MangaFormComponent implements OnInit {
|
||||
@Input() mode: 'add' | 'edit' = 'add';
|
||||
@Input() manga?: Manga;
|
||||
@Input() initialData?: Partial<CreateMangaDto>;
|
||||
@Output() formSubmit = new EventEmitter<CreateMangaDto | UpdateMangaDto>();
|
||||
@Output() formCancel = new EventEmitter<void>();
|
||||
|
||||
MangaStatus = MangaStatus;
|
||||
|
||||
formData: Partial<CreateMangaDto | UpdateMangaDto> = {
|
||||
tags: [],
|
||||
links: []
|
||||
};
|
||||
|
||||
timeHours = 0;
|
||||
timeMinutes = 0;
|
||||
tagInput = '';
|
||||
linkTitle = '';
|
||||
linkUrl = '';
|
||||
imagePreview = signal<string | null>(null);
|
||||
imageError = signal<string | null>(null);
|
||||
|
||||
ngOnInit() {
|
||||
if (this.mode === 'edit' && this.manga) {
|
||||
this.formData = {
|
||||
title: this.manga.title,
|
||||
author: this.manga.author,
|
||||
status: this.manga.status,
|
||||
dateStarted: this.manga.dateStarted,
|
||||
dateFinished: this.manga.dateFinished,
|
||||
rating: this.manga.rating,
|
||||
notes: this.manga.notes,
|
||||
coverImage: this.manga.coverImage,
|
||||
tags: [...(this.manga.tags || [])],
|
||||
links: [...(this.manga.links || [])],
|
||||
timeSpent: this.manga.timeSpent
|
||||
};
|
||||
|
||||
if (this.manga.timeSpent) {
|
||||
this.timeHours = Math.floor(this.manga.timeSpent / 60);
|
||||
this.timeMinutes = this.manga.timeSpent % 60;
|
||||
}
|
||||
|
||||
this.imagePreview.set(this.manga.coverImage || null);
|
||||
} else {
|
||||
this.formData = {
|
||||
status: MangaStatus.wantToRead,
|
||||
tags: [],
|
||||
links: [],
|
||||
...this.initialData
|
||||
};
|
||||
|
||||
if (this.initialData?.coverImage) {
|
||||
this.imagePreview.set(this.initialData.coverImage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateTimeSpent() {
|
||||
this.formData.timeSpent = (this.timeHours * 60) + this.timeMinutes;
|
||||
}
|
||||
|
||||
addTag() {
|
||||
if (this.tagInput.trim() && !this.formData.tags?.includes(this.tagInput.trim())) {
|
||||
this.formData.tags = [...(this.formData.tags || []), this.tagInput.trim()];
|
||||
this.tagInput = '';
|
||||
}
|
||||
}
|
||||
|
||||
removeTag(index: number) {
|
||||
this.formData.tags = this.formData.tags?.filter((_, i) => i !== index) || [];
|
||||
}
|
||||
|
||||
addLink() {
|
||||
if (this.linkTitle.trim() && this.linkUrl.trim()) {
|
||||
const newLink: Link = {
|
||||
title: this.linkTitle.trim(),
|
||||
url: this.linkUrl.trim()
|
||||
};
|
||||
this.formData.links = [...(this.formData.links || []), newLink];
|
||||
this.linkTitle = '';
|
||||
this.linkUrl = '';
|
||||
}
|
||||
}
|
||||
|
||||
removeLink(index: number) {
|
||||
this.formData.links = this.formData.links?.filter((_, i) => i !== index) || [];
|
||||
}
|
||||
|
||||
onImageSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 500000) {
|
||||
this.imageError.set('Image must be under 500KB');
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
this.imageError.set(null);
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
const base64String = reader.result as string;
|
||||
this.formData.coverImage = base64String;
|
||||
this.imagePreview.set(base64String);
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
clearImage() {
|
||||
this.formData.coverImage = undefined;
|
||||
this.imagePreview.set(null);
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
if (!this.formData.title || !this.formData.author || !this.formData.status) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data: CreateMangaDto | UpdateMangaDto = {
|
||||
...this.formData as CreateMangaDto | UpdateMangaDto,
|
||||
dateStarted: this.formData.dateStarted ? new Date(this.formData.dateStarted) : undefined,
|
||||
dateFinished: this.formData.dateFinished ? new Date(this.formData.dateFinished) : undefined
|
||||
};
|
||||
|
||||
this.formSubmit.emit(data);
|
||||
}
|
||||
|
||||
onCancel() {
|
||||
this.formCancel.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan, Hikari
|
||||
*/
|
||||
|
||||
import { Component, Input, Output, EventEmitter, OnInit, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Link } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-music-form',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<form (ngSubmit)="onSubmit()" class="music-form">
|
||||
<h3>{{ mode === 'add' ? 'Add New Music' : 'Edit Music' }}</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="title">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
[(ngModel)]="formData.title"
|
||||
name="title"
|
||||
required
|
||||
placeholder="Enter album/single/EP title"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="artist">Artist</label>
|
||||
<input
|
||||
type="text"
|
||||
id="artist"
|
||||
[(ngModel)]="formData.artist"
|
||||
name="artist"
|
||||
required
|
||||
placeholder="Enter artist name"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="type">Type</label>
|
||||
<select id="type" [(ngModel)]="formData.type" name="type" required>
|
||||
<option [value]="MusicType.album">Album</option>
|
||||
<option [value]="MusicType.single">Single</option>
|
||||
<option [value]="MusicType.ep">EP</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="status">Status</label>
|
||||
<select id="status" [(ngModel)]="formData.status" name="status" required>
|
||||
<option [value]="MusicStatus.listening">Currently Listening</option>
|
||||
<option [value]="MusicStatus.completed">Completed</option>
|
||||
<option [value]="MusicStatus.wantToListen">Want to Listen</option>
|
||||
<option [value]="MusicStatus.retired">Retired</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="dateStarted">Date Started</label>
|
||||
<input
|
||||
type="date"
|
||||
id="dateStarted"
|
||||
[(ngModel)]="formData.dateStarted"
|
||||
name="dateStarted"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="dateFinished">Date Finished</label>
|
||||
<input
|
||||
type="date"
|
||||
id="dateFinished"
|
||||
[(ngModel)]="formData.dateFinished"
|
||||
name="dateFinished"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="rating">Rating (1-10)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="rating"
|
||||
[(ngModel)]="formData.rating"
|
||||
name="rating"
|
||||
min="1"
|
||||
max="10"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeHours"
|
||||
[(ngModel)]="timeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeMinutes"
|
||||
[(ngModel)]="timeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
[(ngModel)]="formData.notes"
|
||||
name="notes"
|
||||
rows="3"
|
||||
placeholder="Any thoughts about the music..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="coverArt">Cover Art (max 500KB)</label>
|
||||
<input
|
||||
type="file"
|
||||
id="coverArt"
|
||||
name="coverArt"
|
||||
accept="image/*"
|
||||
(change)="onImageSelected($event)"
|
||||
>
|
||||
@if (imagePreview()) {
|
||||
<div class="image-preview">
|
||||
<img [src]="imagePreview()" alt="Cover preview">
|
||||
<button type="button" (click)="clearImage()" class="btn btn-danger btn-sm">Remove</button>
|
||||
</div>
|
||||
}
|
||||
@if (imageError()) {
|
||||
<span class="error-text">{{ imageError() }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="tags-input-container" aria-label="Tags">
|
||||
@for (tag of formData.tags; track tag; let i = $index) {
|
||||
<span class="tag">
|
||||
{{ tag }}
|
||||
<button type="button" (click)="removeTag(i)" class="tag-remove">×</button>
|
||||
</span>
|
||||
}
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="tagInput"
|
||||
name="tagInput"
|
||||
placeholder="Add a tag and press Enter"
|
||||
(keydown.enter)="addTag(); $event.preventDefault()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" aria-label="External Links">
|
||||
<div class="links-list">
|
||||
@for (link of formData.links; track link.url; let i = $index) {
|
||||
<div class="link-item">
|
||||
<span>{{ link.title }}: {{ link.url }}</span>
|
||||
<button type="button" (click)="removeLink(i)" class="btn btn-danger btn-sm">×</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="link-add-form">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="linkTitle"
|
||||
name="linkTitle"
|
||||
placeholder="Link title (e.g., Spotify)"
|
||||
>
|
||||
<input
|
||||
type="url"
|
||||
[(ngModel)]="linkUrl"
|
||||
name="linkUrl"
|
||||
placeholder="https://..."
|
||||
>
|
||||
<button type="button" (click)="addLink()" class="btn btn-secondary btn-sm">Add Link</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ mode === 'add' ? 'Add Music' : 'Save Changes' }}</button>
|
||||
<button type="button" (click)="onCancel()" class="btn btn-secondary">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
`,
|
||||
styles: [`
|
||||
.music-form {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.music-form h3 {
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: #1f2937;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="number"],
|
||||
.form-group input[type="date"],
|
||||
.form-group input[type="url"],
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.625rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #ff6b6b;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.tags-input-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.tags-input-container input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 1rem;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: #ff6b6b;
|
||||
color: white;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tag-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.links-list {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.link-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
background: #f3f4f6;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.link-add-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr auto;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
margin-top: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.image-preview img {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #dc2626;
|
||||
font-size: 0.875rem;
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.625rem 1.25rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #ff6b6b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6b7280;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class MusicFormComponent implements OnInit {
|
||||
@Input() mode: 'add' | 'edit' = 'add';
|
||||
@Input() music?: Music;
|
||||
@Input() initialData?: Partial<CreateMusicDto>;
|
||||
@Output() formSubmit = new EventEmitter<CreateMusicDto | UpdateMusicDto>();
|
||||
@Output() formCancel = new EventEmitter<void>();
|
||||
|
||||
MusicStatus = MusicStatus;
|
||||
MusicType = MusicType;
|
||||
|
||||
formData: Partial<CreateMusicDto | UpdateMusicDto> = {
|
||||
tags: [],
|
||||
links: []
|
||||
};
|
||||
|
||||
timeHours = 0;
|
||||
timeMinutes = 0;
|
||||
tagInput = '';
|
||||
linkTitle = '';
|
||||
linkUrl = '';
|
||||
imagePreview = signal<string | null>(null);
|
||||
imageError = signal<string | null>(null);
|
||||
|
||||
ngOnInit() {
|
||||
if (this.mode === 'edit' && this.music) {
|
||||
this.formData = {
|
||||
title: this.music.title,
|
||||
artist: this.music.artist,
|
||||
type: this.music.type,
|
||||
status: this.music.status,
|
||||
dateStarted: this.music.dateStarted,
|
||||
dateFinished: this.music.dateFinished,
|
||||
rating: this.music.rating,
|
||||
notes: this.music.notes,
|
||||
coverArt: this.music.coverArt,
|
||||
tags: [...(this.music.tags || [])],
|
||||
links: [...(this.music.links || [])],
|
||||
timeSpent: this.music.timeSpent
|
||||
};
|
||||
|
||||
if (this.music.timeSpent) {
|
||||
this.timeHours = Math.floor(this.music.timeSpent / 60);
|
||||
this.timeMinutes = this.music.timeSpent % 60;
|
||||
}
|
||||
|
||||
this.imagePreview.set(this.music.coverArt || null);
|
||||
} else {
|
||||
this.formData = {
|
||||
type: MusicType.album,
|
||||
status: MusicStatus.wantToListen,
|
||||
tags: [],
|
||||
links: [],
|
||||
...this.initialData
|
||||
};
|
||||
|
||||
if (this.initialData?.coverArt) {
|
||||
this.imagePreview.set(this.initialData.coverArt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateTimeSpent() {
|
||||
this.formData.timeSpent = (this.timeHours * 60) + this.timeMinutes;
|
||||
}
|
||||
|
||||
addTag() {
|
||||
if (this.tagInput.trim() && !this.formData.tags?.includes(this.tagInput.trim())) {
|
||||
this.formData.tags = [...(this.formData.tags || []), this.tagInput.trim()];
|
||||
this.tagInput = '';
|
||||
}
|
||||
}
|
||||
|
||||
removeTag(index: number) {
|
||||
this.formData.tags = this.formData.tags?.filter((_, i) => i !== index) || [];
|
||||
}
|
||||
|
||||
addLink() {
|
||||
if (this.linkTitle.trim() && this.linkUrl.trim()) {
|
||||
const newLink: Link = {
|
||||
title: this.linkTitle.trim(),
|
||||
url: this.linkUrl.trim()
|
||||
};
|
||||
this.formData.links = [...(this.formData.links || []), newLink];
|
||||
this.linkTitle = '';
|
||||
this.linkUrl = '';
|
||||
}
|
||||
}
|
||||
|
||||
removeLink(index: number) {
|
||||
this.formData.links = this.formData.links?.filter((_, i) => i !== index) || [];
|
||||
}
|
||||
|
||||
onImageSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 500000) {
|
||||
this.imageError.set('Image must be under 500KB');
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
this.imageError.set(null);
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
const base64String = reader.result as string;
|
||||
this.formData.coverArt = base64String;
|
||||
this.imagePreview.set(base64String);
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
clearImage() {
|
||||
this.formData.coverArt = undefined;
|
||||
this.imagePreview.set(null);
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
if (!this.formData.title || !this.formData.artist || !this.formData.type || !this.formData.status) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data: CreateMusicDto | UpdateMusicDto = {
|
||||
...this.formData as CreateMusicDto | UpdateMusicDto,
|
||||
dateStarted: this.formData.dateStarted ? new Date(this.formData.dateStarted) : undefined,
|
||||
dateFinished: this.formData.dateFinished ? new Date(this.formData.dateFinished) : undefined
|
||||
};
|
||||
|
||||
this.formSubmit.emit(data);
|
||||
}
|
||||
|
||||
onCancel() {
|
||||
this.formCancel.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan, Hikari
|
||||
*/
|
||||
|
||||
import { Component, Input, Output, EventEmitter, OnInit, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Link } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-show-form',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<form (ngSubmit)="onSubmit()" class="show-form">
|
||||
<h3>{{ mode === 'add' ? 'Add New Show' : 'Edit Show' }}</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="title">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
[(ngModel)]="formData.title"
|
||||
name="title"
|
||||
required
|
||||
placeholder="Enter show title"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="type">Type</label>
|
||||
<select id="type" [(ngModel)]="formData.type" name="type" required>
|
||||
<option [value]="ShowType.tvSeries">TV Series</option>
|
||||
<option [value]="ShowType.anime">Anime</option>
|
||||
<option [value]="ShowType.film">Film</option>
|
||||
<option [value]="ShowType.documentary">Documentary</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="status">Status</label>
|
||||
<select id="status" [(ngModel)]="formData.status" name="status" required>
|
||||
<option [value]="ShowStatus.watching">Currently Watching</option>
|
||||
<option [value]="ShowStatus.completed">Completed</option>
|
||||
<option [value]="ShowStatus.wantToWatch">Want to Watch</option>
|
||||
<option [value]="ShowStatus.retired">Retired</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="dateStarted">Date Started</label>
|
||||
<input
|
||||
type="date"
|
||||
id="dateStarted"
|
||||
[(ngModel)]="formData.dateStarted"
|
||||
name="dateStarted"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="dateFinished">Date Finished</label>
|
||||
<input
|
||||
type="date"
|
||||
id="dateFinished"
|
||||
[(ngModel)]="formData.dateFinished"
|
||||
name="dateFinished"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="rating">Rating (1-10)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="rating"
|
||||
[(ngModel)]="formData.rating"
|
||||
name="rating"
|
||||
min="1"
|
||||
max="10"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeHours"
|
||||
[(ngModel)]="timeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeMinutes"
|
||||
[(ngModel)]="timeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
[(ngModel)]="formData.notes"
|
||||
name="notes"
|
||||
rows="3"
|
||||
placeholder="Any thoughts about the show..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="coverImage">Cover Image (max 500KB)</label>
|
||||
<input
|
||||
type="file"
|
||||
id="coverImage"
|
||||
name="coverImage"
|
||||
accept="image/*"
|
||||
(change)="onImageSelected($event)"
|
||||
>
|
||||
@if (imagePreview()) {
|
||||
<div class="image-preview">
|
||||
<img [src]="imagePreview()" alt="Cover preview">
|
||||
<button type="button" (click)="clearImage()" class="btn btn-danger btn-sm">Remove</button>
|
||||
</div>
|
||||
}
|
||||
@if (imageError()) {
|
||||
<span class="error-text">{{ imageError() }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="tags-input-container" aria-label="Tags">
|
||||
@for (tag of formData.tags; track tag; let i = $index) {
|
||||
<span class="tag">
|
||||
{{ tag }}
|
||||
<button type="button" (click)="removeTag(i)" class="tag-remove">×</button>
|
||||
</span>
|
||||
}
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="tagInput"
|
||||
name="tagInput"
|
||||
placeholder="Add a tag and press Enter"
|
||||
(keydown.enter)="addTag(); $event.preventDefault()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" aria-label="External Links">
|
||||
<div class="links-list">
|
||||
@for (link of formData.links; track link.url; let i = $index) {
|
||||
<div class="link-item">
|
||||
<span>{{ link.title }}: {{ link.url }}</span>
|
||||
<button type="button" (click)="removeLink(i)" class="btn btn-danger btn-sm">×</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="link-add-form">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="linkTitle"
|
||||
name="linkTitle"
|
||||
placeholder="Link title (e.g., IMDB)"
|
||||
>
|
||||
<input
|
||||
type="url"
|
||||
[(ngModel)]="linkUrl"
|
||||
name="linkUrl"
|
||||
placeholder="https://..."
|
||||
>
|
||||
<button type="button" (click)="addLink()" class="btn btn-secondary btn-sm">Add Link</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ mode === 'add' ? 'Add Show' : 'Save Changes' }}</button>
|
||||
<button type="button" (click)="onCancel()" class="btn btn-secondary">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
`,
|
||||
styles: [`
|
||||
.show-form {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.show-form h3 {
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: #1f2937;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="number"],
|
||||
.form-group input[type="date"],
|
||||
.form-group input[type="url"],
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.625rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #ff6b6b;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.tags-input-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.tags-input-container input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 1rem;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: #ff6b6b;
|
||||
color: white;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tag-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.links-list {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.link-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
background: #f3f4f6;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.link-add-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr auto;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
margin-top: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.image-preview img {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #dc2626;
|
||||
font-size: 0.875rem;
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.625rem 1.25rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #ff6b6b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6b7280;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class ShowFormComponent implements OnInit {
|
||||
@Input() mode: 'add' | 'edit' = 'add';
|
||||
@Input() show?: Show;
|
||||
@Input() initialData?: Partial<CreateShowDto>;
|
||||
@Output() formSubmit = new EventEmitter<CreateShowDto | UpdateShowDto>();
|
||||
@Output() formCancel = new EventEmitter<void>();
|
||||
|
||||
ShowStatus = ShowStatus;
|
||||
ShowType = ShowType;
|
||||
|
||||
formData: Partial<CreateShowDto | UpdateShowDto> = {
|
||||
tags: [],
|
||||
links: []
|
||||
};
|
||||
|
||||
timeHours = 0;
|
||||
timeMinutes = 0;
|
||||
tagInput = '';
|
||||
linkTitle = '';
|
||||
linkUrl = '';
|
||||
imagePreview = signal<string | null>(null);
|
||||
imageError = signal<string | null>(null);
|
||||
|
||||
ngOnInit() {
|
||||
if (this.mode === 'edit' && this.show) {
|
||||
this.formData = {
|
||||
title: this.show.title,
|
||||
type: this.show.type,
|
||||
status: this.show.status,
|
||||
dateStarted: this.show.dateStarted,
|
||||
dateFinished: this.show.dateFinished,
|
||||
rating: this.show.rating,
|
||||
notes: this.show.notes,
|
||||
coverImage: this.show.coverImage,
|
||||
tags: [...(this.show.tags || [])],
|
||||
links: [...(this.show.links || [])],
|
||||
timeSpent: this.show.timeSpent
|
||||
};
|
||||
|
||||
if (this.show.timeSpent) {
|
||||
this.timeHours = Math.floor(this.show.timeSpent / 60);
|
||||
this.timeMinutes = this.show.timeSpent % 60;
|
||||
}
|
||||
|
||||
this.imagePreview.set(this.show.coverImage || null);
|
||||
} else {
|
||||
this.formData = {
|
||||
type: ShowType.tvSeries,
|
||||
status: ShowStatus.wantToWatch,
|
||||
tags: [],
|
||||
links: [],
|
||||
...this.initialData
|
||||
};
|
||||
|
||||
if (this.initialData?.coverImage) {
|
||||
this.imagePreview.set(this.initialData.coverImage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateTimeSpent() {
|
||||
this.formData.timeSpent = (this.timeHours * 60) + this.timeMinutes;
|
||||
}
|
||||
|
||||
addTag() {
|
||||
if (this.tagInput.trim() && !this.formData.tags?.includes(this.tagInput.trim())) {
|
||||
this.formData.tags = [...(this.formData.tags || []), this.tagInput.trim()];
|
||||
this.tagInput = '';
|
||||
}
|
||||
}
|
||||
|
||||
removeTag(index: number) {
|
||||
this.formData.tags = this.formData.tags?.filter((_, i) => i !== index) || [];
|
||||
}
|
||||
|
||||
addLink() {
|
||||
if (this.linkTitle.trim() && this.linkUrl.trim()) {
|
||||
const newLink: Link = {
|
||||
title: this.linkTitle.trim(),
|
||||
url: this.linkUrl.trim()
|
||||
};
|
||||
this.formData.links = [...(this.formData.links || []), newLink];
|
||||
this.linkTitle = '';
|
||||
this.linkUrl = '';
|
||||
}
|
||||
}
|
||||
|
||||
removeLink(index: number) {
|
||||
this.formData.links = this.formData.links?.filter((_, i) => i !== index) || [];
|
||||
}
|
||||
|
||||
onImageSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 500000) {
|
||||
this.imageError.set('Image must be under 500KB');
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
this.imageError.set(null);
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
const base64String = reader.result as string;
|
||||
this.formData.coverImage = base64String;
|
||||
this.imagePreview.set(base64String);
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
clearImage() {
|
||||
this.formData.coverImage = undefined;
|
||||
this.imagePreview.set(null);
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
if (!this.formData.title || !this.formData.type || !this.formData.status) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data: CreateShowDto | UpdateShowDto = {
|
||||
...this.formData as CreateShowDto | UpdateShowDto,
|
||||
dateStarted: this.formData.dateStarted ? new Date(this.formData.dateStarted) : undefined,
|
||||
dateFinished: this.formData.dateFinished ? new Date(this.formData.dateFinished) : undefined
|
||||
};
|
||||
|
||||
this.formSubmit.emit(data);
|
||||
}
|
||||
|
||||
onCancel() {
|
||||
this.formCancel.emit();
|
||||
}
|
||||
}
|
||||
@@ -14,18 +14,28 @@ import { AuthService } from '../../services/auth.service';
|
||||
import { SanitizeService } from '../../services/sanitize.service';
|
||||
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
|
||||
import { LikeButtonComponent } from '../shared/like-button.component';
|
||||
import { Show, Comment, ShowStatus, ShowType } from '@library/shared-types';
|
||||
import { ShowFormComponent } from '../shared/show-form.component';
|
||||
import { Show, Comment, ShowStatus, ShowType, UpdateShowDto } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-show-detail',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent],
|
||||
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent, ShowFormComponent],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="breadcrumb">
|
||||
<a routerLink="/shows" class="breadcrumb-link">← Back to Shows</a>
|
||||
</div>
|
||||
|
||||
@if (showEditForm() && authService.user()?.isAdmin && show()) {
|
||||
<app-show-form
|
||||
mode="edit"
|
||||
[show]="show()!"
|
||||
(formSubmit)="saveEdit($event)"
|
||||
(formCancel)="cancelEdit()"
|
||||
></app-show-form>
|
||||
}
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading">Loading show details...</div>
|
||||
} @else if (error()) {
|
||||
@@ -36,11 +46,9 @@ import { Show, Comment, ShowStatus, ShowType } from '@library/shared-types';
|
||||
</div>
|
||||
} @else if (show()) {
|
||||
<div class="show-detail-card">
|
||||
@if (show()!.coverImage) {
|
||||
<div class="show-poster-section">
|
||||
<img [src]="show()!.coverImage" [alt]="show()!.title" class="show-poster-large">
|
||||
</div>
|
||||
}
|
||||
<div class="show-poster-section">
|
||||
<img [src]="show()!.coverImage || '/assets/default-cover.jpg'" [alt]="show()!.title" class="show-poster-large">
|
||||
</div>
|
||||
|
||||
<div class="show-content">
|
||||
<div class="show-header">
|
||||
@@ -52,6 +60,12 @@ import { Show, Comment, ShowStatus, ShowType } from '@library/shared-types';
|
||||
{{ getStatusLabel(show()!.status) }}
|
||||
</span>
|
||||
</div>
|
||||
@if (authService.user()?.isAdmin) {
|
||||
<div class="admin-actions">
|
||||
<button (click)="toggleEditForm()" class="btn btn-edit">✏️ {{ showEditForm() ? 'Cancel Edit' : 'Edit' }}</button>
|
||||
<button (click)="deleteShow()" class="btn btn-delete">🗑️ Delete</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (show()!.rating) {
|
||||
<div class="info-row">
|
||||
@@ -318,6 +332,35 @@ import { Show, Comment, ShowStatus, ShowType } from '@library/shared-types';
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
|
||||
.admin-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: #fef3c7;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #fbbf24;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-edit:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -517,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<string | null>(null);
|
||||
newCommentContent = '';
|
||||
showEditForm = signal(false);
|
||||
|
||||
ngOnInit() {
|
||||
const showId = this.route.snapshot.paramMap.get('id');
|
||||
@@ -661,4 +734,45 @@ export class ShowDetailComponent implements OnInit {
|
||||
return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toggleEditForm() {
|
||||
this.showEditForm.update(value => !value);
|
||||
}
|
||||
|
||||
saveEdit(data: UpdateShowDto) {
|
||||
const show = this.show();
|
||||
if (!show) return;
|
||||
|
||||
this.showsService.updateShow(show.id, data).subscribe({
|
||||
next: (updatedShow) => {
|
||||
this.show.set(updatedShow);
|
||||
this.showEditForm.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
alert('Failed to update show: ' + (err.error?.message || 'Unknown error'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cancelEdit() {
|
||||
this.showEditForm.set(false);
|
||||
}
|
||||
|
||||
deleteShow() {
|
||||
const show = this.show();
|
||||
if (!show) return;
|
||||
|
||||
if (!confirm(`Are you sure you want to delete "${show.title}"? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.showsService.deleteShow(show.id).subscribe({
|
||||
next: () => {
|
||||
this.router.navigate(['/shows']);
|
||||
},
|
||||
error: (err) => {
|
||||
alert('Failed to delete show: ' + (err.error?.message || 'Unknown error'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,10 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
||||
imports: [CommonModule, RouterLink, FormsModule, PaginationComponent, LikeButtonComponent],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="page-hero">
|
||||
<img src="/assets/avatars/shows-avatar.jpg" alt="Shows avatar" class="page-avatar" />
|
||||
</div>
|
||||
|
||||
<div class="header-section">
|
||||
<h2>My Shows & Films</h2>
|
||||
@if (authService.isAdmin()) {
|
||||
@@ -556,9 +560,7 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
||||
@for (show of paginatedShows(); track show.id) {
|
||||
<div class="show-card" [class.completed]="show.status === ShowStatus.completed">
|
||||
<a [routerLink]="['/shows', show.id]" class="card-link">
|
||||
@if (show.coverImage) {
|
||||
<img [src]="show.coverImage" [alt]="show.title" class="show-cover">
|
||||
}
|
||||
<img [src]="show.coverImage || '/assets/default-cover.jpg'" [alt]="show.title" class="show-cover">
|
||||
|
||||
<div class="show-info">
|
||||
<h3>{{ show.title }}</h3>
|
||||
@@ -617,6 +619,26 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.page-hero {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 3px solid #e84393;
|
||||
box-shadow: 0 4px 12px rgba(232, 67, 147, 0.3);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.page-avatar:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 8px 16px rgba(232, 67, 147, 0.5);
|
||||
}
|
||||
|
||||
.header-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -1368,6 +1390,9 @@ export class ShowsListComponent implements OnInit {
|
||||
this.editTagInput = '';
|
||||
this.editLinkTitle = '';
|
||||
this.editLinkUrl = '';
|
||||
|
||||
// Scroll to top so the form is visible
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
cancelEdit() {
|
||||
|
||||
@@ -12,6 +12,11 @@ export class GlobalErrorHandler implements ErrorHandler {
|
||||
private toast = inject(ToastService);
|
||||
|
||||
handleError(error: Error): void {
|
||||
if (error.name === 'ChunkLoadError' || error.message.includes('Loading chunk')) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Global error caught:', error);
|
||||
|
||||
// Show user-friendly error message
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<meta name="description" content="Naomi's curated collection of games, books, music, shows, manga, and art. Browse, engage, and suggest new additions!" />
|
||||
<meta name="theme-color" content="#9d4edd" />
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Creepster&family=Griffy&family=Henny+Penny&family=Kalam:wght@300;400;700&display=swap" rel="stylesheet" />
|
||||
<script defer src="https://analytics.nhcarrigan.com/js/pa-YUXAn1vhhRttySUAw_LMN.js"></script>
|
||||
<script>
|
||||
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
font-family: 'Kalam', cursive;
|
||||
font-size: 14pt;
|
||||
line-height: 1.6;
|
||||
color: var(--foreground);
|
||||
@@ -75,10 +75,30 @@ body::after {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes wiggle {
|
||||
0%, 100% { transform: rotate(-2deg); }
|
||||
50% { transform: rotate(2deg); }
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin-top: 0;
|
||||
line-height: 1.2;
|
||||
color: var(--witch-purple);
|
||||
font-family: 'Griffy', cursive;
|
||||
}
|
||||
|
||||
h1 {
|
||||
animation: wiggle 4s ease-in-out infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.witchy-accent,
|
||||
.spooky-title {
|
||||
font-family: 'Creepster', cursive;
|
||||
}
|
||||
|
||||
.mystical-text {
|
||||
font-family: 'Henny Penny', cursive;
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -121,6 +141,7 @@ select:focus {
|
||||
|
||||
// Button base styles
|
||||
button {
|
||||
font-family: inherit;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ DISCORD_GUILD_ID="op://Environment Variables - Naomi/Library/discord server id"
|
||||
SPONSOR_ROLE_ID="op://Environment Variables - Naomi/Library/sponsor role id"
|
||||
MOD_ROLE_ID="op://Environment Variables - Naomi/Library/mod role id"
|
||||
STAFF_ROLE_ID="op://Environment Variables - Naomi/Library/staff role id"
|
||||
DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Library/discord bot token"
|
||||
LIBRARY_ROLE_ID="op://Environment Variables - Naomi/Library/library role id"
|
||||
|
||||
# Application URL
|
||||
BASE_URL="op://Environment Variables - Naomi/Library/localhost url"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@library/source",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.1",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "nx run-many --target=build --all && NODE_ENV=production op run --env-file=dev.env -- node dist/api/main.js",
|
||||
@@ -31,7 +31,7 @@
|
||||
"@fastify/csrf-protection": "7.1.0",
|
||||
"@fastify/helmet": "13.0.2",
|
||||
"@fastify/jwt": "10.0.0",
|
||||
"@fastify/oauth2": "8.1.2",
|
||||
"@fastify/oauth2": "8.2.0",
|
||||
"@fastify/rate-limit": "10.3.0",
|
||||
"@fastify/sensible": "6.0.4",
|
||||
"@fastify/static": "9.0.0",
|
||||
@@ -44,8 +44,8 @@
|
||||
"dompurify": "3.3.1",
|
||||
"fastify": "5.7.3",
|
||||
"fastify-plugin": "5.0.1",
|
||||
"jsdom": "28.0.0",
|
||||
"marked": "17.0.1",
|
||||
"jsdom": "28.1.0",
|
||||
"marked": "17.0.3",
|
||||
"rxjs": "7.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -73,10 +73,10 @@
|
||||
"@swc/helpers": "0.5.18",
|
||||
"@types/dompurify": "3.2.0",
|
||||
"@types/jest": "30.0.0",
|
||||
"@types/jsdom": "27.0.0",
|
||||
"@types/jsdom": "28.0.0",
|
||||
"@types/jsonwebtoken": "9.0.10",
|
||||
"@types/node": "20.19.9",
|
||||
"@typescript-eslint/utils": "8.54.0",
|
||||
"@typescript-eslint/utils": "8.56.0",
|
||||
"angular-eslint": "21.1.0",
|
||||
"cypress": "15.9.0",
|
||||
"esbuild": "0.19.12",
|
||||
@@ -91,6 +91,6 @@
|
||||
"ts-node": "10.9.1",
|
||||
"tslib": "2.8.1",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.54.0"
|
||||
"typescript-eslint": "8.56.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,8 +45,8 @@ importers:
|
||||
specifier: 10.0.0
|
||||
version: 10.0.0
|
||||
'@fastify/oauth2':
|
||||
specifier: 8.1.2
|
||||
version: 8.1.2
|
||||
specifier: 8.2.0
|
||||
version: 8.2.0
|
||||
'@fastify/rate-limit':
|
||||
specifier: 10.3.0
|
||||
version: 10.3.0
|
||||
@@ -84,18 +84,18 @@ importers:
|
||||
specifier: 5.0.1
|
||||
version: 5.0.1
|
||||
jsdom:
|
||||
specifier: 28.0.0
|
||||
version: 28.0.0
|
||||
specifier: 28.1.0
|
||||
version: 28.1.0
|
||||
marked:
|
||||
specifier: 17.0.1
|
||||
version: 17.0.1
|
||||
specifier: 17.0.3
|
||||
version: 17.0.3
|
||||
rxjs:
|
||||
specifier: 7.8.2
|
||||
version: 7.8.2
|
||||
devDependencies:
|
||||
'@angular-devkit/build-angular':
|
||||
specifier: 21.1.2
|
||||
version: 21.1.2(@angular/compiler-cli@21.1.2(@angular/compiler@21.1.2)(typescript@5.9.3))(@angular/compiler@21.1.2)(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(@angular/platform-browser@21.1.2(@angular/common@21.1.2(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2)))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(@swc/core@1.5.29(@swc/helpers@0.5.18))(@types/node@20.19.9)(chokidar@5.0.0)(jest@30.2.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.5.29(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3)))(jiti@2.6.1)(sass-embedded@1.97.3)(typescript@5.9.3)(vitest@4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.0.0)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2))(yaml@2.8.2)
|
||||
version: 21.1.2(@angular/compiler-cli@21.1.2(@angular/compiler@21.1.2)(typescript@5.9.3))(@angular/compiler@21.1.2)(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(@angular/platform-browser@21.1.2(@angular/common@21.1.2(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2)))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(@swc/core@1.5.29(@swc/helpers@0.5.18))(@types/node@20.19.9)(chokidar@5.0.0)(jest@30.2.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.5.29(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3)))(jiti@2.6.1)(sass-embedded@1.97.3)(typescript@5.9.3)(vitest@4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.1.0)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2))(yaml@2.8.2)
|
||||
'@angular-devkit/core':
|
||||
specifier: 21.1.2
|
||||
version: 21.1.2(chokidar@5.0.0)
|
||||
@@ -116,10 +116,10 @@ importers:
|
||||
version: 9.39.2
|
||||
'@nhcarrigan/eslint-config':
|
||||
specifier: 5.2.0
|
||||
version: 5.2.0(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(playwright@1.58.1)(react@19.2.4)(typescript@5.9.3)(vitest@4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.0.0)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2))
|
||||
version: 5.2.0(@typescript-eslint/utils@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(playwright@1.58.1)(react@19.2.4)(typescript@5.9.3)(vitest@4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.1.0)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2))
|
||||
'@nx/angular':
|
||||
specifier: 22.4.4
|
||||
version: 22.4.4(53644491fdd75e214444a711ff7c7d5c)
|
||||
version: 22.4.4(5a8ec786c6b8ea22e93f5ae8cf5ae8ed)
|
||||
'@nx/cypress':
|
||||
specifier: 22.4.4
|
||||
version: 22.4.4(@babel/traverse@7.29.0)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.18))(@zkochan/js-yaml@0.0.7)(cypress@15.9.0)(eslint@9.39.2(jiti@2.6.1))(nx@22.4.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.18)))(typescript@5.9.3)
|
||||
@@ -131,7 +131,7 @@ importers:
|
||||
version: 22.4.4(@babel/traverse@7.29.0)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.18))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@2.6.1))(nx@22.4.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.18)))
|
||||
'@nx/eslint-plugin':
|
||||
specifier: 22.4.4
|
||||
version: 22.4.4(@babel/traverse@7.29.0)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.18))(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(nx@22.4.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.18)))(typescript@5.9.3)
|
||||
version: 22.4.4(@babel/traverse@7.29.0)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.18))(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(nx@22.4.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.18)))(typescript@5.9.3)
|
||||
'@nx/jest':
|
||||
specifier: 22.4.4
|
||||
version: 22.4.4(@babel/traverse@7.29.0)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.18))(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(nx@22.4.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.18)))(ts-node@10.9.1(@swc/core@1.5.29(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3))(typescript@5.9.3)
|
||||
@@ -166,8 +166,8 @@ importers:
|
||||
specifier: 30.0.0
|
||||
version: 30.0.0
|
||||
'@types/jsdom':
|
||||
specifier: 27.0.0
|
||||
version: 27.0.0
|
||||
specifier: 28.0.0
|
||||
version: 28.0.0
|
||||
'@types/jsonwebtoken':
|
||||
specifier: 9.0.10
|
||||
version: 9.0.10
|
||||
@@ -175,11 +175,11 @@ importers:
|
||||
specifier: 20.19.9
|
||||
version: 20.19.9
|
||||
'@typescript-eslint/utils':
|
||||
specifier: 8.54.0
|
||||
version: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
specifier: 8.56.0
|
||||
version: 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
angular-eslint:
|
||||
specifier: 21.1.0
|
||||
version: 21.1.0(@angular/cli@21.1.2(@types/node@20.19.9)(chokidar@5.0.0)(hono@4.11.7))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript-eslint@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(typescript@5.9.3)
|
||||
version: 21.1.0(@angular/cli@21.1.2(@types/node@20.19.9)(chokidar@5.0.0)(hono@4.11.7))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript-eslint@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(typescript@5.9.3)
|
||||
cypress:
|
||||
specifier: 15.9.0
|
||||
version: 15.9.0
|
||||
@@ -220,8 +220,8 @@ importers:
|
||||
specifier: 5.9.3
|
||||
version: 5.9.3
|
||||
typescript-eslint:
|
||||
specifier: 8.54.0
|
||||
version: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
specifier: 8.56.0
|
||||
version: 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -526,11 +526,12 @@ packages:
|
||||
'@angular/platform-browser': 21.1.2
|
||||
rxjs: ^6.5.3 || ^7.4.0
|
||||
|
||||
'@asamuzakjp/css-color@4.1.1':
|
||||
resolution: {integrity: sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==}
|
||||
'@asamuzakjp/css-color@5.0.1':
|
||||
resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
'@asamuzakjp/dom-selector@6.7.7':
|
||||
resolution: {integrity: sha512-8CO/UQ4tzDd7ula+/CVimJIVWez99UJlbMyIgk8xOnhAVPKLnBZmUFYVgugS441v2ZqUq5EnSh6B0Ua0liSFAA==}
|
||||
'@asamuzakjp/dom-selector@6.8.1':
|
||||
resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==}
|
||||
|
||||
'@asamuzakjp/nwsapi@2.3.9':
|
||||
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
|
||||
@@ -1188,6 +1189,10 @@ packages:
|
||||
'@bcoe/v8-coverage@0.2.3':
|
||||
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
|
||||
|
||||
'@bramus/specificity@2.4.2':
|
||||
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
|
||||
hasBin: true
|
||||
|
||||
'@bufbuild/protobuf@2.11.0':
|
||||
resolution: {integrity: sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==}
|
||||
|
||||
@@ -1195,36 +1200,36 @@ packages:
|
||||
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@csstools/color-helpers@5.1.0':
|
||||
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
|
||||
engines: {node: '>=18'}
|
||||
'@csstools/color-helpers@6.0.2':
|
||||
resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
'@csstools/css-calc@2.1.4':
|
||||
resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==}
|
||||
engines: {node: '>=18'}
|
||||
'@csstools/css-calc@3.1.1':
|
||||
resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
peerDependencies:
|
||||
'@csstools/css-parser-algorithms': ^3.0.5
|
||||
'@csstools/css-tokenizer': ^3.0.4
|
||||
'@csstools/css-parser-algorithms': ^4.0.0
|
||||
'@csstools/css-tokenizer': ^4.0.0
|
||||
|
||||
'@csstools/css-color-parser@3.1.0':
|
||||
resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==}
|
||||
engines: {node: '>=18'}
|
||||
'@csstools/css-color-parser@4.0.2':
|
||||
resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
peerDependencies:
|
||||
'@csstools/css-parser-algorithms': ^3.0.5
|
||||
'@csstools/css-tokenizer': ^3.0.4
|
||||
'@csstools/css-parser-algorithms': ^4.0.0
|
||||
'@csstools/css-tokenizer': ^4.0.0
|
||||
|
||||
'@csstools/css-parser-algorithms@3.0.5':
|
||||
resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==}
|
||||
engines: {node: '>=18'}
|
||||
'@csstools/css-parser-algorithms@4.0.0':
|
||||
resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
peerDependencies:
|
||||
'@csstools/css-tokenizer': ^3.0.4
|
||||
'@csstools/css-tokenizer': ^4.0.0
|
||||
|
||||
'@csstools/css-syntax-patches-for-csstree@1.0.26':
|
||||
resolution: {integrity: sha512-6boXK0KkzT5u5xOgF6TKB+CLq9SOpEGmkZw0g5n9/7yg85wab3UzSxB8TxhLJ31L4SGJ6BCFRw/iftTha1CJXA==}
|
||||
'@csstools/css-syntax-patches-for-csstree@1.0.28':
|
||||
resolution: {integrity: sha512-1NRf1CUBjnr3K7hu8BLxjQrKCxEe8FP/xmPTenAxCRZWVLbmGotkFvG9mfNpjA6k7Bw1bw4BilZq9cu19RA5pg==}
|
||||
|
||||
'@csstools/css-tokenizer@3.0.4':
|
||||
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
|
||||
engines: {node: '>=18'}
|
||||
'@csstools/css-tokenizer@4.0.0':
|
||||
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
'@cypress/request@3.0.10':
|
||||
resolution: {integrity: sha512-hauBrOdvu08vOsagkZ/Aju5XuiZx6ldsLfByg1htFeldhex+PeMrYauANzFsMJeAA0+dyPLbDoX2OYuvVoLDkQ==}
|
||||
@@ -1653,8 +1658,8 @@ packages:
|
||||
'@fastify/merge-json-schemas@0.2.1':
|
||||
resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
|
||||
|
||||
'@fastify/oauth2@8.1.2':
|
||||
resolution: {integrity: sha512-XZWFRWTZE2fkZ2pjuHNGtpFn1tOFgcJbU0205kHbfd16dn9xRc/6HmG0gHtN/g/BNkEL3EsQ54+pYEdh8dnBgA==}
|
||||
'@fastify/oauth2@8.2.0':
|
||||
resolution: {integrity: sha512-JQls0pZVt7k9j+qLrSf5OGflpHvylgEfSCVSA2oDaILCX3dwC/Z0UeI1J9M3luik7dtEbEFfpq0v9hF1v/46pQ==}
|
||||
|
||||
'@fastify/proxy-addr@5.1.0':
|
||||
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
|
||||
@@ -3433,8 +3438,8 @@ packages:
|
||||
'@types/jest@30.0.0':
|
||||
resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==}
|
||||
|
||||
'@types/jsdom@27.0.0':
|
||||
resolution: {integrity: sha512-NZyFl/PViwKzdEkQg96gtnB8wm+1ljhdDay9ahn4hgb+SfVtPCbm3TlmDUFXTA+MGN3CijicnMhG18SI5H3rFw==}
|
||||
'@types/jsdom@28.0.0':
|
||||
resolution: {integrity: sha512-A8TBQQC/xAOojy9kM8E46cqT00sF0h7dWjV8t8BJhUi2rG6JRh7XXQo/oLoENuZIQEpXsxLccLCnknyQd7qssQ==}
|
||||
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
@@ -3528,12 +3533,12 @@ packages:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <5.8.0'
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.54.0':
|
||||
resolution: {integrity: sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==}
|
||||
'@typescript-eslint/eslint-plugin@8.56.0':
|
||||
resolution: {integrity: sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
'@typescript-eslint/parser': ^8.54.0
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
'@typescript-eslint/parser': ^8.56.0
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/parser@8.19.0':
|
||||
@@ -3543,11 +3548,11 @@ packages:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <5.8.0'
|
||||
|
||||
'@typescript-eslint/parser@8.54.0':
|
||||
resolution: {integrity: sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==}
|
||||
'@typescript-eslint/parser@8.56.0':
|
||||
resolution: {integrity: sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/project-service@8.54.0':
|
||||
@@ -3556,6 +3561,12 @@ packages:
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/project-service@8.56.0':
|
||||
resolution: {integrity: sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/scope-manager@7.18.0':
|
||||
resolution: {integrity: sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==}
|
||||
engines: {node: ^18.18.0 || >=20.0.0}
|
||||
@@ -3568,12 +3579,22 @@ packages:
|
||||
resolution: {integrity: sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/scope-manager@8.56.0':
|
||||
resolution: {integrity: sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.54.0':
|
||||
resolution: {integrity: sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.56.0':
|
||||
resolution: {integrity: sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/type-utils@8.19.0':
|
||||
resolution: {integrity: sha512-TZs0I0OSbd5Aza4qAMpp1cdCYVnER94IziudE3JU328YUHgWu9gwiwhag+fuLeJ2LkWLXI+F/182TbG+JaBdTg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -3588,6 +3609,13 @@ packages:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/type-utils@8.56.0':
|
||||
resolution: {integrity: sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/types@7.18.0':
|
||||
resolution: {integrity: sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==}
|
||||
engines: {node: ^18.18.0 || >=20.0.0}
|
||||
@@ -3600,6 +3628,10 @@ packages:
|
||||
resolution: {integrity: sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/types@8.56.0':
|
||||
resolution: {integrity: sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/typescript-estree@7.18.0':
|
||||
resolution: {integrity: sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==}
|
||||
engines: {node: ^18.18.0 || >=20.0.0}
|
||||
@@ -3621,6 +3653,12 @@ packages:
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.56.0':
|
||||
resolution: {integrity: sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/utils@7.18.0':
|
||||
resolution: {integrity: sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==}
|
||||
engines: {node: ^18.18.0 || >=20.0.0}
|
||||
@@ -3641,6 +3679,13 @@ packages:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/utils@8.56.0':
|
||||
resolution: {integrity: sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/visitor-keys@7.18.0':
|
||||
resolution: {integrity: sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==}
|
||||
engines: {node: ^18.18.0 || >=20.0.0}
|
||||
@@ -3653,6 +3698,10 @@ packages:
|
||||
resolution: {integrity: sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.56.0':
|
||||
resolution: {integrity: sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@ungap/structured-clone@1.3.0':
|
||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||
|
||||
@@ -4784,8 +4833,8 @@ packages:
|
||||
resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==}
|
||||
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
|
||||
|
||||
cssstyle@5.3.7:
|
||||
resolution: {integrity: sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==}
|
||||
cssstyle@6.1.0:
|
||||
resolution: {integrity: sha512-Ml4fP2UT2K3CUBQnVlbdV/8aFDdlY69E+YnwJM+3VUWl08S3J8c8aRuJqCkD9Py8DHZ7zNNvsfKl8psocHZEFg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
cypress@15.9.0:
|
||||
@@ -5269,6 +5318,10 @@ packages:
|
||||
resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
eslint-visitor-keys@5.0.1:
|
||||
resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||
|
||||
eslint@9.39.2:
|
||||
resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -5444,6 +5497,9 @@ packages:
|
||||
fastify-plugin@5.0.1:
|
||||
resolution: {integrity: sha512-HCxs+YnRaWzCl+cWRYFnHmeRFyR5GVnJTAaCJQiYzQSDwK9MgJdyAsuL3nh0EWRCYMgQ5MeziymvmAhUHYHDUQ==}
|
||||
|
||||
fastify-plugin@5.1.0:
|
||||
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
|
||||
|
||||
fastify@5.7.3:
|
||||
resolution: {integrity: sha512-QHzWSmTNUg9Ba8tNXzb92FTH77K+c8yeQPH80EeSIc9wyZj85jbPisMP0rwmyKv8oJwUFPe1UpN8HkNIXwCnUQ==}
|
||||
|
||||
@@ -6463,8 +6519,8 @@ packages:
|
||||
resolution: {integrity: sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
jsdom@28.0.0:
|
||||
resolution: {integrity: sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==}
|
||||
jsdom@28.1.0:
|
||||
resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
peerDependencies:
|
||||
canvas: ^3.0.0
|
||||
@@ -6737,6 +6793,10 @@ packages:
|
||||
resolution: {integrity: sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
lru-cache@11.2.6:
|
||||
resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
lru-cache@5.1.1:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
@@ -6765,8 +6825,8 @@ packages:
|
||||
makeerror@1.0.12:
|
||||
resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==}
|
||||
|
||||
marked@17.0.1:
|
||||
resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==}
|
||||
marked@17.0.3:
|
||||
resolution: {integrity: sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A==}
|
||||
engines: {node: '>= 20'}
|
||||
hasBin: true
|
||||
|
||||
@@ -8856,11 +8916,11 @@ packages:
|
||||
typed-assert@1.0.9:
|
||||
resolution: {integrity: sha512-KNNZtayBCtmnNmbo5mG47p1XsCyrx6iVqomjcZnec/1Y5GGARaxPs6r49RnSPeUP3YjNYiU9sQHAtY4BBvnZwg==}
|
||||
|
||||
typescript-eslint@8.54.0:
|
||||
resolution: {integrity: sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==}
|
||||
typescript-eslint@8.56.0:
|
||||
resolution: {integrity: sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
typescript@5.9.3:
|
||||
@@ -8880,12 +8940,15 @@ packages:
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
undici-types@7.22.0:
|
||||
resolution: {integrity: sha512-RKZvifiL60xdsIuC80UY0dq8Z7DbJUV8/l2hOVbyZAxBzEeQU4Z58+4ZzJ6WN2Lidi9KzT5EbiGX+PI/UGYuRw==}
|
||||
|
||||
undici@7.18.2:
|
||||
resolution: {integrity: sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
undici@7.20.0:
|
||||
resolution: {integrity: sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ==}
|
||||
undici@7.22.0:
|
||||
resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
unicode-canonical-property-names-ecmascript@2.0.1:
|
||||
@@ -9518,13 +9581,13 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- chokidar
|
||||
|
||||
'@angular-devkit/build-angular@21.1.2(@angular/compiler-cli@21.1.2(@angular/compiler@21.1.2)(typescript@5.9.3))(@angular/compiler@21.1.2)(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(@angular/platform-browser@21.1.2(@angular/common@21.1.2(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2)))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(@swc/core@1.5.29(@swc/helpers@0.5.18))(@types/node@20.19.9)(chokidar@5.0.0)(jest@30.2.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.5.29(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3)))(jiti@2.6.1)(sass-embedded@1.97.3)(typescript@5.9.3)(vitest@4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.0.0)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2))(yaml@2.8.2)':
|
||||
'@angular-devkit/build-angular@21.1.2(@angular/compiler-cli@21.1.2(@angular/compiler@21.1.2)(typescript@5.9.3))(@angular/compiler@21.1.2)(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(@angular/platform-browser@21.1.2(@angular/common@21.1.2(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2)))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(@swc/core@1.5.29(@swc/helpers@0.5.18))(@types/node@20.19.9)(chokidar@5.0.0)(jest@30.2.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.5.29(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3)))(jiti@2.6.1)(sass-embedded@1.97.3)(typescript@5.9.3)(vitest@4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.1.0)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2))(yaml@2.8.2)':
|
||||
dependencies:
|
||||
'@ampproject/remapping': 2.3.0
|
||||
'@angular-devkit/architect': 0.2101.2(chokidar@5.0.0)
|
||||
'@angular-devkit/build-webpack': 0.2101.2(chokidar@5.0.0)(webpack-dev-server@5.2.2(tslib@2.8.1)(webpack@5.104.1(@swc/core@1.5.29(@swc/helpers@0.5.18))(esbuild@0.27.2)))(webpack@5.104.1(@swc/core@1.5.29(@swc/helpers@0.5.18))(esbuild@0.27.2))
|
||||
'@angular-devkit/core': 21.1.2(chokidar@5.0.0)
|
||||
'@angular/build': 21.1.2(@angular/compiler-cli@21.1.2(@angular/compiler@21.1.2)(typescript@5.9.3))(@angular/compiler@21.1.2)(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(@angular/platform-browser@21.1.2(@angular/common@21.1.2(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2)))(@types/node@20.19.9)(chokidar@5.0.0)(jiti@2.6.1)(less@4.4.2)(postcss@8.5.6)(sass-embedded@1.97.3)(terser@5.44.1)(tslib@2.8.1)(typescript@5.9.3)(vitest@4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.0.0)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2))(yaml@2.8.2)
|
||||
'@angular/build': 21.1.2(@angular/compiler-cli@21.1.2(@angular/compiler@21.1.2)(typescript@5.9.3))(@angular/compiler@21.1.2)(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(@angular/platform-browser@21.1.2(@angular/common@21.1.2(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2)))(@types/node@20.19.9)(chokidar@5.0.0)(jiti@2.6.1)(less@4.4.2)(postcss@8.5.6)(sass-embedded@1.97.3)(terser@5.44.1)(tslib@2.8.1)(typescript@5.9.3)(vitest@4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.1.0)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2))(yaml@2.8.2)
|
||||
'@angular/compiler-cli': 21.1.2(@angular/compiler@21.1.2)(typescript@5.9.3)
|
||||
'@babel/core': 7.28.5
|
||||
'@babel/generator': 7.28.5
|
||||
@@ -9646,33 +9709,33 @@ snapshots:
|
||||
|
||||
'@angular-eslint/bundled-angular-compiler@21.1.0': {}
|
||||
|
||||
'@angular-eslint/eslint-plugin-template@21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/types@8.54.0)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
'@angular-eslint/eslint-plugin-template@21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/types@8.54.0)(@typescript-eslint/utils@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@angular-eslint/bundled-angular-compiler': 21.1.0
|
||||
'@angular-eslint/template-parser': 21.1.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular-eslint/utils': 21.1.0(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular-eslint/utils': 21.1.0(@typescript-eslint/utils@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.54.0
|
||||
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
aria-query: 5.3.2
|
||||
axobject-query: 4.1.0
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
typescript: 5.9.3
|
||||
|
||||
'@angular-eslint/eslint-plugin@21.1.0(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
'@angular-eslint/eslint-plugin@21.1.0(@typescript-eslint/utils@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@angular-eslint/bundled-angular-compiler': 21.1.0
|
||||
'@angular-eslint/utils': 21.1.0(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular-eslint/utils': 21.1.0(@typescript-eslint/utils@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
ts-api-utils: 2.4.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
|
||||
'@angular-eslint/schematics@21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@angular/cli@21.1.2(@types/node@20.19.9)(chokidar@5.0.0)(hono@4.11.7))(@typescript-eslint/types@8.54.0)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
'@angular-eslint/schematics@21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@angular/cli@21.1.2(@types/node@20.19.9)(chokidar@5.0.0)(hono@4.11.7))(@typescript-eslint/types@8.54.0)(@typescript-eslint/utils@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@angular-devkit/core': 21.1.2(chokidar@5.0.0)
|
||||
'@angular-devkit/schematics': 21.1.2(chokidar@5.0.0)
|
||||
'@angular-eslint/eslint-plugin': 21.1.0(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular-eslint/eslint-plugin-template': 21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/types@8.54.0)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular-eslint/eslint-plugin': 21.1.0(@typescript-eslint/utils@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular-eslint/eslint-plugin-template': 21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/types@8.54.0)(@typescript-eslint/utils@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular/cli': 21.1.2(@types/node@20.19.9)(chokidar@5.0.0)(hono@4.11.7)
|
||||
ignore: 7.0.5
|
||||
semver: 7.7.3
|
||||
@@ -9692,14 +9755,14 @@ snapshots:
|
||||
eslint-scope: 9.1.0
|
||||
typescript: 5.9.3
|
||||
|
||||
'@angular-eslint/utils@21.1.0(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
'@angular-eslint/utils@21.1.0(@typescript-eslint/utils@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@angular-eslint/bundled-angular-compiler': 21.1.0
|
||||
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
typescript: 5.9.3
|
||||
|
||||
'@angular/build@21.1.2(@angular/compiler-cli@21.1.2(@angular/compiler@21.1.2)(typescript@5.9.3))(@angular/compiler@21.1.2)(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(@angular/platform-browser@21.1.2(@angular/common@21.1.2(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2)))(@types/node@20.19.9)(chokidar@5.0.0)(jiti@2.6.1)(less@4.4.2)(postcss@8.5.6)(sass-embedded@1.97.3)(terser@5.44.1)(tslib@2.8.1)(typescript@5.9.3)(vitest@4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.0.0)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2))(yaml@2.8.2)':
|
||||
'@angular/build@21.1.2(@angular/compiler-cli@21.1.2(@angular/compiler@21.1.2)(typescript@5.9.3))(@angular/compiler@21.1.2)(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(@angular/platform-browser@21.1.2(@angular/common@21.1.2(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2)))(@types/node@20.19.9)(chokidar@5.0.0)(jiti@2.6.1)(less@4.4.2)(postcss@8.5.6)(sass-embedded@1.97.3)(terser@5.44.1)(tslib@2.8.1)(typescript@5.9.3)(vitest@4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.1.0)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2))(yaml@2.8.2)':
|
||||
dependencies:
|
||||
'@ampproject/remapping': 2.3.0
|
||||
'@angular-devkit/architect': 0.2101.2(chokidar@5.0.0)
|
||||
@@ -9738,7 +9801,7 @@ snapshots:
|
||||
less: 4.4.2
|
||||
lmdb: 3.4.4
|
||||
postcss: 8.5.6
|
||||
vitest: 4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.0.0)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2)
|
||||
vitest: 4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.1.0)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- chokidar
|
||||
@@ -9752,7 +9815,7 @@ snapshots:
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
'@angular/build@21.1.2(@angular/compiler-cli@21.1.2(@angular/compiler@21.1.2)(typescript@5.9.3))(@angular/compiler@21.1.2)(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(@angular/platform-browser@21.1.2(@angular/common@21.1.2(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2)))(@types/node@20.19.9)(chokidar@5.0.0)(jiti@2.6.1)(less@4.5.1)(postcss@8.5.6)(sass-embedded@1.97.3)(terser@5.44.1)(tslib@2.8.1)(typescript@5.9.3)(vitest@4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.0.0)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2))(yaml@2.8.2)':
|
||||
'@angular/build@21.1.2(@angular/compiler-cli@21.1.2(@angular/compiler@21.1.2)(typescript@5.9.3))(@angular/compiler@21.1.2)(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(@angular/platform-browser@21.1.2(@angular/common@21.1.2(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2)))(@types/node@20.19.9)(chokidar@5.0.0)(jiti@2.6.1)(less@4.5.1)(postcss@8.5.6)(sass-embedded@1.97.3)(terser@5.44.1)(tslib@2.8.1)(typescript@5.9.3)(vitest@4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.1.0)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2))(yaml@2.8.2)':
|
||||
dependencies:
|
||||
'@ampproject/remapping': 2.3.0
|
||||
'@angular-devkit/architect': 0.2101.2(chokidar@5.0.0)
|
||||
@@ -9791,7 +9854,7 @@ snapshots:
|
||||
less: 4.5.1
|
||||
lmdb: 3.4.4
|
||||
postcss: 8.5.6
|
||||
vitest: 4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.0.0)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2)
|
||||
vitest: 4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.1.0)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- chokidar
|
||||
@@ -9892,21 +9955,21 @@ snapshots:
|
||||
rxjs: 7.8.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@asamuzakjp/css-color@4.1.1':
|
||||
'@asamuzakjp/css-color@5.0.1':
|
||||
dependencies:
|
||||
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-tokenizer': 3.0.4
|
||||
lru-cache: 11.2.5
|
||||
'@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
lru-cache: 11.2.6
|
||||
|
||||
'@asamuzakjp/dom-selector@6.7.7':
|
||||
'@asamuzakjp/dom-selector@6.8.1':
|
||||
dependencies:
|
||||
'@asamuzakjp/nwsapi': 2.3.9
|
||||
bidi-js: 1.0.3
|
||||
css-tree: 3.1.0
|
||||
is-potential-custom-element-name: 1.0.1
|
||||
lru-cache: 11.2.5
|
||||
lru-cache: 11.2.6
|
||||
|
||||
'@asamuzakjp/nwsapi@2.3.9': {}
|
||||
|
||||
@@ -11287,33 +11350,37 @@ snapshots:
|
||||
|
||||
'@bcoe/v8-coverage@0.2.3': {}
|
||||
|
||||
'@bramus/specificity@2.4.2':
|
||||
dependencies:
|
||||
css-tree: 3.1.0
|
||||
|
||||
'@bufbuild/protobuf@2.11.0': {}
|
||||
|
||||
'@cspotcode/source-map-support@0.8.1':
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.9
|
||||
|
||||
'@csstools/color-helpers@5.1.0': {}
|
||||
'@csstools/color-helpers@6.0.2': {}
|
||||
|
||||
'@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
|
||||
'@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
|
||||
dependencies:
|
||||
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-tokenizer': 3.0.4
|
||||
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
|
||||
'@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
|
||||
'@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
|
||||
dependencies:
|
||||
'@csstools/color-helpers': 5.1.0
|
||||
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-tokenizer': 3.0.4
|
||||
'@csstools/color-helpers': 6.0.2
|
||||
'@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
|
||||
'@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)':
|
||||
'@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)':
|
||||
dependencies:
|
||||
'@csstools/css-tokenizer': 3.0.4
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
|
||||
'@csstools/css-syntax-patches-for-csstree@1.0.26': {}
|
||||
'@csstools/css-syntax-patches-for-csstree@1.0.28': {}
|
||||
|
||||
'@csstools/css-tokenizer@3.0.4': {}
|
||||
'@csstools/css-tokenizer@4.0.0': {}
|
||||
|
||||
'@cypress/request@3.0.10':
|
||||
dependencies:
|
||||
@@ -11638,10 +11705,10 @@ snapshots:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
'@fastify/oauth2@8.1.2':
|
||||
'@fastify/oauth2@8.2.0':
|
||||
dependencies:
|
||||
'@fastify/cookie': 11.0.2
|
||||
fastify-plugin: 5.0.1
|
||||
fastify-plugin: 5.1.0
|
||||
simple-oauth2: 5.1.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -12755,7 +12822,7 @@ snapshots:
|
||||
typescript: 5.9.3
|
||||
webpack: 5.104.1(@swc/core@1.5.29(@swc/helpers@0.5.18))(esbuild@0.27.2)
|
||||
|
||||
'@nhcarrigan/eslint-config@5.2.0(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(playwright@1.58.1)(react@19.2.4)(typescript@5.9.3)(vitest@4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.0.0)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2))':
|
||||
'@nhcarrigan/eslint-config@5.2.0(@typescript-eslint/utils@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(playwright@1.58.1)(react@19.2.4)(typescript@5.9.3)(vitest@4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.1.0)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-plugin-eslint-comments': 4.4.1(eslint@9.39.2(jiti@2.6.1))
|
||||
'@eslint/compat': 1.2.4(eslint@9.39.2(jiti@2.6.1))
|
||||
@@ -12764,7 +12831,7 @@ snapshots:
|
||||
'@stylistic/eslint-plugin': 2.12.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/eslint-plugin': 8.19.0(@typescript-eslint/parser@8.19.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 8.19.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@vitest/eslint-plugin': 1.1.24(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.0.0)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2))
|
||||
'@vitest/eslint-plugin': 1.1.24(@typescript-eslint/utils@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.1.0)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2))
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint-plugin-deprecation: 3.0.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.19.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))
|
||||
@@ -12777,7 +12844,7 @@ snapshots:
|
||||
playwright: 1.58.1
|
||||
react: 19.2.4
|
||||
typescript: 5.9.3
|
||||
vitest: 4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.0.0)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2)
|
||||
vitest: 4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.1.0)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2)
|
||||
transitivePeerDependencies:
|
||||
- '@typescript-eslint/utils'
|
||||
- eslint-import-resolver-typescript
|
||||
@@ -12859,7 +12926,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@nx/angular@22.4.4(53644491fdd75e214444a711ff7c7d5c)':
|
||||
'@nx/angular@22.4.4(5a8ec786c6b8ea22e93f5ae8cf5ae8ed)':
|
||||
dependencies:
|
||||
'@angular-devkit/core': 21.1.2(chokidar@5.0.0)
|
||||
'@angular-devkit/schematics': 21.1.2(chokidar@5.0.0)
|
||||
@@ -12883,8 +12950,8 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
webpack-merge: 5.10.0
|
||||
optionalDependencies:
|
||||
'@angular-devkit/build-angular': 21.1.2(@angular/compiler-cli@21.1.2(@angular/compiler@21.1.2)(typescript@5.9.3))(@angular/compiler@21.1.2)(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(@angular/platform-browser@21.1.2(@angular/common@21.1.2(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2)))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(@swc/core@1.5.29(@swc/helpers@0.5.18))(@types/node@20.19.9)(chokidar@5.0.0)(jest@30.2.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.5.29(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3)))(jiti@2.6.1)(sass-embedded@1.97.3)(typescript@5.9.3)(vitest@4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.0.0)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2))(yaml@2.8.2)
|
||||
'@angular/build': 21.1.2(@angular/compiler-cli@21.1.2(@angular/compiler@21.1.2)(typescript@5.9.3))(@angular/compiler@21.1.2)(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(@angular/platform-browser@21.1.2(@angular/common@21.1.2(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2)))(@types/node@20.19.9)(chokidar@5.0.0)(jiti@2.6.1)(less@4.5.1)(postcss@8.5.6)(sass-embedded@1.97.3)(terser@5.44.1)(tslib@2.8.1)(typescript@5.9.3)(vitest@4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.0.0)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2))(yaml@2.8.2)
|
||||
'@angular-devkit/build-angular': 21.1.2(@angular/compiler-cli@21.1.2(@angular/compiler@21.1.2)(typescript@5.9.3))(@angular/compiler@21.1.2)(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(@angular/platform-browser@21.1.2(@angular/common@21.1.2(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2)))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(@swc/core@1.5.29(@swc/helpers@0.5.18))(@types/node@20.19.9)(chokidar@5.0.0)(jest@30.2.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.5.29(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3)))(jiti@2.6.1)(sass-embedded@1.97.3)(typescript@5.9.3)(vitest@4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.1.0)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2))(yaml@2.8.2)
|
||||
'@angular/build': 21.1.2(@angular/compiler-cli@21.1.2(@angular/compiler@21.1.2)(typescript@5.9.3))(@angular/compiler@21.1.2)(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(@angular/platform-browser@21.1.2(@angular/common@21.1.2(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2)))(@types/node@20.19.9)(chokidar@5.0.0)(jiti@2.6.1)(less@4.5.1)(postcss@8.5.6)(sass-embedded@1.97.3)(terser@5.44.1)(tslib@2.8.1)(typescript@5.9.3)(vitest@4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.1.0)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2))(yaml@2.8.2)
|
||||
transitivePeerDependencies:
|
||||
- '@babel/traverse'
|
||||
- '@module-federation/enhanced'
|
||||
@@ -12982,14 +13049,14 @@ snapshots:
|
||||
- supports-color
|
||||
- verdaccio
|
||||
|
||||
'@nx/eslint-plugin@22.4.4(@babel/traverse@7.29.0)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.18))(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(nx@22.4.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.18)))(typescript@5.9.3)':
|
||||
'@nx/eslint-plugin@22.4.4(@babel/traverse@7.29.0)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.18))(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(nx@22.4.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.18)))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@nx/devkit': 22.4.4(nx@22.4.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.18)))
|
||||
'@nx/js': 22.4.4(@babel/traverse@7.29.0)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.18))(nx@22.4.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.18)))
|
||||
'@phenomnomnominal/tsquery': 6.1.4(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
chalk: 4.1.2
|
||||
confusing-browser-globals: 1.0.11
|
||||
globals: 15.15.0
|
||||
@@ -13826,7 +13893,7 @@ snapshots:
|
||||
|
||||
'@stylistic/eslint-plugin@2.12.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint-visitor-keys: 4.2.1
|
||||
espree: 10.4.0
|
||||
@@ -14046,11 +14113,12 @@ snapshots:
|
||||
expect: 30.2.0
|
||||
pretty-format: 30.2.0
|
||||
|
||||
'@types/jsdom@27.0.0':
|
||||
'@types/jsdom@28.0.0':
|
||||
dependencies:
|
||||
'@types/node': 20.19.9
|
||||
'@types/tough-cookie': 4.0.5
|
||||
parse5: 7.3.0
|
||||
undici-types: 7.22.0
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
@@ -14153,14 +14221,14 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
'@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/scope-manager': 8.54.0
|
||||
'@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.54.0
|
||||
'@typescript-eslint/parser': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/scope-manager': 8.56.0
|
||||
'@typescript-eslint/type-utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.56.0
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
ignore: 7.0.5
|
||||
natural-compare: 1.4.0
|
||||
@@ -14181,12 +14249,12 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
'@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.54.0
|
||||
'@typescript-eslint/types': 8.54.0
|
||||
'@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.54.0
|
||||
'@typescript-eslint/scope-manager': 8.56.0
|
||||
'@typescript-eslint/types': 8.56.0
|
||||
'@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.56.0
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
typescript: 5.9.3
|
||||
@@ -14195,8 +14263,17 @@ snapshots:
|
||||
|
||||
'@typescript-eslint/project-service@8.54.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.54.0(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.54.0
|
||||
'@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.56.0
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/project-service@8.56.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.56.0
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
@@ -14217,10 +14294,19 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.54.0
|
||||
'@typescript-eslint/visitor-keys': 8.54.0
|
||||
|
||||
'@typescript-eslint/scope-manager@8.56.0':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.56.0
|
||||
'@typescript-eslint/visitor-keys': 8.56.0
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.54.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.56.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
'@typescript-eslint/type-utils@8.19.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/typescript-estree': 8.19.0(typescript@5.9.3)
|
||||
@@ -14244,12 +14330,26 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/type-utils@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.56.0
|
||||
'@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
ts-api-utils: 2.4.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/types@7.18.0': {}
|
||||
|
||||
'@typescript-eslint/types@8.19.0': {}
|
||||
|
||||
'@typescript-eslint/types@8.54.0': {}
|
||||
|
||||
'@typescript-eslint/types@8.56.0': {}
|
||||
|
||||
'@typescript-eslint/typescript-estree@7.18.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 7.18.0
|
||||
@@ -14294,6 +14394,21 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.56.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/project-service': 8.56.0(typescript@5.9.3)
|
||||
'@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.56.0
|
||||
'@typescript-eslint/visitor-keys': 8.56.0
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
minimatch: 9.0.5
|
||||
semver: 7.7.3
|
||||
tinyglobby: 0.2.15
|
||||
ts-api-utils: 2.4.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/utils@7.18.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
|
||||
@@ -14327,6 +14442,17 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/utils@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
|
||||
'@typescript-eslint/scope-manager': 8.56.0
|
||||
'@typescript-eslint/types': 8.56.0
|
||||
'@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/visitor-keys@7.18.0':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 7.18.0
|
||||
@@ -14342,6 +14468,11 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.54.0
|
||||
eslint-visitor-keys: 4.2.1
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.56.0':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.56.0
|
||||
eslint-visitor-keys: 5.0.1
|
||||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
|
||||
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
|
||||
@@ -14407,13 +14538,13 @@ snapshots:
|
||||
dependencies:
|
||||
vite: 7.3.0(@types/node@20.19.9)(jiti@2.6.1)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2)
|
||||
|
||||
'@vitest/eslint-plugin@1.1.24(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.0.0)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2))':
|
||||
'@vitest/eslint-plugin@1.1.24(@typescript-eslint/utils@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.1.0)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
vitest: 4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.0.0)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2)
|
||||
vitest: 4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.1.0)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2)
|
||||
|
||||
'@vitest/expect@4.0.18':
|
||||
dependencies:
|
||||
@@ -14643,21 +14774,21 @@ snapshots:
|
||||
'@algolia/requester-fetch': 5.46.2
|
||||
'@algolia/requester-node-http': 5.46.2
|
||||
|
||||
angular-eslint@21.1.0(@angular/cli@21.1.2(@types/node@20.19.9)(chokidar@5.0.0)(hono@4.11.7))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript-eslint@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(typescript@5.9.3):
|
||||
angular-eslint@21.1.0(@angular/cli@21.1.2(@types/node@20.19.9)(chokidar@5.0.0)(hono@4.11.7))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript-eslint@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@angular-devkit/core': 21.1.2(chokidar@5.0.0)
|
||||
'@angular-devkit/schematics': 21.1.2(chokidar@5.0.0)
|
||||
'@angular-eslint/builder': 21.1.0(@angular/cli@21.1.2(@types/node@20.19.9)(chokidar@5.0.0)(hono@4.11.7))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular-eslint/eslint-plugin': 21.1.0(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular-eslint/eslint-plugin-template': 21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/types@8.54.0)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular-eslint/schematics': 21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@angular/cli@21.1.2(@types/node@20.19.9)(chokidar@5.0.0)(hono@4.11.7))(@typescript-eslint/types@8.54.0)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular-eslint/eslint-plugin': 21.1.0(@typescript-eslint/utils@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular-eslint/eslint-plugin-template': 21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/types@8.54.0)(@typescript-eslint/utils@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular-eslint/schematics': 21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@angular/cli@21.1.2(@types/node@20.19.9)(chokidar@5.0.0)(hono@4.11.7))(@typescript-eslint/types@8.54.0)(@typescript-eslint/utils@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular-eslint/template-parser': 21.1.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular/cli': 21.1.2(@types/node@20.19.9)(chokidar@5.0.0)(hono@4.11.7)
|
||||
'@typescript-eslint/types': 8.54.0
|
||||
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
typescript: 5.9.3
|
||||
typescript-eslint: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
typescript-eslint: 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- chokidar
|
||||
- supports-color
|
||||
@@ -15594,12 +15725,12 @@ snapshots:
|
||||
dependencies:
|
||||
css-tree: 2.2.1
|
||||
|
||||
cssstyle@5.3.7:
|
||||
cssstyle@6.1.0:
|
||||
dependencies:
|
||||
'@asamuzakjp/css-color': 4.1.1
|
||||
'@csstools/css-syntax-patches-for-csstree': 1.0.26
|
||||
'@asamuzakjp/css-color': 5.0.1
|
||||
'@csstools/css-syntax-patches-for-csstree': 1.0.28
|
||||
css-tree: 3.1.0
|
||||
lru-cache: 11.2.5
|
||||
lru-cache: 11.2.6
|
||||
|
||||
cypress@15.9.0:
|
||||
dependencies:
|
||||
@@ -16233,6 +16364,8 @@ snapshots:
|
||||
|
||||
eslint-visitor-keys@4.2.1: {}
|
||||
|
||||
eslint-visitor-keys@5.0.1: {}
|
||||
|
||||
eslint@9.39.2(jiti@2.6.1):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
|
||||
@@ -16512,6 +16645,8 @@ snapshots:
|
||||
|
||||
fastify-plugin@5.0.1: {}
|
||||
|
||||
fastify-plugin@5.1.0: {}
|
||||
|
||||
fastify@5.7.3:
|
||||
dependencies:
|
||||
'@fastify/ajv-compiler': 4.0.5
|
||||
@@ -17775,12 +17910,13 @@ snapshots:
|
||||
|
||||
jsdoc-type-pratt-parser@4.1.0: {}
|
||||
|
||||
jsdom@28.0.0:
|
||||
jsdom@28.1.0:
|
||||
dependencies:
|
||||
'@acemir/cssom': 0.9.31
|
||||
'@asamuzakjp/dom-selector': 6.7.7
|
||||
'@asamuzakjp/dom-selector': 6.8.1
|
||||
'@bramus/specificity': 2.4.2
|
||||
'@exodus/bytes': 1.11.0
|
||||
cssstyle: 5.3.7
|
||||
cssstyle: 6.1.0
|
||||
data-urls: 7.0.0
|
||||
decimal.js: 10.6.0
|
||||
html-encoding-sniffer: 6.0.0
|
||||
@@ -17791,7 +17927,7 @@ snapshots:
|
||||
saxes: 6.0.0
|
||||
symbol-tree: 3.2.4
|
||||
tough-cookie: 6.0.0
|
||||
undici: 7.20.0
|
||||
undici: 7.22.0
|
||||
w3c-xmlserializer: 5.0.0
|
||||
webidl-conversions: 8.0.1
|
||||
whatwg-mimetype: 5.0.0
|
||||
@@ -18110,6 +18246,8 @@ snapshots:
|
||||
|
||||
lru-cache@11.2.5: {}
|
||||
|
||||
lru-cache@11.2.6: {}
|
||||
|
||||
lru-cache@5.1.1:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
@@ -18152,7 +18290,7 @@ snapshots:
|
||||
dependencies:
|
||||
tmpl: 1.0.5
|
||||
|
||||
marked@17.0.1: {}
|
||||
marked@17.0.3: {}
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
@@ -20473,12 +20611,12 @@ snapshots:
|
||||
|
||||
typed-assert@1.0.9: {}
|
||||
|
||||
typescript-eslint@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3):
|
||||
typescript-eslint@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/eslint-plugin': 8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
@@ -20498,9 +20636,11 @@ snapshots:
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
undici-types@7.22.0: {}
|
||||
|
||||
undici@7.18.2: {}
|
||||
|
||||
undici@7.20.0: {}
|
||||
undici@7.22.0: {}
|
||||
|
||||
unicode-canonical-property-names-ecmascript@2.0.1: {}
|
||||
|
||||
@@ -20657,7 +20797,7 @@ snapshots:
|
||||
terser: 5.44.1
|
||||
yaml: 2.8.2
|
||||
|
||||
vitest@4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.0.0)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2):
|
||||
vitest@4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.1.0)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.0.18
|
||||
'@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@20.19.9)(jiti@2.6.1)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2))
|
||||
@@ -20681,7 +20821,7 @@ snapshots:
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 20.19.9
|
||||
jsdom: 28.0.0
|
||||
jsdom: 28.1.0
|
||||
transitivePeerDependencies:
|
||||
- jiti
|
||||
- less
|
||||
|
||||
@@ -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"
|
||||
|
||||