Compare commits

..

1 Commits

Author SHA1 Message Date
minori e035bf6c59 deps: update @angular/cli to 21.1.3
Node.js CI / CI (pull_request) Failing after 12s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m27s
2026-02-16 07:08:28 -08:00
115 changed files with 1095 additions and 9401 deletions
+1 -5
View File
@@ -3,12 +3,8 @@ module.exports = {
preset: '../jest.preset.js', preset: '../jest.preset.js',
testEnvironment: 'node', testEnvironment: 'node',
transform: { transform: {
'^.+\\.[tj]s$': ['ts-jest', { '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
tsconfig: '<rootDir>/tsconfig.spec.json',
isolatedModules: true,
}],
}, },
moduleFileExtensions: ['ts', 'js', 'html'], moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../coverage/api', coverageDirectory: '../coverage/api',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
}; };
+21 -111
View File
@@ -24,9 +24,7 @@ model Game {
platform String? platform String?
status GameStatus status GameStatus
dateAdded DateTime @default(now()) dateAdded DateTime @default(now())
dateStarted DateTime?
dateCompleted DateTime? dateCompleted DateTime?
dateFinished DateTime?
rating Int? @db.Int @default(0) rating Int? @db.Int @default(0)
notes String? notes String?
coverImage String? coverImage String?
@@ -41,7 +39,6 @@ enum GameStatus {
PLAYING PLAYING
COMPLETED COMPLETED
BACKLOG BACKLOG
RETIRED
} }
model Book { model Book {
@@ -51,7 +48,6 @@ model Book {
isbn String? isbn String?
status BookStatus status BookStatus
dateAdded DateTime @default(now()) dateAdded DateTime @default(now())
dateStarted DateTime?
dateFinished DateTime? dateFinished DateTime?
rating Int? @db.Int @default(0) rating Int? @db.Int @default(0)
notes String? notes String?
@@ -67,7 +63,6 @@ enum BookStatus {
READING READING
FINISHED FINISHED
TO_READ TO_READ
RETIRED
} }
model Music { model Music {
@@ -77,9 +72,7 @@ model Music {
type MusicType type MusicType
status MusicStatus status MusicStatus
dateAdded DateTime @default(now()) dateAdded DateTime @default(now())
dateStarted DateTime?
dateCompleted DateTime? dateCompleted DateTime?
dateFinished DateTime?
rating Int? @db.Int @default(0) rating Int? @db.Int @default(0)
notes String? notes String?
coverArt String? coverArt String?
@@ -100,7 +93,6 @@ enum MusicStatus {
LISTENING LISTENING
COMPLETED COMPLETED
WANT_TO_LISTEN WANT_TO_LISTEN
RETIRED
} }
model Art { model Art {
@@ -123,9 +115,7 @@ model Show {
type ShowType type ShowType
status ShowStatus status ShowStatus
dateAdded DateTime @default(now()) dateAdded DateTime @default(now())
dateStarted DateTime?
dateCompleted DateTime? dateCompleted DateTime?
dateFinished DateTime?
rating Int? @db.Int @default(0) rating Int? @db.Int @default(0)
notes String? notes String?
coverImage String? coverImage String?
@@ -147,7 +137,6 @@ enum ShowStatus {
WATCHING WATCHING
COMPLETED COMPLETED
WANT_TO_WATCH WANT_TO_WATCH
RETIRED
} }
model Manga { model Manga {
@@ -156,9 +145,7 @@ model Manga {
author String author String
status MangaStatus status MangaStatus
dateAdded DateTime @default(now()) dateAdded DateTime @default(now())
dateStarted DateTime?
dateCompleted DateTime? dateCompleted DateTime?
dateFinished DateTime?
rating Int? @db.Int @default(0) rating Int? @db.Int @default(0)
notes String? notes String?
coverImage String? coverImage String?
@@ -173,7 +160,6 @@ enum MangaStatus {
READING READING
COMPLETED COMPLETED
WANT_TO_READ WANT_TO_READ
RETIRED
} }
model User { model User {
@@ -182,17 +168,6 @@ model User {
username String username String
email String @unique email String @unique
avatar String? avatar String?
slug String?
displayName String?
bio String?
profilePublic Boolean @default(true)
website String?
discordServer String?
bluesky String?
github String?
linkedin String?
twitch String?
youtube String?
isAdmin Boolean @default(false) isAdmin Boolean @default(false)
isBanned Boolean @default(false) isBanned Boolean @default(false)
inDiscord Boolean @default(false) inDiscord Boolean @default(false)
@@ -201,40 +176,32 @@ model User {
isStaff Boolean @default(false) isStaff Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
comments Comment[] comments Comment[]
suggestions Suggestion[] suggestions Suggestion[]
likes Like[] likes Like[]
refreshTokens RefreshToken[] refreshTokens RefreshToken[]
reportsMade ProfileReport[] @relation("Reporter")
reportsReceived ProfileReport[] @relation("ReportedUser")
reportsReviewed ProfileReport[] @relation("Reviewer")
commentReportsMade CommentReport[] @relation("CommentReporter")
commentReportsReviewed CommentReport[] @relation("CommentReviewer")
@@index([slug], map: "User_slug_key")
} }
model Comment { model Comment {
id String @id @default(auto()) @map("_id") @db.ObjectId id String @id @default(auto()) @map("_id") @db.ObjectId
content String content String
rawContent String? rawContent String?
userId String @db.ObjectId userId String @db.ObjectId
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
gameId String? @db.ObjectId gameId String? @db.ObjectId
game Game? @relation(fields: [gameId], references: [id]) game Game? @relation(fields: [gameId], references: [id])
bookId String? @db.ObjectId bookId String? @db.ObjectId
book Book? @relation(fields: [bookId], references: [id]) book Book? @relation(fields: [bookId], references: [id])
musicId String? @db.ObjectId musicId String? @db.ObjectId
music Music? @relation(fields: [musicId], references: [id]) music Music? @relation(fields: [musicId], references: [id])
artId String? @db.ObjectId artId String? @db.ObjectId
art Art? @relation(fields: [artId], references: [id]) art Art? @relation(fields: [artId], references: [id])
showId String? @db.ObjectId showId String? @db.ObjectId
show Show? @relation(fields: [showId], references: [id]) show Show? @relation(fields: [showId], references: [id])
mangaId String? @db.ObjectId mangaId String? @db.ObjectId
manga Manga? @relation(fields: [mangaId], references: [id]) manga Manga? @relation(fields: [mangaId], references: [id])
reports CommentReport[] createdAt DateTime @default(now())
createdAt DateTime @default(now()) updatedAt DateTime @updatedAt
updatedAt DateTime @updatedAt
} }
model AuditLog { model AuditLog {
@@ -335,60 +302,3 @@ model RefreshToken {
@@index([userId]) @@index([userId])
@@index([expiresAt]) @@index([expiresAt])
} }
enum ReportReason {
INAPPROPRIATE_CONTENT
HARASSMENT
SPAM
IMPERSONATION
OFFENSIVE_NAME
MALICIOUS_LINKS
OTHER
}
enum ReportStatus {
PENDING
REVIEWED
DISMISSED
ACTION_TAKEN
}
model ProfileReport {
id String @id @default(auto()) @map("_id") @db.ObjectId
reportedUserId String @db.ObjectId
reportedUser User @relation("ReportedUser", fields: [reportedUserId], references: [id])
reporterId String @db.ObjectId
reporter User @relation("Reporter", fields: [reporterId], references: [id])
reason ReportReason
details String
status ReportStatus @default(PENDING)
reviewedBy String? @db.ObjectId
reviewer User? @relation("Reviewer", fields: [reviewedBy], references: [id])
reviewNotes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([reportedUserId])
@@index([reporterId])
@@index([status])
}
model CommentReport {
id String @id @default(auto()) @map("_id") @db.ObjectId
reportedCommentId String @db.ObjectId
reportedComment Comment @relation(fields: [reportedCommentId], references: [id])
reporterId String @db.ObjectId
reporter User @relation("CommentReporter", fields: [reporterId], references: [id])
reason ReportReason
details String
status ReportStatus @default(PENDING)
reviewedBy String? @db.ObjectId
reviewer User? @relation("CommentReviewer", fields: [reviewedBy], references: [id])
reviewNotes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([reportedCommentId])
@@index([reporterId])
@@index([status])
}
+1 -1
View File
@@ -15,6 +15,6 @@ describe('GET /', () => {
url: '/', url: '/',
}); });
expect(response.json()).toEqual({ version: expect.any(String) }); expect(response.json()).toEqual({ message: 'Hello API' });
}); });
}); });
+4 -12
View File
@@ -13,8 +13,8 @@ export async function app(fastify: FastifyInstance, opts: AppOptions) {
// Log CSRF validation failures // Log CSRF validation failures
if (error.code === 'FST_CSRF_INVALID_TOKEN' || error.code === 'FST_CSRF_MISSING_SECRET') { if (error.code === 'FST_CSRF_INVALID_TOKEN' || error.code === 'FST_CSRF_MISSING_SECRET') {
await AuditService.log({ await AuditService.log({
action: AuditAction.csrfValidationFailed, action: AuditAction.CSRF_VALIDATION_FAILED,
category: AuditCategory.security, category: AuditCategory.SECURITY,
details: `CSRF validation failed: ${error.message}, URL: ${request.url}`, details: `CSRF validation failed: ${error.message}, URL: ${request.url}`,
success: false, success: false,
}, request).catch(() => { }, request).catch(() => {
@@ -25,8 +25,8 @@ export async function app(fastify: FastifyInstance, opts: AppOptions) {
// Log unauthorized access attempts // Log unauthorized access attempts
if (error.statusCode === 401 || error.statusCode === 403) { if (error.statusCode === 401 || error.statusCode === 403) {
await AuditService.log({ await AuditService.log({
action: AuditAction.unauthorizedAccess, action: AuditAction.UNAUTHORIZED_ACCESS,
category: AuditCategory.security, category: AuditCategory.SECURITY,
details: `Unauthorized access attempt: ${error.message}, URL: ${request.url}`, details: `Unauthorized access attempt: ${error.message}, URL: ${request.url}`,
success: false, success: false,
}, request).catch(() => { }, request).catch(() => {
@@ -57,13 +57,5 @@ export async function app(fastify: FastifyInstance, opts: AppOptions) {
fastify.register(AutoLoad, { fastify.register(AutoLoad, {
dir: path.join(__dirname, 'routes'), dir: path.join(__dirname, 'routes'),
options: { ...opts, prefix: '/api' }, options: { ...opts, prefix: '/api' },
ignorePattern: /root\.ts$/,
});
// Register root route without prefix
fastify.register(AutoLoad, {
dir: path.join(__dirname, 'routes'),
options: { ...opts },
matchFilter: /root\.ts$/,
}); });
} }
+1 -3
View File
@@ -82,9 +82,7 @@ const authPlugin: FastifyPluginAsync = async (app) => {
try { try {
await request.jwtVerify(); await request.jwtVerify();
} catch (err) { } catch (err) {
const error = new Error("Invalid token"); throw app.httpErrors.unauthorized("Invalid token");
(error as any).statusCode = 401;
throw error;
} }
}); });
}; };
+2 -2
View File
@@ -17,8 +17,8 @@ const rateLimitPlugin: FastifyPluginAsync = async (app) => {
errorResponseBuilder: (request) => { errorResponseBuilder: (request) => {
// Log rate limit exceeded event // Log rate limit exceeded event
AuditService.log({ AuditService.log({
action: AuditAction.rateLimitExceeded, action: AuditAction.RATE_LIMIT_EXCEEDED,
category: AuditCategory.security, category: AuditCategory.SECURITY,
details: `Rate limit exceeded for URL: ${request.url}`, details: `Rate limit exceeded for URL: ${request.url}`,
success: false, success: false,
}, request).catch(() => { }, request).catch(() => {
+12 -12
View File
@@ -46,8 +46,8 @@ const artRoutes: FastifyPluginAsync = async (app) => {
async (request) => { async (request) => {
const art = await artService.createArt(request.body); const art = await artService.createArt(request.body);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.entryCreate, action: AuditAction.ENTRY_CREATE,
category: AuditCategory.content, category: AuditCategory.CONTENT,
resourceType: "art", resourceType: "art",
resourceId: art.id, resourceId: art.id,
details: `Created art: ${art.title}`, details: `Created art: ${art.title}`,
@@ -74,8 +74,8 @@ const artRoutes: FastifyPluginAsync = async (app) => {
const art = await artService.updateArt(id, request.body); const art = await artService.updateArt(id, request.body);
if (art) { if (art) {
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.entryUpdate, action: AuditAction.ENTRY_UPDATE,
category: AuditCategory.content, category: AuditCategory.CONTENT,
resourceType: "art", resourceType: "art",
resourceId: id, resourceId: id,
details: `Updated art: ${art.title}`, details: `Updated art: ${art.title}`,
@@ -98,8 +98,8 @@ const artRoutes: FastifyPluginAsync = async (app) => {
const { id } = request.params; const { id } = request.params;
await artService.deleteArt(id); await artService.deleteArt(id);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.entryDelete, action: AuditAction.ENTRY_DELETE,
category: AuditCategory.content, category: AuditCategory.CONTENT,
resourceType: "art", resourceType: "art",
resourceId: id, resourceId: id,
details: `Deleted art with ID: ${id}`, details: `Deleted art with ID: ${id}`,
@@ -133,8 +133,8 @@ const artRoutes: FastifyPluginAsync = async (app) => {
const userId = request.user.id; const userId = request.user.id;
const comment = await commentService.createCommentForArt(id, userId, request.body); const comment = await commentService.createCommentForArt(id, userId, request.body);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.commentCreate, action: AuditAction.COMMENT_CREATE,
category: AuditCategory.content, category: AuditCategory.CONTENT,
resourceType: "art", resourceType: "art",
resourceId: id, resourceId: id,
details: `Added comment to art`, details: `Added comment to art`,
@@ -169,8 +169,8 @@ const artRoutes: FastifyPluginAsync = async (app) => {
const comment = await commentService.updateComment(commentId, request.body.content); const comment = await commentService.updateComment(commentId, request.body.content);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.commentUpdate, action: AuditAction.COMMENT_UPDATE,
category: AuditCategory.content, category: AuditCategory.CONTENT,
resourceType: "art", resourceType: "art",
resourceId: id, resourceId: id,
details: `Updated comment ${commentId} on art`, details: `Updated comment ${commentId} on art`,
@@ -205,8 +205,8 @@ const artRoutes: FastifyPluginAsync = async (app) => {
await commentService.deleteComment(commentId); await commentService.deleteComment(commentId);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.commentDelete, action: AuditAction.COMMENT_DELETE,
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.admin : AuditCategory.content, category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT,
resourceType: "art", resourceType: "art",
resourceId: id, resourceId: id,
details: `Deleted comment ${commentId} from art`, details: `Deleted comment ${commentId} from art`,
+6 -6
View File
@@ -85,8 +85,8 @@ const authRoutes: FastifyPluginAsync = async (app) => {
// Log successful login // Log successful login
await AuditService.log({ await AuditService.log({
action: AuditAction.login, action: AuditAction.LOGIN,
category: AuditCategory.auth, category: AuditCategory.AUTH,
userId: user.id, userId: user.id,
details: `User ${user.username} logged in via Discord`, details: `User ${user.username} logged in via Discord`,
success: true, success: true,
@@ -114,8 +114,8 @@ const authRoutes: FastifyPluginAsync = async (app) => {
} catch (error) { } catch (error) {
// Log failed login attempt // Log failed login attempt
await AuditService.log({ await AuditService.log({
action: AuditAction.loginFailed, action: AuditAction.LOGIN_FAILED,
category: AuditCategory.security, category: AuditCategory.SECURITY,
details: error instanceof Error ? error.message : String(error), details: error instanceof Error ? error.message : String(error),
success: false, success: false,
}, request); }, request);
@@ -229,8 +229,8 @@ const authRoutes: FastifyPluginAsync = async (app) => {
const user = request.user as { id?: string; username?: string }; const user = request.user as { id?: string; username?: string };
if (user?.id) { if (user?.id) {
await AuditService.log({ await AuditService.log({
action: AuditAction.logout, action: AuditAction.LOGOUT,
category: AuditCategory.auth, category: AuditCategory.AUTH,
userId: user.id, userId: user.id,
details: `User ${user.username ?? "unknown"} logged out`, details: `User ${user.username ?? "unknown"} logged out`,
success: true, success: true,
+12 -12
View File
@@ -46,8 +46,8 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
async (request) => { async (request) => {
const book = await bookService.createBook(request.body); const book = await bookService.createBook(request.body);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.entryCreate, action: AuditAction.ENTRY_CREATE,
category: AuditCategory.content, category: AuditCategory.CONTENT,
resourceType: "book", resourceType: "book",
resourceId: book.id, resourceId: book.id,
details: `Created book: ${book.title}`, details: `Created book: ${book.title}`,
@@ -74,8 +74,8 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
const book = await bookService.updateBook(id, request.body); const book = await bookService.updateBook(id, request.body);
if (book) { if (book) {
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.entryUpdate, action: AuditAction.ENTRY_UPDATE,
category: AuditCategory.content, category: AuditCategory.CONTENT,
resourceType: "book", resourceType: "book",
resourceId: id, resourceId: id,
details: `Updated book: ${book.title}`, details: `Updated book: ${book.title}`,
@@ -98,8 +98,8 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
const { id } = request.params; const { id } = request.params;
await bookService.deleteBook(id); await bookService.deleteBook(id);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.entryDelete, action: AuditAction.ENTRY_DELETE,
category: AuditCategory.content, category: AuditCategory.CONTENT,
resourceType: "book", resourceType: "book",
resourceId: id, resourceId: id,
details: `Deleted book with ID: ${id}`, details: `Deleted book with ID: ${id}`,
@@ -133,8 +133,8 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
const userId = request.user.id; const userId = request.user.id;
const comment = await commentService.createCommentForBook(id, userId, request.body); const comment = await commentService.createCommentForBook(id, userId, request.body);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.commentCreate, action: AuditAction.COMMENT_CREATE,
category: AuditCategory.content, category: AuditCategory.CONTENT,
resourceType: "book", resourceType: "book",
resourceId: id, resourceId: id,
details: `Added comment to book`, details: `Added comment to book`,
@@ -169,8 +169,8 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
const comment = await commentService.updateComment(commentId, request.body.content); const comment = await commentService.updateComment(commentId, request.body.content);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.commentUpdate, action: AuditAction.COMMENT_UPDATE,
category: AuditCategory.content, category: AuditCategory.CONTENT,
resourceType: "book", resourceType: "book",
resourceId: id, resourceId: id,
details: `Updated comment ${commentId} on book`, details: `Updated comment ${commentId} on book`,
@@ -205,8 +205,8 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
await commentService.deleteComment(commentId); await commentService.deleteComment(commentId);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.commentDelete, action: AuditAction.COMMENT_DELETE,
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.admin : AuditCategory.content, category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT,
resourceType: "book", resourceType: "book",
resourceId: id, resourceId: id,
details: `Deleted comment ${commentId} from book`, details: `Deleted comment ${commentId} from book`,
-140
View File
@@ -1,140 +0,0 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { FastifyPluginAsync } from "fastify";
import type {
CreateCommentReportDto,
CommentReportWithDetails,
ReportStatus,
UpdateCommentReportDto,
} from "@library/shared-types";
import { ReportReason } from "@library/shared-types";
import { CommentReportService } from "../../services/comment-report.service.js";
import { adminGuard } from "../../middleware/admin-guard.js";
const commentReportsRoutes: FastifyPluginAsync = async (fastify) => {
const commentReportService = new CommentReportService();
// Create a new comment report (authenticated users)
fastify.post<{
Body: CreateCommentReportDto;
Reply: CommentReportWithDetails | { error: string };
}>(
"/",
{
preValidation: [fastify.authenticate],
schema: {
body: {
type: "object",
required: ["reportedCommentId", "reason", "details"],
properties: {
reportedCommentId: { type: "string" },
reason: {
type: "string",
enum: Object.values(ReportReason),
},
details: { type: "string", minLength: 10, maxLength: 1000 },
},
},
},
},
async (request, reply) => {
try {
const report = await commentReportService.createReport(
request.user.id,
request.body,
);
return reply.status(201).send(report);
} catch (error) {
if (
error instanceof Error &&
(error.message.includes("already have a pending report") ||
error.message.includes("maximum number of pending reports"))
) {
return reply.status(409).send({ error: error.message });
}
throw error;
}
},
);
// Get all comment reports (admin only)
fastify.get<{
Querystring: { status?: ReportStatus };
Reply: CommentReportWithDetails[];
}>(
"/",
{
preValidation: [fastify.authenticate, adminGuard],
schema: {
querystring: {
type: "object",
properties: {
status: { type: "string" },
},
},
},
},
async (request, reply) => {
const reports = await commentReportService.getAllReports(
request.query.status,
);
return reply.send(reports);
},
);
// Get a single comment report by ID (admin only)
fastify.get<{
Params: { id: string };
Reply: CommentReportWithDetails | { error: string };
}>(
"/:id",
{
preValidation: [fastify.authenticate, adminGuard],
},
async (request, reply) => {
const report = await commentReportService.getReportById(request.params.id);
if (!report) {
return reply.status(404).send({ error: "Report not found" });
}
return reply.send(report);
},
);
// Update a comment report (admin only)
fastify.put<{
Params: { id: string };
Body: UpdateCommentReportDto;
Reply: CommentReportWithDetails;
}>(
"/:id",
{
preValidation: [fastify.authenticate, adminGuard],
schema: {
body: {
type: "object",
required: ["status"],
properties: {
status: { type: "string" },
reviewNotes: { type: "string", maxLength: 1000 },
},
},
},
},
async (request, reply) => {
const report = await commentReportService.updateReport(
request.params.id,
request.user.id,
request.body,
);
return reply.send(report);
},
);
};
export default commentReportsRoutes;
+12 -12
View File
@@ -40,8 +40,8 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
async (request) => { async (request) => {
const game = await gameService.createGame(request.body); const game = await gameService.createGame(request.body);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.entryCreate, action: AuditAction.ENTRY_CREATE,
category: AuditCategory.content, category: AuditCategory.CONTENT,
resourceType: "game", resourceType: "game",
resourceId: game.id, resourceId: game.id,
details: `Created game: ${game.title}`, details: `Created game: ${game.title}`,
@@ -66,8 +66,8 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
const game = await gameService.updateGame(id, request.body); const game = await gameService.updateGame(id, request.body);
if (game) { if (game) {
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.entryUpdate, action: AuditAction.ENTRY_UPDATE,
category: AuditCategory.content, category: AuditCategory.CONTENT,
resourceType: "game", resourceType: "game",
resourceId: id, resourceId: id,
details: `Updated game: ${game.title}`, details: `Updated game: ${game.title}`,
@@ -88,8 +88,8 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
const { id } = request.params; const { id } = request.params;
await gameService.deleteGame(id); await gameService.deleteGame(id);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.entryDelete, action: AuditAction.ENTRY_DELETE,
category: AuditCategory.content, category: AuditCategory.CONTENT,
resourceType: "game", resourceType: "game",
resourceId: id, resourceId: id,
details: `Deleted game with ID: ${id}`, details: `Deleted game with ID: ${id}`,
@@ -119,8 +119,8 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
const userId = request.user.id; const userId = request.user.id;
const comment = await commentService.createCommentForGame(id, userId, request.body); const comment = await commentService.createCommentForGame(id, userId, request.body);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.commentCreate, action: AuditAction.COMMENT_CREATE,
category: AuditCategory.content, category: AuditCategory.CONTENT,
resourceType: "game", resourceType: "game",
resourceId: id, resourceId: id,
details: `Added comment to game`, details: `Added comment to game`,
@@ -153,8 +153,8 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
const comment = await commentService.updateComment(commentId, request.body.content); const comment = await commentService.updateComment(commentId, request.body.content);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.commentUpdate, action: AuditAction.COMMENT_UPDATE,
category: AuditCategory.content, category: AuditCategory.CONTENT,
resourceType: "game", resourceType: "game",
resourceId: id, resourceId: id,
details: `Updated comment ${commentId} on game`, details: `Updated comment ${commentId} on game`,
@@ -187,8 +187,8 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
await commentService.deleteComment(commentId); await commentService.deleteComment(commentId);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.commentDelete, action: AuditAction.COMMENT_DELETE,
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.admin : AuditCategory.content, category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT,
resourceType: "game", resourceType: "game",
resourceId: id, resourceId: id,
details: `Deleted comment ${commentId} from game`, details: `Deleted comment ${commentId} from game`,
-40
View File
@@ -1,40 +0,0 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { FastifyInstance, FastifyRequest } from 'fastify';
import { logger } from '../../utils/logger';
interface LogBody {
level: 'debug' | 'info' | 'warn' | 'error';
message: string;
context?: string;
error?: {
name: string;
message: string;
stack?: string;
};
}
export default async function (fastify: FastifyInstance) {
fastify.post('/', async function (request: FastifyRequest<{ Body: LogBody }>) {
const { level, message, context, error } = request.body;
if (level === 'error' && error) {
const errorObj = new Error(error.message);
errorObj.name = error.name;
if (error.stack) {
errorObj.stack = error.stack;
}
await logger.error(context || 'Frontend', errorObj);
} else if (level === 'error') {
await logger.log('warn', `[Frontend Error] ${message}`);
} else {
await logger.log(level, `[Frontend] ${message}`);
}
return { success: true };
});
}
+12 -12
View File
@@ -37,8 +37,8 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
async (request) => { async (request) => {
const manga = await mangaService.createManga(request.body); const manga = await mangaService.createManga(request.body);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.entryCreate, action: AuditAction.ENTRY_CREATE,
category: AuditCategory.content, category: AuditCategory.CONTENT,
resourceType: "manga", resourceType: "manga",
resourceId: manga.id, resourceId: manga.id,
details: `Created manga: ${manga.title}`, details: `Created manga: ${manga.title}`,
@@ -62,8 +62,8 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
const manga = await mangaService.updateManga(id, request.body); const manga = await mangaService.updateManga(id, request.body);
if (manga) { if (manga) {
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.entryUpdate, action: AuditAction.ENTRY_UPDATE,
category: AuditCategory.content, category: AuditCategory.CONTENT,
resourceType: "manga", resourceType: "manga",
resourceId: id, resourceId: id,
details: `Updated manga: ${manga.title}`, details: `Updated manga: ${manga.title}`,
@@ -83,8 +83,8 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
const { id } = request.params; const { id } = request.params;
await mangaService.deleteManga(id); await mangaService.deleteManga(id);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.entryDelete, action: AuditAction.ENTRY_DELETE,
category: AuditCategory.content, category: AuditCategory.CONTENT,
resourceType: "manga", resourceType: "manga",
resourceId: id, resourceId: id,
details: `Deleted manga with ID: ${id}`, details: `Deleted manga with ID: ${id}`,
@@ -112,8 +112,8 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
const userId = request.user.id; const userId = request.user.id;
const comment = await commentService.createCommentForManga(id, userId, request.body); const comment = await commentService.createCommentForManga(id, userId, request.body);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.commentCreate, action: AuditAction.COMMENT_CREATE,
category: AuditCategory.content, category: AuditCategory.CONTENT,
resourceType: "manga", resourceType: "manga",
resourceId: id, resourceId: id,
details: `Added comment to manga`, details: `Added comment to manga`,
@@ -145,8 +145,8 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
const comment = await commentService.updateComment(commentId, request.body.content); const comment = await commentService.updateComment(commentId, request.body.content);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.commentUpdate, action: AuditAction.COMMENT_UPDATE,
category: AuditCategory.content, category: AuditCategory.CONTENT,
resourceType: "manga", resourceType: "manga",
resourceId: id, resourceId: id,
details: `Updated comment ${commentId} on manga`, details: `Updated comment ${commentId} on manga`,
@@ -178,8 +178,8 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
await commentService.deleteComment(commentId); await commentService.deleteComment(commentId);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.commentDelete, action: AuditAction.COMMENT_DELETE,
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.admin : AuditCategory.content, category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT,
resourceType: "manga", resourceType: "manga",
resourceId: id, resourceId: id,
details: `Deleted comment ${commentId} from manga`, details: `Deleted comment ${commentId} from manga`,
+12 -12
View File
@@ -46,8 +46,8 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
async (request) => { async (request) => {
const music = await musicService.createMusic(request.body); const music = await musicService.createMusic(request.body);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.entryCreate, action: AuditAction.ENTRY_CREATE,
category: AuditCategory.content, category: AuditCategory.CONTENT,
resourceType: "music", resourceType: "music",
resourceId: music.id, resourceId: music.id,
details: `Created music: ${music.title}`, details: `Created music: ${music.title}`,
@@ -74,8 +74,8 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
const music = await musicService.updateMusic(id, request.body); const music = await musicService.updateMusic(id, request.body);
if (music) { if (music) {
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.entryUpdate, action: AuditAction.ENTRY_UPDATE,
category: AuditCategory.content, category: AuditCategory.CONTENT,
resourceType: "music", resourceType: "music",
resourceId: id, resourceId: id,
details: `Updated music: ${music.title}`, details: `Updated music: ${music.title}`,
@@ -98,8 +98,8 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
const { id } = request.params; const { id } = request.params;
await musicService.deleteMusic(id); await musicService.deleteMusic(id);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.entryDelete, action: AuditAction.ENTRY_DELETE,
category: AuditCategory.content, category: AuditCategory.CONTENT,
resourceType: "music", resourceType: "music",
resourceId: id, resourceId: id,
details: `Deleted music with ID: ${id}`, details: `Deleted music with ID: ${id}`,
@@ -133,8 +133,8 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
const userId = request.user.id; const userId = request.user.id;
const comment = await commentService.createCommentForMusic(id, userId, request.body); const comment = await commentService.createCommentForMusic(id, userId, request.body);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.commentCreate, action: AuditAction.COMMENT_CREATE,
category: AuditCategory.content, category: AuditCategory.CONTENT,
resourceType: "music", resourceType: "music",
resourceId: id, resourceId: id,
details: `Added comment to music`, details: `Added comment to music`,
@@ -169,8 +169,8 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
const comment = await commentService.updateComment(commentId, request.body.content); const comment = await commentService.updateComment(commentId, request.body.content);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.commentUpdate, action: AuditAction.COMMENT_UPDATE,
category: AuditCategory.content, category: AuditCategory.CONTENT,
resourceType: "music", resourceType: "music",
resourceId: id, resourceId: id,
details: `Updated comment ${commentId} on music`, details: `Updated comment ${commentId} on music`,
@@ -205,8 +205,8 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
await commentService.deleteComment(commentId); await commentService.deleteComment(commentId);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.commentDelete, action: AuditAction.COMMENT_DELETE,
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.admin : AuditCategory.content, category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT,
resourceType: "music", resourceType: "music",
resourceId: id, resourceId: id,
details: `Deleted comment ${commentId} from music`, details: `Deleted comment ${commentId} from music`,
-139
View File
@@ -1,139 +0,0 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { FastifyPluginAsync } from "fastify";
import type {
CreateReportDto,
ProfileReportWithUsers,
ReportStatus,
UpdateReportDto,
} from "@library/shared-types";
import { ReportReason } from "@library/shared-types";
import { ReportService } from "../../services/report.service.js";
import { adminGuard } from "../../middleware/admin-guard.js";
const reportsRoutes: FastifyPluginAsync = async (fastify) => {
const reportService = new ReportService();
// Create a new report (authenticated users)
fastify.post<{
Body: CreateReportDto;
Reply: ProfileReportWithUsers | { error: string };
}>(
"/",
{
preValidation: [fastify.authenticate],
schema: {
body: {
type: "object",
required: ["reportedUserId", "reason", "details"],
properties: {
reportedUserId: { type: "string" },
reason: {
type: "string",
enum: Object.values(ReportReason),
},
details: { type: "string", minLength: 10, maxLength: 1000 },
},
},
},
},
async (request, reply) => {
try {
const report = await reportService.createReport(
request.user.id,
request.body,
);
return reply.status(201).send(report);
} catch (error) {
if (
error instanceof Error &&
error.message.includes("already have a pending report")
) {
return reply.status(409).send({ error: error.message });
}
throw error;
}
},
);
// Get all reports (admin only)
fastify.get<{
Querystring: { status?: ReportStatus };
Reply: ProfileReportWithUsers[];
}>(
"/",
{
preValidation: [fastify.authenticate, adminGuard],
schema: {
querystring: {
type: "object",
properties: {
status: { type: "string" },
},
},
},
},
async (request, reply) => {
const reports = await reportService.getAllReports(
request.query.status,
);
return reply.send(reports);
},
);
// Get a single report by ID (admin only)
fastify.get<{
Params: { id: string };
Reply: ProfileReportWithUsers | { error: string };
}>(
"/:id",
{
preValidation: [fastify.authenticate, adminGuard],
},
async (request, reply) => {
const report = await reportService.getReportById(request.params.id);
if (!report) {
return reply.status(404).send({ error: "Report not found" });
}
return reply.send(report);
},
);
// Update a report (admin only)
fastify.put<{
Params: { id: string };
Body: UpdateReportDto;
Reply: ProfileReportWithUsers;
}>(
"/:id",
{
preValidation: [fastify.authenticate, adminGuard],
schema: {
body: {
type: "object",
required: ["status"],
properties: {
status: { type: "string" },
reviewNotes: { type: "string", maxLength: 1000 },
},
},
},
},
async (request, reply) => {
const report = await reportService.updateReport(
request.params.id,
request.user.id,
request.body,
);
return reply.send(report);
},
);
};
export default reportsRoutes;
+1 -24
View File
@@ -1,30 +1,7 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { readFileSync } from 'fs';
import { join } from 'path';
interface PackageJson {
version: string;
}
let cachedVersion: string | null = null;
function getVersion(): string {
if (cachedVersion) {
return cachedVersion;
}
try {
const packageJsonPath = join(process.cwd(), 'package.json');
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as PackageJson;
cachedVersion = packageJson.version;
return cachedVersion;
} catch {
return 'unknown';
}
}
export default async function (fastify: FastifyInstance) { export default async function (fastify: FastifyInstance) {
fastify.get('/', async function () { fastify.get('/', async function () {
return { version: getVersion() }; return { message: 'Hello API' };
}); });
} }
+12 -12
View File
@@ -37,8 +37,8 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
async (request) => { async (request) => {
const show = await showService.createShow(request.body); const show = await showService.createShow(request.body);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.entryCreate, action: AuditAction.ENTRY_CREATE,
category: AuditCategory.content, category: AuditCategory.CONTENT,
resourceType: "show", resourceType: "show",
resourceId: show.id, resourceId: show.id,
details: `Created show: ${show.title}`, details: `Created show: ${show.title}`,
@@ -62,8 +62,8 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
const show = await showService.updateShow(id, request.body); const show = await showService.updateShow(id, request.body);
if (show) { if (show) {
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.entryUpdate, action: AuditAction.ENTRY_UPDATE,
category: AuditCategory.content, category: AuditCategory.CONTENT,
resourceType: "show", resourceType: "show",
resourceId: id, resourceId: id,
details: `Updated show: ${show.title}`, details: `Updated show: ${show.title}`,
@@ -83,8 +83,8 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
const { id } = request.params; const { id } = request.params;
await showService.deleteShow(id); await showService.deleteShow(id);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.entryDelete, action: AuditAction.ENTRY_DELETE,
category: AuditCategory.content, category: AuditCategory.CONTENT,
resourceType: "show", resourceType: "show",
resourceId: id, resourceId: id,
details: `Deleted show with ID: ${id}`, details: `Deleted show with ID: ${id}`,
@@ -112,8 +112,8 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
const userId = request.user.id; const userId = request.user.id;
const comment = await commentService.createCommentForShow(id, userId, request.body); const comment = await commentService.createCommentForShow(id, userId, request.body);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.commentCreate, action: AuditAction.COMMENT_CREATE,
category: AuditCategory.content, category: AuditCategory.CONTENT,
resourceType: "show", resourceType: "show",
resourceId: id, resourceId: id,
details: `Added comment to show`, details: `Added comment to show`,
@@ -145,8 +145,8 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
const comment = await commentService.updateComment(commentId, request.body.content); const comment = await commentService.updateComment(commentId, request.body.content);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.commentUpdate, action: AuditAction.COMMENT_UPDATE,
category: AuditCategory.content, category: AuditCategory.CONTENT,
resourceType: "show", resourceType: "show",
resourceId: id, resourceId: id,
details: `Updated comment ${commentId} on show`, details: `Updated comment ${commentId} on show`,
@@ -178,8 +178,8 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
await commentService.deleteComment(commentId); await commentService.deleteComment(commentId);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.commentDelete, action: AuditAction.COMMENT_DELETE,
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.admin : AuditCategory.content, category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT,
resourceType: "show", resourceType: "show",
resourceId: id, resourceId: id,
details: `Deleted comment ${commentId} from show`, details: `Deleted comment ${commentId} from show`,
+10 -10
View File
@@ -85,8 +85,8 @@ export default async function (app: FastifyInstance): Promise<void> {
); );
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.entryCreate, action: AuditAction.ENTRY_CREATE,
category: AuditCategory.content, category: AuditCategory.CONTENT,
resourceType: "Suggestion", resourceType: "Suggestion",
resourceId: suggestion.id, resourceId: suggestion.id,
details: `Created ${suggestion.entityType} suggestion: ${suggestion.title}`, details: `Created ${suggestion.entityType} suggestion: ${suggestion.title}`,
@@ -115,8 +115,8 @@ export default async function (app: FastifyInstance): Promise<void> {
const suggestion = await SuggestionService.acceptSuggestion(id); const suggestion = await SuggestionService.acceptSuggestion(id);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.entryUpdate, action: AuditAction.ENTRY_UPDATE,
category: AuditCategory.admin, category: AuditCategory.ADMIN,
resourceType: "Suggestion", resourceType: "Suggestion",
resourceId: suggestion.id, resourceId: suggestion.id,
details: `Accepted ${suggestion.entityType} suggestion: ${suggestion.title}`, details: `Accepted ${suggestion.entityType} suggestion: ${suggestion.title}`,
@@ -146,8 +146,8 @@ export default async function (app: FastifyInstance): Promise<void> {
const suggestion = await SuggestionService.acceptSuggestionWithEdits(id, editedData); const suggestion = await SuggestionService.acceptSuggestionWithEdits(id, editedData);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.entryUpdate, action: AuditAction.ENTRY_UPDATE,
category: AuditCategory.admin, category: AuditCategory.ADMIN,
resourceType: "Suggestion", resourceType: "Suggestion",
resourceId: suggestion.id, resourceId: suggestion.id,
details: `Accepted ${suggestion.entityType} suggestion with edits: ${suggestion.title}`, details: `Accepted ${suggestion.entityType} suggestion with edits: ${suggestion.title}`,
@@ -177,8 +177,8 @@ export default async function (app: FastifyInstance): Promise<void> {
const suggestion = await SuggestionService.declineSuggestion(id, reason); const suggestion = await SuggestionService.declineSuggestion(id, reason);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.entryUpdate, action: AuditAction.ENTRY_UPDATE,
category: AuditCategory.admin, category: AuditCategory.ADMIN,
resourceType: "Suggestion", resourceType: "Suggestion",
resourceId: suggestion.id, resourceId: suggestion.id,
details: `Declined ${suggestion.entityType} suggestion: ${suggestion.title}${reason ? ` (Reason: ${reason})` : ""}`, details: `Declined ${suggestion.entityType} suggestion: ${suggestion.title}${reason ? ` (Reason: ${reason})` : ""}`,
@@ -209,8 +209,8 @@ export default async function (app: FastifyInstance): Promise<void> {
const suggestion = await SuggestionService.deleteSuggestion(id, userId, isAdmin); const suggestion = await SuggestionService.deleteSuggestion(id, userId, isAdmin);
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.entryDelete, action: AuditAction.ENTRY_DELETE,
category: isAdmin ? AuditCategory.admin : AuditCategory.content, category: isAdmin ? AuditCategory.ADMIN : AuditCategory.CONTENT,
resourceType: "Suggestion", resourceType: "Suggestion",
resourceId: suggestion.id, resourceId: suggestion.id,
details: `Deleted ${suggestion.entityType} suggestion: ${suggestion.title}`, details: `Deleted ${suggestion.entityType} suggestion: ${suggestion.title}`,
+4 -148
View File
@@ -10,49 +10,6 @@ import { UserService } from "../../services/user.service";
import { AuditService } from "../../services/audit.service"; import { AuditService } from "../../services/audit.service";
import { adminGuard } from "../../middleware/admin-guard"; import { adminGuard } from "../../middleware/admin-guard";
interface UpdateUserSettingsBody {
slug?: string;
displayName?: string;
bio?: string;
profilePublic?: boolean;
website?: string;
discordServer?: string;
bluesky?: string;
github?: string;
linkedin?: string;
twitch?: string;
youtube?: string;
}
interface UserProfileResponse {
id: string;
username: string;
displayName?: string;
avatar?: string;
bio?: string;
slug?: string;
website?: string;
discordServer?: string;
bluesky?: string;
github?: string;
linkedin?: string;
twitch?: string;
youtube?: string;
badges: {
isStaff: boolean;
isMod: boolean;
isVip: boolean;
inDiscord: boolean;
};
stats: {
suggestionsCount: number;
suggestionsAcceptedCount: number;
likesCount: number;
commentsCount: number;
};
createdAt: Date;
}
const usersRoutes: FastifyPluginAsync = async (app) => { const usersRoutes: FastifyPluginAsync = async (app) => {
const userService = new UserService(); const userService = new UserService();
@@ -66,107 +23,6 @@ const usersRoutes: FastifyPluginAsync = async (app) => {
} }
); );
app.get<{ Reply: User }>(
"/me",
{
preValidation: [app.authenticate],
},
async (request) => {
const currentUser = request.user as { id: string };
const user = await userService.getUserById(currentUser.id);
if (!user) {
throw new Error("User not found");
}
return user;
}
);
app.put<{ Body: UpdateUserSettingsBody; Reply: User | { error: string } }>(
"/me",
{
preValidation: [app.authenticate],
preHandler: [app.csrfProtection],
},
async (request, reply) => {
const currentUser = request.user as { id: string };
const updates = request.body;
// If slug is being updated, check if it's unique
if (updates.slug) {
const existingUser = await userService.getUserBySlug(updates.slug);
if (existingUser && existingUser.id !== currentUser.id) {
return reply.code(400).send({ error: "Slug already taken" });
}
}
const updatedUser = await userService.updateUserSettings(
currentUser.id,
updates
);
if (!updatedUser) {
return reply.code(404).send({ error: "User not found" });
}
return updatedUser;
}
);
app.get<{
Params: { identifier: string };
Reply: UserProfileResponse | { error: string };
}>(
"/profile/:identifier",
async (request, reply) => {
const { identifier } = request.params;
try {
const profile = await userService.getUserProfile(identifier);
if (!profile) {
return reply.code(404).send({ error: "User not found" });
}
if (!profile.profilePublic) {
// Check if the requesting user is viewing their own profile
const currentUser = request.user as { id: string } | undefined;
if (!currentUser || currentUser.id !== profile.id) {
return reply
.code(403)
.send({ error: "This profile is private" });
}
}
return {
id: profile.id,
username: profile.username,
displayName: profile.displayName,
avatar: profile.avatar,
bio: profile.bio,
slug: profile.slug,
website: profile.website,
discordServer: profile.discordServer,
bluesky: profile.bluesky,
github: profile.github,
linkedin: profile.linkedin,
twitch: profile.twitch,
youtube: profile.youtube,
badges: {
isStaff: profile.isStaff,
isMod: profile.isMod,
isVip: profile.isVip,
inDiscord: profile.inDiscord,
},
stats: profile.stats,
createdAt: profile.createdAt,
};
} catch (error) {
console.error("Error fetching profile:", error);
return reply.code(500).send({ error: "Failed to fetch profile" });
}
}
);
app.get<{ Params: { id: string }; Reply: User | null }>( app.get<{ Params: { id: string }; Reply: User | null }>(
"/:id", "/:id",
{ {
@@ -198,8 +54,8 @@ const usersRoutes: FastifyPluginAsync = async (app) => {
} }
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.userBan, action: AuditAction.USER_BAN,
category: AuditCategory.admin, category: AuditCategory.ADMIN,
targetUserId: id, targetUserId: id,
details: `Banned user: ${user.username}`, details: `Banned user: ${user.username}`,
}); });
@@ -222,8 +78,8 @@ const usersRoutes: FastifyPluginAsync = async (app) => {
} }
await AuditService.logFromRequest(request, { await AuditService.logFromRequest(request, {
action: AuditAction.userUnban, action: AuditAction.USER_UNBAN,
category: AuditCategory.admin, category: AuditCategory.ADMIN,
targetUserId: id, targetUserId: id,
details: `Unbanned user: ${user.username}`, details: `Unbanned user: ${user.username}`,
}); });
+1 -1
View File
@@ -36,7 +36,7 @@ export const AuditService = {
request: FastifyRequest, request: FastifyRequest,
data: Omit<AuditLogData, "userId"> data: Omit<AuditLogData, "userId">
) { ) {
const userId = ((request as any).user as { id?: string } | undefined)?.id; const userId = (request.user as { id?: string } | undefined)?.id;
return this.log( return this.log(
{ {
-27
View File
@@ -71,15 +71,6 @@ export class AuthService {
username: dbUser.username, username: dbUser.username,
email: dbUser.email, email: dbUser.email,
avatar: dbUser.avatar || undefined, avatar: dbUser.avatar || undefined,
slug: dbUser.slug || undefined,
displayName: dbUser.displayName || undefined,
bio: dbUser.bio || undefined,
profilePublic: dbUser.profilePublic,
website: dbUser.website || undefined,
discordServer: dbUser.discordServer || undefined,
bluesky: dbUser.bluesky || undefined,
github: dbUser.github || undefined,
linkedin: dbUser.linkedin || undefined,
isAdmin: dbUser.isAdmin, isAdmin: dbUser.isAdmin,
isBanned: dbUser.isBanned, isBanned: dbUser.isBanned,
inDiscord: dbUser.inDiscord, inDiscord: dbUser.inDiscord,
@@ -176,15 +167,6 @@ export class AuthService {
username: dbUser.username, username: dbUser.username,
email: dbUser.email, email: dbUser.email,
avatar: dbUser.avatar || undefined, avatar: dbUser.avatar || undefined,
slug: dbUser.slug || undefined,
displayName: dbUser.displayName || undefined,
bio: dbUser.bio || undefined,
profilePublic: dbUser.profilePublic,
website: dbUser.website || undefined,
discordServer: dbUser.discordServer || undefined,
bluesky: dbUser.bluesky || undefined,
github: dbUser.github || undefined,
linkedin: dbUser.linkedin || undefined,
isAdmin: dbUser.isAdmin, isAdmin: dbUser.isAdmin,
isBanned: dbUser.isBanned, isBanned: dbUser.isBanned,
inDiscord: dbUser.inDiscord, inDiscord: dbUser.inDiscord,
@@ -235,15 +217,6 @@ export class AuthService {
username: dbUser.username, username: dbUser.username,
email: dbUser.email, email: dbUser.email,
avatar: dbUser.avatar || undefined, avatar: dbUser.avatar || undefined,
slug: dbUser.slug || undefined,
displayName: dbUser.displayName || undefined,
bio: dbUser.bio || undefined,
profilePublic: dbUser.profilePublic,
website: dbUser.website || undefined,
discordServer: dbUser.discordServer || undefined,
bluesky: dbUser.bluesky || undefined,
github: dbUser.github || undefined,
linkedin: dbUser.linkedin || undefined,
isAdmin: dbUser.isAdmin, isAdmin: dbUser.isAdmin,
isBanned: dbUser.isBanned, isBanned: dbUser.isBanned,
inDiscord: dbUser.inDiscord, inDiscord: dbUser.inDiscord,
-4
View File
@@ -24,7 +24,6 @@ export class BookService {
...book, ...book,
status: book.status as unknown as BookStatus, status: book.status as unknown as BookStatus,
dateAdded: book.dateAdded, dateAdded: book.dateAdded,
dateStarted: book.dateStarted || undefined,
dateFinished: book.dateFinished || undefined, dateFinished: book.dateFinished || undefined,
tags: book.tags ?? [], tags: book.tags ?? [],
links: book.links ?? [], links: book.links ?? [],
@@ -47,7 +46,6 @@ export class BookService {
...book, ...book,
status: book.status as unknown as BookStatus, status: book.status as unknown as BookStatus,
dateAdded: book.dateAdded, dateAdded: book.dateAdded,
dateStarted: book.dateStarted || undefined,
dateFinished: book.dateFinished || undefined, dateFinished: book.dateFinished || undefined,
tags: book.tags ?? [], tags: book.tags ?? [],
links: book.links ?? [], links: book.links ?? [],
@@ -71,7 +69,6 @@ export class BookService {
...book, ...book,
status: book.status as unknown as BookStatus, status: book.status as unknown as BookStatus,
dateAdded: book.dateAdded, dateAdded: book.dateAdded,
dateStarted: book.dateStarted || undefined,
dateFinished: book.dateFinished || undefined, dateFinished: book.dateFinished || undefined,
tags: book.tags ?? [], tags: book.tags ?? [],
links: book.links ?? [], links: book.links ?? [],
@@ -98,7 +95,6 @@ export class BookService {
...book, ...book,
status: book.status as unknown as BookStatus, status: book.status as unknown as BookStatus,
dateAdded: book.dateAdded, dateAdded: book.dateAdded,
dateStarted: book.dateStarted || undefined,
dateFinished: book.dateFinished || undefined, dateFinished: book.dateFinished || undefined,
tags: book.tags ?? [], tags: book.tags ?? [],
links: book.links ?? [], links: book.links ?? [],
@@ -1,369 +0,0 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ReportStatus as PrismaReportStatus,
ReportReason as PrismaReportReason,
} from "@prisma/client";
import type {
CreateCommentReportDto,
CommentReportWithDetails,
ReportStatus,
UpdateCommentReportDto,
} from "@library/shared-types";
import { ReportReason } from "@library/shared-types";
import { prisma } from "../lib/prisma.js";
export class CommentReportService {
private prisma = prisma;
/**
* Convert Prisma ReportReason to shared-types ReportReason
*/
private toPrismaReportReason(reason: ReportReason): PrismaReportReason {
return reason as unknown as PrismaReportReason;
}
/**
* Convert Prisma ReportStatus to shared-types ReportStatus
*/
private toPrismaReportStatus(status: ReportStatus): PrismaReportStatus {
return status as unknown as PrismaReportStatus;
}
/**
* Convert Prisma enum back to shared-types enum
*/
private fromPrismaReportReason(reason: PrismaReportReason): ReportReason {
return reason as unknown as ReportReason;
}
/**
* Convert Prisma enum back to shared-types enum
*/
private fromPrismaReportStatus(status: PrismaReportStatus): ReportStatus {
return status as unknown as ReportStatus;
}
/**
* Create a new comment report.
*
* @param reporterId - The ID of the user making the report
* @param createDto - The report details
* @returns The created report
* @throws Error if user already has a pending report for this comment
*/
async createReport(
reporterId: string,
createDto: CreateCommentReportDto,
): Promise<CommentReportWithDetails> {
// Check if user already has a pending report for this comment
const existingReport = await this.prisma.commentReport.findFirst({
where: {
reporterId,
reportedCommentId: createDto.reportedCommentId,
status: PrismaReportStatus.PENDING,
},
});
if (existingReport) {
throw new Error(
"You already have a pending report for this comment. Please wait for it to be reviewed.",
);
}
// Check if user has reached the limit of pending reports (5 max)
const pendingReportsCount = await this.prisma.commentReport.count({
where: {
reporterId,
status: PrismaReportStatus.PENDING,
},
});
if (pendingReportsCount >= 5) {
throw new Error(
"You have reached the maximum number of pending reports (5). Please wait for your existing reports to be reviewed.",
);
}
const report = await this.prisma.commentReport.create({
data: {
reporterId,
reportedCommentId: createDto.reportedCommentId,
reason: this.toPrismaReportReason(createDto.reason),
details: createDto.details,
},
include: {
reportedComment: {
include: {
user: {
select: {
id: true,
username: true,
displayName: true,
avatar: true,
},
},
},
},
reporter: {
select: {
id: true,
username: true,
displayName: true,
avatar: true,
},
},
},
});
return {
id: report.id,
reportedCommentId: report.reportedCommentId,
reporterId: report.reporterId,
reason: this.fromPrismaReportReason(report.reason),
details: report.details,
status: this.fromPrismaReportStatus(report.status),
reviewedBy: report.reviewedBy ?? undefined,
reviewNotes: report.reviewNotes ?? undefined,
createdAt: report.createdAt,
updatedAt: report.updatedAt,
reportedComment: {
id: report.reportedComment.id,
content: report.reportedComment.content,
rawContent: report.reportedComment.rawContent ?? undefined,
userId: report.reportedComment.userId,
user: report.reportedComment.user,
},
reporter: report.reporter,
};
}
/**
* Get all comment reports (admin only). Optionally filter by status.
*
* @param status - Optional status filter
* @returns All reports matching the filter
*/
async getAllReports(
status?: ReportStatus,
): Promise<CommentReportWithDetails[]> {
const reports = await this.prisma.commentReport.findMany({
where: status ? { status: this.toPrismaReportStatus(status) } : undefined,
include: {
reportedComment: {
include: {
user: {
select: {
id: true,
username: true,
displayName: true,
avatar: true,
},
},
},
},
reporter: {
select: {
id: true,
username: true,
displayName: true,
avatar: true,
},
},
reviewer: {
select: {
id: true,
username: true,
displayName: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
return reports.map((report) => ({
id: report.id,
reportedCommentId: report.reportedCommentId,
reporterId: report.reporterId,
reason: this.fromPrismaReportReason(report.reason),
details: report.details,
status: this.fromPrismaReportStatus(report.status),
reviewedBy: report.reviewedBy ?? undefined,
reviewNotes: report.reviewNotes ?? undefined,
createdAt: report.createdAt,
updatedAt: report.updatedAt,
reportedComment: {
id: report.reportedComment.id,
content: report.reportedComment.content,
rawContent: report.reportedComment.rawContent ?? undefined,
userId: report.reportedComment.userId,
user: report.reportedComment.user,
},
reporter: report.reporter,
reviewer: report.reviewer ?? undefined,
}));
}
/**
* Get a single comment report by ID (admin only).
*
* @param id - The report ID
* @returns The report or null
*/
async getReportById(id: string): Promise<CommentReportWithDetails | null> {
const report = await this.prisma.commentReport.findUnique({
where: { id },
include: {
reportedComment: {
include: {
user: {
select: {
id: true,
username: true,
displayName: true,
avatar: true,
},
},
},
},
reporter: {
select: {
id: true,
username: true,
displayName: true,
avatar: true,
},
},
reviewer: {
select: {
id: true,
username: true,
displayName: true,
},
},
},
});
if (!report) {
return null;
}
return {
id: report.id,
reportedCommentId: report.reportedCommentId,
reporterId: report.reporterId,
reason: this.fromPrismaReportReason(report.reason),
details: report.details,
status: this.fromPrismaReportStatus(report.status),
reviewedBy: report.reviewedBy ?? undefined,
reviewNotes: report.reviewNotes ?? undefined,
createdAt: report.createdAt,
updatedAt: report.updatedAt,
reportedComment: {
id: report.reportedComment.id,
content: report.reportedComment.content,
rawContent: report.reportedComment.rawContent ?? undefined,
userId: report.reportedComment.userId,
user: report.reportedComment.user,
},
reporter: report.reporter,
reviewer: report.reviewer ?? undefined,
};
}
/**
* Update a comment report's status and review notes (admin only).
*
* @param id - The report ID
* @param reviewerId - The ID of the admin reviewing the report
* @param updateDto - The update details
* @returns The updated report
*/
async updateReport(
id: string,
reviewerId: string,
updateDto: UpdateCommentReportDto,
): Promise<CommentReportWithDetails> {
const report = await this.prisma.commentReport.update({
where: { id },
data: {
status: this.toPrismaReportStatus(updateDto.status),
reviewNotes: updateDto.reviewNotes,
reviewedBy: reviewerId,
},
include: {
reportedComment: {
include: {
user: {
select: {
id: true,
username: true,
displayName: true,
avatar: true,
},
},
},
},
reporter: {
select: {
id: true,
username: true,
displayName: true,
avatar: true,
},
},
reviewer: {
select: {
id: true,
username: true,
displayName: true,
},
},
},
});
return {
id: report.id,
reportedCommentId: report.reportedCommentId,
reporterId: report.reporterId,
reason: this.fromPrismaReportReason(report.reason),
details: report.details,
status: this.fromPrismaReportStatus(report.status),
reviewedBy: report.reviewedBy ?? undefined,
reviewNotes: report.reviewNotes ?? undefined,
createdAt: report.createdAt,
updatedAt: report.updatedAt,
reportedComment: {
id: report.reportedComment.id,
content: report.reportedComment.content,
rawContent: report.reportedComment.rawContent ?? undefined,
userId: report.reportedComment.userId,
user: report.reportedComment.user,
},
reporter: report.reporter,
reviewer: report.reviewer ?? undefined,
};
}
/**
* Check if a comment has any pending reports.
*
* @param commentId - The comment ID
* @returns True if the comment has pending reports
*/
async hasPendingReports(commentId: string): Promise<boolean> {
const count = await this.prisma.commentReport.count({
where: {
reportedCommentId: commentId,
status: PrismaReportStatus.PENDING,
},
});
return count > 0;
}
}
+20 -26
View File
@@ -50,12 +50,7 @@ export class CommentService {
}); });
} }
private async mapComment(comment: any): Promise<Comment> { private mapComment(comment: any): Comment {
// Check if comment has pending reports
const hasPendingReports = comment.reports
? comment.reports.some((report: any) => report.status === "PENDING")
: false;
return { return {
id: comment.id, id: comment.id,
content: comment.content, content: comment.content,
@@ -76,7 +71,6 @@ export class CommentService {
artId: comment.artId || undefined, artId: comment.artId || undefined,
showId: comment.showId || undefined, showId: comment.showId || undefined,
mangaId: comment.mangaId || undefined, mangaId: comment.mangaId || undefined,
hasPendingReports,
createdAt: comment.createdAt, createdAt: comment.createdAt,
updatedAt: comment.updatedAt, updatedAt: comment.updatedAt,
}; };
@@ -85,28 +79,28 @@ export class CommentService {
async getCommentsForGame(gameId: string): Promise<Comment[]> { async getCommentsForGame(gameId: string): Promise<Comment[]> {
const comments = await this.prisma.comment.findMany({ const comments = await this.prisma.comment.findMany({
where: { gameId }, where: { gameId },
include: { user: true, reports: true }, include: { user: true },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
}); });
return Promise.all(comments.map((c) => this.mapComment(c))); return comments.map((c) => this.mapComment(c));
} }
async getCommentsForBook(bookId: string): Promise<Comment[]> { async getCommentsForBook(bookId: string): Promise<Comment[]> {
const comments = await this.prisma.comment.findMany({ const comments = await this.prisma.comment.findMany({
where: { bookId }, where: { bookId },
include: { user: true, reports: true }, include: { user: true },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
}); });
return Promise.all(comments.map((c) => this.mapComment(c))); return comments.map((c) => this.mapComment(c));
} }
async getCommentsForMusic(musicId: string): Promise<Comment[]> { async getCommentsForMusic(musicId: string): Promise<Comment[]> {
const comments = await this.prisma.comment.findMany({ const comments = await this.prisma.comment.findMany({
where: { musicId }, where: { musicId },
include: { user: true, reports: true }, include: { user: true },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
}); });
return Promise.all(comments.map((c) => this.mapComment(c))); return comments.map((c) => this.mapComment(c));
} }
async createCommentForGame( async createCommentForGame(
@@ -122,7 +116,7 @@ export class CommentService {
userId, userId,
gameId, gameId,
}, },
include: { user: true, reports: true }, include: { user: true },
}); });
return this.mapComment(comment); return this.mapComment(comment);
} }
@@ -140,7 +134,7 @@ export class CommentService {
userId, userId,
bookId, bookId,
}, },
include: { user: true, reports: true }, include: { user: true },
}); });
return this.mapComment(comment); return this.mapComment(comment);
} }
@@ -158,7 +152,7 @@ export class CommentService {
userId, userId,
musicId, musicId,
}, },
include: { user: true, reports: true }, include: { user: true },
}); });
return this.mapComment(comment); return this.mapComment(comment);
} }
@@ -166,10 +160,10 @@ export class CommentService {
async getCommentsForArt(artId: string): Promise<Comment[]> { async getCommentsForArt(artId: string): Promise<Comment[]> {
const comments = await this.prisma.comment.findMany({ const comments = await this.prisma.comment.findMany({
where: { artId }, where: { artId },
include: { user: true, reports: true }, include: { user: true },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
}); });
return Promise.all(comments.map((c) => this.mapComment(c))); return comments.map((c) => this.mapComment(c));
} }
async createCommentForArt( async createCommentForArt(
@@ -185,7 +179,7 @@ export class CommentService {
userId, userId,
artId, artId,
}, },
include: { user: true, reports: true }, include: { user: true },
}); });
return this.mapComment(comment); return this.mapComment(comment);
} }
@@ -193,10 +187,10 @@ export class CommentService {
async getCommentsForShow(showId: string): Promise<Comment[]> { async getCommentsForShow(showId: string): Promise<Comment[]> {
const comments = await this.prisma.comment.findMany({ const comments = await this.prisma.comment.findMany({
where: { showId }, where: { showId },
include: { user: true, reports: true }, include: { user: true },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
}); });
return Promise.all(comments.map((c) => this.mapComment(c))); return comments.map((c) => this.mapComment(c));
} }
async createCommentForShow( async createCommentForShow(
@@ -212,7 +206,7 @@ export class CommentService {
userId, userId,
showId, showId,
}, },
include: { user: true, reports: true }, include: { user: true },
}); });
return this.mapComment(comment); return this.mapComment(comment);
} }
@@ -220,10 +214,10 @@ export class CommentService {
async getCommentsForManga(mangaId: string): Promise<Comment[]> { async getCommentsForManga(mangaId: string): Promise<Comment[]> {
const comments = await this.prisma.comment.findMany({ const comments = await this.prisma.comment.findMany({
where: { mangaId }, where: { mangaId },
include: { user: true, reports: true }, include: { user: true },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
}); });
return Promise.all(comments.map((c) => this.mapComment(c))); return comments.map((c) => this.mapComment(c));
} }
async createCommentForManga( async createCommentForManga(
@@ -239,7 +233,7 @@ export class CommentService {
userId, userId,
mangaId, mangaId,
}, },
include: { user: true, reports: true }, include: { user: true },
}); });
return this.mapComment(comment); return this.mapComment(comment);
} }
@@ -262,7 +256,7 @@ export class CommentService {
content: sanitizedContent, content: sanitizedContent,
rawContent: content, rawContent: content,
}, },
include: { user: true, reports: true }, include: { user: true },
}); });
return this.mapComment(comment); return this.mapComment(comment);
} }
-8
View File
@@ -24,9 +24,7 @@ export class GameService {
...game, ...game,
status: game.status as unknown as GameStatus, status: game.status as unknown as GameStatus,
dateAdded: game.dateAdded, dateAdded: game.dateAdded,
dateStarted: game.dateStarted || undefined,
dateCompleted: game.dateCompleted || undefined, dateCompleted: game.dateCompleted || undefined,
dateFinished: game.dateFinished || undefined,
tags: game.tags ?? [], tags: game.tags ?? [],
links: game.links ?? [], links: game.links ?? [],
createdAt: game.createdAt, createdAt: game.createdAt,
@@ -48,9 +46,7 @@ export class GameService {
...game, ...game,
status: game.status as unknown as GameStatus, status: game.status as unknown as GameStatus,
dateAdded: game.dateAdded, dateAdded: game.dateAdded,
dateStarted: game.dateStarted || undefined,
dateCompleted: game.dateCompleted || undefined, dateCompleted: game.dateCompleted || undefined,
dateFinished: game.dateFinished || undefined,
tags: game.tags ?? [], tags: game.tags ?? [],
links: game.links ?? [], links: game.links ?? [],
createdAt: game.createdAt, createdAt: game.createdAt,
@@ -73,9 +69,7 @@ export class GameService {
...game, ...game,
status: game.status as unknown as GameStatus, status: game.status as unknown as GameStatus,
dateAdded: game.dateAdded, dateAdded: game.dateAdded,
dateStarted: game.dateStarted || undefined,
dateCompleted: game.dateCompleted || undefined, dateCompleted: game.dateCompleted || undefined,
dateFinished: game.dateFinished || undefined,
tags: game.tags ?? [], tags: game.tags ?? [],
links: game.links ?? [], links: game.links ?? [],
createdAt: game.createdAt, createdAt: game.createdAt,
@@ -101,9 +95,7 @@ export class GameService {
...game, ...game,
status: game.status as unknown as GameStatus, status: game.status as unknown as GameStatus,
dateAdded: game.dateAdded, dateAdded: game.dateAdded,
dateStarted: game.dateStarted || undefined,
dateCompleted: game.dateCompleted || undefined, dateCompleted: game.dateCompleted || undefined,
dateFinished: game.dateFinished || undefined,
tags: game.tags ?? [], tags: game.tags ?? [],
links: game.links ?? [], links: game.links ?? [],
createdAt: game.createdAt, createdAt: game.createdAt,
+4 -4
View File
@@ -32,8 +32,8 @@ export class LikeService {
}); });
await AuditService.logFromRequest(req, { await AuditService.logFromRequest(req, {
action: AuditAction.unlike, action: AuditAction.UNLIKE,
category: AuditCategory.content, category: AuditCategory.CONTENT,
resourceType: entityType, resourceType: entityType,
resourceId: entityId, resourceId: entityId,
details: `Unliked ${entityType}` details: `Unliked ${entityType}`
@@ -52,8 +52,8 @@ export class LikeService {
}); });
await AuditService.logFromRequest(req, { await AuditService.logFromRequest(req, {
action: AuditAction.like, action: AuditAction.LIKE,
category: AuditCategory.content, category: AuditCategory.CONTENT,
resourceType: entityType, resourceType: entityType,
resourceId: entityId, resourceId: entityId,
details: `Liked ${entityType}` details: `Liked ${entityType}`
-8
View File
@@ -21,9 +21,7 @@ export class MangaService {
...m, ...m,
status: m.status as unknown as MangaStatus, status: m.status as unknown as MangaStatus,
dateAdded: m.dateAdded, dateAdded: m.dateAdded,
dateStarted: m.dateStarted || undefined,
dateCompleted: m.dateCompleted || undefined, dateCompleted: m.dateCompleted || undefined,
dateFinished: m.dateFinished || undefined,
tags: m.tags ?? [], tags: m.tags ?? [],
links: m.links ?? [], links: m.links ?? [],
createdAt: m.createdAt, createdAt: m.createdAt,
@@ -42,9 +40,7 @@ export class MangaService {
...manga, ...manga,
status: manga.status as unknown as MangaStatus, status: manga.status as unknown as MangaStatus,
dateAdded: manga.dateAdded, dateAdded: manga.dateAdded,
dateStarted: manga.dateStarted || undefined,
dateCompleted: manga.dateCompleted || undefined, dateCompleted: manga.dateCompleted || undefined,
dateFinished: manga.dateFinished || undefined,
tags: manga.tags ?? [], tags: manga.tags ?? [],
links: manga.links ?? [], links: manga.links ?? [],
createdAt: manga.createdAt, createdAt: manga.createdAt,
@@ -64,9 +60,7 @@ export class MangaService {
...manga, ...manga,
status: manga.status as unknown as MangaStatus, status: manga.status as unknown as MangaStatus,
dateAdded: manga.dateAdded, dateAdded: manga.dateAdded,
dateStarted: manga.dateStarted || undefined,
dateCompleted: manga.dateCompleted || undefined, dateCompleted: manga.dateCompleted || undefined,
dateFinished: manga.dateFinished || undefined,
tags: manga.tags ?? [], tags: manga.tags ?? [],
links: manga.links ?? [], links: manga.links ?? [],
createdAt: manga.createdAt, createdAt: manga.createdAt,
@@ -89,9 +83,7 @@ export class MangaService {
...manga, ...manga,
status: manga.status as unknown as MangaStatus, status: manga.status as unknown as MangaStatus,
dateAdded: manga.dateAdded, dateAdded: manga.dateAdded,
dateStarted: manga.dateStarted || undefined,
dateCompleted: manga.dateCompleted || undefined, dateCompleted: manga.dateCompleted || undefined,
dateFinished: manga.dateFinished || undefined,
tags: manga.tags ?? [], tags: manga.tags ?? [],
links: manga.links ?? [], links: manga.links ?? [],
createdAt: manga.createdAt, createdAt: manga.createdAt,
-8
View File
@@ -25,9 +25,7 @@ export class MusicService {
type: music.type as unknown as MusicType, type: music.type as unknown as MusicType,
status: music.status as unknown as MusicStatus, status: music.status as unknown as MusicStatus,
dateAdded: music.dateAdded, dateAdded: music.dateAdded,
dateStarted: music.dateStarted || undefined,
dateCompleted: music.dateCompleted || undefined, dateCompleted: music.dateCompleted || undefined,
dateFinished: music.dateFinished || undefined,
tags: music.tags ?? [], tags: music.tags ?? [],
links: music.links ?? [], links: music.links ?? [],
createdAt: music.createdAt, createdAt: music.createdAt,
@@ -50,9 +48,7 @@ export class MusicService {
type: music.type as unknown as MusicType, type: music.type as unknown as MusicType,
status: music.status as unknown as MusicStatus, status: music.status as unknown as MusicStatus,
dateAdded: music.dateAdded, dateAdded: music.dateAdded,
dateStarted: music.dateStarted || undefined,
dateCompleted: music.dateCompleted || undefined, dateCompleted: music.dateCompleted || undefined,
dateFinished: music.dateFinished || undefined,
tags: music.tags ?? [], tags: music.tags ?? [],
links: music.links ?? [], links: music.links ?? [],
createdAt: music.createdAt, createdAt: music.createdAt,
@@ -77,9 +73,7 @@ export class MusicService {
type: music.type as unknown as MusicType, type: music.type as unknown as MusicType,
status: music.status as unknown as MusicStatus, status: music.status as unknown as MusicStatus,
dateAdded: music.dateAdded, dateAdded: music.dateAdded,
dateStarted: music.dateStarted || undefined,
dateCompleted: music.dateCompleted || undefined, dateCompleted: music.dateCompleted || undefined,
dateFinished: music.dateFinished || undefined,
tags: music.tags ?? [], tags: music.tags ?? [],
links: music.links ?? [], links: music.links ?? [],
createdAt: music.createdAt, createdAt: music.createdAt,
@@ -109,9 +103,7 @@ export class MusicService {
type: music.type as unknown as MusicType, type: music.type as unknown as MusicType,
status: music.status as unknown as MusicStatus, status: music.status as unknown as MusicStatus,
dateAdded: music.dateAdded, dateAdded: music.dateAdded,
dateStarted: music.dateStarted || undefined,
dateCompleted: music.dateCompleted || undefined, dateCompleted: music.dateCompleted || undefined,
dateFinished: music.dateFinished || undefined,
tags: music.tags ?? [], tags: music.tags ?? [],
links: music.links ?? [], links: music.links ?? [],
createdAt: music.createdAt, createdAt: music.createdAt,
-312
View File
@@ -1,312 +0,0 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ReportStatus as PrismaReportStatus,
ReportReason as PrismaReportReason,
} from "@prisma/client";
import type {
CreateReportDto,
ProfileReportWithUsers,
ReportStatus,
UpdateReportDto,
} from "@library/shared-types";
import { ReportReason } from "@library/shared-types";
import { prisma } from "../lib/prisma.js";
export class ReportService {
private prisma = prisma;
/**
* Convert Prisma ReportReason to shared-types ReportReason
*/
private toPrismaReportReason(reason: ReportReason): PrismaReportReason {
return reason as unknown as PrismaReportReason;
}
/**
* Convert Prisma ReportStatus to shared-types ReportStatus
*/
private toPrismaReportStatus(status: ReportStatus): PrismaReportStatus {
return status as unknown as PrismaReportStatus;
}
/**
* Convert Prisma enum back to shared-types enum
*/
private fromPrismaReportReason(reason: PrismaReportReason): ReportReason {
return reason as unknown as ReportReason;
}
/**
* Convert Prisma enum back to shared-types enum
*/
private fromPrismaReportStatus(status: PrismaReportStatus): ReportStatus {
return status as unknown as ReportStatus;
}
/**
* Create a new profile report.
*
* @param reporterId - The ID of the user making the report
* @param createDto - The report details
* @returns The created report
* @throws Error if user already has a pending report for this profile
*/
async createReport(
reporterId: string,
createDto: CreateReportDto,
): Promise<ProfileReportWithUsers> {
// Check if user already has a pending report for this profile
const existingReport = await this.prisma.profileReport.findFirst({
where: {
reporterId,
reportedUserId: createDto.reportedUserId,
status: PrismaReportStatus.PENDING,
},
});
if (existingReport) {
throw new Error(
"You already have a pending report for this profile. Please wait for it to be reviewed.",
);
}
// Check if user has reached the limit of pending reports (5 max)
const pendingReportsCount = await this.prisma.profileReport.count({
where: {
reporterId,
status: PrismaReportStatus.PENDING,
},
});
if (pendingReportsCount >= 5) {
throw new Error(
"You have reached the maximum number of pending reports (5). Please wait for your existing reports to be reviewed.",
);
}
const report = await this.prisma.profileReport.create({
data: {
reporterId,
reportedUserId: createDto.reportedUserId,
reason: this.toPrismaReportReason(createDto.reason),
details: createDto.details,
},
include: {
reportedUser: {
select: {
id: true,
username: true,
displayName: true,
avatar: true,
},
},
reporter: {
select: {
id: true,
username: true,
displayName: true,
avatar: true,
},
},
},
});
return {
id: report.id,
reportedUserId: report.reportedUserId,
reporterId: report.reporterId,
reason: this.fromPrismaReportReason(report.reason),
details: report.details,
status: this.fromPrismaReportStatus(report.status),
reviewedBy: report.reviewedBy ?? undefined,
reviewNotes: report.reviewNotes ?? undefined,
createdAt: report.createdAt,
updatedAt: report.updatedAt,
reportedUser: report.reportedUser,
reporter: report.reporter,
};
}
/**
* Get all reports (admin only). Optionally filter by status.
*
* @param status - Optional status filter
* @returns All reports matching the filter
*/
async getAllReports(
status?: ReportStatus,
): Promise<ProfileReportWithUsers[]> {
const reports = await this.prisma.profileReport.findMany({
where: status ? { status: this.toPrismaReportStatus(status) } : undefined,
include: {
reportedUser: {
select: {
id: true,
username: true,
displayName: true,
avatar: true,
},
},
reporter: {
select: {
id: true,
username: true,
displayName: true,
avatar: true,
},
},
reviewer: {
select: {
id: true,
username: true,
displayName: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
return reports.map((report) => ({
id: report.id,
reportedUserId: report.reportedUserId,
reporterId: report.reporterId,
reason: this.fromPrismaReportReason(report.reason),
details: report.details,
status: this.fromPrismaReportStatus(report.status),
reviewedBy: report.reviewedBy ?? undefined,
reviewNotes: report.reviewNotes ?? undefined,
createdAt: report.createdAt,
updatedAt: report.updatedAt,
reportedUser: report.reportedUser,
reporter: report.reporter,
reviewer: report.reviewer ?? undefined,
}));
}
/**
* Get a single report by ID (admin only).
*
* @param id - The report ID
* @returns The report or null
*/
async getReportById(id: string): Promise<ProfileReportWithUsers | null> {
const report = await this.prisma.profileReport.findUnique({
where: { id },
include: {
reportedUser: {
select: {
id: true,
username: true,
displayName: true,
avatar: true,
},
},
reporter: {
select: {
id: true,
username: true,
displayName: true,
avatar: true,
},
},
reviewer: {
select: {
id: true,
username: true,
displayName: true,
},
},
},
});
if (!report) {
return null;
}
return {
id: report.id,
reportedUserId: report.reportedUserId,
reporterId: report.reporterId,
reason: this.fromPrismaReportReason(report.reason),
details: report.details,
status: this.fromPrismaReportStatus(report.status),
reviewedBy: report.reviewedBy ?? undefined,
reviewNotes: report.reviewNotes ?? undefined,
createdAt: report.createdAt,
updatedAt: report.updatedAt,
reportedUser: report.reportedUser,
reporter: report.reporter,
reviewer: report.reviewer ?? undefined,
};
}
/**
* Update a report's status and review notes (admin only).
*
* @param id - The report ID
* @param reviewerId - The ID of the admin reviewing the report
* @param updateDto - The update details
* @returns The updated report
*/
async updateReport(
id: string,
reviewerId: string,
updateDto: UpdateReportDto,
): Promise<ProfileReportWithUsers> {
const report = await this.prisma.profileReport.update({
where: { id },
data: {
status: this.toPrismaReportStatus(updateDto.status),
reviewNotes: updateDto.reviewNotes,
reviewedBy: reviewerId,
},
include: {
reportedUser: {
select: {
id: true,
username: true,
displayName: true,
avatar: true,
},
},
reporter: {
select: {
id: true,
username: true,
displayName: true,
avatar: true,
},
},
reviewer: {
select: {
id: true,
username: true,
displayName: true,
},
},
},
});
return {
id: report.id,
reportedUserId: report.reportedUserId,
reporterId: report.reporterId,
reason: this.fromPrismaReportReason(report.reason),
details: report.details,
status: this.fromPrismaReportStatus(report.status),
reviewedBy: report.reviewedBy ?? undefined,
reviewNotes: report.reviewNotes ?? undefined,
createdAt: report.createdAt,
updatedAt: report.updatedAt,
reportedUser: report.reportedUser,
reporter: report.reporter,
reviewer: report.reviewer ?? undefined,
};
}
}
-8
View File
@@ -22,9 +22,7 @@ export class ShowService {
type: show.type as unknown as ShowType, type: show.type as unknown as ShowType,
status: show.status as unknown as ShowStatus, status: show.status as unknown as ShowStatus,
dateAdded: show.dateAdded, dateAdded: show.dateAdded,
dateStarted: show.dateStarted || undefined,
dateCompleted: show.dateCompleted || undefined, dateCompleted: show.dateCompleted || undefined,
dateFinished: show.dateFinished || undefined,
tags: show.tags ?? [], tags: show.tags ?? [],
links: show.links ?? [], links: show.links ?? [],
createdAt: show.createdAt, createdAt: show.createdAt,
@@ -44,9 +42,7 @@ export class ShowService {
type: show.type as unknown as ShowType, type: show.type as unknown as ShowType,
status: show.status as unknown as ShowStatus, status: show.status as unknown as ShowStatus,
dateAdded: show.dateAdded, dateAdded: show.dateAdded,
dateStarted: show.dateStarted || undefined,
dateCompleted: show.dateCompleted || undefined, dateCompleted: show.dateCompleted || undefined,
dateFinished: show.dateFinished || undefined,
tags: show.tags ?? [], tags: show.tags ?? [],
links: show.links ?? [], links: show.links ?? [],
createdAt: show.createdAt, createdAt: show.createdAt,
@@ -68,9 +64,7 @@ export class ShowService {
type: show.type as unknown as ShowType, type: show.type as unknown as ShowType,
status: show.status as unknown as ShowStatus, status: show.status as unknown as ShowStatus,
dateAdded: show.dateAdded, dateAdded: show.dateAdded,
dateStarted: show.dateStarted || undefined,
dateCompleted: show.dateCompleted || undefined, dateCompleted: show.dateCompleted || undefined,
dateFinished: show.dateFinished || undefined,
tags: show.tags ?? [], tags: show.tags ?? [],
links: show.links ?? [], links: show.links ?? [],
createdAt: show.createdAt, createdAt: show.createdAt,
@@ -97,9 +91,7 @@ export class ShowService {
type: show.type as unknown as ShowType, type: show.type as unknown as ShowType,
status: show.status as unknown as ShowStatus, status: show.status as unknown as ShowStatus,
dateAdded: show.dateAdded, dateAdded: show.dateAdded,
dateStarted: show.dateStarted || undefined,
dateCompleted: show.dateCompleted || undefined, dateCompleted: show.dateCompleted || undefined,
dateFinished: show.dateFinished || undefined,
tags: show.tags ?? [], tags: show.tags ?? [],
links: show.links ?? [], links: show.links ?? [],
createdAt: show.createdAt, createdAt: show.createdAt,
-213
View File
@@ -6,7 +6,6 @@
import { User } from "@library/shared-types"; import { User } from "@library/shared-types";
import { prisma } from "../lib/prisma"; import { prisma } from "../lib/prisma";
import { SuggestionStatus } from "@prisma/client";
export class UserService { export class UserService {
private prisma = prisma; private prisma = prisma;
@@ -22,17 +21,6 @@ export class UserService {
username: user.username, username: user.username,
email: user.email, email: user.email,
avatar: user.avatar || undefined, avatar: user.avatar || undefined,
slug: user.slug || undefined,
displayName: user.displayName || undefined,
bio: user.bio || undefined,
profilePublic: user.profilePublic,
website: user.website || undefined,
discordServer: user.discordServer || undefined,
bluesky: user.bluesky || undefined,
github: user.github || undefined,
linkedin: user.linkedin || undefined,
twitch: user.twitch || undefined,
youtube: user.youtube || undefined,
isAdmin: user.isAdmin, isAdmin: user.isAdmin,
isBanned: user.isBanned, isBanned: user.isBanned,
inDiscord: user.inDiscord, inDiscord: user.inDiscord,
@@ -57,17 +45,6 @@ export class UserService {
username: user.username, username: user.username,
email: user.email, email: user.email,
avatar: user.avatar || undefined, avatar: user.avatar || undefined,
slug: user.slug || undefined,
displayName: user.displayName || undefined,
bio: user.bio || undefined,
profilePublic: user.profilePublic,
website: user.website || undefined,
discordServer: user.discordServer || undefined,
bluesky: user.bluesky || undefined,
github: user.github || undefined,
linkedin: user.linkedin || undefined,
twitch: user.twitch || undefined,
youtube: user.youtube || undefined,
isAdmin: user.isAdmin, isAdmin: user.isAdmin,
isBanned: user.isBanned, isBanned: user.isBanned,
inDiscord: user.inDiscord, inDiscord: user.inDiscord,
@@ -89,17 +66,6 @@ export class UserService {
username: user.username, username: user.username,
email: user.email, email: user.email,
avatar: user.avatar || undefined, avatar: user.avatar || undefined,
slug: user.slug || undefined,
displayName: user.displayName || undefined,
bio: user.bio || undefined,
profilePublic: user.profilePublic,
website: user.website || undefined,
discordServer: user.discordServer || undefined,
bluesky: user.bluesky || undefined,
github: user.github || undefined,
linkedin: user.linkedin || undefined,
twitch: user.twitch || undefined,
youtube: user.youtube || undefined,
isAdmin: user.isAdmin, isAdmin: user.isAdmin,
isBanned: user.isBanned, isBanned: user.isBanned,
inDiscord: user.inDiscord, inDiscord: user.inDiscord,
@@ -121,17 +87,6 @@ export class UserService {
username: user.username, username: user.username,
email: user.email, email: user.email,
avatar: user.avatar || undefined, avatar: user.avatar || undefined,
slug: user.slug || undefined,
displayName: user.displayName || undefined,
bio: user.bio || undefined,
profilePublic: user.profilePublic,
website: user.website || undefined,
discordServer: user.discordServer || undefined,
bluesky: user.bluesky || undefined,
github: user.github || undefined,
linkedin: user.linkedin || undefined,
twitch: user.twitch || undefined,
youtube: user.youtube || undefined,
isAdmin: user.isAdmin, isAdmin: user.isAdmin,
isBanned: user.isBanned, isBanned: user.isBanned,
inDiscord: user.inDiscord, inDiscord: user.inDiscord,
@@ -149,172 +104,4 @@ export class UserService {
return user?.isBanned ?? false; return user?.isBanned ?? false;
} }
async getUserBySlug(slug: string): Promise<User | null> {
const user = await this.prisma.user.findFirst({
where: { slug },
});
if (!user) {
return null;
}
return {
id: user.id,
discordId: user.discordId,
username: user.username,
email: user.email,
avatar: user.avatar || undefined,
slug: user.slug || undefined,
displayName: user.displayName || undefined,
bio: user.bio || undefined,
profilePublic: user.profilePublic,
website: user.website || undefined,
discordServer: user.discordServer || undefined,
bluesky: user.bluesky || undefined,
github: user.github || undefined,
linkedin: user.linkedin || undefined,
twitch: user.twitch || undefined,
youtube: user.youtube || undefined,
isAdmin: user.isAdmin,
isBanned: user.isBanned,
inDiscord: user.inDiscord,
isVip: user.isVip,
isMod: user.isMod,
isStaff: user.isStaff,
};
}
async updateUserSettings(
id: string,
updates: {
slug?: string;
displayName?: string;
bio?: string;
profilePublic?: boolean;
website?: string;
discordServer?: string;
bluesky?: string;
github?: string;
linkedin?: string;
twitch?: string;
youtube?: string;
}
): Promise<User | null> {
const user = await this.prisma.user.update({
where: { id },
data: updates,
});
return {
id: user.id,
discordId: user.discordId,
username: user.username,
email: user.email,
avatar: user.avatar || undefined,
slug: user.slug || undefined,
displayName: user.displayName || undefined,
bio: user.bio || undefined,
profilePublic: user.profilePublic,
website: user.website || undefined,
discordServer: user.discordServer || undefined,
bluesky: user.bluesky || undefined,
github: user.github || undefined,
linkedin: user.linkedin || undefined,
twitch: user.twitch || undefined,
youtube: user.youtube || undefined,
isAdmin: user.isAdmin,
isBanned: user.isBanned,
inDiscord: user.inDiscord,
isVip: user.isVip,
isMod: user.isMod,
isStaff: user.isStaff,
};
}
async getUserProfile(identifier: string): Promise<{
id: string;
username: string;
displayName?: string | null;
avatar?: string | null;
bio?: string | null;
slug?: string | null;
website?: string | null;
discordServer?: string | null;
bluesky?: string | null;
github?: string | null;
linkedin?: string | null;
twitch?: string | null;
youtube?: string | null;
isStaff: boolean;
isMod: boolean;
isVip: boolean;
inDiscord: boolean;
profilePublic: boolean;
createdAt: Date;
stats: {
suggestionsCount: number;
suggestionsAcceptedCount: number;
likesCount: number;
commentsCount: number;
};
} | null> {
// Try to find by slug first, then by id if it's a valid ObjectId
const isValidObjectId = /^[0-9a-f]{24}$/i.test(identifier);
const whereConditions = isValidObjectId
? [{ slug: identifier }, { id: identifier }]
: [{ slug: identifier }];
const user = await this.prisma.user.findFirst({
where: {
OR: whereConditions,
},
include: {
suggestions: {
select: { id: true, status: true },
},
likes: {
select: { id: true },
},
comments: {
select: { id: true },
},
},
});
if (!user) {
return null;
}
return {
id: user.id,
username: user.username,
displayName: user.displayName,
avatar: user.avatar,
bio: user.bio,
slug: user.slug,
website: user.website,
discordServer: user.discordServer,
bluesky: user.bluesky,
github: user.github,
linkedin: user.linkedin,
twitch: user.twitch,
youtube: user.youtube,
isStaff: user.isStaff,
isMod: user.isMod,
isVip: user.isVip,
inDiscord: user.inDiscord,
profilePublic: user.profilePublic,
createdAt: user.createdAt,
stats: {
suggestionsCount: user.suggestions.length,
suggestionsAcceptedCount: user.suggestions.filter(
(suggestion) => suggestion.status === SuggestionStatus.ACCEPTED
).length,
likesCount: user.likes.length,
commentsCount: user.comments.length,
},
};
}
} }
-9
View File
@@ -1,9 +0,0 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Logger } from "@nhcarrigan/logger";
export const logger = new Logger("Library", process.env.LOG_TOKEN ?? "");
+1 -34
View File
@@ -1,42 +1,9 @@
import Fastify from 'fastify'; import Fastify from 'fastify';
import { app } from './app/app'; import { app } from './app/app';
import { logger } from './app/utils/logger';
const host = process.env.HOST ?? 'localhost'; const host = process.env.HOST ?? 'localhost';
const port = process.env.PORT ? Number(process.env.PORT) : 12321; const port = process.env.PORT ? Number(process.env.PORT) : 12321;
// Global error handlers
process.on('uncaughtException', (error: Error) => {
void logger.error('Uncaught Exception', error);
process.exit(1);
});
process.on('unhandledRejection', (reason: unknown) => {
const error = reason instanceof Error ? reason : new Error(String(reason));
void logger.error('Unhandled Rejection', error);
process.exit(1);
});
process.on('warning', (warning: Error) => {
void logger.log('warn', `Process Warning: ${warning.name} - ${warning.message}`);
});
process.on('SIGTERM', () => {
void logger.log('info', 'SIGTERM signal received: closing HTTP server');
server.close(() => {
void logger.log('info', 'HTTP server closed');
process.exit(0);
});
});
process.on('SIGINT', () => {
void logger.log('info', 'SIGINT signal received: closing HTTP server');
server.close(() => {
void logger.log('info', 'HTTP server closed');
process.exit(0);
});
});
// Instantiate Fastify with some config // Instantiate Fastify with some config
const server = Fastify({ const server = Fastify({
logger: true, logger: true,
@@ -52,6 +19,6 @@ server.listen({ port, host }, (err) => {
server.log.error(err); server.log.error(err);
process.exit(1); process.exit(1);
} else { } else {
void logger.log('info', `Server ready at http://${host}:${port}`); console.log(`[ ready ] http://${host}:${port}`);
} }
}); });
-47
View File
@@ -1,47 +0,0 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
// Set required environment variables for tests
process.env.JWT_SECRET = 'test-secret';
process.env.DISCORD_CLIENT_ID = 'test-client-id';
process.env.DISCORD_CLIENT_SECRET = 'test-client-secret';
process.env.DOMAIN = 'http://localhost:3000';
process.env.API_URL = 'http://localhost:3000/api';
process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test';
process.env.BASE_URL = 'http://localhost:4200';
process.env.NODE_ENV = 'test';
// Mock ESM packages to avoid import issues in Jest
jest.mock('jsdom', () => ({
JSDOM: class {
window = {
document: {
createElement: jest.fn(() => ({})),
},
};
},
}));
jest.mock('marked', () => ({
marked: jest.fn((input: string) => `<p>${input}</p>`),
}));
jest.mock('dompurify', () => {
const mockDOMPurify = {
sanitize: jest.fn((input: string) => input),
addHook: jest.fn(),
};
const createDOMPurify = jest.fn(() => mockDOMPurify);
return createDOMPurify;
});
jest.mock('@nhcarrigan/logger', () => ({
Logger: class {
log = jest.fn().mockResolvedValue(undefined);
error = jest.fn().mockResolvedValue(undefined);
metric = jest.fn().mockResolvedValue(undefined);
},
}));
+1 -2
View File
@@ -10,7 +10,6 @@
"jest.config.ts", "jest.config.ts",
"jest.config.cts", "jest.config.cts",
"src/**/*.spec.ts", "src/**/*.spec.ts",
"src/**/*.test.ts", "src/**/*.test.ts"
"src/test-setup.ts"
] ]
} }
+1 -6
View File
@@ -4,9 +4,4 @@
* @license Naomi's Public License * @license Naomi's Public License
*/ */
/** export const getGreeting = (): Cypress.Chainable => cy.get("h1");
*
*/
export const getGreeting = (): Cypress.Chainable => {
return cy.get("h1");
};
+8 -14
View File
@@ -13,14 +13,14 @@
* *
* For more comprehensive examples of custom * For more comprehensive examples of custom
* commands please read more here: * commands please read more here:
* https://on.cypress.io/custom-commands. * https://on.cypress.io/custom-commands
*/ */
// eslint-disable-next-line @typescript-eslint/no-namespace -- Required for Cypress type extensions // eslint-disable-next-line @typescript-eslint/no-namespace -- Required for Cypress type extensions
declare namespace Cypress { declare namespace Cypress {
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Subject is required for type definition // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Subject is required for type definition
interface Chainable<Subject> { interface Chainable<Subject> {
login: (email: string, password: string)=> void; login: (email: string, password: string) => void;
} }
} }
@@ -30,17 +30,11 @@ Cypress.Commands.add("login", (email, password) => {
console.log("Custom command example: Login", email, password); console.log("Custom command example: Login", email, password);
}); });
/* // -- This is a child command --
* -- This is a child command -- // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
* Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
*/
/* // -- This is a dual command --
* -- This is a dual command -- // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
* Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
*/
/* // -- This will overwrite an existing command --
* -- This will overwrite an existing command -- // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
* Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
*/
+1 -1
View File
@@ -16,7 +16,7 @@
* 'supportFile' configuration option. * 'supportFile' configuration option.
* *
* You can read more here: * You can read more here:
* https://on.cypress.io/configuration. * https://on.cypress.io/configuration
*/ */
// Import commands.ts using ES2015 syntax: // Import commands.ts using ES2015 syntax:
-14
View File
@@ -2,7 +2,6 @@ import {
ApplicationConfig, ApplicationConfig,
provideBrowserGlobalErrorListeners, provideBrowserGlobalErrorListeners,
APP_INITIALIZER, APP_INITIALIZER,
ErrorHandler,
} from '@angular/core'; } from '@angular/core';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors, HTTP_INTERCEPTORS } from '@angular/common/http'; import { provideHttpClient, withInterceptors, HTTP_INTERCEPTORS } from '@angular/common/http';
@@ -10,19 +9,12 @@ import { appRoutes } from './app.routes';
import { AuthService } from './services/auth.service'; import { AuthService } from './services/auth.service';
import { AuthInterceptor } from './interceptors/auth.interceptor'; import { AuthInterceptor } from './interceptors/auth.interceptor';
import { initializeAuth } from './initializers/auth.initializer'; import { initializeAuth } from './initializers/auth.initializer';
import { GlobalErrorHandler } from './services/global-error-handler.service';
import { ConsoleLoggerService } from './services/console-logger.service';
import { initializeConsoleLogger } from './initializers/console-logger.initializer';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideBrowserGlobalErrorListeners(), provideBrowserGlobalErrorListeners(),
provideRouter(appRoutes), provideRouter(appRoutes),
provideHttpClient(), provideHttpClient(),
{
provide: ErrorHandler,
useClass: GlobalErrorHandler
},
{ {
provide: HTTP_INTERCEPTORS, provide: HTTP_INTERCEPTORS,
useClass: AuthInterceptor, useClass: AuthInterceptor,
@@ -33,12 +25,6 @@ export const appConfig: ApplicationConfig = {
useFactory: initializeAuth, useFactory: initializeAuth,
deps: [AuthService], deps: [AuthService],
multi: true multi: true
},
{
provide: APP_INITIALIZER,
useFactory: initializeConsoleLogger,
deps: [ConsoleLoggerService],
multi: true
} }
], ],
}; };
-1
View File
@@ -3,4 +3,3 @@
<router-outlet></router-outlet> <router-outlet></router-outlet>
</main> </main>
<app-footer></app-footer> <app-footer></app-footer>
<app-toast></app-toast>
-12
View File
@@ -41,10 +41,6 @@ export const appRoutes: Route[] = [
path: 'admin/suggestions', path: 'admin/suggestions',
loadComponent: () => import('./components/admin/admin-suggestions.component').then(m => m.AdminSuggestionsComponent) loadComponent: () => import('./components/admin/admin-suggestions.component').then(m => m.AdminSuggestionsComponent)
}, },
{
path: 'admin/reports',
loadComponent: () => import('./components/admin-reports/admin-reports.component').then(m => m.AdminReportsComponent)
},
{ {
path: 'my-suggestions', path: 'my-suggestions',
loadComponent: () => import('./components/my-suggestions/my-suggestions.component').then(m => m.MySuggestionsComponent) loadComponent: () => import('./components/my-suggestions/my-suggestions.component').then(m => m.MySuggestionsComponent)
@@ -53,14 +49,6 @@ export const appRoutes: Route[] = [
path: 'my-likes', path: 'my-likes',
loadComponent: () => import('./components/my-likes/my-likes.component').then(m => m.MyLikesComponent) loadComponent: () => import('./components/my-likes/my-likes.component').then(m => m.MyLikesComponent)
}, },
{
path: 'profile/:identifier',
loadComponent: () => import('./components/profile/profile.component').then(m => m.ProfileComponent)
},
{
path: 'settings',
loadComponent: () => import('./components/settings/settings.component').then(m => m.SettingsComponent)
},
{ {
path: '**', path: '**',
redirectTo: '' redirectTo: ''
+1 -2
View File
@@ -2,11 +2,10 @@ import { Component, inject, OnInit } from '@angular/core';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { HeaderComponent } from './components/header/header.component'; import { HeaderComponent } from './components/header/header.component';
import { FooterComponent } from './components/footer/footer.component'; import { FooterComponent } from './components/footer/footer.component';
import { ToastComponent } from './components/toast/toast.component';
import { AnalyticsService } from './services/analytics.service'; import { AnalyticsService } from './services/analytics.service';
@Component({ @Component({
imports: [RouterModule, HeaderComponent, FooterComponent, ToastComponent], imports: [RouterModule, HeaderComponent, FooterComponent],
selector: 'app-root', selector: 'app-root',
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.scss', styleUrl: './app.scss',
File diff suppressed because it is too large Load Diff
@@ -39,22 +39,22 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
All ({{ suggestions().length }}) All ({{ suggestions().length }})
</button> </button>
<button <button
(click)="setFilter(SuggestionStatus.unreviewed)" (click)="setFilter(SuggestionStatus.UNREVIEWED)"
[class.active]="statusFilter() === SuggestionStatus.unreviewed" [class.active]="statusFilter() === SuggestionStatus.UNREVIEWED"
class="filter-btn pending" class="filter-btn pending"
> >
Pending ({{ unreviewedCount() }}) Pending ({{ unreviewedCount() }})
</button> </button>
<button <button
(click)="setFilter(SuggestionStatus.accepted)" (click)="setFilter(SuggestionStatus.ACCEPTED)"
[class.active]="statusFilter() === SuggestionStatus.accepted" [class.active]="statusFilter() === SuggestionStatus.ACCEPTED"
class="filter-btn accepted" class="filter-btn accepted"
> >
Accepted ({{ acceptedCount() }}) Accepted ({{ acceptedCount() }})
</button> </button>
<button <button
(click)="setFilter(SuggestionStatus.declined)" (click)="setFilter(SuggestionStatus.DECLINED)"
[class.active]="statusFilter() === SuggestionStatus.declined" [class.active]="statusFilter() === SuggestionStatus.DECLINED"
class="filter-btn declined" class="filter-btn declined"
> >
Declined ({{ declinedCount() }}) Declined ({{ declinedCount() }})
@@ -171,7 +171,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
} }
</div> </div>
@if (suggestion.status === SuggestionStatus.declined && suggestion.declineReason) { @if (suggestion.status === SuggestionStatus.DECLINED && suggestion.declineReason) {
<div class="decline-reason"> <div class="decline-reason">
<strong>Decline reason:</strong> {{ suggestion.declineReason }} <strong>Decline reason:</strong> {{ suggestion.declineReason }}
</div> </div>
@@ -180,7 +180,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
<div class="suggestion-footer"> <div class="suggestion-footer">
<span class="date">Suggested on {{ formatDate(suggestion.createdAt) }}</span> <span class="date">Suggested on {{ formatDate(suggestion.createdAt) }}</span>
@if (suggestion.status === SuggestionStatus.unreviewed) { @if (suggestion.status === SuggestionStatus.UNREVIEWED) {
<div class="actions"> <div class="actions">
<button (click)="acceptSuggestion(suggestion)" class="btn btn-accept"> <button (click)="acceptSuggestion(suggestion)" class="btn btn-accept">
Accept Accept
@@ -206,8 +206,8 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
} }
@if (showDeclineModal()) { @if (showDeclineModal()) {
<div class="modal-overlay" (click)="closeDeclineModal()" (keyup.escape)="closeDeclineModal()" tabindex="0" role="button"> <div class="modal-overlay" (click)="closeDeclineModal()">
<div class="modal" (click)="$event.stopPropagation()" (keyup)="$event.stopPropagation()" tabindex="-1"> <div class="modal" (click)="$event.stopPropagation()">
<h3>Decline Suggestion</h3> <h3>Decline Suggestion</h3>
<p>Are you sure you want to decline "{{ decliningsuggestion()?.title }}"?</p> <p>Are you sure you want to decline "{{ decliningsuggestion()?.title }}"?</p>
<div class="form-group"> <div class="form-group">
@@ -229,8 +229,8 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
} }
@if (showEditModal()) { @if (showEditModal()) {
<div class="modal-overlay" (click)="closeEditModal()" (keyup.escape)="closeEditModal()" tabindex="0" role="button"> <div class="modal-overlay" (click)="closeEditModal()">
<div class="modal edit-modal" (click)="$event.stopPropagation()" (keyup)="$event.stopPropagation()" tabindex="-1"> <div class="modal edit-modal" (click)="$event.stopPropagation()">
<h3>Review & Edit Before Accepting</h3> <h3>Review & Edit Before Accepting</h3>
<p>Review and edit the details before adding to your collection.</p> <p>Review and edit the details before adding to your collection.</p>
@@ -248,7 +248,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
</div> </div>
@switch (editingSuggestion()!.entityType) { @switch (editingSuggestion()!.entityType) {
@case (SuggestionEntity.book) { @case ('BOOK') {
<div class="form-group"> <div class="form-group">
<label for="edit-author">Author</label> <label for="edit-author">Author</label>
<input <input
@@ -269,7 +269,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
> >
</div> </div>
} }
@case (SuggestionEntity.game) { @case ('GAME') {
<div class="form-group"> <div class="form-group">
<label for="edit-platform">Platform</label> <label for="edit-platform">Platform</label>
<input <input
@@ -280,7 +280,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
> >
</div> </div>
} }
@case (SuggestionEntity.music) { @case ('MUSIC') {
<div class="form-group"> <div class="form-group">
<label for="edit-artist">Artist</label> <label for="edit-artist">Artist</label>
<input <input
@@ -300,7 +300,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
</select> </select>
</div> </div>
} }
@case (SuggestionEntity.art) { @case ('ART') {
<div class="form-group"> <div class="form-group">
<label for="edit-artist">Artist</label> <label for="edit-artist">Artist</label>
<input <input
@@ -331,7 +331,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
> >
</div> </div>
} }
@case (SuggestionEntity.show) { @case ('SHOW') {
<div class="form-group"> <div class="form-group">
<label for="edit-type">Type</label> <label for="edit-type">Type</label>
<select id="edit-type" [(ngModel)]="editedData.type" name="type" required> <select id="edit-type" [(ngModel)]="editedData.type" name="type" required>
@@ -342,7 +342,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
</select> </select>
</div> </div>
} }
@case (SuggestionEntity.manga) { @case ('MANGA') {
<div class="form-group"> <div class="form-group">
<label for="edit-author">Author</label> <label for="edit-author">Author</label>
<input <input
@@ -366,7 +366,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
></textarea> ></textarea>
</div> </div>
@if (editingSuggestion()!.entityType !== SuggestionEntity.art) { @if (editingSuggestion()!.entityType !== 'ART') {
<div class="form-group"> <div class="form-group">
<label for="edit-coverImage">Cover Image URL</label> <label for="edit-coverImage">Cover Image URL</label>
<input <input
@@ -727,11 +727,10 @@ export class AdminSuggestionsComponent implements OnInit {
pageSize = signal(25); pageSize = signal(25);
SuggestionStatus = SuggestionStatus; SuggestionStatus = SuggestionStatus;
SuggestionEntity = SuggestionEntity;
unreviewedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.unreviewed).length; unreviewedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.UNREVIEWED).length;
acceptedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.accepted).length; acceptedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.ACCEPTED).length;
declinedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.declined).length; declinedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.DECLINED).length;
filteredSuggestions = computed(() => { filteredSuggestions = computed(() => {
const filter = this.statusFilter(); const filter = this.statusFilter();
@@ -789,20 +788,20 @@ export class AdminSuggestionsComponent implements OnInit {
getStatusLabel(status: SuggestionStatus): string { getStatusLabel(status: SuggestionStatus): string {
switch (status) { switch (status) {
case SuggestionStatus.unreviewed: return 'Pending'; case SuggestionStatus.UNREVIEWED: return 'Pending';
case SuggestionStatus.accepted: return 'Accepted'; case SuggestionStatus.ACCEPTED: return 'Accepted';
case SuggestionStatus.declined: return 'Declined'; case SuggestionStatus.DECLINED: return 'Declined';
} }
} }
getEntityIcon(entityType: SuggestionEntity): string { getEntityIcon(entityType: SuggestionEntity): string {
switch (entityType) { switch (entityType) {
case SuggestionEntity.game: return '🎮'; case SuggestionEntity.GAME: return '🎮';
case SuggestionEntity.book: return '📚'; case SuggestionEntity.BOOK: return '📚';
case SuggestionEntity.music: return '🎵'; case SuggestionEntity.MUSIC: return '🎵';
case SuggestionEntity.manga: return 'đź“–'; case SuggestionEntity.MANGA: return 'đź“–';
case SuggestionEntity.show: return '📺'; case SuggestionEntity.SHOW: return '📺';
case SuggestionEntity.art: return '🎨'; case SuggestionEntity.ART: return '🎨';
} }
} }
@@ -823,39 +822,39 @@ export class AdminSuggestionsComponent implements OnInit {
// Add entity-specific data // Add entity-specific data
switch (suggestion.entityType) { switch (suggestion.entityType) {
case SuggestionEntity.book: case 'BOOK':
const bookData = suggestion.bookData as any; const bookData = suggestion.bookData as any;
this.editedData.author = bookData?.author || ''; this.editedData.author = bookData?.author || '';
this.editedData.isbn = bookData?.isbn || ''; this.editedData.isbn = bookData?.isbn || '';
this.editedData.notes = bookData?.notes || ''; this.editedData.notes = bookData?.notes || '';
this.editedData.coverImage = bookData?.coverImage || ''; this.editedData.coverImage = bookData?.coverImage || '';
break; break;
case SuggestionEntity.game: case 'GAME':
const gameData = suggestion.gameData as any; const gameData = suggestion.gameData as any;
this.editedData.platform = gameData?.platform || ''; this.editedData.platform = gameData?.platform || '';
this.editedData.notes = gameData?.notes || ''; this.editedData.notes = gameData?.notes || '';
this.editedData.coverImage = gameData?.coverImage || ''; this.editedData.coverImage = gameData?.coverImage || '';
break; break;
case SuggestionEntity.music: case 'MUSIC':
const musicData = suggestion.musicData as any; const musicData = suggestion.musicData as any;
this.editedData.artist = musicData?.artist || ''; this.editedData.artist = musicData?.artist || '';
this.editedData.type = musicData?.type || 'ALBUM'; this.editedData.type = musicData?.type || 'ALBUM';
this.editedData.notes = musicData?.notes || ''; this.editedData.notes = musicData?.notes || '';
this.editedData.coverArt = musicData?.coverArt || ''; this.editedData.coverArt = musicData?.coverArt || '';
break; break;
case SuggestionEntity.art: case 'ART':
const artData = suggestion.artData as any; const artData = suggestion.artData as any;
this.editedData.artist = artData?.artist || ''; this.editedData.artist = artData?.artist || '';
this.editedData.description = artData?.description || ''; this.editedData.description = artData?.description || '';
this.editedData.imageUrl = artData?.imageUrl || ''; this.editedData.imageUrl = artData?.imageUrl || '';
break; break;
case SuggestionEntity.show: case 'SHOW':
const showData = suggestion.showData as any; const showData = suggestion.showData as any;
this.editedData.type = showData?.type || 'TV_SERIES'; this.editedData.type = showData?.type || 'TV_SERIES';
this.editedData.notes = showData?.notes || ''; this.editedData.notes = showData?.notes || '';
this.editedData.coverImage = showData?.coverImage || ''; this.editedData.coverImage = showData?.coverImage || '';
break; break;
case SuggestionEntity.manga: case 'MANGA':
const mangaData = suggestion.mangaData as any; const mangaData = suggestion.mangaData as any;
this.editedData.author = mangaData?.author || ''; this.editedData.author = mangaData?.author || '';
this.editedData.notes = mangaData?.notes || ''; this.editedData.notes = mangaData?.notes || '';
@@ -14,13 +14,12 @@ import { SanitizeService } from '../../services/sanitize.service';
import { SuggestionService } from '../../services/suggestion.service'; import { SuggestionService } from '../../services/suggestion.service';
import { PaginationComponent } from '../shared/pagination.component'; import { PaginationComponent } from '../shared/pagination.component';
import { LikeButtonComponent } from '../shared/like-button.component'; import { LikeButtonComponent } from '../shared/like-button.component';
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from '@library/shared-types'; import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
@Component({ @Component({
selector: 'app-art-gallery', selector: 'app-art-gallery',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent], imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
template: ` template: `
<div class="container"> <div class="container">
<div class="header-section"> <div class="header-section">
@@ -106,7 +105,8 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
} }
<div class="form-group"> <div class="form-group">
<div class="tags-input-container" aria-label="Tags"> <label>Tags</label>
<div class="tags-input-container">
@for (tag of newArt.tags; track tag; let i = $index) { @for (tag of newArt.tags; track tag; let i = $index) {
<span class="tag"> <span class="tag">
{{ tag }} {{ tag }}
@@ -123,7 +123,8 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
</div> </div>
</div> </div>
<div class="form-group" aria-label="External Links"> <div class="form-group">
<label>External Links</label>
<div class="links-list"> <div class="links-list">
@for (link of newArt.links; track link.url; let i = $index) { @for (link of newArt.links; track link.url; let i = $index) {
<div class="link-item"> <div class="link-item">
@@ -279,7 +280,8 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
} }
<div class="form-group"> <div class="form-group">
<div class="tags-input-container" aria-label="Tags"> <label>Tags</label>
<div class="tags-input-container">
@for (tag of editArt.tags; track tag; let i = $index) { @for (tag of editArt.tags; track tag; let i = $index) {
<span class="tag"> <span class="tag">
{{ tag }} {{ tag }}
@@ -296,7 +298,8 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
</div> </div>
</div> </div>
<div class="form-group" aria-label="External Links"> <div class="form-group">
<label>External Links</label>
<div class="links-list"> <div class="links-list">
@for (link of editArt.links; track link.url; let i = $index) { @for (link of editArt.links; track link.url; let i = $index) {
<div class="link-item"> <div class="link-item">
@@ -398,10 +401,6 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
[alt]="art.description || art.title" [alt]="art.description || art.title"
class="art-image" class="art-image"
(click)="openLightbox(art)" (click)="openLightbox(art)"
(keyup.enter)="openLightbox(art)"
(keyup.space)="openLightbox(art)"
tabindex="0"
role="button"
> >
</div> </div>
@@ -469,11 +468,56 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
} }
} }
<app-comment-display @if (commentsLoading()[art.id]) {
[comments]="getCommentsSignal(art.id)" <div class="comments-loading">Loading comments...</div>
(onEdit)="handleCommentEdit(art.id, $event)" } @else {
(onDelete)="deleteComment(art.id, $event)" @for (comment of comments()[art.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>
@if (comment.user.inDiscord) {
<span class="discord-badge">Discord</span>
}
@if (comment.user.isVip) {
<span class="vip-badge">VIP</span>
}
@if (comment.user.isMod) {
<span class="mod-badge">Mod</span>
}
@if (comment.user.isStaff) {
<span class="staff-badge">Staff</span>
}
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
@if (canEditComment(comment)) {
<button (click)="startEditComment(art.id, comment)" class="btn btn-secondary btn-xs">Edit</button>
}
@if (canDeleteComment(comment)) {
<button (click)="deleteComment(art.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
}
</div>
@if (editingCommentId() === comment.id) {
<div class="comment-edit-form">
<textarea
[(ngModel)]="editCommentContent"
name="editComment"
rows="3"
></textarea>
<div class="comment-edit-actions">
<button (click)="saveCommentEdit(art.id, comment.id)" class="btn btn-primary btn-xs">Save</button>
<button (click)="cancelCommentEdit()" class="btn btn-secondary btn-xs">Cancel</button>
</div>
</div>
} @else {
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
}
</div>
} @empty {
<div class="no-comments">No comments yet. Be the first to comment!</div>
}
}
</div> </div>
} }
</div> </div>
@@ -492,8 +536,8 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
} }
@if (lightboxArt()) { @if (lightboxArt()) {
<div class="lightbox" (click)="closeLightbox()" (keyup.escape)="closeLightbox()" tabindex="0" role="button"> <div class="lightbox" (click)="closeLightbox()">
<div class="lightbox-content" (click)="$event.stopPropagation()" (keyup)="$event.stopPropagation()" tabindex="-1"> <div class="lightbox-content" (click)="$event.stopPropagation()">
<button class="lightbox-close" (click)="closeLightbox()">&times;</button> <button class="lightbox-close" (click)="closeLightbox()">&times;</button>
<img [src]="lightboxArt()!.imageUrl" [alt]="lightboxArt()!.description || lightboxArt()!.title"> <img [src]="lightboxArt()!.imageUrl" [alt]="lightboxArt()!.description || lightboxArt()!.title">
<div class="lightbox-info"> <div class="lightbox-info">
@@ -1527,7 +1571,7 @@ export class ArtGalleryComponent implements OnInit {
try { try {
await this.suggestionService.createSuggestion({ await this.suggestionService.createSuggestion({
entityType: SuggestionEntity.art, entityType: SuggestionEntity.ART,
title: this.suggestedArt.title, title: this.suggestedArt.title,
artist: this.suggestedArt.artist, artist: this.suggestedArt.artist,
imageUrl: this.suggestedArt.imageUrl, imageUrl: this.suggestedArt.imageUrl,
@@ -1571,21 +1615,4 @@ export class ArtGalleryComponent implements OnInit {
toggleFilters() { toggleFilters() {
this.showFilters.update(v => !v); this.showFilters.update(v => !v);
} }
handleCommentEdit(artId: string, event: { commentId: string; content: string }) {
this.commentsService.updateCommentOnArt(artId, event.commentId, event.content).subscribe({
next: (updatedComment) => {
this.comments.set({
...this.comments(),
[artId]: (this.comments()[artId] || []).map(c =>
c.id === event.commentId ? updatedComment : c
)
});
}
});
}
getCommentsSignal(artId: string) {
return signal(this.comments()[artId] || []);
}
} }
@@ -14,13 +14,12 @@ import { SanitizeService } from '../../services/sanitize.service';
import { SuggestionService } from '../../services/suggestion.service'; import { SuggestionService } from '../../services/suggestion.service';
import { PaginationComponent } from '../shared/pagination.component'; import { PaginationComponent } from '../shared/pagination.component';
import { LikeButtonComponent } from '../shared/like-button.component'; import { LikeButtonComponent } from '../shared/like-button.component';
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEntity, Link } from '@library/shared-types'; import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
@Component({ @Component({
selector: 'app-books-list', selector: 'app-books-list',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent], imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
template: ` template: `
<div class="container"> <div class="container">
<div class="header-section"> <div class="header-section">
@@ -80,30 +79,9 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
<option [value]="BookStatus.reading">Currently Reading</option> <option [value]="BookStatus.reading">Currently Reading</option>
<option [value]="BookStatus.finished">Finished</option> <option [value]="BookStatus.finished">Finished</option>
<option [value]="BookStatus.toRead">To Read</option> <option [value]="BookStatus.toRead">To Read</option>
<option [value]="BookStatus.retired">Retired</option>
</select> </select>
</div> </div>
<div class="form-group">
<label for="dateStarted">Date Started</label>
<input
type="date"
id="dateStarted"
[(ngModel)]="newBook.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="dateFinished">Date Finished</label>
<input
type="date"
id="dateFinished"
[(ngModel)]="newBook.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group"> <div class="form-group">
<label for="rating">Rating (1-10)</label> <label for="rating">Rating (1-10)</label>
<input <input
@@ -148,7 +126,8 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="tags-input-container" aria-label="Tags"> <label>Tags</label>
<div class="tags-input-container">
@for (tag of newBook.tags; track tag; let i = $index) { @for (tag of newBook.tags; track tag; let i = $index) {
<span class="tag"> <span class="tag">
{{ tag }} {{ tag }}
@@ -165,7 +144,8 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
</div> </div>
</div> </div>
<div class="form-group" aria-label="External Links"> <div class="form-group">
<label>External Links</label>
<div class="links-list"> <div class="links-list">
@for (link of newBook.links; track link.url; let i = $index) { @for (link of newBook.links; track link.url; let i = $index) {
<div class="link-item"> <div class="link-item">
@@ -242,30 +222,9 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
<option [value]="BookStatus.reading">Currently Reading</option> <option [value]="BookStatus.reading">Currently Reading</option>
<option [value]="BookStatus.finished">Finished</option> <option [value]="BookStatus.finished">Finished</option>
<option [value]="BookStatus.toRead">To Read</option> <option [value]="BookStatus.toRead">To Read</option>
<option [value]="BookStatus.retired">Retired</option>
</select> </select>
</div> </div>
<div class="form-group">
<label for="edit-dateStarted">Date Started</label>
<input
type="date"
id="edit-dateStarted"
[(ngModel)]="editBook.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="edit-dateFinished">Date Finished</label>
<input
type="date"
id="edit-dateFinished"
[(ngModel)]="editBook.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group"> <div class="form-group">
<label for="edit-rating">Rating (1-10)</label> <label for="edit-rating">Rating (1-10)</label>
<input <input
@@ -310,7 +269,8 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="tags-input-container" aria-label="Tags"> <label>Tags</label>
<div class="tags-input-container">
@for (tag of editBook.tags; track tag; let i = $index) { @for (tag of editBook.tags; track tag; let i = $index) {
<span class="tag"> <span class="tag">
{{ tag }} {{ tag }}
@@ -327,7 +287,8 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
</div> </div>
</div> </div>
<div class="form-group" aria-label="External Links"> <div class="form-group">
<label>External Links</label>
<div class="links-list"> <div class="links-list">
@for (link of editBook.links; track link.url; let i = $index) { @for (link of editBook.links; track link.url; let i = $index) {
<div class="link-item"> <div class="link-item">
@@ -460,7 +421,8 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
@if (showFilters()) { @if (showFilters()) {
<div class="advanced-filters"> <div class="advanced-filters">
<div class="filter-group"> <div class="filter-group">
<div class="tags-filter" aria-label="Filter by Tags"> <label>Filter by Tags:</label>
<div class="tags-filter">
@for (tag of allTags(); track tag) { @for (tag of allTags(); track tag) {
<label class="tag-checkbox"> <label class="tag-checkbox">
<input <input
@@ -513,13 +475,6 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
> >
To Read ({{ toReadCount() }}) To Read ({{ toReadCount() }})
</button> </button>
<button
(click)="setFilter(BookStatus.retired)"
[class.active]="statusFilter() === BookStatus.retired"
class="filter-btn"
>
Retired ({{ retiredCount() }})
</button>
</div> </div>
@if (loading()) { @if (loading()) {
@@ -593,30 +548,12 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
</div> </div>
} }
@if (book.dateStarted) {
<p class="date-started">
Started: {{ formatDate(book.dateStarted) }}
</p>
}
@if (book.dateFinished) { @if (book.dateFinished) {
<p class="date-finished"> <p class="date-finished">
Finished: {{ formatDate(book.dateFinished) }} Finished: {{ formatDate(book.dateFinished) }}
</p> </p>
} }
@if (book.createdAt) {
<p class="date-added">
Added: {{ formatDate(book.createdAt) }}
</p>
}
@if (book.updatedAt) {
<p class="date-updated">
Updated: {{ formatDate(book.updatedAt) }}
</p>
}
@if (authService.isAdmin()) { @if (authService.isAdmin()) {
<div class="actions"> <div class="actions">
<button (click)="startEdit(book)" class="btn btn-secondary btn-sm"> <button (click)="startEdit(book)" class="btn btn-secondary btn-sm">
@@ -656,11 +593,52 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
@if (commentsLoading()[book.id]) { @if (commentsLoading()[book.id]) {
<div class="comments-loading">Loading comments...</div> <div class="comments-loading">Loading comments...</div>
} @else { } @else {
<app-comment-display @for (comment of comments()[book.id] || []; track comment.id) {
[comments]="getCommentsSignal(book.id)" <div class="comment">
(onEdit)="handleCommentEdit(book.id, $event)" <div class="comment-header">
(onDelete)="deleteComment(book.id, $event)" @if (comment.user.avatar) {
/> <img [src]="comment.user.avatar" [alt]="comment.user.username" class="comment-avatar">
}
<span class="comment-author">{{ comment.user.username }}</span>
@if (comment.user.inDiscord) {
<span class="discord-badge">Discord</span>
}
@if (comment.user.isVip) {
<span class="vip-badge">VIP</span>
}
@if (comment.user.isMod) {
<span class="mod-badge">Mod</span>
}
@if (comment.user.isStaff) {
<span class="staff-badge">Staff</span>
}
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
@if (canEditComment(comment)) {
<button (click)="startEditComment(book.id, comment)" class="btn btn-secondary btn-xs">Edit</button>
}
@if (canDeleteComment(comment)) {
<button (click)="deleteComment(book.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
}
</div>
@if (editingCommentId() === comment.id) {
<div class="comment-edit-form">
<textarea
[(ngModel)]="editCommentContent"
name="editComment"
rows="3"
></textarea>
<div class="comment-edit-actions">
<button (click)="saveCommentEdit(book.id, comment.id)" class="btn btn-primary btn-xs">Save</button>
<button (click)="cancelCommentEdit()" class="btn btn-secondary btn-xs">Cancel</button>
</div>
</div>
} @else {
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
}
</div>
} @empty {
<div class="no-comments">No comments yet. Be the first to comment!</div>
}
} }
</div> </div>
} }
@@ -1011,10 +989,7 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
margin: 0.5rem 0; margin: 0.5rem 0;
} }
.date-started, .date-finished {
.date-finished,
.date-added,
.date-updated {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--witch-plum); color: var(--witch-plum);
margin-top: 0.5rem; margin-top: 0.5rem;
@@ -1442,7 +1417,6 @@ export class BooksListComponent implements OnInit {
readingCount = computed(() => this.books().filter(book => book.status === BookStatus.reading).length); readingCount = computed(() => this.books().filter(book => book.status === BookStatus.reading).length);
finishedCount = computed(() => this.books().filter(book => book.status === BookStatus.finished).length); finishedCount = computed(() => this.books().filter(book => book.status === BookStatus.finished).length);
toReadCount = computed(() => this.books().filter(book => book.status === BookStatus.toRead).length); toReadCount = computed(() => this.books().filter(book => book.status === BookStatus.toRead).length);
retiredCount = computed(() => this.books().filter(book => book.status === BookStatus.retired).length);
// Get all unique tags from all books // Get all unique tags from all books
allTags = computed(() => { allTags = computed(() => {
@@ -1493,13 +1467,11 @@ export class BooksListComponent implements OnInit {
totalFilteredBooks = computed(() => this.filteredBooks().length); totalFilteredBooks = computed(() => this.filteredBooks().length);
newBook: Partial<CreateBookDto> & { dateStarted?: Date; dateFinished?: Date } = { newBook: Partial<CreateBookDto> = {
title: '', title: '',
author: '', author: '',
isbn: '', isbn: '',
status: BookStatus.toRead, status: BookStatus.toRead,
dateStarted: undefined,
dateFinished: undefined,
rating: undefined, rating: undefined,
notes: '', notes: '',
tags: [], tags: [],
@@ -1580,7 +1552,6 @@ export class BooksListComponent implements OnInit {
case BookStatus.reading: return 'Currently Reading'; case BookStatus.reading: return 'Currently Reading';
case BookStatus.finished: return 'Finished'; case BookStatus.finished: return 'Finished';
case BookStatus.toRead: return 'To Read'; case BookStatus.toRead: return 'To Read';
case BookStatus.retired: return 'Retired';
} }
} }
@@ -1597,8 +1568,6 @@ export class BooksListComponent implements OnInit {
author: '', author: '',
isbn: '', isbn: '',
status: BookStatus.toRead, status: BookStatus.toRead,
dateStarted: undefined,
dateFinished: undefined,
rating: undefined, rating: undefined,
notes: '', notes: '',
coverImage: undefined, coverImage: undefined,
@@ -1665,8 +1634,6 @@ export class BooksListComponent implements OnInit {
author: this.newBook.author, author: this.newBook.author,
isbn: this.newBook.isbn, isbn: this.newBook.isbn,
status: this.newBook.status, status: this.newBook.status,
dateStarted: this.newBook.dateStarted ? new Date(this.newBook.dateStarted) : undefined,
dateFinished: this.newBook.dateFinished ? new Date(this.newBook.dateFinished) : undefined,
rating: this.newBook.rating, rating: this.newBook.rating,
notes: this.newBook.notes, notes: this.newBook.notes,
coverImage: this.newBook.coverImage, coverImage: this.newBook.coverImage,
@@ -1695,8 +1662,6 @@ export class BooksListComponent implements OnInit {
author: book.author, author: book.author,
isbn: book.isbn, isbn: book.isbn,
status: book.status, status: book.status,
dateStarted: book.dateStarted,
dateFinished: book.dateFinished,
rating: book.rating, rating: book.rating,
notes: book.notes, notes: book.notes,
coverImage: book.coverImage, coverImage: book.coverImage,
@@ -1725,13 +1690,7 @@ export class BooksListComponent implements OnInit {
const book = this.editingBook(); const book = this.editingBook();
if (!book || !this.editBook.title || !this.editBook.author || !this.editBook.status) return; if (!book || !this.editBook.title || !this.editBook.author || !this.editBook.status) return;
const updateData = { this.booksService.updateBook(book.id, this.editBook).subscribe(() => {
...this.editBook,
dateStarted: this.editBook.dateStarted ? new Date(this.editBook.dateStarted) : undefined,
dateFinished: this.editBook.dateFinished ? new Date(this.editBook.dateFinished) : undefined,
};
this.booksService.updateBook(book.id, updateData).subscribe(() => {
this.loadBooks(); this.loadBooks();
this.cancelEdit(); this.cancelEdit();
}); });
@@ -1929,7 +1888,7 @@ export class BooksListComponent implements OnInit {
try { try {
await this.suggestionService.createSuggestion({ await this.suggestionService.createSuggestion({
entityType: SuggestionEntity.book, entityType: SuggestionEntity.BOOK,
title: this.suggestedBook.title, title: this.suggestedBook.title,
author: this.suggestedBook.author, author: this.suggestedBook.author,
isbn: this.suggestedBook.isbn, isbn: this.suggestedBook.isbn,
@@ -1942,21 +1901,4 @@ export class BooksListComponent implements OnInit {
alert('Failed to submit suggestion. Please try again.'); alert('Failed to submit suggestion. Please try again.');
} }
} }
handleCommentEdit(bookId: string, event: { commentId: string; content: string }) {
this.commentsService.updateCommentOnBook(bookId, event.commentId, event.content).subscribe({
next: (updatedComment) => {
this.comments.set({
...this.comments(),
[bookId]: (this.comments()[bookId] || []).map(c =>
c.id === event.commentId ? updatedComment : c
)
});
}
});
}
getCommentsSignal(bookId: string) {
return signal(this.comments()[bookId] || []);
}
} }
@@ -1,310 +0,0 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Component, Input, Output, EventEmitter, signal, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import type { Comment } from '@library/shared-types';
import { AuthService } from '../../services/auth.service';
import { SanitizeService } from '../../services/sanitize.service';
import { ReportModalComponent } from '../report-modal/report-modal.component';
@Component({
selector: 'app-comment-display',
standalone: true,
imports: [CommonModule, FormsModule, ReportModalComponent],
template: `
<div class="comments-section">
@if (comments().length > 0) {
@for (comment of comments(); 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>
@if (comment.user.inDiscord) {
<span class="discord-badge">Discord</span>
}
@if (comment.user.isVip) {
<span class="vip-badge">VIP</span>
}
@if (comment.user.isMod) {
<span class="mod-badge">Mod</span>
}
@if (comment.user.isStaff) {
<span class="staff-badge">Staff</span>
}
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
@if (canEditComment(comment)) {
<button (click)="startEdit(comment)" class="btn btn-secondary btn-xs">Edit</button>
}
@if (canDeleteComment(comment)) {
<button (click)="onDelete.emit(comment.id)" class="btn btn-danger btn-xs">Delete</button>
}
@if (canReportComment(comment)) {
<button (click)="openReportModal(comment)" class="btn btn-warning btn-xs">Report</button>
}
</div>
@if (editingCommentId() === comment.id) {
<div class="comment-edit-form">
<textarea
[(ngModel)]="editCommentContent"
name="editComment"
rows="3"
></textarea>
<div class="comment-edit-actions">
<button (click)="saveEdit(comment.id)" class="btn btn-primary btn-xs">Save</button>
<button (click)="cancelEdit()" class="btn btn-secondary btn-xs">Cancel</button>
</div>
</div>
} @else {
@if (comment.hasPendingReports) {
<div class="comment-pending-review">[comment pending admin review]</div>
} @else {
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
}
}
</div>
}
} @else {
<div class="no-comments">No comments yet. Be the first to comment!</div>
}
</div>
@if (showReportModal()) {
<app-report-modal
[reportType]="'comment'"
[targetId]="reportingCommentId()"
(closeModal)="closeReportModal()"
/>
}
`,
styles: [`
.comments-section {
margin-top: 1rem;
}
.comment {
background: #f9fafb;
padding: 0.75rem;
border-radius: 0.375rem;
margin-bottom: 0.75rem;
}
.comment-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.comment-avatar {
width: 2rem;
height: 2rem;
border-radius: 50%;
object-fit: cover;
}
.comment-author {
font-weight: 600;
color: #1f2937;
}
.discord-badge,
.vip-badge,
.mod-badge,
.staff-badge {
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 600;
}
.discord-badge {
background: #5865f2;
color: white;
}
.vip-badge {
background: #fbbf24;
color: #78350f;
}
.mod-badge {
background: #10b981;
color: white;
}
.staff-badge {
background: #8b5cf6;
color: white;
}
.comment-date {
font-size: 0.75rem;
color: #6b7280;
}
.comment-content {
font-size: 0.9rem;
color: #4b5563;
}
.comment-pending-review {
font-size: 0.9rem;
color: #9b59b6;
font-style: italic;
padding: 0.5rem;
background: #f3e8ff;
border-radius: 0.25rem;
}
.comment-edit-form {
margin-top: 0.5rem;
}
.comment-edit-form textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-family: inherit;
resize: vertical;
}
.comment-edit-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.no-comments {
text-align: center;
color: #6b7280;
padding: 2rem;
font-style: italic;
}
.btn {
padding: 0.25rem 0.75rem;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
}
.btn-xs {
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover {
background: #2563eb;
}
.btn-secondary {
background: #6b7280;
color: white;
}
.btn-secondary:hover {
background: #4b5563;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-warning {
background: #f59e0b;
color: white;
}
.btn-warning:hover {
background: #d97706;
}
`]
})
export class CommentDisplayComponent {
private readonly authService = inject(AuthService);
readonly sanitizeService = inject(SanitizeService);
@Input({ required: true }) comments = signal<Comment[]>([]);
@Output() onEdit = new EventEmitter<{ commentId: string; content: string }>();
@Output() onDelete = new EventEmitter<string>();
editingCommentId = signal<string | null>(null);
editCommentContent = '';
showReportModal = signal(false);
reportingCommentId = signal<string>('');
formatDate(date: Date | string): string {
const d = new Date(date);
return d.toLocaleDateString('en-GB', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
canEditComment(comment: Comment): boolean {
const user = this.authService.user();
if (!user) return false;
return comment.userId === user.id || this.authService.isAdmin();
}
canDeleteComment(comment: Comment): boolean {
const user = this.authService.user();
if (!user) return false;
return comment.userId === user.id || this.authService.isAdmin();
}
canReportComment(comment: Comment): boolean {
const user = this.authService.user();
if (!user) return false;
// Users can report comments they didn't write (but not their own)
return comment.userId !== user.id;
}
startEdit(comment: Comment): void {
this.editingCommentId.set(comment.id);
this.editCommentContent = comment.rawContent ?? comment.content;
}
saveEdit(commentId: string): void {
this.onEdit.emit({ commentId, content: this.editCommentContent });
this.cancelEdit();
}
cancelEdit(): void {
this.editingCommentId.set(null);
this.editCommentContent = '';
}
openReportModal(comment: Comment): void {
this.reportingCommentId.set(comment.id);
this.showReportModal.set(true);
}
closeReportModal(): void {
this.showReportModal.set(false);
this.reportingCommentId.set('');
}
}
@@ -14,13 +14,12 @@ import { SanitizeService } from '../../services/sanitize.service';
import { SuggestionService } from '../../services/suggestion.service'; import { SuggestionService } from '../../services/suggestion.service';
import { PaginationComponent } from '../shared/pagination.component'; import { PaginationComponent } from '../shared/pagination.component';
import { LikeButtonComponent } from '../shared/like-button.component'; import { LikeButtonComponent } from '../shared/like-button.component';
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEntity, Link } from '@library/shared-types'; import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
@Component({ @Component({
selector: 'app-games-list', selector: 'app-games-list',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent], imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
template: ` template: `
<div class="container"> <div class="container">
<div class="header-section"> <div class="header-section">
@@ -68,30 +67,9 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
<option [value]="GameStatus.playing">Currently Playing</option> <option [value]="GameStatus.playing">Currently Playing</option>
<option [value]="GameStatus.completed">Completed</option> <option [value]="GameStatus.completed">Completed</option>
<option [value]="GameStatus.backlog">In Backlog</option> <option [value]="GameStatus.backlog">In Backlog</option>
<option [value]="GameStatus.retired">Retired</option>
</select> </select>
</div> </div>
<div class="form-group">
<label for="dateStarted">Date Started</label>
<input
type="date"
id="dateStarted"
[(ngModel)]="newGame.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="dateFinished">Date Finished</label>
<input
type="date"
id="dateFinished"
[(ngModel)]="newGame.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group"> <div class="form-group">
<label for="rating">Rating (1-10)</label> <label for="rating">Rating (1-10)</label>
<input <input
@@ -136,7 +114,8 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="tags-input-container" aria-label="Tags"> <label>Tags</label>
<div class="tags-input-container">
@for (tag of newGame.tags; track tag; let i = $index) { @for (tag of newGame.tags; track tag; let i = $index) {
<span class="tag"> <span class="tag">
{{ tag }} {{ tag }}
@@ -153,7 +132,8 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
</div> </div>
</div> </div>
<div class="form-group" aria-label="External Links"> <div class="form-group">
<label>External Links</label>
<div class="links-list"> <div class="links-list">
@for (link of newGame.links; track link.url; let i = $index) { @for (link of newGame.links; track link.url; let i = $index) {
<div class="link-item"> <div class="link-item">
@@ -218,30 +198,9 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
<option [value]="GameStatus.playing">Currently Playing</option> <option [value]="GameStatus.playing">Currently Playing</option>
<option [value]="GameStatus.completed">Completed</option> <option [value]="GameStatus.completed">Completed</option>
<option [value]="GameStatus.backlog">In Backlog</option> <option [value]="GameStatus.backlog">In Backlog</option>
<option [value]="GameStatus.retired">Retired</option>
</select> </select>
</div> </div>
<div class="form-group">
<label for="edit-dateStarted">Date Started</label>
<input
type="date"
id="edit-dateStarted"
[(ngModel)]="editGame.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="edit-dateFinished">Date Finished</label>
<input
type="date"
id="edit-dateFinished"
[(ngModel)]="editGame.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group"> <div class="form-group">
<label for="edit-rating">Rating (1-10)</label> <label for="edit-rating">Rating (1-10)</label>
<input <input
@@ -286,7 +245,8 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="tags-input-container" aria-label="Tags"> <label>Tags</label>
<div class="tags-input-container">
@for (tag of editGame.tags; track tag; let i = $index) { @for (tag of editGame.tags; track tag; let i = $index) {
<span class="tag"> <span class="tag">
{{ tag }} {{ tag }}
@@ -303,7 +263,8 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
</div> </div>
</div> </div>
<div class="form-group" aria-label="External Links"> <div class="form-group">
<label>External Links</label>
<div class="links-list"> <div class="links-list">
@for (link of editGame.links; track link.url; let i = $index) { @for (link of editGame.links; track link.url; let i = $index) {
<div class="link-item"> <div class="link-item">
@@ -475,13 +436,6 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
> >
Backlog ({{ backlogCount() }}) Backlog ({{ backlogCount() }})
</button> </button>
<button
(click)="setFilter(GameStatus.retired)"
[class.active]="statusFilter() === GameStatus.retired"
class="filter-btn"
>
Retired ({{ retiredCount() }})
</button>
</div> </div>
@if (loading()) { @if (loading()) {
@@ -550,26 +504,6 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
</div> </div>
} }
@if (game.dateStarted) {
<p class="date-started">
Started: {{ formatDate(game.dateStarted) }}
</p>
}
@if (game.dateFinished) {
<p class="date-finished">
Finished: {{ formatDate(game.dateFinished) }}
</p>
}
<p class="date-added">
Added: {{ formatDate(game.createdAt) }}
</p>
<p class="date-updated">
Updated: {{ formatDate(game.updatedAt) }}
</p>
@if (authService.isAdmin()) { @if (authService.isAdmin()) {
<div class="actions"> <div class="actions">
<button (click)="startEdit(game)" class="btn btn-secondary btn-sm"> <button (click)="startEdit(game)" class="btn btn-secondary btn-sm">
@@ -609,11 +543,52 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
@if (commentsLoading()[game.id]) { @if (commentsLoading()[game.id]) {
<div class="comments-loading">Loading comments...</div> <div class="comments-loading">Loading comments...</div>
} @else { } @else {
<app-comment-display @for (comment of comments()[game.id] || []; track comment.id) {
[comments]="getCommentsSignal(game.id)" <div class="comment">
(onEdit)="handleCommentEdit(game.id, $event)" <div class="comment-header">
(onDelete)="deleteComment(game.id, $event)" @if (comment.user.avatar) {
/> <img [src]="comment.user.avatar" [alt]="comment.user.username" class="comment-avatar">
}
<span class="comment-author">{{ comment.user.username }}</span>
@if (comment.user.inDiscord) {
<span class="discord-badge">Discord</span>
}
@if (comment.user.isVip) {
<span class="vip-badge">VIP</span>
}
@if (comment.user.isMod) {
<span class="mod-badge">Mod</span>
}
@if (comment.user.isStaff) {
<span class="staff-badge">Staff</span>
}
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
@if (canEditComment(comment)) {
<button (click)="startEditComment(game.id, comment)" class="btn btn-secondary btn-xs">Edit</button>
}
@if (canDeleteComment(comment)) {
<button (click)="deleteComment(game.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
}
</div>
@if (editingCommentId() === comment.id) {
<div class="comment-edit-form">
<textarea
[(ngModel)]="editCommentContent"
name="editComment"
rows="3"
></textarea>
<div class="comment-edit-actions">
<button (click)="saveCommentEdit(game.id, comment.id)" class="btn btn-primary btn-xs">Save</button>
<button (click)="cancelCommentEdit()" class="btn btn-secondary btn-xs">Cancel</button>
</div>
</div>
} @else {
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
}
</div>
} @empty {
<div class="no-comments">No comments yet. Be the first to comment!</div>
}
} }
</div> </div>
} }
@@ -883,15 +858,6 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
margin: 0.5rem 0; margin: 0.5rem 0;
} }
.date-started,
.date-finished,
.date-added,
.date-updated {
font-size: 0.85rem;
color: #4b5563;
margin-top: 0.5rem;
}
.actions { .actions {
margin-top: 1rem; margin-top: 1rem;
} }
@@ -1247,7 +1213,6 @@ export class GamesListComponent implements OnInit {
playingCount = computed(() => this.games().filter(game => game.status === GameStatus.playing).length); playingCount = computed(() => this.games().filter(game => game.status === GameStatus.playing).length);
completedCount = computed(() => this.games().filter(game => game.status === GameStatus.completed).length); completedCount = computed(() => this.games().filter(game => game.status === GameStatus.completed).length);
backlogCount = computed(() => this.games().filter(game => game.status === GameStatus.backlog).length); backlogCount = computed(() => this.games().filter(game => game.status === GameStatus.backlog).length);
retiredCount = computed(() => this.games().filter(game => game.status === GameStatus.retired).length);
allTags = computed(() => { allTags = computed(() => {
const tagsSet = new Set<string>(); const tagsSet = new Set<string>();
@@ -1296,12 +1261,10 @@ export class GamesListComponent implements OnInit {
totalFilteredGames = computed(() => this.filteredGames().length); totalFilteredGames = computed(() => this.filteredGames().length);
newGame: Partial<CreateGameDto> & { dateStarted?: Date; dateFinished?: Date } = { newGame: Partial<CreateGameDto> = {
title: '', title: '',
platform: '', platform: '',
status: GameStatus.backlog, status: GameStatus.backlog,
dateStarted: undefined,
dateFinished: undefined,
rating: undefined, rating: undefined,
notes: '', notes: '',
tags: [], tags: [],
@@ -1378,7 +1341,6 @@ export class GamesListComponent implements OnInit {
case GameStatus.playing: return 'Currently Playing'; case GameStatus.playing: return 'Currently Playing';
case GameStatus.completed: return 'Completed'; case GameStatus.completed: return 'Completed';
case GameStatus.backlog: return 'In Backlog'; case GameStatus.backlog: return 'In Backlog';
case GameStatus.retired: return 'Retired';
} }
} }
@@ -1394,8 +1356,6 @@ export class GamesListComponent implements OnInit {
title: '', title: '',
platform: '', platform: '',
status: GameStatus.backlog, status: GameStatus.backlog,
dateStarted: undefined,
dateFinished: undefined,
rating: undefined, rating: undefined,
notes: '', notes: '',
coverImage: undefined, coverImage: undefined,
@@ -1464,8 +1424,6 @@ export class GamesListComponent implements OnInit {
rating: this.newGame.rating, rating: this.newGame.rating,
notes: this.newGame.notes, notes: this.newGame.notes,
coverImage: this.newGame.coverImage, coverImage: this.newGame.coverImage,
dateStarted: this.newGame.dateStarted ? new Date(this.newGame.dateStarted) : undefined,
dateFinished: this.newGame.dateFinished ? new Date(this.newGame.dateFinished) : undefined,
tags: this.newGame.tags || [], tags: this.newGame.tags || [],
links: this.newGame.links || [] links: this.newGame.links || []
}; };
@@ -1490,8 +1448,6 @@ export class GamesListComponent implements OnInit {
title: game.title, title: game.title,
platform: game.platform, platform: game.platform,
status: game.status, status: game.status,
dateStarted: game.dateStarted,
dateFinished: game.dateFinished,
rating: game.rating, rating: game.rating,
notes: game.notes, notes: game.notes,
coverImage: game.coverImage, coverImage: game.coverImage,
@@ -1520,13 +1476,7 @@ export class GamesListComponent implements OnInit {
const game = this.editingGame(); const game = this.editingGame();
if (!game || !this.editGame.title || !this.editGame.status) return; if (!game || !this.editGame.title || !this.editGame.status) return;
const updateData: UpdateGameDto = { this.gamesService.updateGame(game.id, this.editGame).subscribe(() => {
...this.editGame,
dateStarted: this.editGame.dateStarted ? new Date(this.editGame.dateStarted) : undefined,
dateFinished: this.editGame.dateFinished ? new Date(this.editGame.dateFinished) : undefined
};
this.gamesService.updateGame(game.id, updateData).subscribe(() => {
this.loadGames(); this.loadGames();
this.cancelEdit(); this.cancelEdit();
}); });
@@ -1699,23 +1649,6 @@ export class GamesListComponent implements OnInit {
}); });
} }
handleCommentEdit(gameId: string, event: { commentId: string; content: string }) {
this.commentsService.updateCommentOnGame(gameId, event.commentId, event.content).subscribe({
next: (updatedComment) => {
this.comments.set({
...this.comments(),
[gameId]: (this.comments()[gameId] || []).map(c =>
c.id === event.commentId ? updatedComment : c
)
});
}
});
}
getCommentsSignal(gameId: string) {
return signal(this.comments()[gameId] || []);
}
// Suggestion methods // Suggestion methods
toggleSuggestForm() { toggleSuggestForm() {
this.showSuggestForm.update(v => !v); this.showSuggestForm.update(v => !v);
@@ -1740,7 +1673,7 @@ export class GamesListComponent implements OnInit {
try { try {
await this.suggestionService.createSuggestion({ await this.suggestionService.createSuggestion({
entityType: SuggestionEntity.game, entityType: SuggestionEntity.GAME,
title: this.suggestedGame.title, title: this.suggestedGame.title,
platform: this.suggestedGame.platform, platform: this.suggestedGame.platform,
notes: this.suggestedGame.notes, notes: this.suggestedGame.notes,
@@ -35,37 +35,17 @@ import { ApiService } from '../../services/api.service';
<div class="auth-section"> <div class="auth-section">
@if (authService.user(); as user) { @if (authService.user(); as user) {
<div class="user-menu"> <span class="welcome">Welcome, {{ user.username }}!</span>
@if (user.avatar) { @if (!user.isAdmin) {
<img <a routerLink="/my-suggestions" class="user-link">My Suggestions</a>
[src]="user.avatar" }
[alt]="user.username" <a routerLink="/my-likes" class="user-link">My Likes</a>
class="user-avatar" @if (user.isAdmin) {
(click)="toggleDropdown()" <a routerLink="/admin/users" class="admin-badge">Users</a>
(keyup.enter)="toggleDropdown()" <a routerLink="/admin/audit" class="admin-badge">Audit</a>
(keyup.space)="toggleDropdown()" <a routerLink="/admin/suggestions" class="admin-badge">Suggestions</a>
tabindex="0" }
role="button" <button (click)="logout()" class="btn btn-secondary">Logout</button>
/>
}
@if (showDropdown()) {
<div class="dropdown-menu">
<a [routerLink]="['/profile', user.slug || user.id]" class="dropdown-item" (click)="closeDropdown()">My Profile</a>
<a routerLink="/settings" class="dropdown-item" (click)="closeDropdown()">Settings</a>
@if (!user.isAdmin) {
<a routerLink="/my-suggestions" class="dropdown-item" (click)="closeDropdown()">My Suggestions</a>
}
<a routerLink="/my-likes" class="dropdown-item" (click)="closeDropdown()">My Likes</a>
@if (user.isAdmin) {
<a routerLink="/admin/users" class="dropdown-item" (click)="closeDropdown()">Users</a>
<a routerLink="/admin/audit" class="dropdown-item" (click)="closeDropdown()">Audit</a>
<a routerLink="/admin/suggestions" class="dropdown-item" (click)="closeDropdown()">Suggestions</a>
<a routerLink="/admin/reports" class="dropdown-item" (click)="closeDropdown()">Reports</a>
}
<button (click)="logout()" class="dropdown-item logout-btn">Logout</button>
</div>
}
</div>
} @else { } @else {
<button (click)="login()" class="btn btn-primary">Login with Discord</button> <button (click)="login()" class="btn btn-primary">Login with Discord</button>
} }
@@ -142,75 +122,6 @@ import { ApiService } from '../../services/api.service';
color: var(--witch-lavender); color: var(--witch-lavender);
} }
.user-menu {
position: relative;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
border: 2px solid var(--witch-lavender);
transition: all 0.3s;
cursor: pointer;
}
.user-avatar:hover {
border-color: var(--witch-moon);
transform: scale(1.1);
}
.dropdown-menu {
position: absolute;
top: 50px;
right: 0;
background-color: var(--witch-purple);
border: 2px solid var(--witch-lavender);
border-radius: 8px;
padding: 0.5rem 0;
min-width: 180px;
box-shadow: 0 4px 12px var(--witch-shadow);
z-index: 1000;
animation: fadeIn 0.2s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.dropdown-item {
display: block;
width: 100%;
padding: 0.75rem 1rem;
color: var(--witch-lavender);
text-decoration: none;
background: none;
border: none;
text-align: left;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
}
.dropdown-item:hover {
background-color: var(--witch-plum);
color: var(--witch-moon);
}
.logout-btn {
border-top: 1px solid var(--witch-lavender);
margin-top: 0.5rem;
padding-top: 0.75rem;
font-weight: 500;
}
.admin-badge { .admin-badge {
background-color: var(--witch-rose); background-color: var(--witch-rose);
color: var(--witch-moon); color: var(--witch-moon);
@@ -279,7 +190,6 @@ export class HeaderComponent implements OnInit {
authService = inject(AuthService); authService = inject(AuthService);
private apiService = inject(ApiService); private apiService = inject(ApiService);
version = signal<string | null>(null); version = signal<string | null>(null);
showDropdown = signal<boolean>(false);
ngOnInit() { ngOnInit() {
this.apiService.get<{ version: string }>('/version').subscribe({ this.apiService.get<{ version: string }>('/version').subscribe({
@@ -288,20 +198,11 @@ export class HeaderComponent implements OnInit {
}); });
} }
toggleDropdown() {
this.showDropdown.update(v => !v);
}
closeDropdown() {
this.showDropdown.set(false);
}
login() { login() {
this.authService.login(); this.authService.login();
} }
logout() { logout() {
this.closeDropdown();
this.authService.logout().subscribe(); this.authService.logout().subscribe();
} }
} }
@@ -14,13 +14,12 @@ import { SanitizeService } from '../../services/sanitize.service';
import { SuggestionService } from '../../services/suggestion.service'; import { SuggestionService } from '../../services/suggestion.service';
import { PaginationComponent } from '../shared/pagination.component'; import { PaginationComponent } from '../shared/pagination.component';
import { LikeButtonComponent } from '../shared/like-button.component'; import { LikeButtonComponent } from '../shared/like-button.component';
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, SuggestionEntity, Link } from '@library/shared-types'; import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
@Component({ @Component({
selector: 'app-manga-list', selector: 'app-manga-list',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent], imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
template: ` template: `
<div class="container"> <div class="container">
<div class="header-section"> <div class="header-section">
@@ -69,30 +68,9 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
<option [value]="MangaStatus.reading">Currently Reading</option> <option [value]="MangaStatus.reading">Currently Reading</option>
<option [value]="MangaStatus.completed">Completed</option> <option [value]="MangaStatus.completed">Completed</option>
<option [value]="MangaStatus.wantToRead">Want to Read</option> <option [value]="MangaStatus.wantToRead">Want to Read</option>
<option [value]="MangaStatus.retired">Retired</option>
</select> </select>
</div> </div>
<div class="form-group">
<label for="dateStarted">Date Started</label>
<input
type="date"
id="dateStarted"
[(ngModel)]="newManga.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="dateFinished">Date Finished</label>
<input
type="date"
id="dateFinished"
[(ngModel)]="newManga.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group"> <div class="form-group">
<label for="rating">Rating (1-10)</label> <label for="rating">Rating (1-10)</label>
<input <input
@@ -137,7 +115,8 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="tags-input-container" aria-label="Tags"> <label>Tags</label>
<div class="tags-input-container">
@for (tag of newManga.tags; track tag; let i = $index) { @for (tag of newManga.tags; track tag; let i = $index) {
<span class="tag"> <span class="tag">
{{ tag }} {{ tag }}
@@ -154,7 +133,8 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
</div> </div>
</div> </div>
<div class="form-group" aria-label="External Links"> <div class="form-group">
<label>External Links</label>
<div class="links-list"> <div class="links-list">
@for (link of newManga.links; track link.url; let i = $index) { @for (link of newManga.links; track link.url; let i = $index) {
<div class="link-item"> <div class="link-item">
@@ -220,30 +200,9 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
<option [value]="MangaStatus.reading">Currently Reading</option> <option [value]="MangaStatus.reading">Currently Reading</option>
<option [value]="MangaStatus.completed">Completed</option> <option [value]="MangaStatus.completed">Completed</option>
<option [value]="MangaStatus.wantToRead">Want to Read</option> <option [value]="MangaStatus.wantToRead">Want to Read</option>
<option [value]="MangaStatus.retired">Retired</option>
</select> </select>
</div> </div>
<div class="form-group">
<label for="edit-dateStarted">Date Started</label>
<input
type="date"
id="edit-dateStarted"
[(ngModel)]="editManga.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="edit-dateFinished">Date Finished</label>
<input
type="date"
id="edit-dateFinished"
[(ngModel)]="editManga.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group"> <div class="form-group">
<label for="edit-rating">Rating (1-10)</label> <label for="edit-rating">Rating (1-10)</label>
<input <input
@@ -288,7 +247,8 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="tags-input-container" aria-label="Tags"> <label>Tags</label>
<div class="tags-input-container">
@for (tag of editManga.tags; track tag; let i = $index) { @for (tag of editManga.tags; track tag; let i = $index) {
<span class="tag"> <span class="tag">
{{ tag }} {{ tag }}
@@ -305,7 +265,8 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
</div> </div>
</div> </div>
<div class="form-group" aria-label="External Links"> <div class="form-group">
<label>External Links</label>
<div class="links-list"> <div class="links-list">
@for (link of editManga.links; track link.url; let i = $index) { @for (link of editManga.links; track link.url; let i = $index) {
<div class="link-item"> <div class="link-item">
@@ -478,13 +439,6 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
> >
Want to Read ({{ wantToReadCount() }}) Want to Read ({{ wantToReadCount() }})
</button> </button>
<button
(click)="setFilter(MangaStatus.retired)"
[class.active]="statusFilter() === MangaStatus.retired"
class="filter-btn"
>
Retired ({{ retiredCount() }})
</button>
</div> </div>
@if (loading()) { @if (loading()) {
@@ -551,30 +505,6 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
</div> </div>
} }
@if (manga.dateStarted) {
<p class="date-started">
Started: {{ formatDate(manga.dateStarted) }}
</p>
}
@if (manga.dateFinished) {
<p class="date-finished">
Finished: {{ formatDate(manga.dateFinished) }}
</p>
}
@if (manga.createdAt) {
<p class="date-added">
Added: {{ formatDate(manga.createdAt) }}
</p>
}
@if (manga.updatedAt) {
<p class="date-updated">
Updated: {{ formatDate(manga.updatedAt) }}
</p>
}
@if (authService.isAdmin()) { @if (authService.isAdmin()) {
<div class="actions"> <div class="actions">
<button (click)="startEdit(manga)" class="btn btn-secondary btn-sm"> <button (click)="startEdit(manga)" class="btn btn-secondary btn-sm">
@@ -611,11 +541,56 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
} }
} }
<app-comment-display @if (commentsLoading()[manga.id]) {
[comments]="getCommentsSignal(manga.id)" <div class="comments-loading">Loading comments...</div>
(onEdit)="handleCommentEdit(manga.id, $event)" } @else {
(onDelete)="deleteComment(manga.id, $event)" @for (comment of comments()[manga.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>
@if (comment.user.inDiscord) {
<span class="discord-badge">Discord</span>
}
@if (comment.user.isVip) {
<span class="vip-badge">VIP</span>
}
@if (comment.user.isMod) {
<span class="mod-badge">Mod</span>
}
@if (comment.user.isStaff) {
<span class="staff-badge">Staff</span>
}
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
@if (canEditComment(comment)) {
<button (click)="startEditComment(manga.id, comment)" class="btn btn-secondary btn-xs">Edit</button>
}
@if (canDeleteComment(comment)) {
<button (click)="deleteComment(manga.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
}
</div>
@if (editingCommentId() === comment.id) {
<div class="comment-edit-form">
<textarea
[(ngModel)]="editCommentContent"
name="editComment"
rows="3"
></textarea>
<div class="comment-edit-actions">
<button (click)="saveCommentEdit(manga.id, comment.id)" class="btn btn-primary btn-xs">Save</button>
<button (click)="cancelCommentEdit()" class="btn btn-secondary btn-xs">Cancel</button>
</div>
</div>
} @else {
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
}
</div>
} @empty {
<div class="no-comments">No comments yet. Be the first to comment!</div>
}
}
</div> </div>
} }
</div> </div>
@@ -886,15 +861,6 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
margin: 0.5rem 0; margin: 0.5rem 0;
} }
.date-started,
.date-finished,
.date-added,
.date-updated {
font-size: 0.85rem;
color: #4b5563;
margin-top: 0.5rem;
}
.actions { .actions {
margin-top: 1rem; margin-top: 1rem;
} }
@@ -1250,7 +1216,6 @@ export class MangaListComponent implements OnInit {
readingCount = computed(() => this.mangaList().filter(m => m.status === MangaStatus.reading).length); readingCount = computed(() => this.mangaList().filter(m => m.status === MangaStatus.reading).length);
completedCount = computed(() => this.mangaList().filter(m => m.status === MangaStatus.completed).length); completedCount = computed(() => this.mangaList().filter(m => m.status === MangaStatus.completed).length);
wantToReadCount = computed(() => this.mangaList().filter(m => m.status === MangaStatus.wantToRead).length); wantToReadCount = computed(() => this.mangaList().filter(m => m.status === MangaStatus.wantToRead).length);
retiredCount = computed(() => this.mangaList().filter(m => m.status === MangaStatus.retired).length);
allTags = computed(() => { allTags = computed(() => {
const tagsSet = new Set<string>(); const tagsSet = new Set<string>();
@@ -1299,12 +1264,10 @@ export class MangaListComponent implements OnInit {
totalFilteredManga = computed(() => this.filteredManga().length); totalFilteredManga = computed(() => this.filteredManga().length);
newManga: Partial<CreateMangaDto> & { dateStarted?: Date; dateFinished?: Date } = { newManga: Partial<CreateMangaDto> = {
title: '', title: '',
author: '', author: '',
status: MangaStatus.wantToRead, status: MangaStatus.wantToRead,
dateStarted: undefined,
dateFinished: undefined,
rating: undefined, rating: undefined,
notes: '', notes: '',
tags: [], tags: [],
@@ -1381,7 +1344,6 @@ export class MangaListComponent implements OnInit {
case MangaStatus.reading: return 'Currently Reading'; case MangaStatus.reading: return 'Currently Reading';
case MangaStatus.completed: return 'Completed'; case MangaStatus.completed: return 'Completed';
case MangaStatus.wantToRead: return 'Want to Read'; case MangaStatus.wantToRead: return 'Want to Read';
case MangaStatus.retired: return 'Retired';
} }
} }
@@ -1397,8 +1359,6 @@ export class MangaListComponent implements OnInit {
title: '', title: '',
author: '', author: '',
status: MangaStatus.wantToRead, status: MangaStatus.wantToRead,
dateStarted: undefined,
dateFinished: undefined,
rating: undefined, rating: undefined,
notes: '', notes: '',
coverImage: undefined, coverImage: undefined,
@@ -1464,8 +1424,6 @@ export class MangaListComponent implements OnInit {
title: this.newManga.title, title: this.newManga.title,
author: this.newManga.author, author: this.newManga.author,
status: this.newManga.status, status: this.newManga.status,
dateStarted: this.newManga.dateStarted ? new Date(this.newManga.dateStarted) : undefined,
dateFinished: this.newManga.dateFinished ? new Date(this.newManga.dateFinished) : undefined,
rating: this.newManga.rating, rating: this.newManga.rating,
notes: this.newManga.notes, notes: this.newManga.notes,
coverImage: this.newManga.coverImage, coverImage: this.newManga.coverImage,
@@ -1493,8 +1451,6 @@ export class MangaListComponent implements OnInit {
title: manga.title, title: manga.title,
author: manga.author, author: manga.author,
status: manga.status, status: manga.status,
dateStarted: manga.dateStarted,
dateFinished: manga.dateFinished,
rating: manga.rating, rating: manga.rating,
notes: manga.notes, notes: manga.notes,
coverImage: manga.coverImage, coverImage: manga.coverImage,
@@ -1523,13 +1479,7 @@ export class MangaListComponent implements OnInit {
const manga = this.editingManga(); const manga = this.editingManga();
if (!manga || !this.editManga.title || !this.editManga.author || !this.editManga.status) return; if (!manga || !this.editManga.title || !this.editManga.author || !this.editManga.status) return;
const updateData = { this.mangaService.updateManga(manga.id, this.editManga).subscribe(() => {
...this.editManga,
dateStarted: this.editManga.dateStarted ? new Date(this.editManga.dateStarted) : undefined,
dateFinished: this.editManga.dateFinished ? new Date(this.editManga.dateFinished) : undefined,
};
this.mangaService.updateManga(manga.id, updateData).subscribe(() => {
this.loadManga(); this.loadManga();
this.cancelEdit(); this.cancelEdit();
}); });
@@ -1724,7 +1674,7 @@ export class MangaListComponent implements OnInit {
try { try {
await this.suggestionService.createSuggestion({ await this.suggestionService.createSuggestion({
entityType: SuggestionEntity.manga, entityType: SuggestionEntity.MANGA,
title: this.suggestedManga.title, title: this.suggestedManga.title,
author: this.suggestedManga.author, author: this.suggestedManga.author,
notes: this.suggestedManga.notes, notes: this.suggestedManga.notes,
@@ -1736,21 +1686,4 @@ export class MangaListComponent implements OnInit {
alert('Failed to submit suggestion. Please try again.'); alert('Failed to submit suggestion. Please try again.');
} }
} }
handleCommentEdit(mangaId: string, event: { commentId: string; content: string }) {
this.commentsService.updateCommentOnManga(mangaId, event.commentId, event.content).subscribe({
next: (updatedComment) => {
this.comments.set({
...this.comments(),
[mangaId]: (this.comments()[mangaId] || []).map(c =>
c.id === event.commentId ? updatedComment : c
)
});
}
});
}
getCommentsSignal(mangaId: string) {
return signal(this.comments()[mangaId] || []);
}
} }
@@ -14,13 +14,12 @@ import { SanitizeService } from '../../services/sanitize.service';
import { SuggestionService } from '../../services/suggestion.service'; import { SuggestionService } from '../../services/suggestion.service';
import { PaginationComponent } from '../shared/pagination.component'; import { PaginationComponent } from '../shared/pagination.component';
import { LikeButtonComponent } from '../shared/like-button.component'; import { LikeButtonComponent } from '../shared/like-button.component';
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment, SuggestionEntity, Link } from '@library/shared-types'; import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
@Component({ @Component({
selector: 'app-music-list', selector: 'app-music-list',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent], imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
template: ` template: `
<div class="container"> <div class="container">
<div class="header-section"> <div class="header-section">
@@ -78,30 +77,9 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
<option [value]="MusicStatus.listening">Currently Listening</option> <option [value]="MusicStatus.listening">Currently Listening</option>
<option [value]="MusicStatus.completed">Completed</option> <option [value]="MusicStatus.completed">Completed</option>
<option [value]="MusicStatus.wantToListen">Want to Listen</option> <option [value]="MusicStatus.wantToListen">Want to Listen</option>
<option [value]="MusicStatus.retired">Retired</option>
</select> </select>
</div> </div>
<div class="form-group">
<label for="dateStarted">Date Started</label>
<input
type="date"
id="dateStarted"
[(ngModel)]="newMusic.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="dateFinished">Date Finished</label>
<input
type="date"
id="dateFinished"
[(ngModel)]="newMusic.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group"> <div class="form-group">
<label for="rating">Rating (1-10)</label> <label for="rating">Rating (1-10)</label>
<input <input
@@ -146,7 +124,8 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="tags-input-container" aria-label="Tags"> <label>Tags</label>
<div class="tags-input-container">
@for (tag of newMusic.tags; track tag; let i = $index) { @for (tag of newMusic.tags; track tag; let i = $index) {
<span class="tag"> <span class="tag">
{{ tag }} {{ tag }}
@@ -163,7 +142,8 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
</div> </div>
</div> </div>
<div class="form-group" aria-label="External Links"> <div class="form-group">
<label>External Links</label>
<div class="links-list"> <div class="links-list">
@for (link of newMusic.links; track link.url; let i = $index) { @for (link of newMusic.links; track link.url; let i = $index) {
<div class="link-item"> <div class="link-item">
@@ -238,30 +218,9 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
<option [value]="MusicStatus.listening">Currently Listening</option> <option [value]="MusicStatus.listening">Currently Listening</option>
<option [value]="MusicStatus.completed">Completed</option> <option [value]="MusicStatus.completed">Completed</option>
<option [value]="MusicStatus.wantToListen">Want to Listen</option> <option [value]="MusicStatus.wantToListen">Want to Listen</option>
<option [value]="MusicStatus.retired">Retired</option>
</select> </select>
</div> </div>
<div class="form-group">
<label for="edit-dateStarted">Date Started</label>
<input
type="date"
id="edit-dateStarted"
[(ngModel)]="editMusicData.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="edit-dateFinished">Date Finished</label>
<input
type="date"
id="edit-dateFinished"
[(ngModel)]="editMusicData.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group"> <div class="form-group">
<label for="edit-rating">Rating (1-10)</label> <label for="edit-rating">Rating (1-10)</label>
<input <input
@@ -306,7 +265,8 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="tags-input-container" aria-label="Tags"> <label>Tags</label>
<div class="tags-input-container">
@for (tag of editMusicData.tags; track tag; let i = $index) { @for (tag of editMusicData.tags; track tag; let i = $index) {
<span class="tag"> <span class="tag">
{{ tag }} {{ tag }}
@@ -323,7 +283,8 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
</div> </div>
</div> </div>
<div class="form-group" aria-label="External Links"> <div class="form-group">
<label>External Links</label>
<div class="links-list"> <div class="links-list">
@for (link of editMusicData.links; track link.url; let i = $index) { @for (link of editMusicData.links; track link.url; let i = $index) {
<div class="link-item"> <div class="link-item">
@@ -539,13 +500,6 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
> >
Want to Listen ({{ wantToListenCount() }}) Want to Listen ({{ wantToListenCount() }})
</button> </button>
<button
(click)="setStatusFilter(MusicStatus.retired)"
[class.active]="statusFilter() === MusicStatus.retired"
class="filter-btn"
>
Retired ({{ retiredCount() }})
</button>
</div> </div>
</div> </div>
@@ -627,27 +581,9 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
</div> </div>
} }
@if (music.dateStarted) { @if (music.dateCompleted) {
<p class="date-started"> <p class="date-completed">
Started: {{ formatDate(music.dateStarted) }} Completed: {{ formatDate(music.dateCompleted) }}
</p>
}
@if (music.dateFinished) {
<p class="date-finished">
Finished: {{ formatDate(music.dateFinished) }}
</p>
}
@if (music.createdAt) {
<p class="date-added">
Added: {{ formatDate(music.createdAt) }}
</p>
}
@if (music.updatedAt) {
<p class="date-updated">
Updated: {{ formatDate(music.updatedAt) }}
</p> </p>
} }
@@ -687,11 +623,56 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
} }
} }
<app-comment-display @if (commentsLoading()[music.id]) {
[comments]="getCommentsSignal(music.id)" <div class="comments-loading">Loading comments...</div>
(onEdit)="handleCommentEdit(music.id, $event)" } @else {
(onDelete)="deleteComment(music.id, $event)" @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>
@if (comment.user.inDiscord) {
<span class="discord-badge">Discord</span>
}
@if (comment.user.isVip) {
<span class="vip-badge">VIP</span>
}
@if (comment.user.isMod) {
<span class="mod-badge">Mod</span>
}
@if (comment.user.isStaff) {
<span class="staff-badge">Staff</span>
}
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
@if (canEditComment(comment)) {
<button (click)="startEditComment(music.id, comment)" class="btn btn-secondary btn-xs">Edit</button>
}
@if (canDeleteComment(comment)) {
<button (click)="deleteComment(music.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
}
</div>
@if (editingCommentId() === comment.id) {
<div class="comment-edit-form">
<textarea
[(ngModel)]="editCommentContent"
name="editComment"
rows="3"
></textarea>
<div class="comment-edit-actions">
<button (click)="saveCommentEdit(music.id, comment.id)" class="btn btn-primary btn-xs">Save</button>
<button (click)="cancelCommentEdit()" class="btn btn-secondary btn-xs">Cancel</button>
</div>
</div>
} @else {
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
}
</div>
} @empty {
<div class="no-comments">No comments yet. Be the first to comment!</div>
}
}
</div> </div>
} }
</div> </div>
@@ -1036,10 +1017,7 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
margin: 0.5rem 0; margin: 0.5rem 0;
} }
.date-started, .date-completed {
.date-finished,
.date-added,
.date-updated {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--witch-plum); color: var(--witch-plum);
margin-top: 0.5rem; margin-top: 0.5rem;
@@ -1457,7 +1435,6 @@ export class MusicListComponent implements OnInit {
listeningCount = computed(() => this.music().filter(m => m.status === MusicStatus.listening).length); listeningCount = computed(() => this.music().filter(m => m.status === MusicStatus.listening).length);
completedCount = computed(() => this.music().filter(m => m.status === MusicStatus.completed).length); completedCount = computed(() => this.music().filter(m => m.status === MusicStatus.completed).length);
wantToListenCount = computed(() => this.music().filter(m => m.status === MusicStatus.wantToListen).length); wantToListenCount = computed(() => this.music().filter(m => m.status === MusicStatus.wantToListen).length);
retiredCount = computed(() => this.music().filter(m => m.status === MusicStatus.retired).length);
allTags = computed(() => { allTags = computed(() => {
const tagsSet = new Set<string>(); const tagsSet = new Set<string>();
@@ -1511,13 +1488,11 @@ export class MusicListComponent implements OnInit {
totalFilteredMusic = computed(() => this.filteredMusic().length); totalFilteredMusic = computed(() => this.filteredMusic().length);
newMusic: Partial<CreateMusicDto> & { dateStarted?: Date; dateFinished?: Date } = { newMusic: Partial<CreateMusicDto> = {
title: '', title: '',
artist: '', artist: '',
type: MusicType.album, type: MusicType.album,
status: MusicStatus.wantToListen, status: MusicStatus.wantToListen,
dateStarted: undefined,
dateFinished: undefined,
rating: undefined, rating: undefined,
notes: '', notes: '',
tags: [], tags: [],
@@ -1608,7 +1583,6 @@ export class MusicListComponent implements OnInit {
case MusicStatus.listening: return 'Currently Listening'; case MusicStatus.listening: return 'Currently Listening';
case MusicStatus.completed: return 'Completed'; case MusicStatus.completed: return 'Completed';
case MusicStatus.wantToListen: return 'Want to Listen'; case MusicStatus.wantToListen: return 'Want to Listen';
case MusicStatus.retired: return 'Retired';
} }
} }
@@ -1625,8 +1599,6 @@ export class MusicListComponent implements OnInit {
artist: '', artist: '',
type: MusicType.album, type: MusicType.album,
status: MusicStatus.wantToListen, status: MusicStatus.wantToListen,
dateStarted: undefined,
dateFinished: undefined,
rating: undefined, rating: undefined,
notes: '', notes: '',
coverArt: undefined, coverArt: undefined,
@@ -1693,8 +1665,6 @@ export class MusicListComponent implements OnInit {
artist: this.newMusic.artist, artist: this.newMusic.artist,
type: this.newMusic.type, type: this.newMusic.type,
status: this.newMusic.status, status: this.newMusic.status,
dateStarted: this.newMusic.dateStarted ? new Date(this.newMusic.dateStarted) : undefined,
dateFinished: this.newMusic.dateFinished ? new Date(this.newMusic.dateFinished) : undefined,
rating: this.newMusic.rating, rating: this.newMusic.rating,
notes: this.newMusic.notes, notes: this.newMusic.notes,
coverArt: this.newMusic.coverArt, coverArt: this.newMusic.coverArt,
@@ -1723,8 +1693,6 @@ export class MusicListComponent implements OnInit {
artist: music.artist, artist: music.artist,
type: music.type, type: music.type,
status: music.status, status: music.status,
dateStarted: music.dateStarted,
dateFinished: music.dateFinished,
rating: music.rating, rating: music.rating,
notes: music.notes, notes: music.notes,
coverArt: music.coverArt, coverArt: music.coverArt,
@@ -1753,13 +1721,7 @@ export class MusicListComponent implements OnInit {
const music = this.editingMusic(); const music = this.editingMusic();
if (!music || !this.editMusicData.title || !this.editMusicData.artist || !this.editMusicData.type || !this.editMusicData.status) return; if (!music || !this.editMusicData.title || !this.editMusicData.artist || !this.editMusicData.type || !this.editMusicData.status) return;
const updateData = { this.musicService.updateMusic(music.id, this.editMusicData).subscribe(() => {
...this.editMusicData,
dateStarted: this.editMusicData.dateStarted ? new Date(this.editMusicData.dateStarted) : undefined,
dateFinished: this.editMusicData.dateFinished ? new Date(this.editMusicData.dateFinished) : undefined,
};
this.musicService.updateMusic(music.id, updateData).subscribe(() => {
this.loadMusic(); this.loadMusic();
this.cancelEdit(); this.cancelEdit();
}); });
@@ -1957,7 +1919,7 @@ export class MusicListComponent implements OnInit {
try { try {
await this.suggestionService.createSuggestion({ await this.suggestionService.createSuggestion({
entityType: SuggestionEntity.music, entityType: SuggestionEntity.MUSIC,
title: this.suggestedMusic.title, title: this.suggestedMusic.title,
artist: this.suggestedMusic.artist, artist: this.suggestedMusic.artist,
type: this.suggestedMusic.type, type: this.suggestedMusic.type,
@@ -1970,21 +1932,4 @@ export class MusicListComponent implements OnInit {
alert('Failed to submit suggestion. Please try again.'); alert('Failed to submit suggestion. Please try again.');
} }
} }
handleCommentEdit(musicId: string, event: { commentId: string; content: string }) {
this.commentsService.updateCommentOnMusic(musicId, event.commentId, event.content).subscribe({
next: (updatedComment) => {
this.comments.set({
...this.comments(),
[musicId]: (this.comments()[musicId] || []).map(c =>
c.id === event.commentId ? updatedComment : c
)
});
}
});
}
getCommentsSignal(musicId: string) {
return signal(this.comments()[musicId] || []);
}
} }
@@ -373,12 +373,12 @@ export class MyLikesComponent implements OnInit {
} }
getItemTitle(likedItem: LikedItemDto): string { getItemTitle(likedItem: LikedItemDto): string {
const item = likedItem.item as any; const item = likedItem.item;
return item.title || item.name || 'Untitled'; return item.title || item.name || 'Untitled';
} }
getItemSubtitle(likedItem: LikedItemDto): string { getItemSubtitle(likedItem: LikedItemDto): string {
const item = likedItem.item as any; const item = likedItem.item;
const type = likedItem.like.entityType; const type = likedItem.like.entityType;
switch (type) { switch (type) {
@@ -400,7 +400,7 @@ export class MyLikesComponent implements OnInit {
} }
getItemImage(likedItem: LikedItemDto): string | null { getItemImage(likedItem: LikedItemDto): string | null {
const item = likedItem.item as any; const item = likedItem.item;
return item.coverImage || item.imageUrl || null; return item.coverImage || item.imageUrl || null;
} }
@@ -43,22 +43,22 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
All ({{ suggestions().length }}) All ({{ suggestions().length }})
</button> </button>
<button <button
(click)="setFilter(SuggestionStatus.unreviewed)" (click)="setFilter(SuggestionStatus.UNREVIEWED)"
[class.active]="statusFilter() === SuggestionStatus.unreviewed" [class.active]="statusFilter() === SuggestionStatus.UNREVIEWED"
class="filter-btn pending" class="filter-btn pending"
> >
Pending ({{ unreviewedCount() }}) Pending ({{ unreviewedCount() }})
</button> </button>
<button <button
(click)="setFilter(SuggestionStatus.accepted)" (click)="setFilter(SuggestionStatus.ACCEPTED)"
[class.active]="statusFilter() === SuggestionStatus.accepted" [class.active]="statusFilter() === SuggestionStatus.ACCEPTED"
class="filter-btn accepted" class="filter-btn accepted"
> >
Accepted ({{ acceptedCount() }}) Accepted ({{ acceptedCount() }})
</button> </button>
<button <button
(click)="setFilter(SuggestionStatus.declined)" (click)="setFilter(SuggestionStatus.DECLINED)"
[class.active]="statusFilter() === SuggestionStatus.declined" [class.active]="statusFilter() === SuggestionStatus.DECLINED"
class="filter-btn declined" class="filter-btn declined"
> >
Declined ({{ declinedCount() }}) Declined ({{ declinedCount() }})
@@ -120,7 +120,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
} }
</div> </div>
@if (suggestion.status === SuggestionStatus.declined && suggestion.declineReason) { @if (suggestion.status === SuggestionStatus.DECLINED && suggestion.declineReason) {
<div class="decline-reason"> <div class="decline-reason">
<strong>Reason:</strong> {{ suggestion.declineReason }} <strong>Reason:</strong> {{ suggestion.declineReason }}
</div> </div>
@@ -337,9 +337,9 @@ export class MySuggestionsComponent implements OnInit {
SuggestionStatus = SuggestionStatus; SuggestionStatus = SuggestionStatus;
unreviewedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.unreviewed).length; unreviewedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.UNREVIEWED).length;
acceptedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.accepted).length; acceptedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.ACCEPTED).length;
declinedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.declined).length; declinedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.DECLINED).length;
filteredSuggestions = computed(() => { filteredSuggestions = computed(() => {
const filter = this.statusFilter(); const filter = this.statusFilter();
@@ -397,20 +397,20 @@ export class MySuggestionsComponent implements OnInit {
getStatusLabel(status: SuggestionStatus): string { getStatusLabel(status: SuggestionStatus): string {
switch (status) { switch (status) {
case SuggestionStatus.unreviewed: return 'Pending Review'; case SuggestionStatus.UNREVIEWED: return 'Pending Review';
case SuggestionStatus.accepted: return 'Accepted'; case SuggestionStatus.ACCEPTED: return 'Accepted';
case SuggestionStatus.declined: return 'Declined'; case SuggestionStatus.DECLINED: return 'Declined';
} }
} }
getEntityIcon(entityType: SuggestionEntity): string { getEntityIcon(entityType: SuggestionEntity): string {
switch (entityType) { switch (entityType) {
case SuggestionEntity.game: return '🎮'; case SuggestionEntity.GAME: return '🎮';
case SuggestionEntity.book: return '📚'; case SuggestionEntity.BOOK: return '📚';
case SuggestionEntity.music: return '🎵'; case SuggestionEntity.MUSIC: return '🎵';
case SuggestionEntity.manga: return 'đź“–'; case SuggestionEntity.MANGA: return 'đź“–';
case SuggestionEntity.show: return '📺'; case SuggestionEntity.SHOW: return '📺';
case SuggestionEntity.art: return '🎨'; case SuggestionEntity.ART: return '🎨';
} }
} }
@@ -1,498 +0,0 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Component, inject, signal, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { CommonModule } from '@angular/common';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { faGlobe, faCloud, faFlag } from '@fortawesome/free-solid-svg-icons';
import { faGithub, faLinkedin, faTwitch, faYoutube, faDiscord } from '@fortawesome/free-brands-svg-icons';
import { UserService, UserProfileResponse } from '../../services/user.service';
import { ToastService } from '../../services/toast.service';
import { AuthService } from '../../services/auth.service';
import { ReportModalComponent } from '../report-modal/report-modal.component';
@Component({
selector: 'app-profile',
standalone: true,
imports: [CommonModule, FontAwesomeModule, ReportModalComponent],
template: `
<div class="profile-container">
@if (loading()) {
<div class="loading">Loading profile...</div>
} @else if (error()) {
<div class="error">{{ error() }}</div>
} @else if (profile()) {
<div class="profile-card">
<div class="profile-header">
@if (profile()?.avatar) {
<img [src]="profile()!.avatar" [alt]="profile()!.username" class="profile-avatar" />
} @else {
<div class="profile-avatar-placeholder">
{{ profile()!.username[0]?.toUpperCase() }}
</div>
}
<div class="profile-info">
<h1 class="profile-username">{{ profile()!.displayName || profile()!.username }}</h1>
@if (profile()!.displayName) {
<p class="profile-handle">\@{{ profile()!.username }}</p>
}
@if (profile()!.slug) {
<p class="profile-slug">library.nhcarrigan.com/profile/{{ profile()!.slug }}</p>
}
</div>
@if (showReportButton()) {
<button
class="report-button"
(click)="openReportModal()"
[attr.aria-label]="'Report ' + profile()!.username + ' profile'"
type="button"
>
<fa-icon [icon]="faFlag"></fa-icon>
<span>Report</span>
</button>
}
</div>
<div class="badges-section">
@if (profile()!.badges.isStaff) {
<span class="badge badge-staff">Staff</span>
}
@if (profile()!.badges.isMod) {
<span class="badge badge-mod">Moderator</span>
}
@if (profile()!.badges.isVip) {
<span class="badge badge-vip">VIP</span>
}
@if (profile()!.badges.inDiscord) {
<span class="badge badge-member">Discord Member</span>
}
</div>
@if (profile()!.bio) {
<div class="bio-section">
<p class="bio-text">{{ profile()!.bio }}</p>
</div>
}
@if (profile()!.website || profile()!.github || profile()!.bluesky || profile()!.linkedin || profile()!.twitch || profile()!.youtube || profile()!.discordServer) {
<div class="social-links-section">
<h2>Social Links</h2>
<div class="social-links">
@if (profile()!.website) {
<a [href]="profile()!.website" target="_blank" rel="noopener noreferrer" class="social-link" title="Website">
<fa-icon [icon]="faGlobe" class="icon"></fa-icon>
<span class="label">Website</span>
</a>
}
@if (profile()!.github) {
<a [href]="'https://github.com/' + profile()!.github" target="_blank" rel="noopener noreferrer" class="social-link" title="GitHub">
<fa-icon [icon]="faGithub" class="icon"></fa-icon>
<span class="label">GitHub</span>
</a>
}
@if (profile()!.bluesky) {
<a [href]="'https://bsky.app/profile/' + profile()!.bluesky" target="_blank" rel="noopener noreferrer" class="social-link" title="Bluesky">
<fa-icon [icon]="faCloud" class="icon"></fa-icon>
<span class="label">Bluesky</span>
</a>
}
@if (profile()!.linkedin) {
<a [href]="'https://linkedin.com/in/' + profile()!.linkedin" target="_blank" rel="noopener noreferrer" class="social-link" title="LinkedIn">
<fa-icon [icon]="faLinkedin" class="icon"></fa-icon>
<span class="label">LinkedIn</span>
</a>
}
@if (profile()!.twitch) {
<a [href]="'https://twitch.tv/' + profile()!.twitch" target="_blank" rel="noopener noreferrer" class="social-link" title="Twitch">
<fa-icon [icon]="faTwitch" class="icon"></fa-icon>
<span class="label">Twitch</span>
</a>
}
@if (profile()!.youtube) {
<a [href]="'https://youtube.com/' + profile()!.youtube" target="_blank" rel="noopener noreferrer" class="social-link" title="YouTube">
<fa-icon [icon]="faYoutube" class="icon"></fa-icon>
<span class="label">YouTube</span>
</a>
}
@if (profile()!.discordServer) {
<a [href]="'https://discord.gg/' + profile()!.discordServer" target="_blank" rel="noopener noreferrer" class="social-link" title="Discord Server">
<fa-icon [icon]="faDiscord" class="icon"></fa-icon>
<span class="label">Discord</span>
</a>
}
</div>
</div>
}
<div class="stats-section">
<h2>Activity Statistics</h2>
<div class="stats-grid">
<div class="stat-card">
<span class="stat-value">{{ profile()!.stats.suggestionsCount }}</span>
<span class="stat-label">Suggestions</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ profile()!.stats.suggestionsAcceptedCount }}</span>
<span class="stat-label">Accepted</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ profile()!.stats.likesCount }}</span>
<span class="stat-label">Likes</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ profile()!.stats.commentsCount }}</span>
<span class="stat-label">Comments</span>
</div>
</div>
</div>
<div class="footer-section">
<p class="member-since">Member since {{ formatDate(profile()!.createdAt) }}</p>
</div>
</div>
}
@if (reportModalOpen()) {
<app-report-modal
[reportType]="'profile'"
[targetId]="profile()!.id"
[reportedUsername]="profile()!.displayName || profile()!.username"
(closeModal)="closeReportModal()"
/>
}
</div>
`,
styles: [`
.profile-container {
max-width: 800px;
margin: 2rem auto;
padding: 0 1rem;
}
.loading, .error {
text-align: center;
padding: 2rem;
font-size: 1.2rem;
}
.error {
color: var(--error-colour, #c41e3a);
}
.profile-card {
background: var(--card-background, #1a1a2e);
border-radius: 12px;
padding: 2rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.profile-header {
display: flex;
align-items: center;
gap: 1.5rem;
margin-bottom: 1.5rem;
position: relative;
}
.profile-avatar {
width: 100px;
height: 100px;
border-radius: 50%;
border: 3px solid var(--accent-colour, #9b59b6);
}
.profile-avatar-placeholder {
width: 100px;
height: 100px;
border-radius: 50%;
background: var(--accent-colour, #9b59b6);
display: flex;
align-items: center;
justify-content: center;
font-size: 2.5rem;
font-weight: bold;
color: white;
}
.profile-info {
flex: 1;
}
.profile-username {
margin: 0;
font-size: 2rem;
color: var(--text-colour, #e0e0e0);
}
.profile-handle {
margin: 0.25rem 0;
color: var(--text-muted, #a0a0a0);
font-size: 1.1rem;
}
.profile-slug {
margin: 0.25rem 0;
color: var(--text-muted, #a0a0a0);
font-size: 0.9rem;
}
.report-button {
position: absolute;
top: 0;
right: 0;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1rem;
background: linear-gradient(135deg, #c41e3a 0%, #e74c3c 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.report-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(196, 30, 58, 0.4);
}
.report-button fa-icon {
font-size: 1rem;
}
.badges-section {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.badge {
padding: 0.4rem 0.8rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 600;
}
.badge-staff {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.badge-mod {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
}
.badge-vip {
background: linear-gradient(135deg, #ffd89b 0%, #19547b 100%);
color: white;
}
.badge-member {
background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
color: #333;
}
.bio-section {
margin-bottom: 1.5rem;
padding: 1rem;
background: rgba(155, 89, 182, 0.1);
border-radius: 8px;
}
.bio-text {
margin: 0;
color: var(--text-colour, #e0e0e0);
line-height: 1.6;
white-space: pre-wrap;
}
.social-links-section {
margin-bottom: 1.5rem;
}
.social-links-section h2 {
margin: 0 0 1rem 0;
color: var(--accent-colour, #9b59b6);
font-size: 1.5rem;
}
.social-links {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.social-link {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: rgba(155, 89, 182, 0.2);
border: 1px solid rgba(155, 89, 182, 0.3);
border-radius: 8px;
color: var(--text-colour, #e0e0e0);
text-decoration: none;
transition: all 0.2s ease;
}
.social-link:hover {
background: rgba(155, 89, 182, 0.3);
border-color: var(--accent-colour, #9b59b6);
transform: translateY(-2px);
}
.social-link fa-icon {
font-size: 1.3rem;
width: 1.5rem;
}
.social-link fa-icon ::ng-deep svg {
width: 1.3rem;
height: 1.3rem;
}
.social-link .label {
font-weight: 500;
}
.stats-section {
margin-bottom: 1.5rem;
}
.stats-section h2 {
margin: 0 0 1rem 0;
color: var(--accent-colour, #9b59b6);
font-size: 1.5rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 1rem;
}
.stat-card {
text-align: center;
padding: 1rem;
background: rgba(155, 89, 182, 0.2);
border-radius: 8px;
border: 1px solid rgba(155, 89, 182, 0.3);
}
.stat-value {
display: block;
font-size: 2rem;
font-weight: bold;
color: var(--accent-colour, #9b59b6);
}
.stat-label {
display: block;
font-size: 0.9rem;
color: var(--text-muted, #a0a0a0);
margin-top: 0.25rem;
}
.footer-section {
text-align: center;
padding-top: 1rem;
border-top: 1px solid rgba(155, 89, 182, 0.3);
}
.member-since {
margin: 0;
color: var(--text-muted, #a0a0a0);
font-size: 0.9rem;
}
`]
})
export class ProfileComponent implements OnInit {
private route = inject(ActivatedRoute);
private userService = inject(UserService);
private toastService = inject(ToastService);
private authService = inject(AuthService);
profile = signal<UserProfileResponse | null>(null);
loading = signal(true);
error = signal<string | null>(null);
reportModalOpen = signal(false);
// Font Awesome icons
faGlobe = faGlobe;
faGithub = faGithub;
faCloud = faCloud;
faLinkedin = faLinkedin;
faTwitch = faTwitch;
faYoutube = faYoutube;
faDiscord = faDiscord;
faFlag = faFlag;
ngOnInit(): void {
const identifier = this.route.snapshot.paramMap.get('identifier');
if (!identifier) {
this.error.set('No user identifier provided');
this.loading.set(false);
return;
}
this.userService.getProfile(identifier).subscribe({
next: (profileData: UserProfileResponse) => {
this.profile.set(profileData);
this.loading.set(false);
},
error: (err: Error) => {
console.error('Error loading profile:', err);
this.error.set('Failed to load profile');
this.loading.set(false);
this.toastService.error('Failed to load profile');
}
});
}
formatDate(date: Date | string): string {
return new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
/**
* Determine whether to show the report button.
* Only show if the user is authenticated and viewing someone else's profile.
*/
showReportButton(): boolean {
const currentUser = this.authService.user();
const profileData = this.profile();
if (!currentUser || !profileData) {
return false;
}
// Don't show report button on your own profile
return currentUser.id !== profileData.id;
}
/**
* Open the report modal.
*/
openReportModal(): void {
this.reportModalOpen.set(true);
}
/**
* Close the report modal.
*/
closeReportModal(): void {
this.reportModalOpen.set(false);
}
}
@@ -1,398 +0,0 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Component, inject, signal, input, output, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ReportService } from '../../services/report.service';
import { CommentReportService } from '../../services/comment-report.service';
import { ToastService } from '../../services/toast.service';
import { ReportReason } from '@library/shared-types';
@Component({
selector: 'app-report-modal',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="modal-overlay" (click)="onOverlayClick($event)" (keydown.escape)="closeModal.emit()" tabindex="-1">
<div class="modal-card" role="dialog" aria-labelledby="modal-title" aria-modal="true">
<div class="modal-header">
<h2 id="modal-title">{{ modalTitle() }}</h2>
<button
class="close-button"
(click)="onClose()"
aria-label="Close modal"
type="button"
>
Ă—
</button>
</div>
<div class="modal-body">
<p class="report-info">
{{ reportInfo() }}
</p>
<form (ngSubmit)="onSubmit()" #reportForm="ngForm">
<div class="form-group">
<label for="reason">Reason *</label>
<select
id="reason"
name="reason"
[(ngModel)]="selectedReason"
required
class="form-control"
aria-required="true"
>
<option value="" disabled>Select a reason</option>
<option [value]="ReportReason.INAPPROPRIATE_CONTENT">Inappropriate Content</option>
<option [value]="ReportReason.HARASSMENT">Harassment</option>
<option [value]="ReportReason.SPAM">Spam</option>
<option [value]="ReportReason.IMPERSONATION">Impersonation</option>
<option [value]="ReportReason.OFFENSIVE_NAME">Offensive Name</option>
<option [value]="ReportReason.MALICIOUS_LINKS">Malicious Links</option>
<option [value]="ReportReason.OTHER">Other</option>
</select>
</div>
<div class="form-group">
<label for="details">Details *</label>
<textarea
id="details"
name="details"
[(ngModel)]="details"
required
minlength="10"
maxlength="1000"
rows="5"
class="form-control"
placeholder="Please provide specific details about why you are reporting this profile (10-1000 characters)"
aria-required="true"
[attr.aria-invalid]="detailsInvalid()"
></textarea>
<div class="character-count" [class.invalid]="detailsInvalid()">
{{ details().length }} / 1000 characters
@if (details().length < 10 && details().length > 0) {
<span class="error-text">(minimum 10 characters)</span>
}
</div>
</div>
<div class="modal-footer">
<button
type="button"
class="button button-secondary"
(click)="onClose()"
[disabled]="submitting()"
>
Cancel
</button>
<button
type="submit"
class="button button-danger"
[disabled]="!reportForm.valid || submitting()"
>
@if (submitting()) {
<span>Submitting...</span>
} @else {
<span>Submit Report</span>
}
</button>
</div>
</form>
</div>
</div>
</div>
`,
styles: [`
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal-card {
background: var(--card-background, #1a1a2e);
border-radius: 12px;
width: 100%;
max-width: 600px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 2px solid rgba(155, 89, 182, 0.3);
}
.modal-header h2 {
margin: 0;
color: var(--accent-colour, #9b59b6);
font-size: 1.5rem;
}
.close-button {
background: none;
border: none;
font-size: 2rem;
color: var(--text-colour, #e0e0e0);
cursor: pointer;
padding: 0;
width: 2rem;
height: 2rem;
line-height: 1;
transition: color 0.2s ease;
}
.close-button:hover {
color: var(--accent-colour, #9b59b6);
}
.modal-body {
padding: 1.5rem;
}
.report-info {
margin: 0 0 1.5rem 0;
color: var(--text-colour, #e0e0e0);
line-height: 1.6;
}
.report-info strong {
color: var(--accent-colour, #9b59b6);
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: var(--text-colour, #e0e0e0);
font-weight: 500;
}
.form-control {
width: 100%;
padding: 0.75rem;
background: rgba(155, 89, 182, 0.1);
border: 2px solid rgba(155, 89, 182, 0.3);
border-radius: 8px;
color: var(--text-colour, #e0e0e0);
font-size: 1rem;
font-family: inherit;
transition: border-color 0.2s ease;
}
.form-control:focus {
outline: none;
border-color: var(--accent-colour, #9b59b6);
}
.form-control:invalid:not(:focus):not(:placeholder-shown) {
border-color: var(--error-colour, #c41e3a);
}
textarea.form-control {
resize: vertical;
min-height: 120px;
}
select.form-control {
cursor: pointer;
appearance: none;
background-image: url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%23e0e0e0"><path d="M7 10l5 5 5-5z"/></svg>');
background-repeat: no-repeat;
background-position: right 0.75rem center;
background-size: 1.5rem;
padding-right: 2.5rem;
}
select.form-control option {
background: #2d2d2d;
color: #e0e0e0;
padding: 0.5rem;
}
.character-count {
margin-top: 0.5rem;
font-size: 0.875rem;
color: var(--text-muted, #a0a0a0);
text-align: right;
}
.character-count.invalid {
color: var(--error-colour, #c41e3a);
}
.error-text {
color: var(--error-colour, #c41e3a);
margin-left: 0.5rem;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 2px solid rgba(155, 89, 182, 0.3);
}
.button {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.button-secondary {
background: rgba(155, 89, 182, 0.2);
color: var(--text-colour, #e0e0e0);
border: 2px solid rgba(155, 89, 182, 0.3);
}
.button-secondary:hover:not(:disabled) {
background: rgba(155, 89, 182, 0.3);
border-color: var(--accent-colour, #9b59b6);
}
.button-danger {
background: linear-gradient(135deg, #c41e3a 0%, #e74c3c 100%);
color: white;
}
.button-danger:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(196, 30, 58, 0.4);
}
`]
})
export class ReportModalComponent {
private reportService = inject(ReportService);
private commentReportService = inject(CommentReportService);
private toastService = inject(ToastService);
// Inputs
reportType = input.required<'profile' | 'comment'>();
targetId = input.required<string>(); // userId for profile, commentId for comment
reportedUsername = input<string>(''); // Only used for profile reports
// Outputs
closeModal = output<void>();
// State
selectedReason = signal<string>('');
details = signal<string>('');
submitting = signal<boolean>(false);
// Computed values
modalTitle = computed(() => {
return this.reportType() === 'profile' ? 'Report Profile' : 'Report Comment';
});
reportInfo = computed(() => {
if (this.reportType() === 'profile') {
return `You are reporting ${this.reportedUsername()}. Please provide a reason and details for this report.`;
} else {
return 'You are reporting this comment. Please provide a reason and details for this report.';
}
});
// Expose enum for template
ReportReason = ReportReason;
/**
* Check if details are invalid (less than 10 characters or more than 1000).
*/
detailsInvalid(): boolean {
const length = this.details().length;
return (length > 0 && length < 10) || length > 1000;
}
/**
* Handle overlay click to close modal.
*/
onOverlayClick(event: MouseEvent): void {
if ((event.target as HTMLElement).classList.contains('modal-overlay')) {
this.onClose();
}
}
/**
* Close the modal.
*/
onClose(): void {
this.closeModal.emit();
}
/**
* Submit the report.
*/
onSubmit(): void {
// Validate form
if (!this.selectedReason() || this.detailsInvalid()) {
this.toastService.error('Please fill in all required fields correctly');
return;
}
this.submitting.set(true);
if (this.reportType() === 'profile') {
this.reportService.createReport(
this.targetId(),
this.selectedReason(),
this.details()
).subscribe(this.getSubscribeHandlers());
} else {
this.commentReportService.createReport({
reportedCommentId: this.targetId(),
reason: this.selectedReason() as ReportReason,
details: this.details(),
}).subscribe(this.getSubscribeHandlers());
}
}
private getSubscribeHandlers() {
return {
next: () => {
this.toastService.success('Report submitted successfully');
this.onClose();
},
error: (err: { status?: number; error?: { error?: string } }) => {
console.error('Error submitting report:', err);
// Check if it's a conflict error (duplicate pending report or rate limit)
if (err.status === 409 && err.error?.error) {
this.toastService.error(err.error.error);
} else {
this.toastService.error('Failed to submit report. Please try again.');
}
this.submitting.set(false);
}
};
}
}
@@ -1,450 +0,0 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Component, inject, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { UserService, UpdateUserSettingsRequest } from '../../services/user.service';
import { AuthService } from '../../services/auth.service';
import { ToastService } from '../../services/toast.service';
import { User } from '@library/shared-types';
@Component({
selector: 'app-settings',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="settings-container">
<h1>Profile Settings</h1>
@if (loading()) {
<div class="loading">Loading settings...</div>
} @else if (user()) {
<form class="settings-form" (ngSubmit)="saveSettings()">
<div class="form-section">
<h2>Profile Information</h2>
<div class="form-group">
<label for="displayName">Display Name</label>
<input
type="text"
id="displayName"
name="displayName"
[(ngModel)]="formData.displayName"
placeholder="Your display name"
maxlength="50"
/>
<small class="form-help">This will be shown instead of your username</small>
</div>
<div class="form-group">
<label for="slug">Profile URL Slug</label>
<input
type="text"
id="slug"
name="slug"
[(ngModel)]="formData.slug"
placeholder="your-custom-url"
maxlength="30"
pattern="[a-z0-9-]+"
/>
<small class="form-help">
Your profile will be at: library.nhcarrigan.com/profile/{{ formData.slug || 'your-slug' }}
</small>
<small class="form-help">Only lowercase letters, numbers, and hyphens allowed</small>
</div>
<div class="form-group">
<label for="bio">Bio</label>
<textarea
id="bio"
name="bio"
[(ngModel)]="formData.bio"
placeholder="Tell us about yourself..."
rows="4"
maxlength="500"
></textarea>
<small class="form-help">{{ (formData.bio?.length || 0) }} / 500 characters</small>
</div>
</div>
<div class="form-section">
<h2>Social Links</h2>
<div class="form-group">
<label for="website">Website</label>
<input
type="text"
id="website"
name="website"
[(ngModel)]="formData.website"
placeholder="https://yourwebsite.com"
pattern="https?://.+"
/>
<small class="form-help">Your personal website or portfolio (must start with http:// or https://)</small>
</div>
<div class="form-group">
<label for="github">GitHub</label>
<input
type="text"
id="github"
name="github"
[(ngModel)]="formData.github"
placeholder="username"
pattern="[a-zA-Z0-9]([a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?"
/>
<small class="form-help">Just your GitHub username (alphanumeric and hyphens, 1-39 characters)</small>
</div>
<div class="form-group">
<label for="bluesky">Bluesky</label>
<input
type="text"
id="bluesky"
name="bluesky"
[(ngModel)]="formData.bluesky"
placeholder="username.bsky.social"
pattern="[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"
/>
<small class="form-help">Your full Bluesky handle (e.g., username.bsky.social)</small>
</div>
<div class="form-group">
<label for="linkedin">LinkedIn</label>
<input
type="text"
id="linkedin"
name="linkedin"
[(ngModel)]="formData.linkedin"
placeholder="username"
pattern="[a-zA-Z0-9-]{3,100}"
/>
<small class="form-help">Just your LinkedIn username (alphanumeric and hyphens, 3-100 characters)</small>
</div>
<div class="form-group">
<label for="twitch">Twitch</label>
<input
type="text"
id="twitch"
name="twitch"
[(ngModel)]="formData.twitch"
placeholder="username"
pattern="[a-zA-Z0-9_]{4,25}"
/>
<small class="form-help">Just your Twitch username (alphanumeric and underscores, 4-25 characters)</small>
</div>
<div class="form-group">
<label for="youtube">YouTube</label>
<input
type="text"
id="youtube"
name="youtube"
[(ngModel)]="formData.youtube"
placeholder="@username or channel-id"
pattern="(@[a-zA-Z0-9_.-]{3,30}|UC[a-zA-Z0-9_-]{22})"
/>
<small class="form-help">Your YouTube handle (@username) or channel ID (UC...)</small>
</div>
<div class="form-group">
<label for="discordServer">Discord Server</label>
<input
type="text"
id="discordServer"
name="discordServer"
[(ngModel)]="formData.discordServer"
placeholder="invite-code"
pattern="[a-zA-Z0-9]{2,32}"
/>
<small class="form-help">Just your Discord server invite code (alphanumeric, 2-32 characters)</small>
</div>
</div>
<div class="form-section">
<h2>Privacy</h2>
<div class="form-group checkbox-group">
<label>
<input
type="checkbox"
name="profilePublic"
[(ngModel)]="formData.profilePublic"
/>
<span>Make my profile public</span>
</label>
<small class="form-help">
When disabled, only you can view your profile
</small>
</div>
</div>
<div class="form-section">
<h2>Account Information</h2>
<div class="info-item">
<strong>Username:</strong> {{ user()!.username }}
</div>
<div class="info-item">
<strong>Email:</strong> {{ user()!.email }}
</div>
@if (user()!.avatar) {
<div class="info-item">
<strong>Avatar:</strong>
<img [src]="user()!.avatar" alt="Avatar" class="avatar-preview" />
</div>
}
<small class="form-help">
Username, email, and avatar are managed through Discord and cannot be changed here
</small>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" [disabled]="saving()">
{{ saving() ? 'Saving...' : 'Save Changes' }}
</button>
</div>
</form>
}
</div>
`,
styles: [`
.settings-container {
max-width: 700px;
margin: 2rem auto;
padding: 0 1rem;
}
h1 {
color: var(--accent-colour, #9b59b6);
margin-bottom: 2rem;
}
.loading {
text-align: center;
padding: 2rem;
font-size: 1.2rem;
}
.settings-form {
background: var(--card-background, #1a1a2e);
border-radius: 12px;
padding: 2rem;
}
.form-section {
margin-bottom: 2rem;
padding-bottom: 2rem;
border-bottom: 1px solid rgba(155, 89, 182, 0.3);
}
.form-section:last-of-type {
border-bottom: none;
}
.form-section h2 {
color: var(--accent-colour, #9b59b6);
font-size: 1.3rem;
margin-bottom: 1rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: var(--text-colour, #e0e0e0);
font-weight: 600;
}
.form-group input[type="text"],
.form-group textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid rgba(155, 89, 182, 0.5);
border-radius: 8px;
background: rgba(0, 0, 0, 0.2);
color: var(--text-colour, #e0e0e0);
font-size: 1rem;
font-family: inherit;
}
.form-group input[type="text"]:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent-colour, #9b59b6);
box-shadow: 0 0 0 2px rgba(155, 89, 182, 0.3);
}
.form-group input[type="text"]:invalid:not(:focus):not(:placeholder-shown),
.form-group textarea:invalid:not(:focus):not(:placeholder-shown) {
border-color: var(--error-colour, #c41e3a);
}
.form-group input[type="text"]:valid:not(:placeholder-shown),
.form-group textarea:valid:not(:placeholder-shown) {
border-color: rgba(46, 204, 113, 0.5);
}
.form-group textarea {
resize: vertical;
min-height: 100px;
}
.form-help {
display: block;
margin-top: 0.25rem;
color: var(--text-muted, #a0a0a0);
font-size: 0.85rem;
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-group input[type="checkbox"] {
width: 20px;
height: 20px;
cursor: pointer;
}
.info-item {
margin-bottom: 1rem;
color: var(--text-colour, #e0e0e0);
}
.info-item strong {
color: var(--accent-colour, #9b59b6);
}
.avatar-preview {
width: 50px;
height: 50px;
border-radius: 50%;
margin-left: 0.5rem;
vertical-align: middle;
}
.form-actions {
display: flex;
justify-content: flex-end;
padding-top: 1rem;
}
.btn {
padding: 0.75rem 2rem;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`]
})
export class SettingsComponent implements OnInit {
private userService = inject(UserService);
private authService = inject(AuthService);
private toastService = inject(ToastService);
user = signal<User | null>(null);
loading = signal(true);
saving = signal(false);
formData: UpdateUserSettingsRequest & { bio?: string } = {
displayName: '',
slug: '',
bio: '',
profilePublic: true,
website: '',
discordServer: '',
bluesky: '',
github: '',
linkedin: '',
twitch: '',
youtube: ''
};
ngOnInit(): void {
this.userService.getMe().subscribe({
next: (userData: User) => {
this.user.set(userData);
this.formData = {
displayName: userData.displayName || '',
slug: userData.slug || '',
bio: userData.bio || '',
profilePublic: userData.profilePublic ?? true,
website: userData.website || '',
discordServer: userData.discordServer || '',
bluesky: userData.bluesky || '',
github: userData.github || '',
linkedin: userData.linkedin || '',
twitch: userData.twitch || '',
youtube: userData.youtube || ''
};
this.loading.set(false);
},
error: (err: Error) => {
console.error('Error loading user profile:', err);
this.toastService.error('Failed to load profile');
this.loading.set(false);
}
});
}
saveSettings(): void {
this.saving.set(true);
const updates: UpdateUserSettingsRequest = {
displayName: this.formData.displayName || undefined,
slug: this.formData.slug || undefined,
bio: this.formData.bio || undefined,
profilePublic: this.formData.profilePublic,
website: this.formData.website || undefined,
discordServer: this.formData.discordServer || undefined,
bluesky: this.formData.bluesky || undefined,
github: this.formData.github || undefined,
linkedin: this.formData.linkedin || undefined,
twitch: this.formData.twitch || undefined,
youtube: this.formData.youtube || undefined
};
this.userService.updateSettings(updates).subscribe({
next: (updatedUser: User) => {
this.user.set(updatedUser);
this.authService.updateUser(updatedUser);
this.saving.set(false);
this.toastService.success('Settings saved successfully!');
},
error: (err: Error) => {
console.error('Error saving settings:', err);
this.toastService.error('Failed to save settings');
this.saving.set(false);
}
});
}
}
@@ -14,13 +14,12 @@ import { SanitizeService } from '../../services/sanitize.service';
import { SuggestionService } from '../../services/suggestion.service'; import { SuggestionService } from '../../services/suggestion.service';
import { PaginationComponent } from '../shared/pagination.component'; import { PaginationComponent } from '../shared/pagination.component';
import { LikeButtonComponent } from '../shared/like-button.component'; import { LikeButtonComponent } from '../shared/like-button.component';
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, SuggestionEntity, Link } from '@library/shared-types'; import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
@Component({ @Component({
selector: 'app-shows-list', selector: 'app-shows-list',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent], imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
template: ` template: `
<div class="container"> <div class="container">
<div class="header-section"> <div class="header-section">
@@ -67,30 +66,9 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
<option [value]="ShowStatus.watching">Currently Watching</option> <option [value]="ShowStatus.watching">Currently Watching</option>
<option [value]="ShowStatus.completed">Completed</option> <option [value]="ShowStatus.completed">Completed</option>
<option [value]="ShowStatus.wantToWatch">Want to Watch</option> <option [value]="ShowStatus.wantToWatch">Want to Watch</option>
<option [value]="ShowStatus.retired">Retired</option>
</select> </select>
</div> </div>
<div class="form-group">
<label for="dateStarted">Date Started</label>
<input
type="date"
id="dateStarted"
[(ngModel)]="newShow.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="dateFinished">Date Finished</label>
<input
type="date"
id="dateFinished"
[(ngModel)]="newShow.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group"> <div class="form-group">
<label for="rating">Rating (1-10)</label> <label for="rating">Rating (1-10)</label>
<input <input
@@ -135,7 +113,8 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="tags-input-container" aria-label="Tags"> <label>Tags</label>
<div class="tags-input-container">
@for (tag of newShow.tags; track tag; let i = $index) { @for (tag of newShow.tags; track tag; let i = $index) {
<span class="tag"> <span class="tag">
{{ tag }} {{ tag }}
@@ -152,7 +131,8 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
</div> </div>
</div> </div>
<div class="form-group" aria-label="External Links"> <div class="form-group">
<label>External Links</label>
<div class="links-list"> <div class="links-list">
@for (link of newShow.links; track link.url; let i = $index) { @for (link of newShow.links; track link.url; let i = $index) {
<div class="link-item"> <div class="link-item">
@@ -216,30 +196,9 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
<option [value]="ShowStatus.watching">Currently Watching</option> <option [value]="ShowStatus.watching">Currently Watching</option>
<option [value]="ShowStatus.completed">Completed</option> <option [value]="ShowStatus.completed">Completed</option>
<option [value]="ShowStatus.wantToWatch">Want to Watch</option> <option [value]="ShowStatus.wantToWatch">Want to Watch</option>
<option [value]="ShowStatus.retired">Retired</option>
</select> </select>
</div> </div>
<div class="form-group">
<label for="edit-dateStarted">Date Started</label>
<input
type="date"
id="edit-dateStarted"
[(ngModel)]="editShow.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="edit-dateFinished">Date Finished</label>
<input
type="date"
id="edit-dateFinished"
[(ngModel)]="editShow.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group"> <div class="form-group">
<label for="edit-rating">Rating (1-10)</label> <label for="edit-rating">Rating (1-10)</label>
<input <input
@@ -284,7 +243,8 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="tags-input-container" aria-label="Tags"> <label>Tags</label>
<div class="tags-input-container">
@for (tag of editShow.tags; track tag; let i = $index) { @for (tag of editShow.tags; track tag; let i = $index) {
<span class="tag"> <span class="tag">
{{ tag }} {{ tag }}
@@ -301,7 +261,8 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
</div> </div>
</div> </div>
<div class="form-group" aria-label="External Links"> <div class="form-group">
<label>External Links</label>
<div class="links-list"> <div class="links-list">
@for (link of editShow.links; track link.url; let i = $index) { @for (link of editShow.links; track link.url; let i = $index) {
<div class="link-item"> <div class="link-item">
@@ -472,13 +433,6 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
> >
Want to Watch ({{ wantToWatchCount() }}) Want to Watch ({{ wantToWatchCount() }})
</button> </button>
<button
(click)="setFilter(ShowStatus.retired)"
[class.active]="statusFilter() === ShowStatus.retired"
class="filter-btn"
>
Retired ({{ retiredCount() }})
</button>
</div> </div>
@if (loading()) { @if (loading()) {
@@ -545,30 +499,6 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
</div> </div>
} }
@if (show.dateStarted) {
<p class="date-started">
Started: {{ formatDate(show.dateStarted) }}
</p>
}
@if (show.dateFinished) {
<p class="date-finished">
Finished: {{ formatDate(show.dateFinished) }}
</p>
}
@if (show.createdAt) {
<p class="date-added">
Added: {{ formatDate(show.createdAt) }}
</p>
}
@if (show.updatedAt) {
<p class="date-updated">
Updated: {{ formatDate(show.updatedAt) }}
</p>
}
@if (authService.isAdmin()) { @if (authService.isAdmin()) {
<div class="actions"> <div class="actions">
<button (click)="startEdit(show)" class="btn btn-secondary btn-sm"> <button (click)="startEdit(show)" class="btn btn-secondary btn-sm">
@@ -605,11 +535,56 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
} }
} }
<app-comment-display @if (commentsLoading()[show.id]) {
[comments]="getCommentsSignal(show.id)" <div class="comments-loading">Loading comments...</div>
(onEdit)="handleCommentEdit(show.id, $event)" } @else {
(onDelete)="deleteComment(show.id, $event)" @for (comment of comments()[show.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>
@if (comment.user.inDiscord) {
<span class="discord-badge">Discord</span>
}
@if (comment.user.isVip) {
<span class="vip-badge">VIP</span>
}
@if (comment.user.isMod) {
<span class="mod-badge">Mod</span>
}
@if (comment.user.isStaff) {
<span class="staff-badge">Staff</span>
}
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
@if (canEditComment(comment)) {
<button (click)="startEditComment(show.id, comment)" class="btn btn-secondary btn-xs">Edit</button>
}
@if (canDeleteComment(comment)) {
<button (click)="deleteComment(show.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
}
</div>
@if (editingCommentId() === comment.id) {
<div class="comment-edit-form">
<textarea
[(ngModel)]="editCommentContent"
name="editComment"
rows="3"
></textarea>
<div class="comment-edit-actions">
<button (click)="saveCommentEdit(show.id, comment.id)" class="btn btn-primary btn-xs">Save</button>
<button (click)="cancelCommentEdit()" class="btn btn-secondary btn-xs">Cancel</button>
</div>
</div>
} @else {
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
}
</div>
} @empty {
<div class="no-comments">No comments yet. Be the first to comment!</div>
}
}
</div> </div>
} }
</div> </div>
@@ -879,15 +854,6 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
margin: 0.5rem 0; margin: 0.5rem 0;
} }
.date-started,
.date-finished,
.date-added,
.date-updated {
font-size: 0.85rem;
color: #4b5563;
margin-top: 0.5rem;
}
.actions { .actions {
margin-top: 1rem; margin-top: 1rem;
} }
@@ -1244,7 +1210,6 @@ export class ShowsListComponent implements OnInit {
watchingCount = computed(() => this.shows().filter(show => show.status === ShowStatus.watching).length); watchingCount = computed(() => this.shows().filter(show => show.status === ShowStatus.watching).length);
completedCount = computed(() => this.shows().filter(show => show.status === ShowStatus.completed).length); completedCount = computed(() => this.shows().filter(show => show.status === ShowStatus.completed).length);
wantToWatchCount = computed(() => this.shows().filter(show => show.status === ShowStatus.wantToWatch).length); wantToWatchCount = computed(() => this.shows().filter(show => show.status === ShowStatus.wantToWatch).length);
retiredCount = computed(() => this.shows().filter(show => show.status === ShowStatus.retired).length);
allTags = computed(() => { allTags = computed(() => {
const tagsSet = new Set<string>(); const tagsSet = new Set<string>();
@@ -1293,14 +1258,12 @@ export class ShowsListComponent implements OnInit {
totalFilteredShows = computed(() => this.filteredShows().length); totalFilteredShows = computed(() => this.filteredShows().length);
newShow: Partial<CreateShowDto> & { dateStarted?: Date; dateFinished?: Date } = { newShow: Partial<CreateShowDto> = {
title: '', title: '',
type: ShowType.tvSeries, type: ShowType.tvSeries,
status: ShowStatus.wantToWatch, status: ShowStatus.wantToWatch,
rating: undefined, rating: undefined,
notes: '', notes: '',
dateStarted: undefined,
dateFinished: undefined,
tags: [], tags: [],
links: [] links: []
}; };
@@ -1375,7 +1338,6 @@ export class ShowsListComponent implements OnInit {
case ShowStatus.watching: return 'Currently Watching'; case ShowStatus.watching: return 'Currently Watching';
case ShowStatus.completed: return 'Completed'; case ShowStatus.completed: return 'Completed';
case ShowStatus.wantToWatch: return 'Want to Watch'; case ShowStatus.wantToWatch: return 'Want to Watch';
case ShowStatus.retired: return 'Retired';
} }
} }
@@ -1403,8 +1365,6 @@ export class ShowsListComponent implements OnInit {
rating: undefined, rating: undefined,
notes: '', notes: '',
coverImage: undefined, coverImage: undefined,
dateStarted: undefined,
dateFinished: undefined,
tags: [], tags: [],
links: [] links: []
}; };
@@ -1467,8 +1427,6 @@ export class ShowsListComponent implements OnInit {
title: this.newShow.title, title: this.newShow.title,
type: this.newShow.type, type: this.newShow.type,
status: this.newShow.status, status: this.newShow.status,
dateStarted: this.newShow.dateStarted ? new Date(this.newShow.dateStarted) : undefined,
dateFinished: this.newShow.dateFinished ? new Date(this.newShow.dateFinished) : undefined,
rating: this.newShow.rating, rating: this.newShow.rating,
notes: this.newShow.notes, notes: this.newShow.notes,
coverImage: this.newShow.coverImage, coverImage: this.newShow.coverImage,
@@ -1496,8 +1454,6 @@ export class ShowsListComponent implements OnInit {
title: show.title, title: show.title,
type: show.type, type: show.type,
status: show.status, status: show.status,
dateStarted: show.dateStarted,
dateFinished: show.dateFinished,
rating: show.rating, rating: show.rating,
notes: show.notes, notes: show.notes,
coverImage: show.coverImage, coverImage: show.coverImage,
@@ -1526,13 +1482,7 @@ export class ShowsListComponent implements OnInit {
const show = this.editingShow(); const show = this.editingShow();
if (!show || !this.editShow.title || !this.editShow.type || !this.editShow.status) return; if (!show || !this.editShow.title || !this.editShow.type || !this.editShow.status) return;
const updateData = { this.showsService.updateShow(show.id, this.editShow).subscribe(() => {
...this.editShow,
dateStarted: this.editShow.dateStarted ? new Date(this.editShow.dateStarted) : undefined,
dateFinished: this.editShow.dateFinished ? new Date(this.editShow.dateFinished) : undefined,
};
this.showsService.updateShow(show.id, updateData).subscribe(() => {
this.loadShows(); this.loadShows();
this.cancelEdit(); this.cancelEdit();
}); });
@@ -1727,7 +1677,7 @@ export class ShowsListComponent implements OnInit {
try { try {
await this.suggestionService.createSuggestion({ await this.suggestionService.createSuggestion({
entityType: SuggestionEntity.show, entityType: SuggestionEntity.SHOW,
title: this.suggestedShow.title, title: this.suggestedShow.title,
type: this.suggestedShow.type, type: this.suggestedShow.type,
notes: this.suggestedShow.notes, notes: this.suggestedShow.notes,
@@ -1739,21 +1689,4 @@ export class ShowsListComponent implements OnInit {
alert('Failed to submit suggestion. Please try again.'); alert('Failed to submit suggestion. Please try again.');
} }
} }
handleCommentEdit(showId: string, event: { commentId: string; content: string }) {
this.commentsService.updateCommentOnShow(showId, event.commentId, event.content).subscribe({
next: (updatedComment) => {
this.comments.set({
...this.comments(),
[showId]: (this.comments()[showId] || []).map(c =>
c.id === event.commentId ? updatedComment : c
)
});
}
});
}
getCommentsSignal(showId: string) {
return signal(this.comments()[showId] || []);
}
} }
@@ -1,91 +0,0 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
.toast-container {
position: fixed;
top: 80px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 10px;
max-width: 400px;
}
.toast {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
cursor: pointer;
animation: slideIn 0.3s ease-out;
border: 2px solid;
background-color: var(--witch-purple);
color: var(--moon-white);
}
.toast-error {
border-color: #ff4444;
background-color: rgba(255, 68, 68, 0.4);
}
.toast-success {
border-color: #44ff88;
background-color: rgba(68, 255, 136, 0.4);
}
.toast-info {
border-color: var(--witch-lavender);
background-color: rgba(200, 162, 200, 0.4);
}
.toast-warning {
border-color: #ffaa44;
background-color: rgba(255, 170, 68, 0.4);
}
.toast-icon {
font-size: 20px;
flex-shrink: 0;
}
.toast-message {
flex: 1;
word-wrap: break-word;
}
.toast-close {
background: none;
border: none;
color: var(--moon-white);
font-size: 24px;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: transform 0.2s ease;
}
.toast-close:hover {
transform: scale(1.2);
}
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@@ -1,29 +0,0 @@
<!--
@copyright 2026 NHCarrigan
@license Naomi's Public License
@author Naomi Carrigan
-->
<div class="toast-container">
@for (toast of toastService.toastList(); track toast.id) {
<div
class="toast toast-{{ toast.type }}"
(click)="toastService.remove(toast.id)"
(keyup.enter)="toastService.remove(toast.id)"
(keyup.space)="toastService.remove(toast.id)"
tabindex="0"
role="button"
>
<div class="toast-icon">
@switch (toast.type) {
@case ('error') { ❌ }
@case ('success') { âś… }
@case ('info') { ℹ️ }
@case ('warning') { ⚠️ }
}
</div>
<div class="toast-message">{{ toast.message }}</div>
<button class="toast-close" (click)="toastService.remove(toast.id)">Ă—</button>
</div>
}
</div>
@@ -1,18 +0,0 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Component, inject } from '@angular/core';
import { ToastService } from '../../services/toast.service';
@Component({
selector: 'app-toast',
standalone: true,
templateUrl: './toast.component.html',
styleUrls: ['./toast.component.css']
})
export class ToastComponent {
public toastService = inject(ToastService);
}
@@ -1,13 +0,0 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { ConsoleLoggerService } from '../services/console-logger.service';
export function initializeConsoleLogger(consoleLogger: ConsoleLoggerService) {
return () => {
consoleLogger.initialise();
};
}
@@ -16,17 +16,17 @@ import {
import { Observable, catchError, throwError, switchMap, BehaviorSubject, filter, take } from 'rxjs'; import { Observable, catchError, throwError, switchMap, BehaviorSubject, filter, take } from 'rxjs';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
import { ToastService } from '../services/toast.service';
@Injectable() @Injectable()
export class AuthInterceptor implements HttpInterceptor { export class AuthInterceptor implements HttpInterceptor {
private router = inject(Router);
private http = inject(HttpClient);
private toast = inject(ToastService);
private isRefreshing = false; private isRefreshing = false;
private refreshTokenSubject = new BehaviorSubject<boolean | null>(null); private refreshTokenSubject = new BehaviorSubject<boolean | null>(null);
constructor(
private router: Router,
private http: HttpClient
) {}
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> { intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
// Clone the request to add withCredentials // Clone the request to add withCredentials
const authReq = request.clone({ const authReq = request.clone({
@@ -38,9 +38,6 @@ export class AuthInterceptor implements HttpInterceptor {
if (error.status === 401 && !request.url.includes('/auth/refresh') && !request.url.includes('/auth/logout')) { if (error.status === 401 && !request.url.includes('/auth/refresh') && !request.url.includes('/auth/logout')) {
return this.handle401Error(authReq, next); return this.handle401Error(authReq, next);
} }
// Show toast for other HTTP errors
this.showErrorToast(error);
return throwError(() => error); return throwError(() => error);
}) })
); );
@@ -64,7 +61,6 @@ export class AuthInterceptor implements HttpInterceptor {
catchError((err) => { catchError((err) => {
this.isRefreshing = false; this.isRefreshing = false;
this.refreshTokenSubject.next(false); this.refreshTokenSubject.next(false);
this.toast.error('Your session has expired. Please log in again.');
this.router.navigate(['/']); this.router.navigate(['/']);
return throwError(() => err); return throwError(() => err);
}) })
@@ -82,28 +78,4 @@ export class AuthInterceptor implements HttpInterceptor {
}) })
); );
} }
private showErrorToast(error: HttpErrorResponse): void {
let message = 'Something went wrong. Please try again.';
switch (error.status) {
case 400:
message = error.error?.message || 'Invalid request. Please check your input.';
break;
case 403:
message = 'You do not have permission to perform this action.';
break;
case 404:
message = 'Resource not found.';
break;
case 500:
message = 'Server error. Please try again later.';
break;
case 0:
message = 'Network error. Please check your connection.';
break;
}
this.toast.error(message);
}
} }
@@ -4,7 +4,7 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
import { Injectable, inject } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { ApiService } from './api.service'; import { ApiService } from './api.service';
import { Art, CreateArtDto, UpdateArtDto } from '@library/shared-types'; import { Art, CreateArtDto, UpdateArtDto } from '@library/shared-types';
@@ -13,8 +13,7 @@ import { Art, CreateArtDto, UpdateArtDto } from '@library/shared-types';
providedIn: 'root' providedIn: 'root'
}) })
export class ArtService { export class ArtService {
private api = inject(ApiService); constructor(private api: ApiService) {}
getAllArt(): Observable<Art[]> { getAllArt(): Observable<Art[]> {
return this.api.get<Art[]>('/art'); return this.api.get<Art[]>('/art');
+7 -39
View File
@@ -4,7 +4,7 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
import { Injectable, signal, inject } from '@angular/core'; import { Injectable, signal } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Observable, tap, catchError, switchMap, throwError, of } from 'rxjs'; import { Observable, tap, catchError, switchMap, throwError, of } from 'rxjs';
import { ApiService } from './api.service'; import { ApiService } from './api.service';
@@ -16,14 +16,15 @@ import { HttpClient } from '@angular/common/http';
providedIn: 'root' providedIn: 'root'
}) })
export class AuthService { export class AuthService {
private api = inject(ApiService);
private router = inject(Router);
private http = inject(HttpClient);
private currentUser = signal<User | null>(null); private currentUser = signal<User | null>(null);
public readonly user = this.currentUser.asReadonly(); public readonly user = this.currentUser.asReadonly();
private refreshing = false; private refreshing = false;
private refreshInterval?: ReturnType<typeof setInterval>;
constructor(
private api: ApiService,
private router: Router,
private http: HttpClient
) {}
login(): void { login(): void {
// Redirect to API login endpoint // Redirect to API login endpoint
@@ -34,7 +35,6 @@ export class AuthService {
return this.api.get<AuthResponse>('/auth/me').pipe( return this.api.get<AuthResponse>('/auth/me').pipe(
tap(response => { tap(response => {
this.currentUser.set(response.user); this.currentUser.set(response.user);
this.startRefreshTimer();
}), }),
catchError(error => { catchError(error => {
if (error.status === 401) { if (error.status === 401) {
@@ -42,11 +42,9 @@ export class AuthService {
switchMap(() => this.api.get<AuthResponse>('/auth/me')), switchMap(() => this.api.get<AuthResponse>('/auth/me')),
tap(response => { tap(response => {
this.currentUser.set(response.user); this.currentUser.set(response.user);
this.startRefreshTimer();
}), }),
catchError(() => { catchError(() => {
this.currentUser.set(null); this.currentUser.set(null);
this.stopRefreshTimer();
return throwError(() => error); return throwError(() => error);
}) })
); );
@@ -70,45 +68,20 @@ export class AuthService {
tap(response => { tap(response => {
this.currentUser.set(response.user); this.currentUser.set(response.user);
this.refreshing = false; this.refreshing = false;
this.startRefreshTimer();
}), }),
catchError(error => { catchError(error => {
this.refreshing = false; this.refreshing = false;
this.currentUser.set(null); this.currentUser.set(null);
this.stopRefreshTimer();
return throwError(() => error); return throwError(() => error);
}) })
); );
} }
private startRefreshTimer(): void {
this.stopRefreshTimer();
// Refresh token every 13 minutes (before 15-minute expiry)
const refreshIntervalMs = 13 * 60 * 1000;
this.refreshInterval = setInterval(() => {
this.refreshToken().subscribe({
error: (err) => {
console.error('Background token refresh failed:', err);
this.stopRefreshTimer();
}
});
}, refreshIntervalMs);
}
private stopRefreshTimer(): void {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
this.refreshInterval = undefined;
}
}
logout(): Observable<{ message: string }> { logout(): Observable<{ message: string }> {
return this.api.post<{ message: string }>('/auth/logout', {}).pipe( return this.api.post<{ message: string }>('/auth/logout', {}).pipe(
tap(() => { tap(() => {
this.currentUser.set(null); this.currentUser.set(null);
this.api.clearCsrfToken(); this.api.clearCsrfToken();
this.stopRefreshTimer();
this.router.navigate(['/']); this.router.navigate(['/']);
}) })
); );
@@ -116,17 +89,12 @@ export class AuthService {
clearUser(): void { clearUser(): void {
this.currentUser.set(null); this.currentUser.set(null);
this.stopRefreshTimer();
} }
isAuthenticated(): boolean { isAuthenticated(): boolean {
return this.user() !== null; return this.user() !== null;
} }
updateUser(user: User): void {
this.currentUser.set(user);
}
isAdmin(): boolean { isAdmin(): boolean {
return this.user()?.isAdmin === true; return this.user()?.isAdmin === true;
} }
@@ -4,7 +4,7 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
import { Injectable, inject } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { ApiService } from './api.service'; import { ApiService } from './api.service';
import { Book, CreateBookDto, UpdateBookDto } from '@library/shared-types'; import { Book, CreateBookDto, UpdateBookDto } from '@library/shared-types';
@@ -13,8 +13,7 @@ import { Book, CreateBookDto, UpdateBookDto } from '@library/shared-types';
providedIn: 'root' providedIn: 'root'
}) })
export class BooksService { export class BooksService {
private api = inject(ApiService); constructor(private api: ApiService) {}
getAllBooks(): Observable<Book[]> { getAllBooks(): Observable<Book[]> {
return this.api.get<Book[]>('/books'); return this.api.get<Book[]>('/books');
@@ -1,50 +0,0 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import type {
CommentReportWithDetails,
CreateCommentReportDto,
UpdateCommentReportDto,
ReportStatus,
} from '@library/shared-types';
import { environment } from '../../environments/environment';
@Injectable({
providedIn: 'root',
})
export class CommentReportService {
private readonly http = inject(HttpClient);
private readonly apiUrl = `${environment.apiUrl}/comment-reports`;
createReport(
dto: CreateCommentReportDto,
): Observable<CommentReportWithDetails> {
return this.http.post<CommentReportWithDetails>(this.apiUrl, dto);
}
getAllReports(
status?: ReportStatus,
): Observable<CommentReportWithDetails[]> {
const params: Record<string, string> = {};
if (status) {
params['status'] = status;
}
return this.http.get<CommentReportWithDetails[]>(this.apiUrl, { params });
}
getReportById(id: string): Observable<CommentReportWithDetails> {
return this.http.get<CommentReportWithDetails>(`${this.apiUrl}/${id}`);
}
updateReport(
id: string,
dto: UpdateCommentReportDto,
): Observable<CommentReportWithDetails> {
return this.http.put<CommentReportWithDetails>(`${this.apiUrl}/${id}`, dto);
}
}
@@ -4,7 +4,7 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
import { Injectable, inject } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { ApiService } from './api.service'; import { ApiService } from './api.service';
import { Comment, CreateCommentDto } from '@library/shared-types'; import { Comment, CreateCommentDto } from '@library/shared-types';
@@ -13,8 +13,7 @@ import { Comment, CreateCommentDto } from '@library/shared-types';
providedIn: 'root' providedIn: 'root'
}) })
export class CommentsService { export class CommentsService {
private api = inject(ApiService); constructor(private api: ApiService) {}
getCommentsForGame(gameId: string): Observable<Comment[]> { getCommentsForGame(gameId: string): Observable<Comment[]> {
return this.api.get<Comment[]>(`/games/${gameId}/comments`); return this.api.get<Comment[]>(`/games/${gameId}/comments`);
@@ -1,127 +0,0 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../environments/environment';
interface LogPayload {
level: 'debug' | 'info' | 'warn' | 'error';
message: string;
context?: string;
error?: {
name: string;
message: string;
stack?: string;
};
}
@Injectable({
providedIn: 'root'
})
export class ConsoleLoggerService {
private http = inject(HttpClient);
private originalConsole = {
log: console.log.bind(console),
error: console.error.bind(console),
warn: console.warn.bind(console),
debug: console.debug.bind(console),
info: console.info.bind(console)
};
/**
* Initialises the console override to pipe logs to the API.
*/
initialise(): void {
console.log = (...args: unknown[]) => {
this.originalConsole.log(...args);
this.sendLog('info', this.formatArgs(args));
};
console.info = (...args: unknown[]) => {
this.originalConsole.info(...args);
this.sendLog('info', this.formatArgs(args));
};
console.debug = (...args: unknown[]) => {
this.originalConsole.debug(...args);
this.sendLog('debug', this.formatArgs(args));
};
console.warn = (...args: unknown[]) => {
this.originalConsole.warn(...args);
this.sendLog('warn', this.formatArgs(args));
};
console.error = (...args: unknown[]) => {
this.originalConsole.error(...args);
// Check if the first argument is an Error object
if (args[0] instanceof Error) {
const error = args[0];
this.sendLog('error', error.message, 'Console', {
name: error.name,
message: error.message,
stack: error.stack
});
} else {
this.sendLog('error', this.formatArgs(args));
}
};
// Global error handlers
window.addEventListener('error', (event: ErrorEvent) => {
this.originalConsole.error('Uncaught Error:', event.error);
this.sendLog('error', event.message, 'Window Error', {
name: event.error?.name || 'Error',
message: event.message,
stack: event.error?.stack
});
});
window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
this.originalConsole.error('Unhandled Promise Rejection:', event.reason);
const error = event.reason instanceof Error ? event.reason : new Error(String(event.reason));
this.sendLog('error', error.message, 'Unhandled Rejection', {
name: error.name,
message: error.message,
stack: error.stack
});
});
}
private formatArgs(args: unknown[]): string {
return args.map(arg => {
if (typeof arg === 'string') {
return arg;
}
if (arg instanceof Error) {
return `${arg.name}: ${arg.message}`;
}
try {
return JSON.stringify(arg);
} catch {
return String(arg);
}
}).join(' ');
}
private sendLog(level: LogPayload['level'], message: string, context?: string, error?: LogPayload['error']): void {
const payload: LogPayload = {
level,
message,
context,
error
};
this.http.post(`${environment.apiUrl}/log`, payload).subscribe({
error: (err) => {
this.originalConsole.error('Failed to send log to API:', err);
}
});
}
}
@@ -4,7 +4,7 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
import { Injectable, inject } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { ApiService } from './api.service'; import { ApiService } from './api.service';
import { Game, CreateGameDto, UpdateGameDto } from '@library/shared-types'; import { Game, CreateGameDto, UpdateGameDto } from '@library/shared-types';
@@ -13,8 +13,7 @@ import { Game, CreateGameDto, UpdateGameDto } from '@library/shared-types';
providedIn: 'root' providedIn: 'root'
}) })
export class GamesService { export class GamesService {
private api = inject(ApiService); constructor(private api: ApiService) {}
getAllGames(): Observable<Game[]> { getAllGames(): Observable<Game[]> {
return this.api.get<Game[]>('/games'); return this.api.get<Game[]>('/games');
@@ -1,43 +0,0 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { ErrorHandler, Injectable, inject } from '@angular/core';
import { ToastService } from './toast.service';
@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
private toast = inject(ToastService);
handleError(error: Error): void {
console.error('Global error caught:', error);
// Show user-friendly error message
const message = this.getUserFriendlyMessage(error);
this.toast.error(message);
}
private getUserFriendlyMessage(error: Error): string {
// Check for common error types
if (error.message.includes('Http failure')) {
return 'Network error. Please check your connection.';
}
if (error.message.includes('401') || error.message.includes('403')) {
return 'Your session has expired. Please refresh the page.';
}
if (error.message.includes('404')) {
return 'Resource not found.';
}
if (error.message.includes('500')) {
return 'Server error. Please try again later.';
}
// Generic error message
return 'Something went wrong. Please try again.';
}
}
-1
View File
@@ -9,4 +9,3 @@ export { AuthService } from './auth.service';
export { BooksService } from './books.service'; export { BooksService } from './books.service';
export { GamesService } from './games.service'; export { GamesService } from './games.service';
export { MusicService } from './music.service'; export { MusicService } from './music.service';
export { ReportService } from './report.service';
@@ -4,7 +4,7 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
import { Injectable, inject } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { ApiService } from './api.service'; import { ApiService } from './api.service';
import { Manga, CreateMangaDto, UpdateMangaDto } from '@library/shared-types'; import { Manga, CreateMangaDto, UpdateMangaDto } from '@library/shared-types';
@@ -13,8 +13,7 @@ import { Manga, CreateMangaDto, UpdateMangaDto } from '@library/shared-types';
providedIn: 'root' providedIn: 'root'
}) })
export class MangaService { export class MangaService {
private api = inject(ApiService); constructor(private api: ApiService) {}
getAllManga(): Observable<Manga[]> { getAllManga(): Observable<Manga[]> {
return this.api.get<Manga[]>('/manga'); return this.api.get<Manga[]>('/manga');
@@ -4,7 +4,7 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
import { Injectable, inject } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { ApiService } from './api.service'; import { ApiService } from './api.service';
import { Music, CreateMusicDto, UpdateMusicDto } from '@library/shared-types'; import { Music, CreateMusicDto, UpdateMusicDto } from '@library/shared-types';
@@ -13,8 +13,7 @@ import { Music, CreateMusicDto, UpdateMusicDto } from '@library/shared-types';
providedIn: 'root' providedIn: 'root'
}) })
export class MusicService { export class MusicService {
private api = inject(ApiService); constructor(private api: ApiService) {}
getAllMusic(): Observable<Music[]> { getAllMusic(): Observable<Music[]> {
return this.api.get<Music[]>('/music'); return this.api.get<Music[]>('/music');
@@ -1,74 +0,0 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { ApiService } from './api.service';
import type {
ProfileReportWithUsers,
CreateReportDto,
ReportStatus
} from '@library/shared-types';
@Injectable({
providedIn: 'root'
})
export class ReportService {
private api = inject(ApiService);
/**
* Create a new profile report.
*
* @param reportedUserId - The ID of the user being reported
* @param reason - The reason for the report
* @param details - Additional details about the report
* @returns Observable of the created report
*/
createReport(reportedUserId: string, reason: string, details: string): Observable<ProfileReportWithUsers> {
const dto: CreateReportDto = {
reportedUserId,
reason: reason as CreateReportDto['reason'],
details
};
return this.api.post<ProfileReportWithUsers>('/reports', dto);
}
/**
* Get all reports (admin only). Optionally filter by status.
*
* @param status - Optional status to filter by
* @returns Observable of all matching reports
*/
getAllReports(status?: ReportStatus): Observable<ProfileReportWithUsers[]> {
const url = status ? `/reports?status=${status}` : '/reports';
return this.api.get<ProfileReportWithUsers[]>(url);
}
/**
* Get a specific report by ID (admin only).
*
* @param id - The report ID
* @returns Observable of the report
*/
getReportById(id: string): Observable<ProfileReportWithUsers> {
return this.api.get<ProfileReportWithUsers>(`/reports/${id}`);
}
/**
* Update a report's status and review notes (admin only).
*
* @param id - The report ID
* @param status - The new status
* @param reviewNotes - Optional review notes
* @returns Observable of the updated report
*/
updateReport(id: string, status: ReportStatus, reviewNotes?: string): Observable<ProfileReportWithUsers> {
return this.api.put<ProfileReportWithUsers>(`/reports/${id}`, {
status,
reviewNotes
});
}
}
@@ -4,7 +4,7 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
import { Injectable, inject } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { ApiService } from './api.service'; import { ApiService } from './api.service';
import { Show, CreateShowDto, UpdateShowDto } from '@library/shared-types'; import { Show, CreateShowDto, UpdateShowDto } from '@library/shared-types';
@@ -13,8 +13,7 @@ import { Show, CreateShowDto, UpdateShowDto } from '@library/shared-types';
providedIn: 'root' providedIn: 'root'
}) })
export class ShowsService { export class ShowsService {
private api = inject(ApiService); constructor(private api: ApiService) {}
getAllShows(): Observable<Show[]> { getAllShows(): Observable<Show[]> {
return this.api.get<Show[]>('/shows'); return this.api.get<Show[]>('/shows');
@@ -1,70 +0,0 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Injectable, signal } from '@angular/core';
export interface Toast {
id: number;
message: string;
type: 'error' | 'success' | 'info' | 'warning';
duration: number;
}
@Injectable({
providedIn: 'root'
})
export class ToastService {
private toasts = signal<Toast[]>([]);
public readonly toastList = this.toasts.asReadonly();
private nextId = 0;
/**
* Show an error toast notification.
*/
error(message: string, duration = 5000): void {
this.addToast(message, 'error', duration);
}
/**
* Show a success toast notification.
*/
success(message: string, duration = 3000): void {
this.addToast(message, 'success', duration);
}
/**
* Show an info toast notification.
*/
info(message: string, duration = 3000): void {
this.addToast(message, 'info', duration);
}
/**
* Show a warning toast notification.
*/
warning(message: string, duration = 4000): void {
this.addToast(message, 'warning', duration);
}
/**
* Remove a toast by ID.
*/
remove(id: number): void {
this.toasts.update(toasts => toasts.filter(t => t.id !== id));
}
private addToast(message: string, type: Toast['type'], duration: number): void {
const id = this.nextId++;
const toast: Toast = { id, message, type, duration };
this.toasts.update(toasts => [...toasts, toast]);
// Auto-remove after duration
setTimeout(() => {
this.remove(id);
}, duration);
}
}
@@ -9,49 +9,6 @@ import { Observable } from 'rxjs';
import { ApiService } from './api.service'; import { ApiService } from './api.service';
import { User } from '@library/shared-types'; import { User } from '@library/shared-types';
export interface UserProfileResponse {
id: string;
username: string;
displayName?: string;
avatar?: string;
bio?: string;
slug?: string;
website?: string;
discordServer?: string;
bluesky?: string;
github?: string;
linkedin?: string;
twitch?: string;
youtube?: string;
badges: {
isStaff: boolean;
isMod: boolean;
isVip: boolean;
inDiscord: boolean;
};
stats: {
suggestionsCount: number;
suggestionsAcceptedCount: number;
likesCount: number;
commentsCount: number;
};
createdAt: Date;
}
export interface UpdateUserSettingsRequest {
slug?: string;
displayName?: string;
bio?: string;
profilePublic?: boolean;
website?: string;
discordServer?: string;
bluesky?: string;
github?: string;
linkedin?: string;
twitch?: string;
youtube?: string;
}
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
@@ -69,16 +26,4 @@ export class UserService {
unbanUser(userId: string): Observable<User> { unbanUser(userId: string): Observable<User> {
return this.api.post<User>(`/users/${userId}/unban`, {}); return this.api.post<User>(`/users/${userId}/unban`, {});
} }
getMe(): Observable<User> {
return this.api.get<User>('/users/me');
}
updateSettings(settings: UpdateUserSettingsRequest): Observable<User> {
return this.api.put<User>('/users/me', settings);
}
getProfile(identifier: string): Observable<UserProfileResponse> {
return this.api.get<UserProfileResponse>(`/users/profile/${identifier}`);
}
} }
-24
View File
@@ -1,24 +0,0 @@
# Database
DATABASE_URL="op://Environment Variables - Naomi/Library/mongo url"
# JWT Secret
JWT_SECRET="op://Environment Variables - Naomi/Library/jwt secret"
# Discord OAuth
DISCORD_CLIENT_ID="op://Environment Variables - Naomi/Library/discord client id"
DISCORD_CLIENT_SECRET="op://Environment Variables - Naomi/Library/discord client secret"
# Admin Configuration
ADMIN_DISCORD_ID="op://Environment Variables - Naomi/Library/admin discord id"
# Discord Server
DISCORD_GUILD_ID="op://Environment Variables - Naomi/Library/discord server id"
SPONSOR_ROLE_ID="op://Environment Variables - Naomi/Library/sponsor role id"
MOD_ROLE_ID="op://Environment Variables - Naomi/Library/mod role id"
STAFF_ROLE_ID="op://Environment Variables - Naomi/Library/staff role id"
# Application URL
BASE_URL="op://Environment Variables - Naomi/Library/localhost url"
# Logger
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
+6 -1
View File
@@ -1,3 +1,8 @@
import nhcarrigan from '@nhcarrigan/eslint-config'; import nhcarrigan from '@nhcarrigan/eslint-config';
export default nhcarrigan; export default [
...nhcarrigan,
{
ignores: ['**/dist', '**/out-tsc', 'node_modules'],
},
];
+16 -22
View File
@@ -3,9 +3,8 @@
"version": "0.0.0", "version": "0.0.0",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"dev": "nx run-many --target=build --all && NODE_ENV=production op run --env-file=dev.env -- node dist/api/main.js",
"lint": "nx run-many --target=lint --all", "lint": "nx run-many --target=lint --all",
"build": "pnpm db:gen && nx run-many --target=build --all", "build": "nx run-many --target=build --all",
"test": "nx run-many --target=test --all --passWithNoTests", "test": "nx run-many --target=test --all --passWithNoTests",
"build:frontend": "nx build frontend --configuration=production", "build:frontend": "nx build frontend --configuration=production",
"build:api": "nx build api", "build:api": "nx build api",
@@ -26,33 +25,28 @@
"@angular/platform-browser": "21.1.2", "@angular/platform-browser": "21.1.2",
"@angular/router": "21.1.2", "@angular/router": "21.1.2",
"@fastify/autoload": "6.0.3", "@fastify/autoload": "6.0.3",
"@fastify/cookie": "11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/cors": "11.0.0", "@fastify/cors": "^11.0.0",
"@fastify/csrf-protection": "7.1.0", "@fastify/csrf-protection": "^7.1.0",
"@fastify/helmet": "13.0.2", "@fastify/helmet": "^13.0.2",
"@fastify/jwt": "10.0.0", "@fastify/jwt": "^10.0.0",
"@fastify/oauth2": "8.1.2", "@fastify/oauth2": "^8.1.2",
"@fastify/rate-limit": "10.3.0", "@fastify/rate-limit": "^10.3.0",
"@fastify/sensible": "6.0.4", "@fastify/sensible": "6.0.4",
"@fastify/static": "9.0.0", "@fastify/static": "^9.0.0",
"@fortawesome/angular-fontawesome": "4.0.0",
"@fortawesome/fontawesome-svg-core": "7.2.0",
"@fortawesome/free-brands-svg-icons": "7.2.0",
"@fortawesome/free-solid-svg-icons": "7.2.0",
"@nhcarrigan/logger": "1.1.1",
"@prisma/client": "6.19.2", "@prisma/client": "6.19.2",
"dompurify": "3.3.1", "dompurify": "^3.3.1",
"fastify": "5.7.3", "fastify": "5.7.3",
"fastify-plugin": "5.0.1", "fastify-plugin": "5.0.1",
"jsdom": "28.0.0", "jsdom": "^28.0.0",
"marked": "17.0.1", "marked": "^17.0.1",
"rxjs": "7.8.2" "rxjs": "7.8.2"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "21.1.2", "@angular-devkit/build-angular": "21.1.2",
"@angular-devkit/core": "21.1.2", "@angular-devkit/core": "21.1.2",
"@angular-devkit/schematics": "21.1.2", "@angular-devkit/schematics": "21.1.2",
"@angular/cli": "21.1.2", "@angular/cli": "21.1.3",
"@angular/compiler-cli": "21.1.2", "@angular/compiler-cli": "21.1.2",
"@angular/language-service": "21.1.2", "@angular/language-service": "21.1.2",
"@eslint/js": "9.39.2", "@eslint/js": "9.39.2",
@@ -71,10 +65,10 @@
"@swc-node/register": "1.9.2", "@swc-node/register": "1.9.2",
"@swc/core": "1.5.29", "@swc/core": "1.5.29",
"@swc/helpers": "0.5.18", "@swc/helpers": "0.5.18",
"@types/dompurify": "3.2.0", "@types/dompurify": "^3.2.0",
"@types/jest": "30.0.0", "@types/jest": "30.0.0",
"@types/jsdom": "27.0.0", "@types/jsdom": "^27.0.0",
"@types/jsonwebtoken": "9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "20.19.9", "@types/node": "20.19.9",
"@typescript-eslint/utils": "8.54.0", "@typescript-eslint/utils": "8.54.0",
"angular-eslint": "21.1.0", "angular-eslint": "21.1.0",
+112 -115
View File
@@ -30,52 +30,37 @@ importers:
specifier: 6.0.3 specifier: 6.0.3
version: 6.0.3 version: 6.0.3
'@fastify/cookie': '@fastify/cookie':
specifier: 11.0.2 specifier: ^11.0.2
version: 11.0.2 version: 11.0.2
'@fastify/cors': '@fastify/cors':
specifier: 11.0.0 specifier: ^11.0.0
version: 11.0.0 version: 11.2.0
'@fastify/csrf-protection': '@fastify/csrf-protection':
specifier: 7.1.0 specifier: ^7.1.0
version: 7.1.0 version: 7.1.0
'@fastify/helmet': '@fastify/helmet':
specifier: 13.0.2 specifier: ^13.0.2
version: 13.0.2 version: 13.0.2
'@fastify/jwt': '@fastify/jwt':
specifier: 10.0.0 specifier: ^10.0.0
version: 10.0.0 version: 10.0.0
'@fastify/oauth2': '@fastify/oauth2':
specifier: 8.1.2 specifier: ^8.1.2
version: 8.1.2 version: 8.1.2
'@fastify/rate-limit': '@fastify/rate-limit':
specifier: 10.3.0 specifier: ^10.3.0
version: 10.3.0 version: 10.3.0
'@fastify/sensible': '@fastify/sensible':
specifier: 6.0.4 specifier: 6.0.4
version: 6.0.4 version: 6.0.4
'@fastify/static': '@fastify/static':
specifier: 9.0.0 specifier: ^9.0.0
version: 9.0.0 version: 9.0.0
'@fortawesome/angular-fontawesome':
specifier: 4.0.0
version: 4.0.0(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))
'@fortawesome/fontawesome-svg-core':
specifier: 7.2.0
version: 7.2.0
'@fortawesome/free-brands-svg-icons':
specifier: 7.2.0
version: 7.2.0
'@fortawesome/free-solid-svg-icons':
specifier: 7.2.0
version: 7.2.0
'@nhcarrigan/logger':
specifier: 1.1.1
version: 1.1.1
'@prisma/client': '@prisma/client':
specifier: 6.19.2 specifier: 6.19.2
version: 6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3) version: 6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3)
dompurify: dompurify:
specifier: 3.3.1 specifier: ^3.3.1
version: 3.3.1 version: 3.3.1
fastify: fastify:
specifier: 5.7.3 specifier: 5.7.3
@@ -84,10 +69,10 @@ importers:
specifier: 5.0.1 specifier: 5.0.1
version: 5.0.1 version: 5.0.1
jsdom: jsdom:
specifier: 28.0.0 specifier: ^28.0.0
version: 28.0.0 version: 28.0.0
marked: marked:
specifier: 17.0.1 specifier: ^17.0.1
version: 17.0.1 version: 17.0.1
rxjs: rxjs:
specifier: 7.8.2 specifier: 7.8.2
@@ -103,8 +88,8 @@ importers:
specifier: 21.1.2 specifier: 21.1.2
version: 21.1.2(chokidar@5.0.0) version: 21.1.2(chokidar@5.0.0)
'@angular/cli': '@angular/cli':
specifier: 21.1.2 specifier: 21.1.3
version: 21.1.2(@types/node@20.19.9)(chokidar@5.0.0)(hono@4.11.7) version: 21.1.3(@types/node@20.19.9)(chokidar@5.0.0)
'@angular/compiler-cli': '@angular/compiler-cli':
specifier: 21.1.2 specifier: 21.1.2
version: 21.1.2(@angular/compiler@21.1.2)(typescript@5.9.3) version: 21.1.2(@angular/compiler@21.1.2)(typescript@5.9.3)
@@ -160,16 +145,16 @@ importers:
specifier: 0.5.18 specifier: 0.5.18
version: 0.5.18 version: 0.5.18
'@types/dompurify': '@types/dompurify':
specifier: 3.2.0 specifier: ^3.2.0
version: 3.2.0 version: 3.2.0
'@types/jest': '@types/jest':
specifier: 30.0.0 specifier: 30.0.0
version: 30.0.0 version: 30.0.0
'@types/jsdom': '@types/jsdom':
specifier: 27.0.0 specifier: ^27.0.0
version: 27.0.0 version: 27.0.0
'@types/jsonwebtoken': '@types/jsonwebtoken':
specifier: 9.0.10 specifier: ^9.0.10
version: 9.0.10 version: 9.0.10
'@types/node': '@types/node':
specifier: 20.19.9 specifier: 20.19.9
@@ -179,7 +164,7 @@ importers:
version: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) version: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
angular-eslint: angular-eslint:
specifier: 21.1.0 specifier: 21.1.0
version: 21.1.0(@angular/cli@21.1.2(@types/node@20.19.9)(chokidar@5.0.0)(hono@4.11.7))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript-eslint@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(typescript@5.9.3) version: 21.1.0(@angular/cli@21.1.3(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript-eslint@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(typescript@5.9.3)
cypress: cypress:
specifier: 15.9.0 specifier: 15.9.0
version: 15.9.0 version: 15.9.0
@@ -293,6 +278,11 @@ packages:
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
hasBin: true hasBin: true
'@angular-devkit/architect@0.2101.3':
resolution: {integrity: sha512-vKz8aPA62W+e9+pF6ct4CRDG/MjlIH7sWFGYkxPPRst2g46ZQsRkrzfMZAWv/wnt6OZ1OwyRuO3RW83EMhag8g==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
hasBin: true
'@angular-devkit/build-angular@21.1.2': '@angular-devkit/build-angular@21.1.2':
resolution: {integrity: sha512-i/FTbqVwj0Wk6B5RA2H9iVsDC/kIK/5koSEwkIQjXGZuDVFUoEuWiIR2PGGSSQ9u3DmkpVPZmKEXWRl+g7Qn5g==} resolution: {integrity: sha512-i/FTbqVwj0Wk6B5RA2H9iVsDC/kIK/5koSEwkIQjXGZuDVFUoEuWiIR2PGGSSQ9u3DmkpVPZmKEXWRl+g7Qn5g==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
@@ -359,10 +349,23 @@ packages:
chokidar: chokidar:
optional: true optional: true
'@angular-devkit/core@21.1.3':
resolution: {integrity: sha512-huEXd1tWQHwwN+0VGRT+vSVplV0KNrGFUGJzkIW6iJE1SQElxn6etMai+pSd5DJcePkx6+SuscVsxbfwf70hnA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
peerDependencies:
chokidar: ^5.0.0
peerDependenciesMeta:
chokidar:
optional: true
'@angular-devkit/schematics@21.1.2': '@angular-devkit/schematics@21.1.2':
resolution: {integrity: sha512-PA3gkiFhHUuXd2XuP7yzKg/9N++bjw+uOl473KwIsMuZwMPhncKa4+mUYBaffDoPqaujZvjfo6mjtCBuiBv05w==} resolution: {integrity: sha512-PA3gkiFhHUuXd2XuP7yzKg/9N++bjw+uOl473KwIsMuZwMPhncKa4+mUYBaffDoPqaujZvjfo6mjtCBuiBv05w==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
'@angular-devkit/schematics@21.1.3':
resolution: {integrity: sha512-Ps7bRl5uOcM7WpNJHbSls/jz5/wAI0ldkTlKyiBFA7RtNeQIABAV+hvlw5DJuEb1Lo5hnK0hXj90AyZdOxzY+w==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
'@angular-eslint/builder@21.1.0': '@angular-eslint/builder@21.1.0':
resolution: {integrity: sha512-pcUlDkGqeZ+oQC0oEjnkDDlB96gbgHQhnBUKdhYAiAOSuiBod4+npP0xQOq5chYtRNPBprhDqgrJrp5DBeDMOA==} resolution: {integrity: sha512-pcUlDkGqeZ+oQC0oEjnkDDlB96gbgHQhnBUKdhYAiAOSuiBod4+npP0xQOq5chYtRNPBprhDqgrJrp5DBeDMOA==}
peerDependencies: peerDependencies:
@@ -453,8 +456,8 @@ packages:
vitest: vitest:
optional: true optional: true
'@angular/cli@21.1.2': '@angular/cli@21.1.3':
resolution: {integrity: sha512-AHjXCBl2PEilMJct6DX3ih5Fl5PiKpNDIj0ViTyVh1YcfpYjt6NzhVlV2o++8VNPNH/vMcmf2551LZIDProXXA==} resolution: {integrity: sha512-UPtDcpKyrKZRPfym9gTovcibPzl2O/Woy7B8sm45sAnjDH+jDUCcCvuIak7GpH47shQkC2J4yvnHZbD4c6XxcQ==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
hasBin: true hasBin: true
@@ -1626,8 +1629,8 @@ packages:
'@fastify/cookie@11.0.2': '@fastify/cookie@11.0.2':
resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==} resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==}
'@fastify/cors@11.0.0': '@fastify/cors@11.2.0':
resolution: {integrity: sha512-41Bx0LVGr2a6DnnhDN/SgfDlTRNZtEs8niPxyoymV6Hw09AIdz/9Rn/0Fpu+pBOs6kviwS44JY2mB8NcU2qSAA==} resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==}
'@fastify/csrf-protection@7.1.0': '@fastify/csrf-protection@7.1.0':
resolution: {integrity: sha512-I2TDd4SRRYQivKCMHdB/8py+CPO9DT0e63lh4DO8MDCJh8NROq8HD/iO0IjYtwhsD3bZhr0cBXsFdfPvyTmzNw==} resolution: {integrity: sha512-I2TDd4SRRYQivKCMHdB/8py+CPO9DT0e63lh4DO8MDCJh8NROq8HD/iO0IjYtwhsD3bZhr0cBXsFdfPvyTmzNw==}
@@ -1671,27 +1674,6 @@ packages:
'@fastify/static@9.0.0': '@fastify/static@9.0.0':
resolution: {integrity: sha512-r64H8Woe/vfilg5RTy7lwWlE8ZZcTrc3kebYFMEUBrMqlydhQyoiExQXdYAy2REVpST/G35+stAM8WYp1WGmMA==} resolution: {integrity: sha512-r64H8Woe/vfilg5RTy7lwWlE8ZZcTrc3kebYFMEUBrMqlydhQyoiExQXdYAy2REVpST/G35+stAM8WYp1WGmMA==}
'@fortawesome/angular-fontawesome@4.0.0':
resolution: {integrity: sha512-TCqHqT5ovFY1A4RgMpoBUgS+RX3OVs39+CzHFgzDhbCPAopOa26J748TZJcuZwJAvGAk9tbWeVEmWuLByINAeg==}
peerDependencies:
'@angular/core': ^21.0.0
'@fortawesome/fontawesome-common-types@7.2.0':
resolution: {integrity: sha512-IpR0bER9FY25p+e7BmFH25MZKEwFHTfRAfhOyJubgiDnoJNsSvJ7nigLraHtp4VOG/cy8D7uiV0dLkHOne5Fhw==}
engines: {node: '>=6'}
'@fortawesome/fontawesome-svg-core@7.2.0':
resolution: {integrity: sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q==}
engines: {node: '>=6'}
'@fortawesome/free-brands-svg-icons@7.2.0':
resolution: {integrity: sha512-VNG8xqOip1JuJcC3zsVsKRQ60oXG9+oYNDCosjoU/H9pgYmLTEwWw8pE0jhPz/JWdHeUuK6+NQ3qsM4gIbdbYQ==}
engines: {node: '>=6'}
'@fortawesome/free-solid-svg-icons@7.2.0':
resolution: {integrity: sha512-YTVITFGN0/24PxzXrwqCgnyd7njDuzp5ZvaCx5nq/jg55kUYd94Nj8UTchBdBofi/L0nwRfjGOg0E41d2u9T1w==}
engines: {node: '>=6'}
'@hapi/boom@10.0.1': '@hapi/boom@10.0.1':
resolution: {integrity: sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==} resolution: {integrity: sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==}
@@ -2171,8 +2153,8 @@ packages:
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
engines: {node: '>=8'} engines: {node: '>=8'}
'@modelcontextprotocol/sdk@1.25.2': '@modelcontextprotocol/sdk@1.26.0':
resolution: {integrity: sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==} resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==}
engines: {node: '>=18'} engines: {node: '>=18'}
peerDependencies: peerDependencies:
'@cfworker/json-schema': ^4.1.1 '@cfworker/json-schema': ^4.1.1
@@ -2524,9 +2506,6 @@ packages:
typescript: '>=5' typescript: '>=5'
vitest: '>=2' vitest: '>=2'
'@nhcarrigan/logger@1.1.1':
resolution: {integrity: sha512-P6OEQFHDtf6psybYGljuCxkSW6DLQCsx1aZZ3w4YKBXHBFjDbhuvpM9K1kPhVN48hakitx2WPLEoIFr6YZELYw==}
'@noble/hashes@1.4.0': '@noble/hashes@1.4.0':
resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==}
engines: {node: '>= 16'} engines: {node: '>= 16'}
@@ -3182,6 +3161,10 @@ packages:
resolution: {integrity: sha512-kxwxhCIUrj7DfzEtDSs/pi/w+aII/WQLpPfLgoQCWE8/95v60WnTfd1afmsXsFoxikKPxkwoPWtU2YbhSoX9MQ==} resolution: {integrity: sha512-kxwxhCIUrj7DfzEtDSs/pi/w+aII/WQLpPfLgoQCWE8/95v60WnTfd1afmsXsFoxikKPxkwoPWtU2YbhSoX9MQ==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
'@schematics/angular@21.1.3':
resolution: {integrity: sha512-obJvWBhzRdsYL2msM4+8bQD21vFl3VxaVsuiq6iIfYsxhU5i2Iar2wM9NaRaIIqAYhZ8ehQQ/moB9BEbWvDCTw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
'@sideway/address@4.1.5': '@sideway/address@4.1.5':
resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==} resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==}
@@ -5375,8 +5358,8 @@ packages:
exponential-backoff@3.1.3: exponential-backoff@3.1.3:
resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==}
express-rate-limit@7.5.1: express-rate-limit@8.2.1:
resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==} resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==}
engines: {node: '>= 16'} engines: {node: '>= 16'}
peerDependencies: peerDependencies:
express: '>= 4.11' express: '>= 4.11'
@@ -6001,6 +5984,10 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
ip-address@10.0.1:
resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==}
engines: {node: '>= 12'}
ip-address@10.1.0: ip-address@10.1.0:
resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
engines: {node: '>= 12'} engines: {node: '>= 12'}
@@ -6929,9 +6916,6 @@ packages:
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
mnemonist@0.40.0:
resolution: {integrity: sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==}
mnemonist@0.40.3: mnemonist@0.40.3:
resolution: {integrity: sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==} resolution: {integrity: sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==}
@@ -8571,6 +8555,7 @@ packages:
tar@7.5.7: tar@7.5.7:
resolution: {integrity: sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==} resolution: {integrity: sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
tcp-port-used@1.0.2: tcp-port-used@1.0.2:
resolution: {integrity: sha512-l7ar8lLUD3XS1V2lfoJlCBaeoaWo/2xfYt81hM7VlvR4RrMVFqfmzfhLVk40hAb368uitje5gPtBRL1m/DGvLA==} resolution: {integrity: sha512-l7ar8lLUD3XS1V2lfoJlCBaeoaWo/2xfYt81hM7VlvR4RrMVFqfmzfhLVk40hAb368uitje5gPtBRL1m/DGvLA==}
@@ -9518,6 +9503,13 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- chokidar - chokidar
'@angular-devkit/architect@0.2101.3(chokidar@5.0.0)':
dependencies:
'@angular-devkit/core': 21.1.3(chokidar@5.0.0)
rxjs: 7.8.2
transitivePeerDependencies:
- chokidar
'@angular-devkit/build-angular@21.1.2(@angular/compiler-cli@21.1.2(@angular/compiler@21.1.2)(typescript@5.9.3))(@angular/compiler@21.1.2)(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(@angular/platform-browser@21.1.2(@angular/common@21.1.2(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2)))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(@swc/core@1.5.29(@swc/helpers@0.5.18))(@types/node@20.19.9)(chokidar@5.0.0)(jest@30.2.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.5.29(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3)))(jiti@2.6.1)(sass-embedded@1.97.3)(typescript@5.9.3)(vitest@4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.0.0)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2))(yaml@2.8.2)': '@angular-devkit/build-angular@21.1.2(@angular/compiler-cli@21.1.2(@angular/compiler@21.1.2)(typescript@5.9.3))(@angular/compiler@21.1.2)(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(@angular/platform-browser@21.1.2(@angular/common@21.1.2(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2)))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(@swc/core@1.5.29(@swc/helpers@0.5.18))(@types/node@20.19.9)(chokidar@5.0.0)(jest@30.2.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.5.29(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3)))(jiti@2.6.1)(sass-embedded@1.97.3)(typescript@5.9.3)(vitest@4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.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: dependencies:
'@ampproject/remapping': 2.3.0 '@ampproject/remapping': 2.3.0
@@ -9624,6 +9616,17 @@ snapshots:
optionalDependencies: optionalDependencies:
chokidar: 5.0.0 chokidar: 5.0.0
'@angular-devkit/core@21.1.3(chokidar@5.0.0)':
dependencies:
ajv: 8.17.1
ajv-formats: 3.0.1(ajv@8.17.1)
jsonc-parser: 3.3.1
picomatch: 4.0.3
rxjs: 7.8.2
source-map: 0.7.6
optionalDependencies:
chokidar: 5.0.0
'@angular-devkit/schematics@21.1.2(chokidar@5.0.0)': '@angular-devkit/schematics@21.1.2(chokidar@5.0.0)':
dependencies: dependencies:
'@angular-devkit/core': 21.1.2(chokidar@5.0.0) '@angular-devkit/core': 21.1.2(chokidar@5.0.0)
@@ -9634,11 +9637,21 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- chokidar - chokidar
'@angular-eslint/builder@21.1.0(@angular/cli@21.1.2(@types/node@20.19.9)(chokidar@5.0.0)(hono@4.11.7))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': '@angular-devkit/schematics@21.1.3(chokidar@5.0.0)':
dependencies:
'@angular-devkit/core': 21.1.3(chokidar@5.0.0)
jsonc-parser: 3.3.1
magic-string: 0.30.21
ora: 9.0.0
rxjs: 7.8.2
transitivePeerDependencies:
- chokidar
'@angular-eslint/builder@21.1.0(@angular/cli@21.1.3(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies: dependencies:
'@angular-devkit/architect': 0.2101.2(chokidar@5.0.0) '@angular-devkit/architect': 0.2101.2(chokidar@5.0.0)
'@angular-devkit/core': 21.1.2(chokidar@5.0.0) '@angular-devkit/core': 21.1.2(chokidar@5.0.0)
'@angular/cli': 21.1.2(@types/node@20.19.9)(chokidar@5.0.0)(hono@4.11.7) '@angular/cli': 21.1.3(@types/node@20.19.9)(chokidar@5.0.0)
eslint: 9.39.2(jiti@2.6.1) eslint: 9.39.2(jiti@2.6.1)
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
@@ -9667,13 +9680,13 @@ snapshots:
ts-api-utils: 2.4.0(typescript@5.9.3) ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3 typescript: 5.9.3
'@angular-eslint/schematics@21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@angular/cli@21.1.2(@types/node@20.19.9)(chokidar@5.0.0)(hono@4.11.7))(@typescript-eslint/types@8.54.0)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': '@angular-eslint/schematics@21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@angular/cli@21.1.3(@types/node@20.19.9)(chokidar@5.0.0))(@typescript-eslint/types@8.54.0)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies: dependencies:
'@angular-devkit/core': 21.1.2(chokidar@5.0.0) '@angular-devkit/core': 21.1.2(chokidar@5.0.0)
'@angular-devkit/schematics': 21.1.2(chokidar@5.0.0) '@angular-devkit/schematics': 21.1.2(chokidar@5.0.0)
'@angular-eslint/eslint-plugin': 21.1.0(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@angular-eslint/eslint-plugin': 21.1.0(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@angular-eslint/eslint-plugin-template': 21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/types@8.54.0)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@angular-eslint/eslint-plugin-template': 21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/types@8.54.0)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@angular/cli': 21.1.2(@types/node@20.19.9)(chokidar@5.0.0)(hono@4.11.7) '@angular/cli': 21.1.3(@types/node@20.19.9)(chokidar@5.0.0)
ignore: 7.0.5 ignore: 7.0.5
semver: 7.7.3 semver: 7.7.3
strip-json-comments: 3.1.1 strip-json-comments: 3.1.1
@@ -9806,15 +9819,15 @@ snapshots:
- yaml - yaml
optional: true optional: true
'@angular/cli@21.1.2(@types/node@20.19.9)(chokidar@5.0.0)(hono@4.11.7)': '@angular/cli@21.1.3(@types/node@20.19.9)(chokidar@5.0.0)':
dependencies: dependencies:
'@angular-devkit/architect': 0.2101.2(chokidar@5.0.0) '@angular-devkit/architect': 0.2101.3(chokidar@5.0.0)
'@angular-devkit/core': 21.1.2(chokidar@5.0.0) '@angular-devkit/core': 21.1.3(chokidar@5.0.0)
'@angular-devkit/schematics': 21.1.2(chokidar@5.0.0) '@angular-devkit/schematics': 21.1.3(chokidar@5.0.0)
'@inquirer/prompts': 7.10.1(@types/node@20.19.9) '@inquirer/prompts': 7.10.1(@types/node@20.19.9)
'@listr2/prompt-adapter-inquirer': 3.0.5(@inquirer/prompts@7.10.1(@types/node@20.19.9))(@types/node@20.19.9)(listr2@9.0.5) '@listr2/prompt-adapter-inquirer': 3.0.5(@inquirer/prompts@7.10.1(@types/node@20.19.9))(@types/node@20.19.9)(listr2@9.0.5)
'@modelcontextprotocol/sdk': 1.25.2(hono@4.11.7)(zod@4.3.5) '@modelcontextprotocol/sdk': 1.26.0(zod@4.3.5)
'@schematics/angular': 21.1.2(chokidar@5.0.0) '@schematics/angular': 21.1.3(chokidar@5.0.0)
'@yarnpkg/lockfile': 1.1.0 '@yarnpkg/lockfile': 1.1.0
algoliasearch: 5.46.2 algoliasearch: 5.46.2
ini: 6.0.0 ini: 6.0.0
@@ -9831,7 +9844,6 @@ snapshots:
- '@cfworker/json-schema' - '@cfworker/json-schema'
- '@types/node' - '@types/node'
- chokidar - chokidar
- hono
- supports-color - supports-color
'@angular/common@21.1.2(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(rxjs@7.8.2)': '@angular/common@21.1.2(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(rxjs@7.8.2)':
@@ -11600,10 +11612,10 @@ snapshots:
cookie: 1.1.1 cookie: 1.1.1
fastify-plugin: 5.0.1 fastify-plugin: 5.0.1
'@fastify/cors@11.0.0': '@fastify/cors@11.2.0':
dependencies: dependencies:
fastify-plugin: 5.0.1 fastify-plugin: 5.0.1
mnemonist: 0.40.0 toad-cache: 3.7.0
'@fastify/csrf-protection@7.1.0': '@fastify/csrf-protection@7.1.0':
dependencies: dependencies:
@@ -11684,26 +11696,6 @@ snapshots:
fastq: 1.20.1 fastq: 1.20.1
glob: 13.0.1 glob: 13.0.1
'@fortawesome/angular-fontawesome@4.0.0(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))':
dependencies:
'@angular/core': 21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2)
'@fortawesome/fontawesome-svg-core': 7.2.0
tslib: 2.8.1
'@fortawesome/fontawesome-common-types@7.2.0': {}
'@fortawesome/fontawesome-svg-core@7.2.0':
dependencies:
'@fortawesome/fontawesome-common-types': 7.2.0
'@fortawesome/free-brands-svg-icons@7.2.0':
dependencies:
'@fortawesome/fontawesome-common-types': 7.2.0
'@fortawesome/free-solid-svg-icons@7.2.0':
dependencies:
'@fortawesome/fontawesome-common-types': 7.2.0
'@hapi/boom@10.0.1': '@hapi/boom@10.0.1':
dependencies: dependencies:
'@hapi/hoek': 11.0.7 '@hapi/hoek': 11.0.7
@@ -12274,7 +12266,7 @@ snapshots:
'@lukeed/ms@2.0.2': {} '@lukeed/ms@2.0.2': {}
'@modelcontextprotocol/sdk@1.25.2(hono@4.11.7)(zod@4.3.5)': '@modelcontextprotocol/sdk@1.26.0(zod@4.3.5)':
dependencies: dependencies:
'@hono/node-server': 1.19.9(hono@4.11.7) '@hono/node-server': 1.19.9(hono@4.11.7)
ajv: 8.17.1 ajv: 8.17.1
@@ -12285,7 +12277,8 @@ snapshots:
eventsource: 3.0.7 eventsource: 3.0.7
eventsource-parser: 3.0.6 eventsource-parser: 3.0.6
express: 5.2.1 express: 5.2.1
express-rate-limit: 7.5.1(express@5.2.1) express-rate-limit: 8.2.1(express@5.2.1)
hono: 4.11.7
jose: 6.1.3 jose: 6.1.3
json-schema-typed: 8.0.2 json-schema-typed: 8.0.2
pkce-challenge: 5.0.1 pkce-challenge: 5.0.1
@@ -12293,7 +12286,6 @@ snapshots:
zod: 4.3.5 zod: 4.3.5
zod-to-json-schema: 3.25.1(zod@4.3.5) zod-to-json-schema: 3.25.1(zod@4.3.5)
transitivePeerDependencies: transitivePeerDependencies:
- hono
- supports-color - supports-color
'@module-federation/bridge-react-webpack-plugin@0.21.6': '@module-federation/bridge-react-webpack-plugin@0.21.6':
@@ -12784,8 +12776,6 @@ snapshots:
- eslint-import-resolver-webpack - eslint-import-resolver-webpack
- supports-color - supports-color
'@nhcarrigan/logger@1.1.1': {}
'@noble/hashes@1.4.0': {} '@noble/hashes@1.4.0': {}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
@@ -13770,6 +13760,14 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- chokidar - chokidar
'@schematics/angular@21.1.3(chokidar@5.0.0)':
dependencies:
'@angular-devkit/core': 21.1.3(chokidar@5.0.0)
'@angular-devkit/schematics': 21.1.3(chokidar@5.0.0)
jsonc-parser: 3.3.1
transitivePeerDependencies:
- chokidar
'@sideway/address@4.1.5': '@sideway/address@4.1.5':
dependencies: dependencies:
'@hapi/hoek': 9.3.0 '@hapi/hoek': 9.3.0
@@ -14643,16 +14641,16 @@ snapshots:
'@algolia/requester-fetch': 5.46.2 '@algolia/requester-fetch': 5.46.2
'@algolia/requester-node-http': 5.46.2 '@algolia/requester-node-http': 5.46.2
angular-eslint@21.1.0(@angular/cli@21.1.2(@types/node@20.19.9)(chokidar@5.0.0)(hono@4.11.7))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript-eslint@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(typescript@5.9.3): angular-eslint@21.1.0(@angular/cli@21.1.3(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript-eslint@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(typescript@5.9.3):
dependencies: dependencies:
'@angular-devkit/core': 21.1.2(chokidar@5.0.0) '@angular-devkit/core': 21.1.2(chokidar@5.0.0)
'@angular-devkit/schematics': 21.1.2(chokidar@5.0.0) '@angular-devkit/schematics': 21.1.2(chokidar@5.0.0)
'@angular-eslint/builder': 21.1.0(@angular/cli@21.1.2(@types/node@20.19.9)(chokidar@5.0.0)(hono@4.11.7))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@angular-eslint/builder': 21.1.0(@angular/cli@21.1.3(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@angular-eslint/eslint-plugin': 21.1.0(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@angular-eslint/eslint-plugin': 21.1.0(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@angular-eslint/eslint-plugin-template': 21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/types@8.54.0)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@angular-eslint/eslint-plugin-template': 21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/types@8.54.0)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@angular-eslint/schematics': 21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@angular/cli@21.1.2(@types/node@20.19.9)(chokidar@5.0.0)(hono@4.11.7))(@typescript-eslint/types@8.54.0)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@angular-eslint/schematics': 21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@angular/cli@21.1.3(@types/node@20.19.9)(chokidar@5.0.0))(@typescript-eslint/types@8.54.0)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@angular-eslint/template-parser': 21.1.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@angular-eslint/template-parser': 21.1.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@angular/cli': 21.1.2(@types/node@20.19.9)(chokidar@5.0.0)(hono@4.11.7) '@angular/cli': 21.1.3(@types/node@20.19.9)(chokidar@5.0.0)
'@typescript-eslint/types': 8.54.0 '@typescript-eslint/types': 8.54.0
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.39.2(jiti@2.6.1) eslint: 9.39.2(jiti@2.6.1)
@@ -16375,9 +16373,10 @@ snapshots:
exponential-backoff@3.1.3: {} exponential-backoff@3.1.3: {}
express-rate-limit@7.5.1(express@5.2.1): express-rate-limit@8.2.1(express@5.2.1):
dependencies: dependencies:
express: 5.2.1 express: 5.2.1
ip-address: 10.0.1
express@4.22.1: express@4.22.1:
dependencies: dependencies:
@@ -17147,6 +17146,8 @@ snapshots:
hasown: 2.0.2 hasown: 2.0.2
side-channel: 1.1.0 side-channel: 1.1.0
ip-address@10.0.1: {}
ip-address@10.1.0: {} ip-address@10.1.0: {}
ip-regex@4.3.0: {} ip-regex@4.3.0: {}
@@ -18293,10 +18294,6 @@ snapshots:
dependencies: dependencies:
minipass: 7.1.2 minipass: 7.1.2
mnemonist@0.40.0:
dependencies:
obliterator: 2.0.5
mnemonist@0.40.3: mnemonist@0.40.3:
dependencies: dependencies:
obliterator: 2.0.5 obliterator: 2.0.5
-3
View File
@@ -19,6 +19,3 @@ STAFF_ROLE_ID="op://Environment Variables - Naomi/Library/staff role id"
# Application URL # Application URL
BASE_URL="op://Environment Variables - Naomi/Library/base url" BASE_URL="op://Environment Variables - Naomi/Library/base url"
# Logger
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
+8 -102
View File
@@ -3,113 +3,19 @@ import { fileURLToPath } from 'url';
import { dirname } from 'path'; import { dirname } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const nhcarriganArray = Array.isArray(nhcarrigan) ? nhcarrigan : [nhcarrigan];
// Jest globals that should be available in test files
const jestGlobals = {
afterAll: 'readonly',
afterEach: 'readonly',
beforeAll: 'readonly',
beforeEach: 'readonly',
describe: 'readonly',
expect: 'readonly',
it: 'readonly',
jest: 'readonly',
test: 'readonly',
};
// Map the nhcarrigan configs to handle shared-types directory structure
const mappedConfigs = nhcarriganArray.flatMap(config => {
if (!config.files) {
return [config];
}
const newFiles = config.files
.map(pattern => {
if (pattern.startsWith('src/')) {
return pattern.replace('src/', 'shared-types/src/');
} else if (pattern.startsWith('test/')) {
return pattern.replace('test/', 'shared-types/test/');
}
return pattern;
});
// Determine if this is a test file config
const isTestFile = newFiles[0]?.includes('test/');
// Update configs to handle shared-types directory structure
let updatedConfig = { ...config, files: newFiles };
if (config.languageOptions) {
const updatedLanguageOptions = { ...config.languageOptions };
// Add Jest globals for test files
if (isTestFile) {
updatedLanguageOptions.globals = { ...updatedLanguageOptions.globals, ...jestGlobals };
}
// Update parserOptions to use our tsconfig with proper tsconfigRootDir
if (config.languageOptions.parserOptions) {
updatedLanguageOptions.parserOptions = {
...config.languageOptions.parserOptions,
tsconfigRootDir: __dirname,
};
}
updatedConfig = { ...updatedConfig, languageOptions: updatedLanguageOptions };
}
return [updatedConfig];
});
export default [ export default [
...nhcarrigan,
{ {
ignores: ['dist', 'out-tsc', 'node_modules'], ignores: ['**/dist', '**/out-tsc', 'node_modules'],
}, },
...mappedConfigs,
// Disable vitest rules for this Jest project
{ {
files: ['shared-types/test/**/*.spec.ts'], files: ['**/*.ts'],
rules: { languageOptions: {
'vitest/consistent-test-filename': 'off', parserOptions: {
'vitest/consistent-test-it': 'off', project: './tsconfig.lib.json',
'vitest/expect-expect': 'off', tsconfigRootDir: __dirname,
'vitest/no-alias-methods': 'off', },
'vitest/no-commented-out-tests': 'off',
'vitest/no-conditional-expect': 'off',
'vitest/no-conditional-in-test': 'off',
'vitest/no-conditional-tests': 'off',
'vitest/no-disabled-tests': 'off',
'vitest/no-duplicate-hooks': 'off',
'vitest/no-focused-tests': 'off',
'vitest/no-identical-title': 'off',
'vitest/no-standalone-expect': 'off',
'vitest/no-test-prefixes': 'off',
'vitest/no-test-return-statement': 'off',
'vitest/prefer-comparison-matcher': 'off',
'vitest/prefer-each': 'off',
'vitest/prefer-equality-matcher': 'off',
'vitest/prefer-expect-assertions': 'off',
'vitest/prefer-expect-resolves': 'off',
'vitest/prefer-hooks-in-order': 'off',
'vitest/prefer-hooks-on-top': 'off',
'vitest/prefer-lowercase-title': 'off',
'vitest/prefer-mock-promise-shorthand': 'off',
'vitest/prefer-spy-on': 'off',
'vitest/prefer-strict-equal': 'off',
'vitest/prefer-to-be': 'off',
'vitest/prefer-to-be-falsy': 'off',
'vitest/prefer-to-be-object': 'off',
'vitest/prefer-to-be-truthy': 'off',
'vitest/prefer-to-contain': 'off',
'vitest/prefer-to-have-length': 'off',
'vitest/prefer-todo': 'off',
'vitest/require-hook': 'off',
'vitest/require-to-throw-message': 'off',
'vitest/require-top-level-describe': 'off',
'vitest/valid-describe-callback': 'off',
'vitest/valid-expect': 'off',
'vitest/valid-title': 'off',
}, },
}, },
]; ];
-22
View File
@@ -1,22 +0,0 @@
export default {
displayName: "shared-types",
preset: "../jest.preset.js",
testEnvironment: "node",
transform: {
"^.+\\.[tj]s$": ["ts-jest", { tsconfig: "<rootDir>/tsconfig.spec.json" }],
},
moduleFileExtensions: ["ts", "js", "html"],
coverageDirectory: "../coverage/shared-types",
coverageThreshold: {
global: {
branches: 100,
functions: 100,
lines: 100,
statements: 100,
},
},
collectCoverageFrom: [
"src/**/*.ts",
"!src/index.ts", // Just re-exports
],
};
+1 -16
View File
@@ -5,20 +5,5 @@
"projectType": "library", "projectType": "library",
"tags": [], "tags": [],
"// targets": "to see all targets run: nx show project shared-types --web", "// targets": "to see all targets run: nx show project shared-types --web",
"targets": { "targets": {}
"lint": {
"executor": "@nx/eslint:lint",
"options": {
"lintFilePatterns": ["shared-types/**/*.ts"]
}
},
"test": {
"executor": "@nx/jest:jest",
"options": {
"jestConfig": "shared-types/jest.config.ts",
"passWithNoTests": true
},
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"]
}
}
} }
+9 -10
View File
@@ -1,18 +1,17 @@
/** /**
* @copyright NHCarrigan * @copyright 2026 NHCarrigan
* @license Naomi's Public License * @license Naomi's Public License
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
export type * from "./lib/art.types";
export * from "./lib/audit.types";
export type * from "./lib/auth.types";
export * from "./lib/book.types";
export type * from "./lib/comment.types";
export type * from "./lib/common.types";
export * from "./lib/game.types"; export * from "./lib/game.types";
export type * from "./lib/like.types"; export * from "./lib/book.types";
export * from "./lib/manga.types";
export * from "./lib/music.types"; export * from "./lib/music.types";
export * from "./lib/report.types"; export * from "./lib/art.types";
export * from "./lib/show.types"; export * from "./lib/show.types";
export * from "./lib/manga.types";
export type * from "./lib/auth.types";
export * from "./lib/comment.types";
export * from "./lib/audit.types";
export * from "./lib/suggestion.types"; export * from "./lib/suggestion.types";
export * from "./lib/common.types";
export * from "./lib/like.types";
+19 -22
View File
@@ -1,33 +1,30 @@
/** /**
* @copyright NHCarrigan * @copyright 2026 NHCarrigan
* @license Naomi's Public License * @license Naomi's Public License
* @author Naomi Carrigan
*/ */
import type { Link } from "./common.types"; import { Link } from "./common.types";
interface Art { export interface Art {
id: string; id: string;
title: string; title: string;
artist: string; artist: string;
description?: string; description?: string;
imageUrl: string; imageUrl: string;
tags: Array<string>; tags: string[];
links: Array<Link>; links: Link[];
dateAdded: Date; dateAdded: Date;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
interface CreateArtDto { export interface CreateArtDto {
title: string; title: string;
artist: string; artist: string;
description?: string; description?: string;
imageUrl: string; imageUrl: string;
tags?: Array<string>; tags?: string[];
links?: Array<Link>; links?: Link[];
} }
type UpdateArtDto = Partial<CreateArtDto>; export interface UpdateArtDto extends Partial<CreateArtDto> {}
export type { Art, CreateArtDto, UpdateArtDto };
+45 -59
View File
@@ -1,72 +1,58 @@
/** export enum AuditAction {
* @copyright NHCarrigan LOGIN = "LOGIN",
* @license Naomi's Public License LOGOUT = "LOGOUT",
* @author Naomi Carrigan LOGIN_FAILED = "LOGIN_FAILED",
*/ COMMENT_CREATE = "COMMENT_CREATE",
COMMENT_UPDATE = "COMMENT_UPDATE",
enum AuditAction { COMMENT_DELETE = "COMMENT_DELETE",
login = "LOGIN", ENTRY_CREATE = "ENTRY_CREATE",
logout = "LOGOUT", ENTRY_UPDATE = "ENTRY_UPDATE",
loginFailed = "LOGIN_FAILED", ENTRY_DELETE = "ENTRY_DELETE",
commentCreate = "COMMENT_CREATE", LIKE = "LIKE",
commentUpdate = "COMMENT_UPDATE", UNLIKE = "UNLIKE",
commentDelete = "COMMENT_DELETE", USER_BAN = "USER_BAN",
entryCreate = "ENTRY_CREATE", USER_UNBAN = "USER_UNBAN",
entryUpdate = "ENTRY_UPDATE", RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED",
entryDelete = "ENTRY_DELETE", CSRF_VALIDATION_FAILED = "CSRF_VALIDATION_FAILED",
like = "LIKE", UNAUTHORIZED_ACCESS = "UNAUTHORIZED_ACCESS",
unlike = "UNLIKE",
userBan = "USER_BAN",
userUnban = "USER_UNBAN",
rateLimitExceeded = "RATE_LIMIT_EXCEEDED",
csrfValidationFailed = "CSRF_VALIDATION_FAILED",
unauthorizedAccess = "UNAUTHORIZED_ACCESS",
} }
enum AuditCategory { export enum AuditCategory {
auth = "AUTH", AUTH = "AUTH",
content = "CONTENT", CONTENT = "CONTENT",
admin = "ADMIN", ADMIN = "ADMIN",
security = "SECURITY", SECURITY = "SECURITY",
} }
interface AuditLogUser { export interface AuditLogUser {
id: string; id: string;
username: string; username: string;
avatar?: string; avatar?: string;
} }
interface AuditLog { export interface AuditLog {
id: string; id: string;
action: AuditAction; action: AuditAction;
category: AuditCategory; category: AuditCategory;
userId?: string; userId?: string;
user?: AuditLogUser; user?: AuditLogUser;
targetUserId?: string; targetUserId?: string;
targetUser?: AuditLogUser; targetUser?: AuditLogUser;
resourceType?: string; resourceType?: string;
resourceId?: string; resourceId?: string;
details?: string; details?: string;
userAgent?: string; userAgent?: string;
success: boolean; success: boolean;
createdAt: Date; createdAt: Date;
} }
interface AuditLogFilters { export interface AuditLogFilters {
action?: AuditAction; action?: AuditAction;
category?: AuditCategory; category?: AuditCategory;
userId?: string; userId?: string;
success?: boolean; success?: boolean;
startDate?: Date; startDate?: Date;
endDate?: Date; endDate?: Date;
page?: number; page?: number;
limit?: number; limit?: number;
} }
export {
AuditAction,
AuditCategory,
type AuditLog,
type AuditLogFilters,
type AuditLogUser,
};
+20 -34
View File
@@ -4,47 +4,33 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
interface User { export interface User {
id: string; id: string;
email: string; email: string;
username: string; username: string;
avatar?: string; avatar?: string;
slug?: string; discordId: string;
displayName?: string; isAdmin: boolean;
bio?: string; isBanned: boolean;
profilePublic: boolean; inDiscord: boolean;
website?: string; isVip: boolean;
discordServer?: string; isMod: boolean;
bluesky?: string; isStaff: boolean;
github?: string;
linkedin?: string;
twitch?: string;
youtube?: string;
discordId: string;
isAdmin: boolean;
isBanned: boolean;
inDiscord: boolean;
isVip: boolean;
isMod: boolean;
isStaff: boolean;
} }
interface JwtPayload { export interface JwtPayload {
/** /**
* User id. * User id.
*/ */
sub: string; sub: string;
email: string; email: string;
username: string; username: string;
isAdmin: boolean; isAdmin: boolean;
iat?: number; iat?: number;
exp?: number; exp?: number;
} }
interface AuthResponse { export interface AuthResponse {
accessToken: string; accessToken: string;
user: User; user: User;
} }
export type { AuthResponse, JwtPayload, User };
+19 -26
View File
@@ -1,53 +1,46 @@
/** /**
* @copyright NHCarrigan * @copyright 2026 NHCarrigan
* @copyright 2026 NHCarrigan
* @license Naomi's Public License * @license Naomi's Public License
* @author Naomi Carrigan
*/ */
import type { Link } from "./common.types"; export enum BookStatus {
enum BookStatus {
reading = "READING", reading = "READING",
finished = "FINISHED", finished = "FINISHED",
toRead = "TO_READ", toRead = "TO_READ",
retired = "RETIRED",
} }
interface Book { import { Link } from "./common.types";
export interface Book {
id: string; id: string;
title: string; title: string;
author: string; author: string;
isbn?: string; isbn?: string;
status: BookStatus; status: BookStatus;
dateAdded: Date; dateAdded: Date;
dateStarted?: Date;
dateFinished?: Date; dateFinished?: Date;
rating?: number; rating?: number;
notes?: string; notes?: string;
coverImage?: string; coverImage?: string;
tags: Array<string>; tags: string[];
links: Array<Link>; links: Link[];
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
interface CreateBookDto { export interface CreateBookDto {
title: string; title: string;
author: string; author: string;
isbn?: string; isbn?: string;
status: BookStatus; status: BookStatus;
dateStarted?: Date; rating?: number;
dateFinished?: Date; notes?: string;
rating?: number; coverImage?: string;
notes?: string; tags?: string[];
coverImage?: string; links?: Link[];
tags?: Array<string>;
links?: Array<Link>;
} }
interface UpdateBookDto extends Partial<CreateBookDto> { export interface UpdateBookDto extends Partial<CreateBookDto> {
dateStarted?: Date;
dateFinished?: Date; dateFinished?: Date;
} }
export { type Book, BookStatus, type CreateBookDto, type UpdateBookDto };
+22 -25
View File
@@ -4,35 +4,32 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
interface CommentUser { export interface CommentUser {
id: string; id: string;
username: string; username: string;
avatar?: string; avatar?: string;
inDiscord?: boolean; inDiscord?: boolean;
isVip?: boolean; isVip?: boolean;
isMod?: boolean; isMod?: boolean;
isStaff?: boolean; isStaff?: boolean;
} }
interface Comment { export interface Comment {
id: string; id: string;
content: string; content: string;
rawContent?: string; rawContent?: string;
userId: string; userId: string;
user: CommentUser; user: CommentUser;
gameId?: string; gameId?: string;
bookId?: string; bookId?: string;
musicId?: string; musicId?: string;
artId?: string; artId?: string;
showId?: string; showId?: string;
mangaId?: string; mangaId?: string;
hasPendingReports?: boolean; createdAt: Date;
createdAt: Date; updatedAt: Date;
updatedAt: Date;
} }
interface CreateCommentDto { export interface CreateCommentDto {
content: string; content: string;
} }
export type { Comment, CommentUser, CreateCommentDto };
+1 -7
View File
@@ -1,10 +1,4 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
export interface Link { export interface Link {
title: string; title: string;
url: string; url: string;
} }
+18 -27
View File
@@ -1,53 +1,44 @@
/** /**
* @copyright NHCarrigan * @copyright 2026 NHCarrigan
* @copyright 2026 NHCarrigan
* @license Naomi's Public License * @license Naomi's Public License
* @author Naomi Carrigan
*/ */
import type { Link } from "./common.types"; export enum GameStatus {
enum GameStatus {
playing = "PLAYING", playing = "PLAYING",
completed = "COMPLETED", completed = "COMPLETED",
backlog = "BACKLOG", backlog = "BACKLOG",
retired = "RETIRED",
} }
interface Game { import { Link } from "./common.types";
export interface Game {
id: string; id: string;
title: string; title: string;
platform?: string; platform?: string;
status: GameStatus; status: GameStatus;
dateAdded: Date; dateAdded: Date;
dateStarted?: Date;
dateCompleted?: Date; dateCompleted?: Date;
dateFinished?: Date;
rating?: number; rating?: number;
notes?: string; notes?: string;
coverImage?: string; coverImage?: string;
tags: Array<string>; tags: string[];
links: Array<Link>; links: Link[];
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
interface CreateGameDto { export interface CreateGameDto {
title: string; title: string;
platform?: string; platform?: string;
status: GameStatus; status: GameStatus;
dateStarted?: Date; rating?: number;
dateFinished?: Date; notes?: string;
rating?: number; coverImage?: string;
notes?: string; tags?: string[];
coverImage?: string; links?: Link[];
tags?: Array<string>;
links?: Array<Link>;
} }
interface UpdateGameDto extends Partial<CreateGameDto> { export interface UpdateGameDto extends Partial<CreateGameDto> {
dateStarted?: Date;
dateCompleted?: Date; dateCompleted?: Date;
dateFinished?: Date;
} }
export { type CreateGameDto, type Game, GameStatus, type UpdateGameDto };
+20 -26
View File
@@ -1,38 +1,32 @@
/** /**
* @copyright NHCarrigan * @copyright 2026 NHCarrigan
* @license Naomi's Public License * @license Naomi's Public License
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
interface Like { export interface Like {
id: string; id: string;
userId: string; userId: string;
entityType: "book" | "game" | "show" | "manga" | "music" | "art"; entityType: 'book' | 'game' | 'show' | 'manga' | 'music' | 'art';
entityId: string; entityId: string;
createdAt: Date; createdAt: Date;
} }
type CreateLikeDto = Pick<Like, "entityType" | "entityId">; export type CreateLikeDto = Pick<Like, 'entityType' | 'entityId'>;
interface LikeCountDto { export interface LikeCountDto {
entityId: string; entityId: string;
entityType: string; entityType: string;
count: number;
}
interface LikedItemDto {
like: Like;
/**
* This will be the actual entity (Book, Game, etc.).
*/
item: unknown;
}
// Response types
interface LikeResponse {
liked: boolean;
count: number; count: number;
} }
export type { CreateLikeDto, Like, LikeCountDto, LikedItemDto, LikeResponse }; export interface LikedItemDto {
like: Like;
item: any; // This will be the actual entity (Book, Game, etc.)
}
// Response types
export interface LikeResponse {
liked: boolean;
count: number;
}
+17 -27
View File
@@ -1,54 +1,44 @@
/** /**
* @copyright NHCarrigan * @copyright 2026 NHCarrigan
* @license Naomi's Public License * @license Naomi's Public License
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
import type { Link } from "./common.types"; export enum MangaStatus {
enum MangaStatus {
reading = "READING", reading = "READING",
completed = "COMPLETED", completed = "COMPLETED",
wantToRead = "WANT_TO_READ", wantToRead = "WANT_TO_READ",
retired = "RETIRED",
} }
interface Manga { import { Link } from "./common.types";
export interface Manga {
id: string; id: string;
title: string; title: string;
author: string; author: string;
status: MangaStatus; status: MangaStatus;
dateAdded: Date; dateAdded: Date;
dateStarted?: Date;
dateCompleted?: Date; dateCompleted?: Date;
dateFinished?: Date;
rating?: number; rating?: number;
notes?: string; notes?: string;
coverImage?: string; coverImage?: string;
tags: Array<string>; tags: string[];
links: Array<Link>; links: Link[];
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
interface CreateMangaDto { export interface CreateMangaDto {
title: string; title: string;
author: string; author: string;
status: MangaStatus; status: MangaStatus;
dateStarted?: Date; rating?: number;
dateFinished?: Date; notes?: string;
rating?: number; coverImage?: string;
notes?: string; tags?: string[];
coverImage?: string; links?: Link[];
tags?: Array<string>;
links?: Array<Link>;
} }
interface UpdateMangaDto extends Partial<CreateMangaDto> { export interface UpdateMangaDto extends Partial<CreateMangaDto> {
dateStarted?: Date;
dateCompleted?: Date; dateCompleted?: Date;
dateFinished?: Date;
} }
export { MangaStatus };
export type { CreateMangaDto, Manga, UpdateMangaDto };
+20 -30
View File
@@ -1,62 +1,52 @@
/** /**
* @copyright NHCarrigan * @copyright 2026 NHCarrigan
* @copyright 2026 NHCarrigan
* @license Naomi's Public License * @license Naomi's Public License
* @author Naomi Carrigan
*/ */
import type { Link } from "./common.types"; export enum MusicType {
enum MusicType {
album = "ALBUM", album = "ALBUM",
single = "SINGLE", single = "SINGLE",
ep = "EP", ep = "EP",
} }
enum MusicStatus { export enum MusicStatus {
listening = "LISTENING", listening = "LISTENING",
completed = "COMPLETED", completed = "COMPLETED",
wantToListen = "WANT_TO_LISTEN", wantToListen = "WANT_TO_LISTEN",
retired = "RETIRED",
} }
interface Music { import { Link } from "./common.types";
export interface Music {
id: string; id: string;
title: string; title: string;
artist: string; artist: string;
type: MusicType; type: MusicType;
status: MusicStatus; status: MusicStatus;
dateAdded: Date; dateAdded: Date;
dateStarted?: Date;
dateCompleted?: Date; dateCompleted?: Date;
dateFinished?: Date;
rating?: number; rating?: number;
notes?: string; notes?: string;
coverArt?: string; coverArt?: string;
tags: Array<string>; tags: string[];
links: Array<Link>; links: Link[];
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
interface CreateMusicDto { export interface CreateMusicDto {
title: string; title: string;
artist: string; artist: string;
type: MusicType; type: MusicType;
status: MusicStatus; status: MusicStatus;
dateStarted?: Date; rating?: number;
dateFinished?: Date; notes?: string;
rating?: number; coverArt?: string;
notes?: string; tags?: string[];
coverArt?: string; links?: Link[];
tags?: Array<string>;
links?: Array<Link>;
} }
interface UpdateMusicDto extends Partial<CreateMusicDto> { export interface UpdateMusicDto extends Partial<CreateMusicDto> {
dateStarted?: Date;
dateCompleted?: Date; dateCompleted?: Date;
dateFinished?: Date;
} }
export { MusicStatus, MusicType };
export type { CreateMusicDto, Music, UpdateMusicDto };
-110
View File
@@ -1,110 +0,0 @@
export enum ReportReason {
INAPPROPRIATE_CONTENT = "INAPPROPRIATE_CONTENT",
HARASSMENT = "HARASSMENT",
SPAM = "SPAM",
IMPERSONATION = "IMPERSONATION",
OFFENSIVE_NAME = "OFFENSIVE_NAME",
MALICIOUS_LINKS = "MALICIOUS_LINKS",
OTHER = "OTHER",
}
export enum ReportStatus {
PENDING = "PENDING",
REVIEWED = "REVIEWED",
DISMISSED = "DISMISSED",
ACTION_TAKEN = "ACTION_TAKEN",
}
export interface ProfileReport {
id: string;
reportedUserId: string;
reporterId: string;
reason: ReportReason;
details: string;
status: ReportStatus;
reviewedBy?: string;
reviewNotes?: string;
createdAt: Date;
updatedAt: Date;
}
export interface ProfileReportWithUsers extends ProfileReport {
reportedUser: {
id: string;
username: string;
displayName?: string;
avatar?: string;
};
reporter: {
id: string;
username: string;
displayName?: string;
avatar?: string;
};
reviewer?: {
id: string;
username: string;
displayName?: string;
};
}
export interface CreateReportDto {
reportedUserId: string;
reason: ReportReason;
details: string;
}
export interface UpdateReportDto {
status: ReportStatus;
reviewNotes?: string;
}
export interface CommentReport {
id: string;
reportedCommentId: string;
reporterId: string;
reason: ReportReason;
details: string;
status: ReportStatus;
reviewedBy?: string;
reviewNotes?: string;
createdAt: Date;
updatedAt: Date;
}
export interface CommentReportWithDetails extends CommentReport {
reportedComment: {
id: string;
content: string;
rawContent?: string;
userId: string;
user: {
id: string;
username: string;
displayName?: string;
avatar?: string;
};
};
reporter: {
id: string;
username: string;
displayName?: string;
avatar?: string;
};
reviewer?: {
id: string;
username: string;
displayName?: string;
};
}
export interface CreateCommentReportDto {
reportedCommentId: string;
reason: ReportReason;
details: string;
}
export interface UpdateCommentReportDto {
status: ReportStatus;
reviewNotes?: string;
}
+18 -28
View File
@@ -1,61 +1,51 @@
/** /**
* @copyright NHCarrigan * @copyright 2026 NHCarrigan
* @license Naomi's Public License * @license Naomi's Public License
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
import type { Link } from "./common.types"; export enum ShowType {
enum ShowType {
tvSeries = "TV_SERIES", tvSeries = "TV_SERIES",
anime = "ANIME", anime = "ANIME",
film = "FILM", film = "FILM",
documentary = "DOCUMENTARY", documentary = "DOCUMENTARY",
} }
enum ShowStatus { export enum ShowStatus {
watching = "WATCHING", watching = "WATCHING",
completed = "COMPLETED", completed = "COMPLETED",
wantToWatch = "WANT_TO_WATCH", wantToWatch = "WANT_TO_WATCH",
retired = "RETIRED",
} }
interface Show { import { Link } from "./common.types";
export interface Show {
id: string; id: string;
title: string; title: string;
type: ShowType; type: ShowType;
status: ShowStatus; status: ShowStatus;
dateAdded: Date; dateAdded: Date;
dateStarted?: Date;
dateCompleted?: Date; dateCompleted?: Date;
dateFinished?: Date;
rating?: number; rating?: number;
notes?: string; notes?: string;
coverImage?: string; coverImage?: string;
tags: Array<string>; tags: string[];
links: Array<Link>; links: Link[];
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
interface CreateShowDto { export interface CreateShowDto {
title: string; title: string;
type: ShowType; type: ShowType;
status: ShowStatus; status: ShowStatus;
dateStarted?: Date; rating?: number;
dateFinished?: Date; notes?: string;
rating?: number; coverImage?: string;
notes?: string; tags?: string[];
coverImage?: string; links?: Link[];
tags?: Array<string>;
links?: Array<Link>;
} }
interface UpdateShowDto extends Partial<CreateShowDto> { export interface UpdateShowDto extends Partial<CreateShowDto> {
dateStarted?: Date;
dateCompleted?: Date; dateCompleted?: Date;
dateFinished?: Date;
} }
export { ShowStatus, ShowType };
export type { CreateShowDto, Show, UpdateShowDto };
+84 -105
View File
@@ -1,110 +1,104 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { CreateArtDto } from "./art.types";
import type { CreateBookDto } from "./book.types";
import type { CreateGameDto } from "./game.types"; import type { CreateGameDto } from "./game.types";
import type { CreateMangaDto } from "./manga.types"; import type { CreateBookDto } from "./book.types";
import type { CreateMusicDto } from "./music.types"; import type { CreateMusicDto } from "./music.types";
import type { CreateArtDto } from "./art.types";
import type { CreateShowDto } from "./show.types"; import type { CreateShowDto } from "./show.types";
import type { CreateMangaDto } from "./manga.types";
enum SuggestionEntity { export enum SuggestionEntity {
game = "GAME", GAME = "GAME",
book = "BOOK", BOOK = "BOOK",
music = "MUSIC", MUSIC = "MUSIC",
art = "ART", ART = "ART",
show = "SHOW", SHOW = "SHOW",
manga = "MANGA", MANGA = "MANGA",
} }
enum SuggestionStatus { export enum SuggestionStatus {
unreviewed = "UNREVIEWED", UNREVIEWED = "UNREVIEWED",
accepted = "ACCEPTED", ACCEPTED = "ACCEPTED",
declined = "DECLINED", DECLINED = "DECLINED",
} }
interface SuggestionUser { export interface SuggestionUser {
id: string; id: string;
username: string; username: string;
avatar?: string; avatar?: string;
inDiscord: boolean; inDiscord: boolean;
isVip: boolean; isVip: boolean;
isMod: boolean; isMod: boolean;
isStaff: boolean; isStaff: boolean;
} }
interface Suggestion { export interface Suggestion {
id: string; id: string;
userId: string; userId: string;
user: SuggestionUser; user: SuggestionUser;
entityType: SuggestionEntity; entityType: SuggestionEntity;
status: SuggestionStatus; status: SuggestionStatus;
declineReason?: string; declineReason?: string;
title: string; title: string;
gameData?: Omit<CreateGameDto, "rating">; gameData?: Omit<CreateGameDto, "rating">;
bookData?: Omit<CreateBookDto, "rating">; bookData?: Omit<CreateBookDto, "rating">;
musicData?: Omit<CreateMusicDto, "rating">; musicData?: Omit<CreateMusicDto, "rating">;
artData?: CreateArtDto; artData?: CreateArtDto;
showData?: Omit<CreateShowDto, "rating">; showData?: Omit<CreateShowDto, "rating">;
mangaData?: Omit<CreateMangaDto, "rating">; mangaData?: Omit<CreateMangaDto, "rating">;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
interface CreateGameSuggestionDto { export interface CreateGameSuggestionDto {
entityType: SuggestionEntity.game; entityType: SuggestionEntity.GAME;
title: string; title: string;
platform?: string; platform?: string;
notes?: string; notes?: string;
coverImage?: string; coverImage?: string;
} }
interface CreateBookSuggestionDto { export interface CreateBookSuggestionDto {
entityType: SuggestionEntity.book; entityType: SuggestionEntity.BOOK;
title: string; title: string;
author: string; author: string;
isbn?: string; isbn?: string;
notes?: string; notes?: string;
coverImage?: string; coverImage?: string;
} }
interface CreateMusicSuggestionDto { export interface CreateMusicSuggestionDto {
entityType: SuggestionEntity.music; entityType: SuggestionEntity.MUSIC;
title: string; title: string;
artist: string; artist: string;
type: string; type: string;
notes?: string; notes?: string;
coverArt?: string; coverArt?: string;
} }
interface CreateArtSuggestionDto { export interface CreateArtSuggestionDto {
entityType: SuggestionEntity.art; entityType: SuggestionEntity.ART;
title: string; title: string;
artist: string; artist: string;
description?: string; description?: string;
imageUrl: string; imageUrl: string;
} }
interface CreateShowSuggestionDto { export interface CreateShowSuggestionDto {
entityType: SuggestionEntity.show; entityType: SuggestionEntity.SHOW;
title: string; title: string;
type: string; type: string;
notes?: string; notes?: string;
coverImage?: string; coverImage?: string;
} }
interface CreateMangaSuggestionDto { export interface CreateMangaSuggestionDto {
entityType: SuggestionEntity.manga; entityType: SuggestionEntity.MANGA;
title: string; title: string;
author: string; author: string;
notes?: string; notes?: string;
coverImage?: string; coverImage?: string;
} }
type CreateSuggestionDto = export type CreateSuggestionDto =
| CreateGameSuggestionDto | CreateGameSuggestionDto
| CreateBookSuggestionDto | CreateBookSuggestionDto
| CreateMusicSuggestionDto | CreateMusicSuggestionDto
@@ -112,37 +106,22 @@ type CreateSuggestionDto =
| CreateShowSuggestionDto | CreateShowSuggestionDto
| CreateMangaSuggestionDto; | CreateMangaSuggestionDto;
interface DeclineSuggestionDto { export interface DeclineSuggestionDto {
reason?: string; reason?: string;
} }
interface AcceptWithEditsDto { export interface AcceptWithEditsDto {
title?: string; title?: string;
platform?: string; platform?: string;
author?: string; author?: string;
artist?: string; artist?: string;
isbn?: string; isbn?: string;
type?: string; type?: string;
notes?: string; notes?: string;
description?: string; description?: string;
coverImage?: string; coverImage?: string;
coverArt?: string; coverArt?: string;
imageUrl?: string; imageUrl?: string;
tags?: Array<string>; tags?: string[];
links?: Array<{ label: string; url: string }>; links?: Array<{ label: string; url: string }>;
} }
export { SuggestionEntity, SuggestionStatus };
export type {
AcceptWithEditsDto,
CreateArtSuggestionDto,
CreateBookSuggestionDto,
CreateGameSuggestionDto,
CreateMangaSuggestionDto,
CreateMusicSuggestionDto,
CreateShowSuggestionDto,
CreateSuggestionDto,
DeclineSuggestionDto,
Suggestion,
SuggestionUser,
};
-110
View File
@@ -1,110 +0,0 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { Art, CreateArtDto, UpdateArtDto } from "../src/lib/art.types";
describe("art Types", () => {
describe("art interface", () => {
it("should accept valid art object with minimal fields", () => {
const art: Art = {
artist: "Jane Doe",
createdAt: new Date(),
dateAdded: new Date(),
id: "art123",
imageUrl: "https://example.com/sunset.jpg",
links: [],
tags: [],
title: "Beautiful Sunset",
updatedAt: new Date(),
};
expect(art.description).toBeUndefined();
expect(art.tags).toEqual([]);
});
it("should accept valid art object with all fields", () => {
const fullArt: Art = {
artist: "John Smith",
createdAt: new Date("2024-01-01"),
dateAdded: new Date("2024-01-01"),
description: "A breathtaking view of the mountains",
id: "art456",
imageUrl: "https://example.com/mountains.jpg",
links: [
{ title: "Artist Portfolio", url: "https://artist.com" },
{ title: "Instagram", url: "https://instagram.com/artist" },
],
tags: [ "landscape", "nature", "mountains" ],
title: "Mountain Landscape",
updatedAt: new Date("2024-01-02"),
};
expect(fullArt.description).toBe("A breathtaking view of the mountains");
expect(fullArt.tags).toHaveLength(3);
expect(fullArt.links).toHaveLength(2);
});
});
describe("createArtDto interface", () => {
it("should accept DTO with required fields only", () => {
const createDto: CreateArtDto = {
artist: "New Artist",
imageUrl: "https://example.com/art.jpg",
title: "New Artwork",
};
expect(createDto.description).toBeUndefined();
expect(createDto.tags).toBeUndefined();
expect(createDto.links).toBeUndefined();
});
it("should accept DTO with all fields", () => {
const fullCreateDto: CreateArtDto = {
artist: "Famous Artist",
description: "Detailed description",
imageUrl: "https://example.com/complete.jpg",
links: [ { title: "Website", url: "https://website.com" } ],
tags: [ "tag1", "tag2" ],
title: "Complete Artwork",
};
expect(fullCreateDto.tags).toEqual([ "tag1", "tag2" ]);
expect(fullCreateDto.links).toHaveLength(1);
});
});
describe("updateArtDto type", () => {
it("should accept empty update DTO", () => {
const emptyUpdate: UpdateArtDto = {};
expect(emptyUpdate).toEqual({});
});
it("should accept partial updates", () => {
const partialUpdate: UpdateArtDto = {
tags: [ "new-tag" ],
title: "Updated Title",
};
expect(partialUpdate.title).toBe("Updated Title");
expect(partialUpdate.artist).toBeUndefined();
expect(partialUpdate.tags).toEqual([ "new-tag" ]);
});
it("should accept full update", () => {
const fullUpdate: UpdateArtDto = {
artist: "Different Artist",
description: "New description",
imageUrl: "https://example.com/new-image.jpg",
links: [ { title: "New Link", url: "https://newlink.com" } ],
tags: [ "updated", "tags" ],
title: "Completely New Title",
};
expect(fullUpdate.title).toBe("Completely New Title");
expect(fullUpdate.links).toHaveLength(1);
});
});
});

Some files were not shown because too many files have changed in this diff Show More