feat: bunch of work done here, got comments and edit and delete

This commit is contained in:
2026-02-04 13:00:16 -08:00
parent b6d66d34cb
commit 318f3bc500
19 changed files with 1868 additions and 117 deletions
+58
View File
@@ -166,6 +166,64 @@ interface Music {
5. **Privacy**: Any items you'd want to keep private even from public view?
## Current Sprint: Edit, Filters & Comments
### Feature 1: Edit Existing Library Entries (Admin Only)
**Backend:**
- Add PUT `/api/games/:id`, `/api/books/:id`, `/api/music/:id` endpoints
- Reuse existing admin authentication middleware
- Validate input against existing schemas
**Frontend:**
- Add edit button to entry cards (visible for admins)
- Reuse existing "Add" modal/form components with pre-filled data
- Update state after successful edit
### Feature 2: Fix Filter Tab State Updates
**Investigation needed:**
- Check how tab counts are calculated (likely not re-fetching after add)
- Ensure Angular state updates reactively when items are added/modified
- May need to refresh counts after mutations or use observables properly
### Feature 3: Comments System
**Database Schema:**
```prisma
model Comment {
id String @id @default(auto()) @map("_id") @db.ObjectId
content String // Markdown content - sanitised on render
userId String @db.ObjectId
user User @relation(fields: [userId], references: [id])
// Polymorphic relation - one of these will be set
gameId String? @db.ObjectId
game Game? @relation(fields: [gameId], references: [id])
bookId String? @db.ObjectId
book Book? @relation(fields: [bookId], references: [id])
musicId String? @db.ObjectId
music Music? @relation(fields: [musicId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
```
**Backend:**
- POST `/api/games/:id/comments` (authenticated users)
- GET `/api/games/:id/comments` (public)
- DELETE `/api/games/:id/comments/:commentId` (admin only)
- Same for books and music
- Use a markdown sanitisation library (e.g., `sanitize-html` or `DOMPurify` on render)
**Frontend:**
- Expandable comments section on each entry card
- Markdown rendering with sanitisation (use `marked` + `DOMPurify`)
- Add comment form for authenticated users
- Delete button for admins
- Show commenter's Discord username/avatar
### Implementation Order
1. Fix filter tab bug (quick win)
2. Add edit functionality (builds on existing patterns)
3. Add comments system (new feature, more complex)
## Next Steps
1. Choose technical stack
+19
View File
@@ -25,6 +25,7 @@ model Game {
coverImage String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
comments Comment[]
}
enum GameStatus {
@@ -46,6 +47,7 @@ model Book {
coverImage String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
comments Comment[]
}
enum BookStatus {
@@ -67,6 +69,7 @@ model Music {
coverArt String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
comments Comment[]
}
enum MusicType {
@@ -90,4 +93,20 @@ model User {
isAdmin Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
comments Comment[]
}
model Comment {
id String @id @default(auto()) @map("_id") @db.ObjectId
content String
userId String @db.ObjectId
user User @relation(fields: [userId], references: [id])
gameId String? @db.ObjectId
game Game? @relation(fields: [gameId], references: [id])
bookId String? @db.ObjectId
book Book? @relation(fields: [bookId], references: [id])
musicId String? @db.ObjectId
music Music? @relation(fields: [musicId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+20
View File
@@ -11,6 +11,18 @@ declare module "fastify" {
}
}
declare module "@fastify/jwt" {
interface FastifyJWT {
user: {
id: string;
username: string;
email?: string;
avatar?: string;
isAdmin: boolean;
};
}
}
const authPlugin: FastifyPluginAsync = async (app) => {
// Register JWT plugin
app.register(fastifyJwt, {
@@ -19,6 +31,14 @@ const authPlugin: FastifyPluginAsync = async (app) => {
cookieName: "auth-token",
signed: false,
},
formatUser: (payload: { sub: string; email?: string; username: string; isAdmin: boolean }) => {
return {
id: payload.sub,
email: payload.email,
username: payload.username,
isAdmin: payload.isAdmin,
};
},
});
// Register cookie plugin
+44 -1
View File
@@ -5,12 +5,14 @@
*/
import { FastifyPluginAsync } from "fastify";
import { Book, CreateBookDto, UpdateBookDto } from "@library/shared-types";
import { Book, CreateBookDto, UpdateBookDto, Comment, CreateCommentDto } from "@library/shared-types";
import { BookService } from "../../services/book.service";
import { CommentService } from "../../services/comment.service";
import { adminGuard } from "../../middleware/admin-guard";
const booksRoutes: FastifyPluginAsync = async (app) => {
const bookService = new BookService();
const commentService = new CommentService();
/**
* Get all books (public route).
@@ -75,6 +77,47 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
return { success: true };
}
);
/**
* Get comments for a book (public route).
*/
app.get<{ Params: { id: string }; Reply: Comment[] }>(
"/:id/comments",
async (request) => {
const { id } = request.params;
return commentService.getCommentsForBook(id);
}
);
/**
* Add comment to a book (authenticated users).
*/
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
"/:id/comments",
{
preValidation: [app.authenticate],
},
async (request) => {
const { id } = request.params;
const userId = request.user.id;
return commentService.createCommentForBook(id, userId, request.body);
}
);
/**
* Delete comment (admin only).
*/
app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } }>(
"/:id/comments/:commentId",
{
preValidation: [app.authenticate, adminGuard],
},
async (request) => {
const { commentId } = request.params;
await commentService.deleteComment(commentId);
return { success: true };
}
);
};
export default booksRoutes;
+38 -1
View File
@@ -5,12 +5,14 @@
*/
import { FastifyPluginAsync } from "fastify";
import { Game, CreateGameDto, UpdateGameDto } from "@library/shared-types";
import { Game, CreateGameDto, UpdateGameDto, Comment, CreateCommentDto } from "@library/shared-types";
import { GameService } from "../../services/game.service";
import { CommentService } from "../../services/comment.service";
import { adminGuard } from "../../middleware/admin-guard";
const gamesRoutes: FastifyPluginAsync = async (app) => {
const gameService = new GameService();
const commentService = new CommentService();
// Get all games (public route)
app.get<{ Reply: Game[] }>("/", async () => {
@@ -65,6 +67,41 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
return { success: true };
}
);
// Get comments for a game (public route)
app.get<{ Params: { id: string }; Reply: Comment[] }>(
"/:id/comments",
async (request) => {
const { id } = request.params;
return commentService.getCommentsForGame(id);
}
);
// Add comment to a game (authenticated users)
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
"/:id/comments",
{
preValidation: [app.authenticate],
},
async (request) => {
const { id } = request.params;
const userId = request.user.id;
return commentService.createCommentForGame(id, userId, request.body);
}
);
// Delete comment (admin only)
app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } }>(
"/:id/comments/:commentId",
{
preValidation: [app.authenticate, adminGuard],
},
async (request) => {
const { commentId } = request.params;
await commentService.deleteComment(commentId);
return { success: true };
}
);
};
export default gamesRoutes;
+44 -1
View File
@@ -5,12 +5,14 @@
*/
import { FastifyPluginAsync } from "fastify";
import { Music, CreateMusicDto, UpdateMusicDto } from "@library/shared-types";
import { Music, CreateMusicDto, UpdateMusicDto, Comment, CreateCommentDto } from "@library/shared-types";
import { MusicService } from "../../services/music.service";
import { CommentService } from "../../services/comment.service";
import { adminGuard } from "../../middleware/admin-guard";
const musicRoutes: FastifyPluginAsync = async (app) => {
const musicService = new MusicService();
const commentService = new CommentService();
/**
* Get all music (public route).
@@ -75,6 +77,47 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
return { success: true };
}
);
/**
* Get comments for a music item (public route).
*/
app.get<{ Params: { id: string }; Reply: Comment[] }>(
"/:id/comments",
async (request) => {
const { id } = request.params;
return commentService.getCommentsForMusic(id);
}
);
/**
* Add comment to a music item (authenticated users).
*/
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
"/:id/comments",
{
preValidation: [app.authenticate],
},
async (request) => {
const { id } = request.params;
const userId = request.user.id;
return commentService.createCommentForMusic(id, userId, request.body);
}
);
/**
* Delete comment (admin only).
*/
app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } }>(
"/:id/comments/:commentId",
{
preValidation: [app.authenticate, adminGuard],
},
async (request) => {
const { commentId } = request.params;
await commentService.deleteComment(commentId);
return { success: true };
}
);
};
export default musicRoutes;
+4 -4
View File
@@ -22,7 +22,7 @@ export class BookService {
return books.map((book) => ({
...book,
status: book.status.toLowerCase() as BookStatus,
status: book.status as unknown as BookStatus,
dateAdded: book.dateAdded,
dateFinished: book.dateFinished || undefined,
createdAt: book.createdAt,
@@ -42,7 +42,7 @@ export class BookService {
return {
...book,
status: book.status.toLowerCase() as BookStatus,
status: book.status as unknown as BookStatus,
dateAdded: book.dateAdded,
dateFinished: book.dateFinished || undefined,
createdAt: book.createdAt,
@@ -63,7 +63,7 @@ export class BookService {
return {
...book,
status: book.status.toLowerCase() as BookStatus,
status: book.status as unknown as BookStatus,
dateAdded: book.dateAdded,
dateFinished: book.dateFinished || undefined,
createdAt: book.createdAt,
@@ -87,7 +87,7 @@ export class BookService {
return {
...book,
status: book.status.toLowerCase() as BookStatus,
status: book.status as unknown as BookStatus,
dateAdded: book.dateAdded,
dateFinished: book.dateFinished || undefined,
createdAt: book.createdAt,
+154
View File
@@ -0,0 +1,154 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Comment, CreateCommentDto } from "@library/shared-types";
import { prisma } from "../lib/prisma";
import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom";
import { marked } from "marked";
const window = new JSDOM("").window;
const DOMPurify = createDOMPurify(window);
// Add hook to sanitise links - prevent javascript: URLs and add security attributes
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
if (node.tagName === "A") {
const href = node.getAttribute("href") || "";
// Block javascript:, data:, and vbscript: URLs
if (/^(javascript|data|vbscript):/i.test(href)) {
node.removeAttribute("href");
} else {
// Add security attributes to external links
node.setAttribute("target", "_blank");
node.setAttribute("rel", "noopener noreferrer nofollow");
}
}
});
export class CommentService {
private prisma = prisma;
constructor() {}
private sanitizeMarkdown(content: string): string {
const html = marked.parse(content, { async: false }) as string;
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: [
"p", "br", "strong", "em", "b", "i", "u", "s", "strike",
"h1", "h2", "h3", "h4", "h5", "h6",
"ul", "ol", "li",
"blockquote", "code", "pre",
"a", "hr",
],
ALLOWED_ATTR: ["href", "target", "rel"],
ALLOW_DATA_ATTR: false,
ADD_ATTR: ["target", "rel"],
FORCE_BODY: true,
});
}
private mapComment(comment: any): Comment {
return {
id: comment.id,
content: comment.content,
userId: comment.userId,
user: {
id: comment.user.id,
username: comment.user.username,
avatar: comment.user.avatar || undefined,
},
gameId: comment.gameId || undefined,
bookId: comment.bookId || undefined,
musicId: comment.musicId || undefined,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
};
}
async getCommentsForGame(gameId: string): Promise<Comment[]> {
const comments = await this.prisma.comment.findMany({
where: { gameId },
include: { user: true },
orderBy: { createdAt: "desc" },
});
return comments.map((c) => this.mapComment(c));
}
async getCommentsForBook(bookId: string): Promise<Comment[]> {
const comments = await this.prisma.comment.findMany({
where: { bookId },
include: { user: true },
orderBy: { createdAt: "desc" },
});
return comments.map((c) => this.mapComment(c));
}
async getCommentsForMusic(musicId: string): Promise<Comment[]> {
const comments = await this.prisma.comment.findMany({
where: { musicId },
include: { user: true },
orderBy: { createdAt: "desc" },
});
return comments.map((c) => this.mapComment(c));
}
async createCommentForGame(
gameId: string,
userId: string,
data: CreateCommentDto
): Promise<Comment> {
const sanitizedContent = this.sanitizeMarkdown(data.content);
const comment = await this.prisma.comment.create({
data: {
content: sanitizedContent,
userId,
gameId,
},
include: { user: true },
});
return this.mapComment(comment);
}
async createCommentForBook(
bookId: string,
userId: string,
data: CreateCommentDto
): Promise<Comment> {
const sanitizedContent = this.sanitizeMarkdown(data.content);
const comment = await this.prisma.comment.create({
data: {
content: sanitizedContent,
userId,
bookId,
},
include: { user: true },
});
return this.mapComment(comment);
}
async createCommentForMusic(
musicId: string,
userId: string,
data: CreateCommentDto
): Promise<Comment> {
const sanitizedContent = this.sanitizeMarkdown(data.content);
const comment = await this.prisma.comment.create({
data: {
content: sanitizedContent,
userId,
musicId,
},
include: { user: true },
});
return this.mapComment(comment);
}
async deleteComment(commentId: string): Promise<void> {
await this.prisma.comment.delete({
where: { id: commentId },
});
}
}
+4 -4
View File
@@ -22,7 +22,7 @@ export class GameService {
return games.map((game) => ({
...game,
status: game.status.toLowerCase() as GameStatus,
status: game.status as unknown as GameStatus,
dateAdded: game.dateAdded,
dateCompleted: game.dateCompleted || undefined,
createdAt: game.createdAt,
@@ -42,7 +42,7 @@ export class GameService {
return {
...game,
status: game.status.toLowerCase() as GameStatus,
status: game.status as unknown as GameStatus,
dateAdded: game.dateAdded,
dateCompleted: game.dateCompleted || undefined,
createdAt: game.createdAt,
@@ -63,7 +63,7 @@ export class GameService {
return {
...game,
status: game.status.toLowerCase() as GameStatus,
status: game.status as unknown as GameStatus,
dateAdded: game.dateAdded,
dateCompleted: game.dateCompleted || undefined,
createdAt: game.createdAt,
@@ -87,7 +87,7 @@ export class GameService {
return {
...game,
status: game.status.toLowerCase() as GameStatus,
status: game.status as unknown as GameStatus,
dateAdded: game.dateAdded,
dateCompleted: game.dateCompleted || undefined,
createdAt: game.createdAt,
+8 -8
View File
@@ -22,8 +22,8 @@ export class MusicService {
return musicItems.map((music) => ({
...music,
type: music.type.toLowerCase() as MusicType,
status: music.status.toLowerCase() as MusicStatus,
type: music.type as unknown as MusicType,
status: music.status as unknown as MusicStatus,
dateAdded: music.dateAdded,
dateCompleted: music.dateCompleted || undefined,
createdAt: music.createdAt,
@@ -43,8 +43,8 @@ export class MusicService {
return {
...music,
type: music.type.toLowerCase() as MusicType,
status: music.status.toLowerCase() as MusicStatus,
type: music.type as unknown as MusicType,
status: music.status as unknown as MusicStatus,
dateAdded: music.dateAdded,
dateCompleted: music.dateCompleted || undefined,
createdAt: music.createdAt,
@@ -66,8 +66,8 @@ export class MusicService {
return {
...music,
type: music.type.toLowerCase() as MusicType,
status: music.status.toLowerCase() as MusicStatus,
type: music.type as unknown as MusicType,
status: music.status as unknown as MusicStatus,
dateAdded: music.dateAdded,
dateCompleted: music.dateCompleted || undefined,
createdAt: music.createdAt,
@@ -94,8 +94,8 @@ export class MusicService {
return {
...music,
type: music.type.toLowerCase() as MusicType,
status: music.status.toLowerCase() as MusicStatus,
type: music.type as unknown as MusicType,
status: music.status as unknown as MusicStatus,
dateAdded: music.dateAdded,
dateCompleted: music.dateCompleted || undefined,
createdAt: music.createdAt,
@@ -4,12 +4,13 @@
* @author Naomi Carrigan
*/
import { Component, OnInit, inject, signal } from '@angular/core';
import { Component, OnInit, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { BooksService } from '../../services/books.service';
import { AuthService } from '../../services/auth.service';
import { Book, BookStatus, CreateBookDto } from '@library/shared-types';
import { CommentsService } from '../../services/comments.service';
import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment } from '@library/shared-types';
@Component({
selector: 'app-books-list',
@@ -67,9 +68,9 @@ import { Book, BookStatus, CreateBookDto } from '@library/shared-types';
<div class="form-group">
<label for="status">Status</label>
<select id="status" [(ngModel)]="newBook.status" name="status" required>
<option value="reading">Currently Reading</option>
<option value="finished">Finished</option>
<option value="toRead">To Read</option>
<option [value]="BookStatus.reading">Currently Reading</option>
<option [value]="BookStatus.finished">Finished</option>
<option [value]="BookStatus.toRead">To Read</option>
</select>
</div>
@@ -103,6 +104,83 @@ import { Book, BookStatus, CreateBookDto } from '@library/shared-types';
</form>
}
@if (editingBook() && authService.isAdmin()) {
<form (ngSubmit)="saveEdit()" class="add-form">
<h3>Edit Book</h3>
<div class="form-group">
<label for="edit-title">Title</label>
<input
type="text"
id="edit-title"
[(ngModel)]="editBook.title"
name="title"
required
placeholder="Enter book title"
>
</div>
<div class="form-group">
<label for="edit-author">Author</label>
<input
type="text"
id="edit-author"
[(ngModel)]="editBook.author"
name="author"
required
placeholder="Enter author name"
>
</div>
<div class="form-group">
<label for="edit-isbn">ISBN</label>
<input
type="text"
id="edit-isbn"
[(ngModel)]="editBook.isbn"
name="isbn"
placeholder="ISBN (optional)"
>
</div>
<div class="form-group">
<label for="edit-status">Status</label>
<select id="edit-status" [(ngModel)]="editBook.status" name="status" required>
<option [value]="BookStatus.reading">Currently Reading</option>
<option [value]="BookStatus.finished">Finished</option>
<option [value]="BookStatus.toRead">To Read</option>
</select>
</div>
<div class="form-group">
<label for="edit-rating">Rating (0-5)</label>
<input
type="number"
id="edit-rating"
[(ngModel)]="editBook.rating"
name="rating"
min="0"
max="5"
>
</div>
<div class="form-group">
<label for="edit-notes">Notes</label>
<textarea
id="edit-notes"
[(ngModel)]="editBook.notes"
name="notes"
rows="3"
placeholder="Your thoughts about the book..."
></textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Save Changes</button>
<button type="button" (click)="cancelEdit()" class="btn btn-secondary">Cancel</button>
</div>
</form>
}
<div class="filters">
<button
(click)="setFilter('all')"
@@ -116,21 +194,21 @@ import { Book, BookStatus, CreateBookDto } from '@library/shared-types';
[class.active]="statusFilter() === BookStatus.reading"
class="filter-btn"
>
Reading ({{ getCountByStatus(BookStatus.reading) }})
Reading ({{ readingCount() }})
</button>
<button
(click)="setFilter(BookStatus.finished)"
[class.active]="statusFilter() === BookStatus.finished"
class="filter-btn"
>
Finished ({{ getCountByStatus(BookStatus.finished) }})
Finished ({{ finishedCount() }})
</button>
<button
(click)="setFilter(BookStatus.toRead)"
[class.active]="statusFilter() === BookStatus.toRead"
class="filter-btn"
>
To Read ({{ getCountByStatus(BookStatus.toRead) }})
To Read ({{ toReadCount() }})
</button>
</div>
@@ -182,11 +260,58 @@ import { Book, BookStatus, CreateBookDto } from '@library/shared-types';
@if (authService.isAdmin()) {
<div class="actions">
<button (click)="startEdit(book)" class="btn btn-secondary btn-sm">
Edit
</button>
<button (click)="deleteBook(book)" class="btn btn-danger btn-sm">
Delete
</button>
</div>
}
<div class="comments-section">
<button (click)="toggleComments(book.id)" class="btn btn-secondary btn-sm comments-toggle">
{{ expandedComments()[book.id] ? 'Hide' : 'Show' }} Comments{{ comments()[book.id] ? ' (' + getCommentCount(book.id) + ')' : '' }}
</button>
@if (expandedComments()[book.id]) {
<div class="comments-container">
@if (authService.isAuthenticated()) {
<form (ngSubmit)="addComment(book.id)" class="comment-form">
<textarea
[(ngModel)]="newCommentContent[book.id]"
name="comment"
placeholder="Add a comment (Markdown supported)..."
rows="2"
></textarea>
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
</form>
}
@if (commentsLoading()[book.id]) {
<div class="comments-loading">Loading comments...</div>
} @else {
@for (comment of comments()[book.id] || []; track comment.id) {
<div class="comment">
<div class="comment-header">
@if (comment.user.avatar) {
<img [src]="comment.user.avatar" [alt]="comment.user.username" class="comment-avatar">
}
<span class="comment-author">{{ comment.user.username }}</span>
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
@if (authService.isAdmin()) {
<button (click)="deleteComment(book.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
}
</div>
<div class="comment-content" [innerHTML]="comment.content"></div>
</div>
} @empty {
<div class="no-comments">No comments yet. Be the first to comment!</div>
}
}
</div>
}
</div>
</div>
</div>
}
@@ -452,20 +577,126 @@ import { Book, BookStatus, CreateBookDto } from '@library/shared-types';
}
.btn-sm { padding: 0.25rem 0.75rem; font-size: 0.85rem; }
.btn-xs { padding: 0.15rem 0.5rem; font-size: 0.75rem; }
.comments-section {
margin-top: 1rem;
border-top: 1px solid var(--witch-lavender);
padding-top: 1rem;
}
.comments-toggle {
width: 100%;
}
.comments-container {
margin-top: 1rem;
}
.comment-form {
margin-bottom: 1rem;
}
.comment-form textarea {
width: 100%;
padding: 0.5rem;
border: 2px solid var(--witch-lavender);
border-radius: 4px;
font-size: 0.9rem;
background-color: var(--witch-moon);
color: var(--witch-purple);
resize: vertical;
margin-bottom: 0.5rem;
}
.comment-form textarea:focus {
outline: none;
border-color: var(--witch-rose);
}
.comment {
background: var(--witch-moon);
border: 1px solid var(--witch-lavender);
border-radius: 4px;
padding: 0.75rem;
margin-bottom: 0.5rem;
}
.comment-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.comment-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
}
.comment-author {
font-weight: 500;
color: var(--witch-plum);
}
.comment-date {
font-size: 0.75rem;
color: var(--witch-mauve);
}
.comment-content {
font-size: 0.9rem;
color: var(--witch-purple);
}
.comment-content :deep(p) {
margin: 0.25rem 0;
}
.comments-loading,
.no-comments {
text-align: center;
padding: 1rem;
color: var(--witch-mauve);
font-size: 0.9rem;
}
`]
})
export class BooksListComponent implements OnInit {
booksService = inject(BooksService);
authService = inject(AuthService);
commentsService = inject(CommentsService);
books = signal<Book[]>([]);
loading = signal(true);
showAddForm = signal(false);
editingBook = signal<Book | null>(null);
statusFilter = signal<'all' | BookStatus>('all');
// Comments state
comments = signal<Record<string, Comment[]>>({});
commentsLoading = signal<Record<string, boolean>>({});
expandedComments = signal<Record<string, boolean>>({});
newCommentContent: Record<string, string> = {};
// Expose BookStatus enum to template
BookStatus = BookStatus;
// Computed signals for reactive count updates
readingCount = computed(() => this.books().filter(book => book.status === BookStatus.reading).length);
finishedCount = computed(() => this.books().filter(book => book.status === BookStatus.finished).length);
toReadCount = computed(() => this.books().filter(book => book.status === BookStatus.toRead).length);
filteredBooks = computed(() => {
const filter = this.statusFilter();
if (filter === 'all') {
return this.books();
}
return this.books().filter(book => book.status === filter);
});
newBook: Partial<CreateBookDto> = {
title: '',
author: '',
@@ -475,6 +706,8 @@ export class BooksListComponent implements OnInit {
notes: ''
};
editBook: Partial<UpdateBookDto> = {};
ngOnInit() {
this.loadBooks();
}
@@ -492,18 +725,6 @@ export class BooksListComponent implements OnInit {
});
}
filteredBooks() {
const filter = this.statusFilter();
if (filter === 'all') {
return this.books();
}
return this.books().filter(book => book.status === filter);
}
getCountByStatus(status: BookStatus): number {
return this.books().filter(book => book.status === status).length;
}
setFilter(filter: 'all' | BookStatus) {
this.statusFilter.set(filter);
}
@@ -560,7 +781,108 @@ export class BooksListComponent implements OnInit {
}
}
startEdit(book: Book) {
this.editingBook.set(book);
this.editBook = {
title: book.title,
author: book.author,
isbn: book.isbn,
status: book.status,
rating: book.rating,
notes: book.notes
};
this.showAddForm.set(false);
}
cancelEdit() {
this.editingBook.set(null);
this.editBook = {};
}
saveEdit() {
const book = this.editingBook();
if (!book || !this.editBook.title || !this.editBook.author || !this.editBook.status) return;
this.booksService.updateBook(book.id, this.editBook).subscribe(() => {
this.loadBooks();
this.cancelEdit();
});
}
formatDate(date: Date | string): string {
return new Date(date).toLocaleDateString();
}
// Comments methods
toggleComments(bookId: string) {
const expanded = this.expandedComments();
const isCurrentlyExpanded = expanded[bookId];
this.expandedComments.set({
...expanded,
[bookId]: !isCurrentlyExpanded
});
if (!isCurrentlyExpanded && !this.comments()[bookId]) {
this.loadComments(bookId);
}
}
loadComments(bookId: string) {
this.commentsLoading.set({
...this.commentsLoading(),
[bookId]: true
});
this.commentsService.getCommentsForBook(bookId).subscribe({
next: (comments) => {
this.comments.set({
...this.comments(),
[bookId]: comments
});
this.commentsLoading.set({
...this.commentsLoading(),
[bookId]: false
});
},
error: () => {
this.commentsLoading.set({
...this.commentsLoading(),
[bookId]: false
});
}
});
}
getCommentCount(bookId: string): number {
return this.comments()[bookId]?.length || 0;
}
addComment(bookId: string) {
const content = this.newCommentContent[bookId];
if (!content?.trim()) return;
this.commentsService.addCommentToBook(bookId, { content }).subscribe({
next: (comment) => {
this.comments.set({
...this.comments(),
[bookId]: [comment, ...(this.comments()[bookId] || [])]
});
this.newCommentContent[bookId] = '';
}
});
}
deleteComment(bookId: string, commentId: string) {
if (!confirm('Are you sure you want to delete this comment?')) return;
this.commentsService.deleteCommentFromBook(bookId, commentId).subscribe({
next: () => {
this.comments.set({
...this.comments(),
[bookId]: (this.comments()[bookId] || []).filter(c => c.id !== commentId)
});
}
});
}
}
@@ -4,12 +4,13 @@
* @author Naomi Carrigan
*/
import { Component, OnInit, inject, signal } from '@angular/core';
import { Component, OnInit, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { GamesService } from '../../services/games.service';
import { AuthService } from '../../services/auth.service';
import { Game, GameStatus, CreateGameDto } from '@library/shared-types';
import { CommentsService } from '../../services/comments.service';
import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment } from '@library/shared-types';
@Component({
selector: 'app-games-list',
@@ -55,9 +56,9 @@ import { Game, GameStatus, CreateGameDto } from '@library/shared-types';
<div class="form-group">
<label for="status">Status</label>
<select id="status" [(ngModel)]="newGame.status" name="status" required>
<option value="playing">Currently Playing</option>
<option value="completed">Completed</option>
<option value="backlog">In Backlog</option>
<option [value]="GameStatus.playing">Currently Playing</option>
<option [value]="GameStatus.completed">Completed</option>
<option [value]="GameStatus.backlog">In Backlog</option>
</select>
</div>
@@ -91,6 +92,71 @@ import { Game, GameStatus, CreateGameDto } from '@library/shared-types';
</form>
}
@if (editingGame() && authService.isAdmin()) {
<form (ngSubmit)="saveEdit()" class="add-form">
<h3>Edit Game</h3>
<div class="form-group">
<label for="edit-title">Title</label>
<input
type="text"
id="edit-title"
[(ngModel)]="editGame.title"
name="title"
required
placeholder="Enter game title"
>
</div>
<div class="form-group">
<label for="edit-platform">Platform</label>
<input
type="text"
id="edit-platform"
[(ngModel)]="editGame.platform"
name="platform"
placeholder="PC, PS5, Xbox, Switch, etc."
>
</div>
<div class="form-group">
<label for="edit-status">Status</label>
<select id="edit-status" [(ngModel)]="editGame.status" name="status" required>
<option [value]="GameStatus.playing">Currently Playing</option>
<option [value]="GameStatus.completed">Completed</option>
<option [value]="GameStatus.backlog">In Backlog</option>
</select>
</div>
<div class="form-group">
<label for="edit-rating">Rating (0-10)</label>
<input
type="number"
id="edit-rating"
[(ngModel)]="editGame.rating"
name="rating"
min="0"
max="10"
>
</div>
<div class="form-group">
<label for="edit-notes">Notes</label>
<textarea
id="edit-notes"
[(ngModel)]="editGame.notes"
name="notes"
rows="3"
placeholder="Any thoughts about the game..."
></textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Save Changes</button>
<button type="button" (click)="cancelEdit()" class="btn btn-secondary">Cancel</button>
</div>
</form>
}
<div class="filters">
<button
(click)="setFilter('all')"
@@ -104,21 +170,21 @@ import { Game, GameStatus, CreateGameDto } from '@library/shared-types';
[class.active]="statusFilter() === GameStatus.playing"
class="filter-btn"
>
Playing ({{ getCountByStatus(GameStatus.playing) }})
Playing ({{ playingCount() }})
</button>
<button
(click)="setFilter(GameStatus.completed)"
[class.active]="statusFilter() === GameStatus.completed"
class="filter-btn"
>
Completed ({{ getCountByStatus(GameStatus.completed) }})
Completed ({{ completedCount() }})
</button>
<button
(click)="setFilter(GameStatus.backlog)"
[class.active]="statusFilter() === GameStatus.backlog"
class="filter-btn"
>
Backlog ({{ getCountByStatus(GameStatus.backlog) }})
Backlog ({{ backlogCount() }})
</button>
</div>
@@ -157,11 +223,58 @@ import { Game, GameStatus, CreateGameDto } from '@library/shared-types';
@if (authService.isAdmin()) {
<div class="actions">
<button (click)="startEdit(game)" class="btn btn-secondary btn-sm">
Edit
</button>
<button (click)="deleteGame(game)" class="btn btn-danger btn-sm">
Delete
</button>
</div>
}
<div class="comments-section">
<button (click)="toggleComments(game.id)" class="btn btn-secondary btn-sm comments-toggle">
{{ expandedComments()[game.id] ? 'Hide' : 'Show' }} Comments{{ comments()[game.id] ? ' (' + getCommentCount(game.id) + ')' : '' }}
</button>
@if (expandedComments()[game.id]) {
<div class="comments-container">
@if (authService.isAuthenticated()) {
<form (ngSubmit)="addComment(game.id)" class="comment-form">
<textarea
[(ngModel)]="newCommentContent[game.id]"
name="comment"
placeholder="Add a comment (Markdown supported)..."
rows="2"
></textarea>
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
</form>
}
@if (commentsLoading()[game.id]) {
<div class="comments-loading">Loading comments...</div>
} @else {
@for (comment of comments()[game.id] || []; track comment.id) {
<div class="comment">
<div class="comment-header">
@if (comment.user.avatar) {
<img [src]="comment.user.avatar" [alt]="comment.user.username" class="comment-avatar">
}
<span class="comment-author">{{ comment.user.username }}</span>
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
@if (authService.isAdmin()) {
<button (click)="deleteComment(game.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
}
</div>
<div class="comment-content" [innerHTML]="comment.content"></div>
</div>
} @empty {
<div class="no-comments">No comments yet. Be the first to comment!</div>
}
}
</div>
}
</div>
</div>
</div>
}
@@ -344,20 +457,115 @@ import { Game, GameStatus, CreateGameDto } from '@library/shared-types';
.btn-secondary { background: #6b7280; color: white; }
.btn-danger { background: #ef4444; color: white; }
.btn-sm { padding: 0.25rem 0.75rem; font-size: 0.85rem; }
.btn-xs { padding: 0.15rem 0.5rem; font-size: 0.75rem; }
.comments-section {
margin-top: 1rem;
border-top: 1px solid #e5e7eb;
padding-top: 1rem;
}
.comments-toggle {
width: 100%;
}
.comments-container {
margin-top: 1rem;
}
.comment-form {
margin-bottom: 1rem;
}
.comment-form textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
resize: vertical;
margin-bottom: 0.5rem;
}
.comment {
background: #f8f9fa;
border: 1px solid #e5e7eb;
border-radius: 4px;
padding: 0.75rem;
margin-bottom: 0.5rem;
}
.comment-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.comment-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
}
.comment-author {
font-weight: 500;
color: #374151;
}
.comment-date {
font-size: 0.75rem;
color: #6b7280;
}
.comment-content {
font-size: 0.9rem;
color: #4b5563;
}
.comments-loading,
.no-comments {
text-align: center;
padding: 1rem;
color: #6b7280;
font-size: 0.9rem;
}
`]
})
export class GamesListComponent implements OnInit {
gamesService = inject(GamesService);
authService = inject(AuthService);
commentsService = inject(CommentsService);
games = signal<Game[]>([]);
loading = signal(true);
showAddForm = signal(false);
editingGame = signal<Game | null>(null);
statusFilter = signal<'all' | GameStatus>('all');
// Comments state
comments = signal<Record<string, Comment[]>>({});
commentsLoading = signal<Record<string, boolean>>({});
expandedComments = signal<Record<string, boolean>>({});
newCommentContent: Record<string, string> = {};
// Expose GameStatus enum to template
GameStatus = GameStatus;
// Computed signals for reactive count updates
playingCount = computed(() => this.games().filter(game => game.status === GameStatus.playing).length);
completedCount = computed(() => this.games().filter(game => game.status === GameStatus.completed).length);
backlogCount = computed(() => this.games().filter(game => game.status === GameStatus.backlog).length);
filteredGames = computed(() => {
const filter = this.statusFilter();
if (filter === 'all') {
return this.games();
}
return this.games().filter(game => game.status === filter);
});
newGame: Partial<CreateGameDto> = {
title: '',
platform: '',
@@ -366,6 +574,8 @@ export class GamesListComponent implements OnInit {
notes: ''
};
editGame: Partial<UpdateGameDto> = {};
ngOnInit() {
this.loadGames();
}
@@ -383,18 +593,6 @@ export class GamesListComponent implements OnInit {
});
}
filteredGames() {
const filter = this.statusFilter();
if (filter === 'all') {
return this.games();
}
return this.games().filter(game => game.status === filter);
}
getCountByStatus(status: GameStatus): number {
return this.games().filter(game => game.status === status).length;
}
setFilter(filter: 'all' | GameStatus) {
this.statusFilter.set(filter);
}
@@ -448,4 +646,108 @@ export class GamesListComponent implements OnInit {
});
}
}
startEdit(game: Game) {
this.editingGame.set(game);
this.editGame = {
title: game.title,
platform: game.platform,
status: game.status,
rating: game.rating,
notes: game.notes
};
this.showAddForm.set(false);
}
cancelEdit() {
this.editingGame.set(null);
this.editGame = {};
}
saveEdit() {
const game = this.editingGame();
if (!game || !this.editGame.title || !this.editGame.status) return;
this.gamesService.updateGame(game.id, this.editGame).subscribe(() => {
this.loadGames();
this.cancelEdit();
});
}
// Comments methods
formatDate(date: Date | string): string {
return new Date(date).toLocaleDateString();
}
toggleComments(gameId: string) {
const expanded = this.expandedComments();
const isCurrentlyExpanded = expanded[gameId];
this.expandedComments.set({
...expanded,
[gameId]: !isCurrentlyExpanded
});
if (!isCurrentlyExpanded && !this.comments()[gameId]) {
this.loadComments(gameId);
}
}
loadComments(gameId: string) {
this.commentsLoading.set({
...this.commentsLoading(),
[gameId]: true
});
this.commentsService.getCommentsForGame(gameId).subscribe({
next: (comments) => {
this.comments.set({
...this.comments(),
[gameId]: comments
});
this.commentsLoading.set({
...this.commentsLoading(),
[gameId]: false
});
},
error: () => {
this.commentsLoading.set({
...this.commentsLoading(),
[gameId]: false
});
}
});
}
getCommentCount(gameId: string): number {
return this.comments()[gameId]?.length || 0;
}
addComment(gameId: string) {
const content = this.newCommentContent[gameId];
if (!content?.trim()) return;
this.commentsService.addCommentToGame(gameId, { content }).subscribe({
next: (comment) => {
this.comments.set({
...this.comments(),
[gameId]: [comment, ...(this.comments()[gameId] || [])]
});
this.newCommentContent[gameId] = '';
}
});
}
deleteComment(gameId: string, commentId: string) {
if (!confirm('Are you sure you want to delete this comment?')) return;
this.commentsService.deleteCommentFromGame(gameId, commentId).subscribe({
next: () => {
this.comments.set({
...this.comments(),
[gameId]: (this.comments()[gameId] || []).filter(c => c.id !== commentId)
});
}
});
}
}
@@ -4,12 +4,13 @@
* @author Naomi Carrigan
*/
import { Component, OnInit, inject, signal } from '@angular/core';
import { Component, OnInit, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MusicService } from '../../services/music.service';
import { AuthService } from '../../services/auth.service';
import { Music, MusicStatus, MusicType, CreateMusicDto } from '@library/shared-types';
import { CommentsService } from '../../services/comments.service';
import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment } from '@library/shared-types';
@Component({
selector: 'app-music-list',
@@ -56,18 +57,18 @@ import { Music, MusicStatus, MusicType, CreateMusicDto } from '@library/shared-t
<div class="form-group">
<label for="type">Type</label>
<select id="type" [(ngModel)]="newMusic.type" name="type" required>
<option value="album">Album</option>
<option value="single">Single</option>
<option value="ep">EP</option>
<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)]="newMusic.status" name="status" required>
<option value="listening">Currently Listening</option>
<option value="completed">Completed</option>
<option value="wantToListen">Want to Listen</option>
<option [value]="MusicStatus.listening">Currently Listening</option>
<option [value]="MusicStatus.completed">Completed</option>
<option [value]="MusicStatus.wantToListen">Want to Listen</option>
</select>
</div>
@@ -101,6 +102,81 @@ import { Music, MusicStatus, MusicType, CreateMusicDto } from '@library/shared-t
</form>
}
@if (editingMusic() && authService.isAdmin()) {
<form (ngSubmit)="saveEdit()" class="add-form">
<h3>Edit Music</h3>
<div class="form-group">
<label for="edit-title">Title</label>
<input
type="text"
id="edit-title"
[(ngModel)]="editMusicData.title"
name="title"
required
placeholder="Album/Single/EP title"
>
</div>
<div class="form-group">
<label for="edit-artist">Artist</label>
<input
type="text"
id="edit-artist"
[(ngModel)]="editMusicData.artist"
name="artist"
required
placeholder="Artist name"
>
</div>
<div class="form-group">
<label for="edit-type">Type</label>
<select id="edit-type" [(ngModel)]="editMusicData.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="edit-status">Status</label>
<select id="edit-status" [(ngModel)]="editMusicData.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>
</select>
</div>
<div class="form-group">
<label for="edit-rating">Rating (0-5)</label>
<input
type="number"
id="edit-rating"
[(ngModel)]="editMusicData.rating"
name="rating"
min="0"
max="5"
>
</div>
<div class="form-group">
<label for="edit-notes">Notes</label>
<textarea
id="edit-notes"
[(ngModel)]="editMusicData.notes"
name="notes"
rows="3"
placeholder="Your thoughts about this music..."
></textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Save Changes</button>
<button type="button" (click)="cancelEdit()" class="btn btn-secondary">Cancel</button>
</div>
</form>
}
<div class="filters">
<div class="filter-group">
<strong>Type:</strong>
@@ -116,21 +192,21 @@ import { Music, MusicStatus, MusicType, CreateMusicDto } from '@library/shared-t
[class.active]="typeFilter() === MusicType.album"
class="filter-btn"
>
Albums ({{ getCountByType(MusicType.album) }})
Albums ({{ albumCount() }})
</button>
<button
(click)="setTypeFilter(MusicType.single)"
[class.active]="typeFilter() === MusicType.single"
class="filter-btn"
>
Singles ({{ getCountByType(MusicType.single) }})
Singles ({{ singleCount() }})
</button>
<button
(click)="setTypeFilter(MusicType.ep)"
[class.active]="typeFilter() === MusicType.ep"
class="filter-btn"
>
EPs ({{ getCountByType(MusicType.ep) }})
EPs ({{ epCount() }})
</button>
</div>
@@ -148,21 +224,21 @@ import { Music, MusicStatus, MusicType, CreateMusicDto } from '@library/shared-t
[class.active]="statusFilter() === MusicStatus.listening"
class="filter-btn"
>
Listening ({{ getCountByStatus(MusicStatus.listening) }})
Listening ({{ listeningCount() }})
</button>
<button
(click)="setStatusFilter(MusicStatus.completed)"
[class.active]="statusFilter() === MusicStatus.completed"
class="filter-btn"
>
Completed ({{ getCountByStatus(MusicStatus.completed) }})
Completed ({{ completedCount() }})
</button>
<button
(click)="setStatusFilter(MusicStatus.wantToListen)"
[class.active]="statusFilter() === MusicStatus.wantToListen"
class="filter-btn"
>
Want to Listen ({{ getCountByStatus(MusicStatus.wantToListen) }})
Want to Listen ({{ wantToListenCount() }})
</button>
</div>
</div>
@@ -222,11 +298,58 @@ import { Music, MusicStatus, MusicType, CreateMusicDto } from '@library/shared-t
@if (authService.isAdmin()) {
<div class="actions">
<button (click)="startEdit(music)" class="btn btn-secondary btn-sm">
Edit
</button>
<button (click)="deleteMusic(music)" class="btn btn-danger btn-sm">
Delete
</button>
</div>
}
<div class="comments-section">
<button (click)="toggleComments(music.id)" class="btn btn-secondary btn-sm comments-toggle">
{{ expandedComments()[music.id] ? 'Hide' : 'Show' }} Comments{{ comments()[music.id] ? ' (' + getCommentCount(music.id) + ')' : '' }}
</button>
@if (expandedComments()[music.id]) {
<div class="comments-container">
@if (authService.isAuthenticated()) {
<form (ngSubmit)="addComment(music.id)" class="comment-form">
<textarea
[(ngModel)]="newCommentContent[music.id]"
name="comment"
placeholder="Add a comment (Markdown supported)..."
rows="2"
></textarea>
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
</form>
}
@if (commentsLoading()[music.id]) {
<div class="comments-loading">Loading comments...</div>
} @else {
@for (comment of comments()[music.id] || []; track comment.id) {
<div class="comment">
<div class="comment-header">
@if (comment.user.avatar) {
<img [src]="comment.user.avatar" [alt]="comment.user.username" class="comment-avatar">
}
<span class="comment-author">{{ comment.user.username }}</span>
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
@if (authService.isAdmin()) {
<button (click)="deleteComment(music.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
}
</div>
<div class="comment-content" [innerHTML]="comment.content"></div>
</div>
} @empty {
<div class="no-comments">No comments yet. Be the first to comment!</div>
}
}
</div>
}
</div>
</div>
</div>
}
@@ -529,22 +652,138 @@ import { Music, MusicStatus, MusicType, CreateMusicDto } from '@library/shared-t
padding: 0.25rem 0.75rem;
font-size: 0.85rem;
}
.btn-xs { padding: 0.15rem 0.5rem; font-size: 0.75rem; }
.comments-section {
margin-top: 1rem;
border-top: 1px solid var(--witch-lavender);
padding-top: 1rem;
}
.comments-toggle {
width: 100%;
}
.comments-container {
margin-top: 1rem;
}
.comment-form {
margin-bottom: 1rem;
}
.comment-form textarea {
width: 100%;
padding: 0.5rem;
border: 2px solid var(--witch-lavender);
border-radius: 4px;
font-size: 0.9rem;
background-color: var(--witch-moon);
color: var(--witch-purple);
resize: vertical;
margin-bottom: 0.5rem;
}
.comment-form textarea:focus {
outline: none;
border-color: var(--witch-rose);
}
.comment {
background: var(--witch-moon);
border: 1px solid var(--witch-lavender);
border-radius: 4px;
padding: 0.75rem;
margin-bottom: 0.5rem;
}
.comment-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.comment-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
}
.comment-author {
font-weight: 500;
color: var(--witch-plum);
}
.comment-date {
font-size: 0.75rem;
color: var(--witch-mauve);
}
.comment-content {
font-size: 0.9rem;
color: var(--witch-purple);
}
.comments-loading,
.no-comments {
text-align: center;
padding: 1rem;
color: var(--witch-mauve);
font-size: 0.9rem;
}
`]
})
export class MusicListComponent implements OnInit {
musicService = inject(MusicService);
authService = inject(AuthService);
commentsService = inject(CommentsService);
music = signal<Music[]>([]);
loading = signal(true);
showAddForm = signal(false);
editingMusic = signal<Music | null>(null);
typeFilter = signal<'all' | MusicType>('all');
statusFilter = signal<'all' | MusicStatus>('all');
// Comments state
comments = signal<Record<string, Comment[]>>({});
commentsLoading = signal<Record<string, boolean>>({});
expandedComments = signal<Record<string, boolean>>({});
newCommentContent: Record<string, string> = {};
// Expose enums to template
MusicType = MusicType;
MusicStatus = MusicStatus;
// Computed signals for reactive count updates (type)
albumCount = computed(() => this.music().filter(m => m.type === MusicType.album).length);
singleCount = computed(() => this.music().filter(m => m.type === MusicType.single).length);
epCount = computed(() => this.music().filter(m => m.type === MusicType.ep).length);
// Computed signals for reactive count updates (status)
listeningCount = computed(() => this.music().filter(m => m.status === MusicStatus.listening).length);
completedCount = computed(() => this.music().filter(m => m.status === MusicStatus.completed).length);
wantToListenCount = computed(() => this.music().filter(m => m.status === MusicStatus.wantToListen).length);
filteredMusic = computed(() => {
let filtered = this.music();
const typeFilter = this.typeFilter();
if (typeFilter !== 'all') {
filtered = filtered.filter(music => music.type === typeFilter);
}
const statusFilter = this.statusFilter();
if (statusFilter !== 'all') {
filtered = filtered.filter(music => music.status === statusFilter);
}
return filtered;
});
newMusic: Partial<CreateMusicDto> = {
title: '',
artist: '',
@@ -554,6 +793,8 @@ export class MusicListComponent implements OnInit {
notes: ''
};
editMusicData: Partial<UpdateMusicDto> = {};
ngOnInit() {
this.loadMusic();
}
@@ -571,30 +812,6 @@ export class MusicListComponent implements OnInit {
});
}
filteredMusic() {
let filtered = this.music();
const typeFilter = this.typeFilter();
if (typeFilter !== 'all') {
filtered = filtered.filter(music => music.type === typeFilter);
}
const statusFilter = this.statusFilter();
if (statusFilter !== 'all') {
filtered = filtered.filter(music => music.status === statusFilter);
}
return filtered;
}
getCountByType(type: MusicType): number {
return this.music().filter(music => music.type === type).length;
}
getCountByStatus(status: MusicStatus): number {
return this.music().filter(music => music.status === status).length;
}
setTypeFilter(filter: 'all' | MusicType) {
this.typeFilter.set(filter);
}
@@ -663,7 +880,108 @@ export class MusicListComponent implements OnInit {
}
}
startEdit(music: Music) {
this.editingMusic.set(music);
this.editMusicData = {
title: music.title,
artist: music.artist,
type: music.type,
status: music.status,
rating: music.rating,
notes: music.notes
};
this.showAddForm.set(false);
}
cancelEdit() {
this.editingMusic.set(null);
this.editMusicData = {};
}
saveEdit() {
const music = this.editingMusic();
if (!music || !this.editMusicData.title || !this.editMusicData.artist || !this.editMusicData.type || !this.editMusicData.status) return;
this.musicService.updateMusic(music.id, this.editMusicData).subscribe(() => {
this.loadMusic();
this.cancelEdit();
});
}
formatDate(date: Date | string): string {
return new Date(date).toLocaleDateString();
}
// Comments methods
toggleComments(musicId: string) {
const expanded = this.expandedComments();
const isCurrentlyExpanded = expanded[musicId];
this.expandedComments.set({
...expanded,
[musicId]: !isCurrentlyExpanded
});
if (!isCurrentlyExpanded && !this.comments()[musicId]) {
this.loadComments(musicId);
}
}
loadComments(musicId: string) {
this.commentsLoading.set({
...this.commentsLoading(),
[musicId]: true
});
this.commentsService.getCommentsForMusic(musicId).subscribe({
next: (comments) => {
this.comments.set({
...this.comments(),
[musicId]: comments
});
this.commentsLoading.set({
...this.commentsLoading(),
[musicId]: false
});
},
error: () => {
this.commentsLoading.set({
...this.commentsLoading(),
[musicId]: false
});
}
});
}
getCommentCount(musicId: string): number {
return this.comments()[musicId]?.length || 0;
}
addComment(musicId: string) {
const content = this.newCommentContent[musicId];
if (!content?.trim()) return;
this.commentsService.addCommentToMusic(musicId, { content }).subscribe({
next: (comment) => {
this.comments.set({
...this.comments(),
[musicId]: [comment, ...(this.comments()[musicId] || [])]
});
this.newCommentContent[musicId] = '';
}
});
}
deleteComment(musicId: string, commentId: string) {
if (!confirm('Are you sure you want to delete this comment?')) return;
this.commentsService.deleteCommentFromMusic(musicId, commentId).subscribe({
next: () => {
this.comments.set({
...this.comments(),
[musicId]: (this.comments()[musicId] || []).filter(c => c.id !== commentId)
});
}
});
}
}
@@ -47,7 +47,6 @@ export class ApiService {
delete<T>(endpoint: string): Observable<T> {
return this.http.delete<T>(`${this.apiUrl}${endpoint}`, {
headers: this.getHeaders(),
withCredentials: true
});
}
@@ -0,0 +1,53 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { ApiService } from './api.service';
import { Comment, CreateCommentDto } from '@library/shared-types';
@Injectable({
providedIn: 'root'
})
export class CommentsService {
constructor(private api: ApiService) {}
getCommentsForGame(gameId: string): Observable<Comment[]> {
return this.api.get<Comment[]>(`/games/${gameId}/comments`);
}
getCommentsForBook(bookId: string): Observable<Comment[]> {
return this.api.get<Comment[]>(`/books/${bookId}/comments`);
}
getCommentsForMusic(musicId: string): Observable<Comment[]> {
return this.api.get<Comment[]>(`/music/${musicId}/comments`);
}
addCommentToGame(gameId: string, comment: CreateCommentDto): Observable<Comment> {
return this.api.post<Comment>(`/games/${gameId}/comments`, comment);
}
addCommentToBook(bookId: string, comment: CreateCommentDto): Observable<Comment> {
return this.api.post<Comment>(`/books/${bookId}/comments`, comment);
}
addCommentToMusic(musicId: string, comment: CreateCommentDto): Observable<Comment> {
return this.api.post<Comment>(`/music/${musicId}/comments`, comment);
}
deleteCommentFromGame(gameId: string, commentId: string): Observable<{ success: boolean }> {
return this.api.delete<{ success: boolean }>(`/games/${gameId}/comments/${commentId}`);
}
deleteCommentFromBook(bookId: string, commentId: string): Observable<{ success: boolean }> {
return this.api.delete<{ success: boolean }>(`/books/${bookId}/comments/${commentId}`);
}
deleteCommentFromMusic(musicId: string, commentId: string): Observable<{ success: boolean }> {
return this.api.delete<{ success: boolean }>(`/music/${musicId}/comments/${commentId}`);
}
}
+5
View File
@@ -29,8 +29,11 @@
"@fastify/sensible": "6.0.4",
"@fastify/static": "^9.0.0",
"@prisma/client": "6.19.2",
"dompurify": "^3.3.1",
"fastify": "5.2.2",
"fastify-plugin": "5.0.1",
"jsdom": "^28.0.0",
"marked": "^17.0.1",
"rxjs": "7.8.2"
},
"devDependencies": {
@@ -56,7 +59,9 @@
"@swc-node/register": "1.9.2",
"@swc/core": "1.5.29",
"@swc/helpers": "0.5.18",
"@types/dompurify": "^3.2.0",
"@types/jest": "30.0.0",
"@types/jsdom": "^27.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "20.19.9",
"@typescript-eslint/utils": "8.54.0",
+368 -18
View File
@@ -47,19 +47,28 @@ importers:
'@prisma/client':
specifier: 6.19.2
version: 6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3)
dompurify:
specifier: ^3.3.1
version: 3.3.1
fastify:
specifier: 5.2.2
version: 5.2.2
fastify-plugin:
specifier: 5.0.1
version: 5.0.1
jsdom:
specifier: ^28.0.0
version: 28.0.0
marked:
specifier: ^17.0.1
version: 17.0.1
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)(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.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/core':
specifier: 21.1.2
version: 21.1.2(chokidar@5.0.0)
@@ -80,10 +89,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)(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.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))
'@nx/angular':
specifier: 22.4.4
version: 22.4.4(ec507f70e00b67864c7695431dce0b70)
version: 22.4.4(53644491fdd75e214444a711ff7c7d5c)
'@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)
@@ -123,9 +132,15 @@ importers:
'@swc/helpers':
specifier: 0.5.18
version: 0.5.18
'@types/dompurify':
specifier: ^3.2.0
version: 3.2.0
'@types/jest':
specifier: 30.0.0
version: 30.0.0
'@types/jsdom':
specifier: ^27.0.0
version: 27.0.0
'@types/jsonwebtoken':
specifier: ^9.0.10
version: 9.0.10
@@ -183,6 +198,9 @@ importers:
packages:
'@acemir/cssom@0.9.31':
resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==}
'@algolia/abtesting@1.12.2':
resolution: {integrity: sha512-oWknd6wpfNrmRcH0vzed3UPX0i17o4kYLM5OMITyMVM2xLgaRbIafoxL0e8mcrNNb0iORCJA0evnNDKRYth5WQ==}
engines: {node: '>= 14.0.0'}
@@ -481,6 +499,15 @@ 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/dom-selector@6.7.7':
resolution: {integrity: sha512-8CO/UQ4tzDd7ula+/CVimJIVWez99UJlbMyIgk8xOnhAVPKLnBZmUFYVgugS441v2ZqUq5EnSh6B0Ua0liSFAA==}
'@asamuzakjp/nwsapi@2.3.9':
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
'@babel/code-frame@7.29.0':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'}
@@ -1141,6 +1168,37 @@ 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/css-calc@2.1.4':
resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==}
engines: {node: '>=18'}
peerDependencies:
'@csstools/css-parser-algorithms': ^3.0.5
'@csstools/css-tokenizer': ^3.0.4
'@csstools/css-color-parser@3.1.0':
resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==}
engines: {node: '>=18'}
peerDependencies:
'@csstools/css-parser-algorithms': ^3.0.5
'@csstools/css-tokenizer': ^3.0.4
'@csstools/css-parser-algorithms@3.0.5':
resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==}
engines: {node: '>=18'}
peerDependencies:
'@csstools/css-tokenizer': ^3.0.4
'@csstools/css-syntax-patches-for-csstree@1.0.26':
resolution: {integrity: sha512-6boXK0KkzT5u5xOgF6TKB+CLq9SOpEGmkZw0g5n9/7yg85wab3UzSxB8TxhLJ31L4SGJ6BCFRw/iftTha1CJXA==}
'@csstools/css-tokenizer@3.0.4':
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
engines: {node: '>=18'}
'@cypress/request@3.0.10':
resolution: {integrity: sha512-hauBrOdvu08vOsagkZ/Aju5XuiZx6ldsLfByg1htFeldhex+PeMrYauANzFsMJeAA0+dyPLbDoX2OYuvVoLDkQ==}
engines: {node: '>= 6'}
@@ -1520,6 +1578,15 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@exodus/bytes@1.11.0':
resolution: {integrity: sha512-wO3vd8nsEHdumsXrjGO/v4p6irbg7hy9kvIeR6i2AwylZSk4HJdWgL0FNaVquW1+AweJcdvU1IEpuIWk/WaPnA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
peerDependencies:
'@noble/hashes': ^1.8.0 || ^2.0.0
peerDependenciesMeta:
'@noble/hashes':
optional: true
'@fastify/accept-negotiator@2.0.1':
resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==}
@@ -3257,6 +3324,10 @@ packages:
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/dompurify@3.2.0':
resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==}
deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.
'@types/eslint-scope@3.7.7':
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
@@ -3296,6 +3367,9 @@ 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/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@@ -3362,6 +3436,12 @@ packages:
'@types/tmp@0.2.6':
resolution: {integrity: sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==}
'@types/tough-cookie@4.0.5':
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
@@ -4098,6 +4178,9 @@ packages:
resolution: {integrity: sha512-NaWu+f4YrJxEttJSm16AzMIFtVldCvaJ68b1L098KpqXmxt9xOLtKoLkKxb8ekhOrLqEJAbvT6n6SEvB/sac7A==}
engines: {node: '>=14.0.0'}
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
big.js@5.2.2:
resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==}
@@ -4596,6 +4679,10 @@ packages:
resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
css-tree@3.1.0:
resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
css-what@6.2.2:
resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
engines: {node: '>= 6'}
@@ -4631,6 +4718,10 @@ 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==}
engines: {node: '>=20'}
cypress@15.9.0:
resolution: {integrity: sha512-Ks6Bdilz3TtkLZtTQyqYaqtL/WT3X3APKaSLhTV96TmTyudzSjc6EJsJCHmBb7DxO+3R12q3Jkbjgm/iPgmwfg==}
engines: {node: ^20.1.0 || ^22.0.0 || >=24.0.0}
@@ -4640,6 +4731,10 @@ packages:
resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==}
engines: {node: '>=0.10'}
data-urls@7.0.0:
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
data-view-buffer@1.0.2:
resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
engines: {node: '>= 0.4'}
@@ -4693,6 +4788,9 @@ packages:
supports-color:
optional: true
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
dedent@1.7.1:
resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==}
peerDependencies:
@@ -4813,6 +4911,9 @@ packages:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
dompurify@3.3.1:
resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==}
domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
@@ -5666,6 +5767,10 @@ packages:
resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==}
engines: {node: '>=12'}
html-encoding-sniffer@6.0.0:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
html-entities@2.6.0:
resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==}
@@ -5980,6 +6085,9 @@ packages:
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
engines: {node: '>=0.10.0'}
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
is-promise@4.0.0:
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
@@ -6285,6 +6393,15 @@ packages:
resolution: {integrity: sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg==}
engines: {node: '>=12.0.0'}
jsdom@28.0.0:
resolution: {integrity: sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
peerDependencies:
canvas: ^3.0.0
peerDependenciesMeta:
canvas:
optional: true
jsesc@0.5.0:
resolution: {integrity: sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==}
hasBin: true
@@ -6578,6 +6695,11 @@ packages:
makeerror@1.0.12:
resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==}
marked@17.0.1:
resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==}
engines: {node: '>= 20'}
hasBin: true
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -6588,6 +6710,9 @@ packages:
mdn-data@2.0.30:
resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==}
mdn-data@2.12.2:
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
media-typer@0.3.0:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
@@ -7091,6 +7216,9 @@ packages:
parse5@4.0.0:
resolution: {integrity: sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA==}
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
parse5@8.0.0:
resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==}
@@ -7944,6 +8072,10 @@ packages:
resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==}
engines: {node: '>=11.0.0'}
saxes@6.0.0:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -8330,6 +8462,9 @@ packages:
engines: {node: '>=14.0.0'}
hasBin: true
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
sync-child-process@1.0.2:
resolution: {integrity: sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==}
engines: {node: '>=16.0.0'}
@@ -8433,10 +8568,17 @@ packages:
tldts-core@6.1.86:
resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==}
tldts-core@7.0.22:
resolution: {integrity: sha512-KgbTDC5wzlL6j/x6np6wCnDSMUq4kucHNm00KXPbfNzmllCmtmvtykJHfmgdHntwIeupW04y8s1N/43S1PkQDw==}
tldts@6.1.86:
resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==}
hasBin: true
tldts@7.0.22:
resolution: {integrity: sha512-nqpKFC53CgopKPjT6Wfb6tpIcZXHcI6G37hesvikhx0EmUGPkZrujRyAjgnmp1SHNgpQfKVanZ+KfpANFt2Hxw==}
hasBin: true
tmp@0.2.5:
resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==}
engines: {node: '>=14.14'}
@@ -8460,9 +8602,17 @@ packages:
resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==}
engines: {node: '>=16'}
tough-cookie@6.0.0:
resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==}
engines: {node: '>=16'}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
tr46@6.0.0:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'}
tree-dump@1.1.0:
resolution: {integrity: sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==}
engines: {node: '>=10.0'}
@@ -8660,6 +8810,10 @@ packages:
resolution: {integrity: sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==}
engines: {node: '>=20.18.1'}
undici@7.20.0:
resolution: {integrity: sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ==}
engines: {node: '>=20.18.1'}
unicode-canonical-property-names-ecmascript@2.0.1:
resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==}
engines: {node: '>=4'}
@@ -8873,6 +9027,10 @@ packages:
jsdom:
optional: true
w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
walker@1.0.8:
resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==}
@@ -8896,6 +9054,10 @@ packages:
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
webidl-conversions@8.0.1:
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
engines: {node: '>=20'}
webpack-dev-middleware@7.4.5:
resolution: {integrity: sha512-uxQ6YqGdE4hgDKNf7hUiPXOdtkXvBJXrfEGYSx7P7LC8hnUYGK70X6xQXUvXeNyBDDcsiQXpG2m3G9vxowaEuA==}
engines: {node: '>= 18.12.0'}
@@ -8990,6 +9152,14 @@ packages:
engines: {node: '>=12'}
deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
whatwg-mimetype@5.0.0:
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
engines: {node: '>=20'}
whatwg-url@16.0.0:
resolution: {integrity: sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
@@ -9093,6 +9263,13 @@ packages:
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
engines: {node: '>=20'}
xml-name-validator@5.0.0:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
@@ -9169,6 +9346,8 @@ packages:
snapshots:
'@acemir/cssom@0.9.31': {}
'@algolia/abtesting@1.12.2':
dependencies:
'@algolia/client-common': 5.46.2
@@ -9265,13 +9444,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)(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.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)':
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)(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.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/compiler-cli': 21.1.2(@angular/compiler@21.1.2)(typescript@5.9.3)
'@babel/core': 7.28.5
'@babel/generator': 7.28.5
@@ -9446,7 +9625,7 @@ snapshots:
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)(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.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)':
dependencies:
'@ampproject/remapping': 2.3.0
'@angular-devkit/architect': 0.2101.2(chokidar@5.0.0)
@@ -9485,7 +9664,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)(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.0.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
@@ -9499,7 +9678,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)(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)':
dependencies:
'@ampproject/remapping': 2.3.0
'@angular-devkit/architect': 0.2101.2(chokidar@5.0.0)
@@ -9538,7 +9717,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)(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.0.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
@@ -9639,6 +9818,24 @@ snapshots:
rxjs: 7.8.2
tslib: 2.8.1
'@asamuzakjp/css-color@4.1.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
'@asamuzakjp/dom-selector@6.7.7':
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
'@asamuzakjp/nwsapi@2.3.9': {}
'@babel/code-frame@7.29.0':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
@@ -11022,6 +11219,28 @@ snapshots:
dependencies:
'@jridgewell/trace-mapping': 0.3.9
'@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)':
dependencies:
'@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)':
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/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)':
dependencies:
'@csstools/css-tokenizer': 3.0.4
'@csstools/css-syntax-patches-for-csstree@1.0.26': {}
'@csstools/css-tokenizer@3.0.4': {}
'@cypress/request@3.0.10':
dependencies:
aws-sign2: 0.7.0
@@ -11290,6 +11509,8 @@ snapshots:
'@eslint/core': 0.17.0
levn: 0.4.1
'@exodus/bytes@1.11.0': {}
'@fastify/accept-negotiator@2.0.1': {}
'@fastify/ajv-compiler@4.0.5':
@@ -12416,7 +12637,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)(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.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))':
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))
@@ -12425,7 +12646,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)(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))
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))
@@ -12438,7 +12659,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)(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.0.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
@@ -12518,7 +12739,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@nx/angular@22.4.4(ec507f70e00b67864c7695431dce0b70)':
'@nx/angular@22.4.4(53644491fdd75e214444a711ff7c7d5c)':
dependencies:
'@angular-devkit/core': 21.1.2(chokidar@5.0.0)
'@angular-devkit/schematics': 21.1.2(chokidar@5.0.0)
@@ -12542,8 +12763,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)(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)(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.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)
transitivePeerDependencies:
- '@babel/traverse'
- '@module-federation/enhanced'
@@ -13648,6 +13869,10 @@ snapshots:
'@types/deep-eql@4.0.2': {}
'@types/dompurify@3.2.0':
dependencies:
dompurify: 3.3.1
'@types/eslint-scope@3.7.7':
dependencies:
'@types/eslint': 9.6.1
@@ -13701,6 +13926,12 @@ snapshots:
expect: 30.2.0
pretty-format: 30.2.0
'@types/jsdom@27.0.0':
dependencies:
'@types/node': 20.19.9
'@types/tough-cookie': 4.0.5
parse5: 7.3.0
'@types/json-schema@7.0.15': {}
'@types/json5@0.0.29': {}
@@ -13765,6 +13996,11 @@ snapshots:
'@types/tmp@0.2.6': {}
'@types/tough-cookie@4.0.5': {}
'@types/trusted-types@2.0.7':
optional: true
'@types/ws@8.18.1':
dependencies:
'@types/node': 20.19.9
@@ -14051,13 +14287,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)(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))':
dependencies:
'@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)
optionalDependencies:
typescript: 5.9.3
vitest: 4.0.18(@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: 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/expect@4.0.18':
dependencies:
@@ -14666,6 +14902,10 @@ snapshots:
postcss: 8.5.6
postcss-media-query-parser: 0.2.3
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
big.js@5.2.2: {}
binary-extensions@2.3.0: {}
@@ -15175,6 +15415,11 @@ snapshots:
mdn-data: 2.0.30
source-map-js: 1.2.1
css-tree@3.1.0:
dependencies:
mdn-data: 2.12.2
source-map-js: 1.2.1
css-what@6.2.2: {}
css-what@7.0.0: {}
@@ -15229,6 +15474,13 @@ snapshots:
dependencies:
css-tree: 2.2.1
cssstyle@5.3.7:
dependencies:
'@asamuzakjp/css-color': 4.1.1
'@csstools/css-syntax-patches-for-csstree': 1.0.26
css-tree: 3.1.0
lru-cache: 11.2.5
cypress@15.9.0:
dependencies:
'@cypress/request': 3.0.10
@@ -15278,6 +15530,13 @@ snapshots:
dependencies:
assert-plus: 1.0.0
data-urls@7.0.0:
dependencies:
whatwg-mimetype: 5.0.0
whatwg-url: 16.0.0
transitivePeerDependencies:
- '@noble/hashes'
data-view-buffer@1.0.2:
dependencies:
call-bound: 1.0.4
@@ -15320,6 +15579,8 @@ snapshots:
optionalDependencies:
supports-color: 8.1.1
decimal.js@10.6.0: {}
dedent@1.7.1(babel-plugin-macros@3.1.0):
optionalDependencies:
babel-plugin-macros: 3.1.0
@@ -15415,6 +15676,10 @@ snapshots:
dependencies:
domelementtype: 2.3.0
dompurify@3.3.1:
optionalDependencies:
'@types/trusted-types': 2.0.7
domutils@3.2.2:
dependencies:
dom-serializer: 2.0.0
@@ -16572,6 +16837,12 @@ snapshots:
dependencies:
whatwg-encoding: 2.0.0
html-encoding-sniffer@6.0.0:
dependencies:
'@exodus/bytes': 1.11.0
transitivePeerDependencies:
- '@noble/hashes'
html-entities@2.6.0: {}
html-escaper@2.0.2: {}
@@ -16880,6 +17151,8 @@ snapshots:
is-plain-object@5.0.0: {}
is-potential-custom-element-name@1.0.1: {}
is-promise@4.0.0: {}
is-regex@1.2.1:
@@ -17380,6 +17653,32 @@ snapshots:
jsdoc-type-pratt-parser@4.1.0: {}
jsdom@28.0.0:
dependencies:
'@acemir/cssom': 0.9.31
'@asamuzakjp/dom-selector': 6.7.7
'@exodus/bytes': 1.11.0
cssstyle: 5.3.7
data-urls: 7.0.0
decimal.js: 10.6.0
html-encoding-sniffer: 6.0.0
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
is-potential-custom-element-name: 1.0.1
parse5: 8.0.0
saxes: 6.0.0
symbol-tree: 3.2.4
tough-cookie: 6.0.0
undici: 7.20.0
w3c-xmlserializer: 5.0.0
webidl-conversions: 8.0.1
whatwg-mimetype: 5.0.0
whatwg-url: 16.0.0
xml-name-validator: 5.0.0
transitivePeerDependencies:
- '@noble/hashes'
- supports-color
jsesc@0.5.0: {}
jsesc@3.1.0: {}
@@ -17731,12 +18030,16 @@ snapshots:
dependencies:
tmpl: 1.0.5
marked@17.0.1: {}
math-intrinsics@1.1.0: {}
mdn-data@2.0.28: {}
mdn-data@2.0.30: {}
mdn-data@2.12.2: {}
media-typer@0.3.0: {}
media-typer@1.1.0: {}
@@ -18327,6 +18630,10 @@ snapshots:
parse5@4.0.0: {}
parse5@7.3.0:
dependencies:
entities: 6.0.1
parse5@8.0.0:
dependencies:
entities: 6.0.1
@@ -19188,6 +19495,10 @@ snapshots:
sax@1.4.4:
optional: true
saxes@6.0.0:
dependencies:
xmlchars: 2.2.0
scheduler@0.27.0: {}
schema-utils@3.3.0:
@@ -19699,6 +20010,8 @@ snapshots:
csso: 5.0.5
picocolors: 1.1.1
symbol-tree@3.2.4: {}
sync-child-process@1.0.2:
dependencies:
sync-message-port: 1.2.0
@@ -19812,10 +20125,16 @@ snapshots:
tldts-core@6.1.86: {}
tldts-core@7.0.22: {}
tldts@6.1.86:
dependencies:
tldts-core: 6.1.86
tldts@7.0.22:
dependencies:
tldts-core: 7.0.22
tmp@0.2.5: {}
tmpl@1.0.5: {}
@@ -19832,8 +20151,16 @@ snapshots:
dependencies:
tldts: 6.1.86
tough-cookie@6.0.0:
dependencies:
tldts: 7.0.22
tr46@0.0.3: {}
tr46@6.0.0:
dependencies:
punycode: 2.3.1
tree-dump@1.1.0(tslib@2.8.1):
dependencies:
tslib: 2.8.1
@@ -20047,6 +20374,8 @@ snapshots:
undici@7.18.2: {}
undici@7.20.0: {}
unicode-canonical-property-names-ecmascript@2.0.1: {}
unicode-match-property-ecmascript@2.0.0:
@@ -20202,7 +20531,7 @@ snapshots:
terser: 5.44.1
yaml: 2.8.2
vitest@4.0.18(@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@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):
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))
@@ -20226,6 +20555,7 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 20.19.9
jsdom: 28.0.0
transitivePeerDependencies:
- jiti
- less
@@ -20239,6 +20569,10 @@ snapshots:
- tsx
- yaml
w3c-xmlserializer@5.0.0:
dependencies:
xml-name-validator: 5.0.0
walker@1.0.8:
dependencies:
makeerror: 1.0.12
@@ -20266,6 +20600,8 @@ snapshots:
webidl-conversions@3.0.1: {}
webidl-conversions@8.0.1: {}
webpack-dev-middleware@7.4.5(tslib@2.8.1)(webpack@5.104.1(@swc/core@1.5.29(@swc/helpers@0.5.18))(esbuild@0.27.2)):
dependencies:
colorette: 2.0.20
@@ -20472,6 +20808,16 @@ snapshots:
dependencies:
iconv-lite: 0.6.3
whatwg-mimetype@5.0.0: {}
whatwg-url@16.0.0:
dependencies:
'@exodus/bytes': 1.11.0
tr46: 6.0.0
webidl-conversions: 8.0.1
transitivePeerDependencies:
- '@noble/hashes'
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
@@ -20585,6 +20931,10 @@ snapshots:
is-wsl: 3.1.0
powershell-utils: 0.1.0
xml-name-validator@5.0.0: {}
xmlchars@2.2.0: {}
xtend@4.0.2: {}
y18n@5.0.8: {}
+2 -1
View File
@@ -6,4 +6,5 @@
export * from "./lib/game.types";
export * from "./lib/book.types";
export * from "./lib/music.types";
export type * from "./lib/auth.types";
export type * from "./lib/auth.types";
export * from "./lib/comment.types";
+27
View File
@@ -0,0 +1,27 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
export interface CommentUser {
id: string;
username: string;
avatar?: string;
}
export interface Comment {
id: string;
content: string;
userId: string;
user: CommentUser;
gameId?: string;
bookId?: string;
musicId?: string;
createdAt: Date;
updatedAt: Date;
}
export interface CreateCommentDto {
content: string;
}