generated from nhcarrigan/template
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e728968fc9 | |||
| e1bac9fd3e | |||
| c514849f12 | |||
| 8f569e0bb4 | |||
| d797d38ddd | |||
| 5eec4c7640 | |||
| 34c7ca8ba2 | |||
| 7579f1ec97 |
+5
-1
@@ -3,8 +3,12 @@ module.exports = {
|
|||||||
preset: '../jest.preset.js',
|
preset: '../jest.preset.js',
|
||||||
testEnvironment: 'node',
|
testEnvironment: 'node',
|
||||||
transform: {
|
transform: {
|
||||||
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
'^.+\\.[tj]s$': ['ts-jest', {
|
||||||
|
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'],
|
||||||
};
|
};
|
||||||
|
|||||||
+111
-21
@@ -24,7 +24,9 @@ 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?
|
||||||
@@ -39,6 +41,7 @@ enum GameStatus {
|
|||||||
PLAYING
|
PLAYING
|
||||||
COMPLETED
|
COMPLETED
|
||||||
BACKLOG
|
BACKLOG
|
||||||
|
RETIRED
|
||||||
}
|
}
|
||||||
|
|
||||||
model Book {
|
model Book {
|
||||||
@@ -48,6 +51,7 @@ 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?
|
||||||
@@ -63,6 +67,7 @@ enum BookStatus {
|
|||||||
READING
|
READING
|
||||||
FINISHED
|
FINISHED
|
||||||
TO_READ
|
TO_READ
|
||||||
|
RETIRED
|
||||||
}
|
}
|
||||||
|
|
||||||
model Music {
|
model Music {
|
||||||
@@ -72,7 +77,9 @@ 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?
|
||||||
@@ -93,6 +100,7 @@ enum MusicStatus {
|
|||||||
LISTENING
|
LISTENING
|
||||||
COMPLETED
|
COMPLETED
|
||||||
WANT_TO_LISTEN
|
WANT_TO_LISTEN
|
||||||
|
RETIRED
|
||||||
}
|
}
|
||||||
|
|
||||||
model Art {
|
model Art {
|
||||||
@@ -115,7 +123,9 @@ 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?
|
||||||
@@ -137,6 +147,7 @@ enum ShowStatus {
|
|||||||
WATCHING
|
WATCHING
|
||||||
COMPLETED
|
COMPLETED
|
||||||
WANT_TO_WATCH
|
WANT_TO_WATCH
|
||||||
|
RETIRED
|
||||||
}
|
}
|
||||||
|
|
||||||
model Manga {
|
model Manga {
|
||||||
@@ -145,7 +156,9 @@ 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?
|
||||||
@@ -160,6 +173,7 @@ enum MangaStatus {
|
|||||||
READING
|
READING
|
||||||
COMPLETED
|
COMPLETED
|
||||||
WANT_TO_READ
|
WANT_TO_READ
|
||||||
|
RETIRED
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
@@ -168,6 +182,17 @@ 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)
|
||||||
@@ -176,32 +201,40 @@ 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])
|
||||||
createdAt DateTime @default(now())
|
reports CommentReport[]
|
||||||
updatedAt DateTime @updatedAt
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
model AuditLog {
|
model AuditLog {
|
||||||
@@ -302,3 +335,60 @@ 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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,6 @@ describe('GET /', () => {
|
|||||||
url: '/',
|
url: '/',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.json()).toEqual({ message: 'Hello API' });
|
expect(response.json()).toEqual({ version: expect.any(String) });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+12
-4
@@ -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.CSRF_VALIDATION_FAILED,
|
action: AuditAction.csrfValidationFailed,
|
||||||
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.UNAUTHORIZED_ACCESS,
|
action: AuditAction.unauthorizedAccess,
|
||||||
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,5 +57,13 @@ 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$/,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,9 @@ const authPlugin: FastifyPluginAsync = async (app) => {
|
|||||||
try {
|
try {
|
||||||
await request.jwtVerify();
|
await request.jwtVerify();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw app.httpErrors.unauthorized("Invalid token");
|
const error = new Error("Invalid token");
|
||||||
|
(error as any).statusCode = 401;
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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.RATE_LIMIT_EXCEEDED,
|
action: AuditAction.rateLimitExceeded,
|
||||||
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(() => {
|
||||||
|
|||||||
@@ -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.ENTRY_CREATE,
|
action: AuditAction.entryCreate,
|
||||||
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.ENTRY_UPDATE,
|
action: AuditAction.entryUpdate,
|
||||||
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.ENTRY_DELETE,
|
action: AuditAction.entryDelete,
|
||||||
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.COMMENT_CREATE,
|
action: AuditAction.commentCreate,
|
||||||
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.COMMENT_UPDATE,
|
action: AuditAction.commentUpdate,
|
||||||
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.COMMENT_DELETE,
|
action: AuditAction.commentDelete,
|
||||||
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`,
|
||||||
|
|||||||
@@ -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.LOGIN_FAILED,
|
action: AuditAction.loginFailed,
|
||||||
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,
|
||||||
|
|||||||
@@ -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.ENTRY_CREATE,
|
action: AuditAction.entryCreate,
|
||||||
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.ENTRY_UPDATE,
|
action: AuditAction.entryUpdate,
|
||||||
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.ENTRY_DELETE,
|
action: AuditAction.entryDelete,
|
||||||
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.COMMENT_CREATE,
|
action: AuditAction.commentCreate,
|
||||||
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.COMMENT_UPDATE,
|
action: AuditAction.commentUpdate,
|
||||||
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.COMMENT_DELETE,
|
action: AuditAction.commentDelete,
|
||||||
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`,
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* @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;
|
||||||
@@ -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.ENTRY_CREATE,
|
action: AuditAction.entryCreate,
|
||||||
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.ENTRY_UPDATE,
|
action: AuditAction.entryUpdate,
|
||||||
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.ENTRY_DELETE,
|
action: AuditAction.entryDelete,
|
||||||
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.COMMENT_CREATE,
|
action: AuditAction.commentCreate,
|
||||||
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.COMMENT_UPDATE,
|
action: AuditAction.commentUpdate,
|
||||||
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.COMMENT_DELETE,
|
action: AuditAction.commentDelete,
|
||||||
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`,
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* @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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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.ENTRY_CREATE,
|
action: AuditAction.entryCreate,
|
||||||
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.ENTRY_UPDATE,
|
action: AuditAction.entryUpdate,
|
||||||
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.ENTRY_DELETE,
|
action: AuditAction.entryDelete,
|
||||||
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.COMMENT_CREATE,
|
action: AuditAction.commentCreate,
|
||||||
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.COMMENT_UPDATE,
|
action: AuditAction.commentUpdate,
|
||||||
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.COMMENT_DELETE,
|
action: AuditAction.commentDelete,
|
||||||
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`,
|
||||||
|
|||||||
@@ -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.ENTRY_CREATE,
|
action: AuditAction.entryCreate,
|
||||||
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.ENTRY_UPDATE,
|
action: AuditAction.entryUpdate,
|
||||||
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.ENTRY_DELETE,
|
action: AuditAction.entryDelete,
|
||||||
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.COMMENT_CREATE,
|
action: AuditAction.commentCreate,
|
||||||
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.COMMENT_UPDATE,
|
action: AuditAction.commentUpdate,
|
||||||
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.COMMENT_DELETE,
|
action: AuditAction.commentDelete,
|
||||||
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`,
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* @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,7 +1,30 @@
|
|||||||
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 { message: 'Hello API' };
|
return { version: getVersion() };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.ENTRY_CREATE,
|
action: AuditAction.entryCreate,
|
||||||
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.ENTRY_UPDATE,
|
action: AuditAction.entryUpdate,
|
||||||
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.ENTRY_DELETE,
|
action: AuditAction.entryDelete,
|
||||||
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.COMMENT_CREATE,
|
action: AuditAction.commentCreate,
|
||||||
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.COMMENT_UPDATE,
|
action: AuditAction.commentUpdate,
|
||||||
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.COMMENT_DELETE,
|
action: AuditAction.commentDelete,
|
||||||
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`,
|
||||||
|
|||||||
@@ -85,8 +85,8 @@ export default async function (app: FastifyInstance): Promise<void> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await AuditService.logFromRequest(request, {
|
await AuditService.logFromRequest(request, {
|
||||||
action: AuditAction.ENTRY_CREATE,
|
action: AuditAction.entryCreate,
|
||||||
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.ENTRY_UPDATE,
|
action: AuditAction.entryUpdate,
|
||||||
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.ENTRY_UPDATE,
|
action: AuditAction.entryUpdate,
|
||||||
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.ENTRY_UPDATE,
|
action: AuditAction.entryUpdate,
|
||||||
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.ENTRY_DELETE,
|
action: AuditAction.entryDelete,
|
||||||
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}`,
|
||||||
|
|||||||
@@ -10,6 +10,49 @@ 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();
|
||||||
|
|
||||||
@@ -23,6 +66,107 @@ 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",
|
||||||
{
|
{
|
||||||
@@ -54,8 +198,8 @@ const usersRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await AuditService.logFromRequest(request, {
|
await AuditService.logFromRequest(request, {
|
||||||
action: AuditAction.USER_BAN,
|
action: AuditAction.userBan,
|
||||||
category: AuditCategory.ADMIN,
|
category: AuditCategory.admin,
|
||||||
targetUserId: id,
|
targetUserId: id,
|
||||||
details: `Banned user: ${user.username}`,
|
details: `Banned user: ${user.username}`,
|
||||||
});
|
});
|
||||||
@@ -78,8 +222,8 @@ const usersRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await AuditService.logFromRequest(request, {
|
await AuditService.logFromRequest(request, {
|
||||||
action: AuditAction.USER_UNBAN,
|
action: AuditAction.userUnban,
|
||||||
category: AuditCategory.ADMIN,
|
category: AuditCategory.admin,
|
||||||
targetUserId: id,
|
targetUserId: id,
|
||||||
details: `Unbanned user: ${user.username}`,
|
details: `Unbanned user: ${user.username}`,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const AuditService = {
|
|||||||
request: FastifyRequest,
|
request: FastifyRequest,
|
||||||
data: Omit<AuditLogData, "userId">
|
data: Omit<AuditLogData, "userId">
|
||||||
) {
|
) {
|
||||||
const userId = (request.user as { id?: string } | undefined)?.id;
|
const userId = ((request as any).user as { id?: string } | undefined)?.id;
|
||||||
|
|
||||||
return this.log(
|
return this.log(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -71,6 +71,15 @@ 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,
|
||||||
@@ -167,6 +176,15 @@ 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,
|
||||||
@@ -217,6 +235,15 @@ 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,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ 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 ?? [],
|
||||||
@@ -46,6 +47,7 @@ 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 ?? [],
|
||||||
@@ -69,6 +71,7 @@ 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 ?? [],
|
||||||
@@ -95,6 +98,7 @@ 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 ?? [],
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
/**
|
||||||
|
* @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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,7 +50,12 @@ export class CommentService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapComment(comment: any): Comment {
|
private async mapComment(comment: any): Promise<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,
|
||||||
@@ -71,6 +76,7 @@ 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,
|
||||||
};
|
};
|
||||||
@@ -79,28 +85,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 },
|
include: { user: true, reports: true },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
return comments.map((c) => this.mapComment(c));
|
return Promise.all(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 },
|
include: { user: true, reports: true },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
return comments.map((c) => this.mapComment(c));
|
return Promise.all(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 },
|
include: { user: true, reports: true },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
return comments.map((c) => this.mapComment(c));
|
return Promise.all(comments.map((c) => this.mapComment(c)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async createCommentForGame(
|
async createCommentForGame(
|
||||||
@@ -116,7 +122,7 @@ export class CommentService {
|
|||||||
userId,
|
userId,
|
||||||
gameId,
|
gameId,
|
||||||
},
|
},
|
||||||
include: { user: true },
|
include: { user: true, reports: true },
|
||||||
});
|
});
|
||||||
return this.mapComment(comment);
|
return this.mapComment(comment);
|
||||||
}
|
}
|
||||||
@@ -134,7 +140,7 @@ export class CommentService {
|
|||||||
userId,
|
userId,
|
||||||
bookId,
|
bookId,
|
||||||
},
|
},
|
||||||
include: { user: true },
|
include: { user: true, reports: true },
|
||||||
});
|
});
|
||||||
return this.mapComment(comment);
|
return this.mapComment(comment);
|
||||||
}
|
}
|
||||||
@@ -152,7 +158,7 @@ export class CommentService {
|
|||||||
userId,
|
userId,
|
||||||
musicId,
|
musicId,
|
||||||
},
|
},
|
||||||
include: { user: true },
|
include: { user: true, reports: true },
|
||||||
});
|
});
|
||||||
return this.mapComment(comment);
|
return this.mapComment(comment);
|
||||||
}
|
}
|
||||||
@@ -160,10 +166,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 },
|
include: { user: true, reports: true },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
return comments.map((c) => this.mapComment(c));
|
return Promise.all(comments.map((c) => this.mapComment(c)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async createCommentForArt(
|
async createCommentForArt(
|
||||||
@@ -179,7 +185,7 @@ export class CommentService {
|
|||||||
userId,
|
userId,
|
||||||
artId,
|
artId,
|
||||||
},
|
},
|
||||||
include: { user: true },
|
include: { user: true, reports: true },
|
||||||
});
|
});
|
||||||
return this.mapComment(comment);
|
return this.mapComment(comment);
|
||||||
}
|
}
|
||||||
@@ -187,10 +193,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 },
|
include: { user: true, reports: true },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
return comments.map((c) => this.mapComment(c));
|
return Promise.all(comments.map((c) => this.mapComment(c)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async createCommentForShow(
|
async createCommentForShow(
|
||||||
@@ -206,7 +212,7 @@ export class CommentService {
|
|||||||
userId,
|
userId,
|
||||||
showId,
|
showId,
|
||||||
},
|
},
|
||||||
include: { user: true },
|
include: { user: true, reports: true },
|
||||||
});
|
});
|
||||||
return this.mapComment(comment);
|
return this.mapComment(comment);
|
||||||
}
|
}
|
||||||
@@ -214,10 +220,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 },
|
include: { user: true, reports: true },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
return comments.map((c) => this.mapComment(c));
|
return Promise.all(comments.map((c) => this.mapComment(c)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async createCommentForManga(
|
async createCommentForManga(
|
||||||
@@ -233,7 +239,7 @@ export class CommentService {
|
|||||||
userId,
|
userId,
|
||||||
mangaId,
|
mangaId,
|
||||||
},
|
},
|
||||||
include: { user: true },
|
include: { user: true, reports: true },
|
||||||
});
|
});
|
||||||
return this.mapComment(comment);
|
return this.mapComment(comment);
|
||||||
}
|
}
|
||||||
@@ -256,7 +262,7 @@ export class CommentService {
|
|||||||
content: sanitizedContent,
|
content: sanitizedContent,
|
||||||
rawContent: content,
|
rawContent: content,
|
||||||
},
|
},
|
||||||
include: { user: true },
|
include: { user: true, reports: true },
|
||||||
});
|
});
|
||||||
return this.mapComment(comment);
|
return this.mapComment(comment);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ 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,
|
||||||
@@ -46,7 +48,9 @@ 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,
|
||||||
@@ -69,7 +73,9 @@ 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,
|
||||||
@@ -95,7 +101,9 @@ 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,
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ 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,
|
||||||
@@ -40,7 +42,9 @@ 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,
|
||||||
@@ -60,7 +64,9 @@ 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,
|
||||||
@@ -83,7 +89,9 @@ 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,
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ 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,
|
||||||
@@ -48,7 +50,9 @@ 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,
|
||||||
@@ -73,7 +77,9 @@ 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,
|
||||||
@@ -103,7 +109,9 @@ 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,
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
/**
|
||||||
|
* @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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,9 @@ 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,
|
||||||
@@ -42,7 +44,9 @@ 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,
|
||||||
@@ -64,7 +68,9 @@ 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,
|
||||||
@@ -91,7 +97,9 @@ 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,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
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;
|
||||||
@@ -21,6 +22,17 @@ 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,
|
||||||
@@ -45,6 +57,17 @@ 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,
|
||||||
@@ -66,6 +89,17 @@ 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,
|
||||||
@@ -87,6 +121,17 @@ 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,
|
||||||
@@ -104,4 +149,172 @@ 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* @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 ?? "");
|
||||||
+34
-1
@@ -1,9 +1,42 @@
|
|||||||
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,
|
||||||
@@ -19,6 +52,6 @@ server.listen({ port, host }, (err) => {
|
|||||||
server.log.error(err);
|
server.log.error(err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
} else {
|
} else {
|
||||||
console.log(`[ ready ] http://${host}:${port}`);
|
void logger.log('info', `Server ready at http://${host}:${port}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* @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);
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
"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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,4 +18,4 @@ describe("frontend-e2e", () => {
|
|||||||
// Function helper example, see `../support/app.po.ts` file
|
// Function helper example, see `../support/app.po.ts` file
|
||||||
getGreeting().contains(/Welcome/);
|
getGreeting().contains(/Welcome/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,4 +4,9 @@
|
|||||||
* @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");
|
||||||
|
};
|
||||||
|
|||||||
@@ -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,11 +30,17 @@ 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 --
|
/*
|
||||||
// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
|
* -- This is a child command --
|
||||||
|
* Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
|
||||||
|
*/
|
||||||
|
|
||||||
// -- This is a dual command --
|
/*
|
||||||
// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
|
* -- This is a dual command --
|
||||||
|
* Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
|
||||||
|
*/
|
||||||
|
|
||||||
// -- This will overwrite an existing command --
|
/*
|
||||||
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
|
* -- This will overwrite an existing command --
|
||||||
|
* Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
|
||||||
|
*/
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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';
|
||||||
@@ -9,12 +10,19 @@ 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,
|
||||||
@@ -25,6 +33,12 @@ 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
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,3 +3,4 @@
|
|||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
</main>
|
</main>
|
||||||
<app-footer></app-footer>
|
<app-footer></app-footer>
|
||||||
|
<app-toast></app-toast>
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ 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)
|
||||||
@@ -49,6 +53,14 @@ 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: ''
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ 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],
|
imports: [RouterModule, HeaderComponent, FooterComponent, ToastComponent],
|
||||||
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()">
|
<div class="modal-overlay" (click)="closeDeclineModal()" (keyup.escape)="closeDeclineModal()" tabindex="0" role="button">
|
||||||
<div class="modal" (click)="$event.stopPropagation()">
|
<div class="modal" (click)="$event.stopPropagation()" (keyup)="$event.stopPropagation()" tabindex="-1">
|
||||||
<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()">
|
<div class="modal-overlay" (click)="closeEditModal()" (keyup.escape)="closeEditModal()" tabindex="0" role="button">
|
||||||
<div class="modal edit-modal" (click)="$event.stopPropagation()">
|
<div class="modal edit-modal" (click)="$event.stopPropagation()" (keyup)="$event.stopPropagation()" tabindex="-1">
|
||||||
<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 ('BOOK') {
|
@case (SuggestionEntity.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 ('GAME') {
|
@case (SuggestionEntity.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 ('MUSIC') {
|
@case (SuggestionEntity.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 ('ART') {
|
@case (SuggestionEntity.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 ('SHOW') {
|
@case (SuggestionEntity.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 ('MANGA') {
|
@case (SuggestionEntity.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 !== 'ART') {
|
@if (editingSuggestion()!.entityType !== SuggestionEntity.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,10 +727,11 @@ 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();
|
||||||
@@ -788,20 +789,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 '🎨';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -822,39 +823,39 @@ export class AdminSuggestionsComponent implements OnInit {
|
|||||||
|
|
||||||
// Add entity-specific data
|
// Add entity-specific data
|
||||||
switch (suggestion.entityType) {
|
switch (suggestion.entityType) {
|
||||||
case 'BOOK':
|
case SuggestionEntity.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 'GAME':
|
case SuggestionEntity.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 'MUSIC':
|
case SuggestionEntity.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 'ART':
|
case SuggestionEntity.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 'SHOW':
|
case SuggestionEntity.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 'MANGA':
|
case SuggestionEntity.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,12 +14,13 @@ 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],
|
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header-section">
|
<div class="header-section">
|
||||||
@@ -105,8 +106,7 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Tags</label>
|
<div class="tags-input-container" aria-label="Tags">
|
||||||
<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,8 +123,7 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group" aria-label="External Links">
|
||||||
<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">
|
||||||
@@ -280,8 +279,7 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Tags</label>
|
<div class="tags-input-container" aria-label="Tags">
|
||||||
<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 }}
|
||||||
@@ -298,8 +296,7 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group" aria-label="External Links">
|
||||||
<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">
|
||||||
@@ -401,6 +398,10 @@ 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>
|
||||||
|
|
||||||
@@ -468,56 +469,11 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (commentsLoading()[art.id]) {
|
<app-comment-display
|
||||||
<div class="comments-loading">Loading comments...</div>
|
[comments]="getCommentsSignal(art.id)"
|
||||||
} @else {
|
(onEdit)="handleCommentEdit(art.id, $event)"
|
||||||
@for (comment of comments()[art.id] || []; track comment.id) {
|
(onDelete)="deleteComment(art.id, $event)"
|
||||||
<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>
|
||||||
@@ -536,8 +492,8 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
|
|||||||
}
|
}
|
||||||
|
|
||||||
@if (lightboxArt()) {
|
@if (lightboxArt()) {
|
||||||
<div class="lightbox" (click)="closeLightbox()">
|
<div class="lightbox" (click)="closeLightbox()" (keyup.escape)="closeLightbox()" tabindex="0" role="button">
|
||||||
<div class="lightbox-content" (click)="$event.stopPropagation()">
|
<div class="lightbox-content" (click)="$event.stopPropagation()" (keyup)="$event.stopPropagation()" tabindex="-1">
|
||||||
<button class="lightbox-close" (click)="closeLightbox()">×</button>
|
<button class="lightbox-close" (click)="closeLightbox()">×</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">
|
||||||
@@ -1571,7 +1527,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,
|
||||||
@@ -1615,4 +1571,21 @@ 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,12 +14,13 @@ 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],
|
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header-section">
|
<div class="header-section">
|
||||||
@@ -79,9 +80,30 @@ 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
|
||||||
@@ -126,8 +148,7 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Tags</label>
|
<div class="tags-input-container" aria-label="Tags">
|
||||||
<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 }}
|
||||||
@@ -144,8 +165,7 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group" aria-label="External Links">
|
||||||
<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">
|
||||||
@@ -222,9 +242,30 @@ 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
|
||||||
@@ -269,8 +310,7 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Tags</label>
|
<div class="tags-input-container" aria-label="Tags">
|
||||||
<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 }}
|
||||||
@@ -287,8 +327,7 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group" aria-label="External Links">
|
||||||
<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">
|
||||||
@@ -421,8 +460,7 @@ 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">
|
||||||
<label>Filter by Tags:</label>
|
<div class="tags-filter" aria-label="Filter by Tags">
|
||||||
<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
|
||||||
@@ -475,6 +513,13 @@ 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()) {
|
||||||
@@ -548,12 +593,30 @@ 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">
|
||||||
@@ -593,52 +656,11 @@ 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 {
|
||||||
@for (comment of comments()[book.id] || []; track comment.id) {
|
<app-comment-display
|
||||||
<div class="comment">
|
[comments]="getCommentsSignal(book.id)"
|
||||||
<div class="comment-header">
|
(onEdit)="handleCommentEdit(book.id, $event)"
|
||||||
@if (comment.user.avatar) {
|
(onDelete)="deleteComment(book.id, $event)"
|
||||||
<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>
|
||||||
}
|
}
|
||||||
@@ -989,7 +1011,10 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
|||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-finished {
|
.date-started,
|
||||||
|
.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;
|
||||||
@@ -1417,6 +1442,7 @@ 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(() => {
|
||||||
@@ -1467,11 +1493,13 @@ export class BooksListComponent implements OnInit {
|
|||||||
|
|
||||||
totalFilteredBooks = computed(() => this.filteredBooks().length);
|
totalFilteredBooks = computed(() => this.filteredBooks().length);
|
||||||
|
|
||||||
newBook: Partial<CreateBookDto> = {
|
newBook: Partial<CreateBookDto> & { dateStarted?: Date; dateFinished?: Date } = {
|
||||||
title: '',
|
title: '',
|
||||||
author: '',
|
author: '',
|
||||||
isbn: '',
|
isbn: '',
|
||||||
status: BookStatus.toRead,
|
status: BookStatus.toRead,
|
||||||
|
dateStarted: undefined,
|
||||||
|
dateFinished: undefined,
|
||||||
rating: undefined,
|
rating: undefined,
|
||||||
notes: '',
|
notes: '',
|
||||||
tags: [],
|
tags: [],
|
||||||
@@ -1552,6 +1580,7 @@ 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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1568,6 +1597,8 @@ 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,
|
||||||
@@ -1634,6 +1665,8 @@ 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,
|
||||||
@@ -1662,6 +1695,8 @@ 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,
|
||||||
@@ -1690,7 +1725,13 @@ 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;
|
||||||
|
|
||||||
this.booksService.updateBook(book.id, this.editBook).subscribe(() => {
|
const updateData = {
|
||||||
|
...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();
|
||||||
});
|
});
|
||||||
@@ -1888,7 +1929,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,
|
||||||
@@ -1901,4 +1942,21 @@ 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] || []);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
/**
|
||||||
|
* @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,12 +14,13 @@ 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],
|
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header-section">
|
<div class="header-section">
|
||||||
@@ -67,9 +68,30 @@ 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
|
||||||
@@ -114,8 +136,7 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Tags</label>
|
<div class="tags-input-container" aria-label="Tags">
|
||||||
<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 }}
|
||||||
@@ -132,8 +153,7 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group" aria-label="External Links">
|
||||||
<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">
|
||||||
@@ -198,9 +218,30 @@ 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
|
||||||
@@ -245,8 +286,7 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Tags</label>
|
<div class="tags-input-container" aria-label="Tags">
|
||||||
<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 }}
|
||||||
@@ -263,8 +303,7 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group" aria-label="External Links">
|
||||||
<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">
|
||||||
@@ -436,6 +475,13 @@ 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()) {
|
||||||
@@ -504,6 +550,26 @@ 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">
|
||||||
@@ -543,52 +609,11 @@ 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 {
|
||||||
@for (comment of comments()[game.id] || []; track comment.id) {
|
<app-comment-display
|
||||||
<div class="comment">
|
[comments]="getCommentsSignal(game.id)"
|
||||||
<div class="comment-header">
|
(onEdit)="handleCommentEdit(game.id, $event)"
|
||||||
@if (comment.user.avatar) {
|
(onDelete)="deleteComment(game.id, $event)"
|
||||||
<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>
|
||||||
}
|
}
|
||||||
@@ -858,6 +883,15 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -1213,6 +1247,7 @@ 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>();
|
||||||
@@ -1261,10 +1296,12 @@ export class GamesListComponent implements OnInit {
|
|||||||
|
|
||||||
totalFilteredGames = computed(() => this.filteredGames().length);
|
totalFilteredGames = computed(() => this.filteredGames().length);
|
||||||
|
|
||||||
newGame: Partial<CreateGameDto> = {
|
newGame: Partial<CreateGameDto> & { dateStarted?: Date; dateFinished?: Date } = {
|
||||||
title: '',
|
title: '',
|
||||||
platform: '',
|
platform: '',
|
||||||
status: GameStatus.backlog,
|
status: GameStatus.backlog,
|
||||||
|
dateStarted: undefined,
|
||||||
|
dateFinished: undefined,
|
||||||
rating: undefined,
|
rating: undefined,
|
||||||
notes: '',
|
notes: '',
|
||||||
tags: [],
|
tags: [],
|
||||||
@@ -1341,6 +1378,7 @@ 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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1356,6 +1394,8 @@ 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,
|
||||||
@@ -1424,6 +1464,8 @@ 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 || []
|
||||||
};
|
};
|
||||||
@@ -1448,6 +1490,8 @@ 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,
|
||||||
@@ -1476,7 +1520,13 @@ 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;
|
||||||
|
|
||||||
this.gamesService.updateGame(game.id, this.editGame).subscribe(() => {
|
const updateData: UpdateGameDto = {
|
||||||
|
...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();
|
||||||
});
|
});
|
||||||
@@ -1649,6 +1699,23 @@ 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);
|
||||||
@@ -1673,7 +1740,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,17 +35,37 @@ import { ApiService } from '../../services/api.service';
|
|||||||
|
|
||||||
<div class="auth-section">
|
<div class="auth-section">
|
||||||
@if (authService.user(); as user) {
|
@if (authService.user(); as user) {
|
||||||
<span class="welcome">Welcome, {{ user.username }}!</span>
|
<div class="user-menu">
|
||||||
@if (!user.isAdmin) {
|
@if (user.avatar) {
|
||||||
<a routerLink="/my-suggestions" class="user-link">My Suggestions</a>
|
<img
|
||||||
}
|
[src]="user.avatar"
|
||||||
<a routerLink="/my-likes" class="user-link">My Likes</a>
|
[alt]="user.username"
|
||||||
@if (user.isAdmin) {
|
class="user-avatar"
|
||||||
<a routerLink="/admin/users" class="admin-badge">Users</a>
|
(click)="toggleDropdown()"
|
||||||
<a routerLink="/admin/audit" class="admin-badge">Audit</a>
|
(keyup.enter)="toggleDropdown()"
|
||||||
<a routerLink="/admin/suggestions" class="admin-badge">Suggestions</a>
|
(keyup.space)="toggleDropdown()"
|
||||||
}
|
tabindex="0"
|
||||||
<button (click)="logout()" class="btn btn-secondary">Logout</button>
|
role="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>
|
||||||
}
|
}
|
||||||
@@ -122,6 +142,75 @@ 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);
|
||||||
@@ -190,6 +279,7 @@ 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({
|
||||||
@@ -198,11 +288,20 @@ 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,12 +14,13 @@ 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],
|
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header-section">
|
<div class="header-section">
|
||||||
@@ -68,9 +69,30 @@ 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
|
||||||
@@ -115,8 +137,7 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Tags</label>
|
<div class="tags-input-container" aria-label="Tags">
|
||||||
<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 }}
|
||||||
@@ -133,8 +154,7 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group" aria-label="External Links">
|
||||||
<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">
|
||||||
@@ -200,9 +220,30 @@ 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
|
||||||
@@ -247,8 +288,7 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Tags</label>
|
<div class="tags-input-container" aria-label="Tags">
|
||||||
<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 }}
|
||||||
@@ -265,8 +305,7 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group" aria-label="External Links">
|
||||||
<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">
|
||||||
@@ -439,6 +478,13 @@ 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()) {
|
||||||
@@ -505,6 +551,30 @@ 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">
|
||||||
@@ -541,56 +611,11 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (commentsLoading()[manga.id]) {
|
<app-comment-display
|
||||||
<div class="comments-loading">Loading comments...</div>
|
[comments]="getCommentsSignal(manga.id)"
|
||||||
} @else {
|
(onEdit)="handleCommentEdit(manga.id, $event)"
|
||||||
@for (comment of comments()[manga.id] || []; track comment.id) {
|
(onDelete)="deleteComment(manga.id, $event)"
|
||||||
<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>
|
||||||
@@ -861,6 +886,15 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -1216,6 +1250,7 @@ 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>();
|
||||||
@@ -1264,10 +1299,12 @@ export class MangaListComponent implements OnInit {
|
|||||||
|
|
||||||
totalFilteredManga = computed(() => this.filteredManga().length);
|
totalFilteredManga = computed(() => this.filteredManga().length);
|
||||||
|
|
||||||
newManga: Partial<CreateMangaDto> = {
|
newManga: Partial<CreateMangaDto> & { dateStarted?: Date; dateFinished?: Date } = {
|
||||||
title: '',
|
title: '',
|
||||||
author: '',
|
author: '',
|
||||||
status: MangaStatus.wantToRead,
|
status: MangaStatus.wantToRead,
|
||||||
|
dateStarted: undefined,
|
||||||
|
dateFinished: undefined,
|
||||||
rating: undefined,
|
rating: undefined,
|
||||||
notes: '',
|
notes: '',
|
||||||
tags: [],
|
tags: [],
|
||||||
@@ -1344,6 +1381,7 @@ 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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1359,6 +1397,8 @@ 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,
|
||||||
@@ -1424,6 +1464,8 @@ 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,
|
||||||
@@ -1451,6 +1493,8 @@ 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,
|
||||||
@@ -1479,7 +1523,13 @@ 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;
|
||||||
|
|
||||||
this.mangaService.updateManga(manga.id, this.editManga).subscribe(() => {
|
const updateData = {
|
||||||
|
...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();
|
||||||
});
|
});
|
||||||
@@ -1674,7 +1724,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,
|
||||||
@@ -1686,4 +1736,21 @@ 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,12 +14,13 @@ 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],
|
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header-section">
|
<div class="header-section">
|
||||||
@@ -77,9 +78,30 @@ 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
|
||||||
@@ -124,8 +146,7 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Tags</label>
|
<div class="tags-input-container" aria-label="Tags">
|
||||||
<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 }}
|
||||||
@@ -142,8 +163,7 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group" aria-label="External Links">
|
||||||
<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">
|
||||||
@@ -218,9 +238,30 @@ 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
|
||||||
@@ -265,8 +306,7 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Tags</label>
|
<div class="tags-input-container" aria-label="Tags">
|
||||||
<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 }}
|
||||||
@@ -283,8 +323,7 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group" aria-label="External Links">
|
||||||
<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">
|
||||||
@@ -500,6 +539,13 @@ 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>
|
||||||
|
|
||||||
@@ -581,9 +627,27 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (music.dateCompleted) {
|
@if (music.dateStarted) {
|
||||||
<p class="date-completed">
|
<p class="date-started">
|
||||||
Completed: {{ formatDate(music.dateCompleted) }}
|
Started: {{ formatDate(music.dateStarted) }}
|
||||||
|
</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>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,56 +687,11 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (commentsLoading()[music.id]) {
|
<app-comment-display
|
||||||
<div class="comments-loading">Loading comments...</div>
|
[comments]="getCommentsSignal(music.id)"
|
||||||
} @else {
|
(onEdit)="handleCommentEdit(music.id, $event)"
|
||||||
@for (comment of comments()[music.id] || []; track comment.id) {
|
(onDelete)="deleteComment(music.id, $event)"
|
||||||
<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>
|
||||||
@@ -1017,7 +1036,10 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
|||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-completed {
|
.date-started,
|
||||||
|
.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;
|
||||||
@@ -1435,6 +1457,7 @@ 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>();
|
||||||
@@ -1488,11 +1511,13 @@ export class MusicListComponent implements OnInit {
|
|||||||
|
|
||||||
totalFilteredMusic = computed(() => this.filteredMusic().length);
|
totalFilteredMusic = computed(() => this.filteredMusic().length);
|
||||||
|
|
||||||
newMusic: Partial<CreateMusicDto> = {
|
newMusic: Partial<CreateMusicDto> & { dateStarted?: Date; dateFinished?: Date } = {
|
||||||
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: [],
|
||||||
@@ -1583,6 +1608,7 @@ 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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1599,6 +1625,8 @@ 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,
|
||||||
@@ -1665,6 +1693,8 @@ 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,
|
||||||
@@ -1693,6 +1723,8 @@ 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,
|
||||||
@@ -1721,7 +1753,13 @@ 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;
|
||||||
|
|
||||||
this.musicService.updateMusic(music.id, this.editMusicData).subscribe(() => {
|
const updateData = {
|
||||||
|
...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();
|
||||||
});
|
});
|
||||||
@@ -1919,7 +1957,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,
|
||||||
@@ -1932,4 +1970,21 @@ 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;
|
const item = likedItem.item as any;
|
||||||
return item.title || item.name || 'Untitled';
|
return item.title || item.name || 'Untitled';
|
||||||
}
|
}
|
||||||
|
|
||||||
getItemSubtitle(likedItem: LikedItemDto): string {
|
getItemSubtitle(likedItem: LikedItemDto): string {
|
||||||
const item = likedItem.item;
|
const item = likedItem.item as any;
|
||||||
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;
|
const item = likedItem.item as any;
|
||||||
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 '🎨';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,498 @@
|
|||||||
|
/**
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
/**
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
/**
|
||||||
|
* @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,12 +14,13 @@ 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],
|
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header-section">
|
<div class="header-section">
|
||||||
@@ -66,9 +67,30 @@ 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
|
||||||
@@ -113,8 +135,7 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Tags</label>
|
<div class="tags-input-container" aria-label="Tags">
|
||||||
<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 }}
|
||||||
@@ -131,8 +152,7 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group" aria-label="External Links">
|
||||||
<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">
|
||||||
@@ -196,9 +216,30 @@ 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
|
||||||
@@ -243,8 +284,7 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Tags</label>
|
<div class="tags-input-container" aria-label="Tags">
|
||||||
<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 }}
|
||||||
@@ -261,8 +301,7 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group" aria-label="External Links">
|
||||||
<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">
|
||||||
@@ -433,6 +472,13 @@ 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()) {
|
||||||
@@ -499,6 +545,30 @@ 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">
|
||||||
@@ -535,56 +605,11 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (commentsLoading()[show.id]) {
|
<app-comment-display
|
||||||
<div class="comments-loading">Loading comments...</div>
|
[comments]="getCommentsSignal(show.id)"
|
||||||
} @else {
|
(onEdit)="handleCommentEdit(show.id, $event)"
|
||||||
@for (comment of comments()[show.id] || []; track comment.id) {
|
(onDelete)="deleteComment(show.id, $event)"
|
||||||
<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>
|
||||||
@@ -854,6 +879,15 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -1210,6 +1244,7 @@ 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>();
|
||||||
@@ -1258,12 +1293,14 @@ export class ShowsListComponent implements OnInit {
|
|||||||
|
|
||||||
totalFilteredShows = computed(() => this.filteredShows().length);
|
totalFilteredShows = computed(() => this.filteredShows().length);
|
||||||
|
|
||||||
newShow: Partial<CreateShowDto> = {
|
newShow: Partial<CreateShowDto> & { dateStarted?: Date; dateFinished?: Date } = {
|
||||||
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: []
|
||||||
};
|
};
|
||||||
@@ -1338,6 +1375,7 @@ 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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1365,6 +1403,8 @@ export class ShowsListComponent implements OnInit {
|
|||||||
rating: undefined,
|
rating: undefined,
|
||||||
notes: '',
|
notes: '',
|
||||||
coverImage: undefined,
|
coverImage: undefined,
|
||||||
|
dateStarted: undefined,
|
||||||
|
dateFinished: undefined,
|
||||||
tags: [],
|
tags: [],
|
||||||
links: []
|
links: []
|
||||||
};
|
};
|
||||||
@@ -1427,6 +1467,8 @@ 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,
|
||||||
@@ -1454,6 +1496,8 @@ 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,
|
||||||
@@ -1482,7 +1526,13 @@ 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;
|
||||||
|
|
||||||
this.showsService.updateShow(show.id, this.editShow).subscribe(() => {
|
const updateData = {
|
||||||
|
...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();
|
||||||
});
|
});
|
||||||
@@ -1677,7 +1727,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,
|
||||||
@@ -1689,4 +1739,21 @@ 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] || []);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* @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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<!--
|
||||||
|
@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>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* @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,6 +38,9 @@ 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);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -61,6 +64,7 @@ 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);
|
||||||
})
|
})
|
||||||
@@ -78,4 +82,28 @@ 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 } from '@angular/core';
|
import { Injectable, inject } 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,7 +13,8 @@ import { Art, CreateArtDto, UpdateArtDto } from '@library/shared-types';
|
|||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class ArtService {
|
export class ArtService {
|
||||||
constructor(private api: ApiService) {}
|
private api = inject(ApiService);
|
||||||
|
|
||||||
|
|
||||||
getAllArt(): Observable<Art[]> {
|
getAllArt(): Observable<Art[]> {
|
||||||
return this.api.get<Art[]>('/art');
|
return this.api.get<Art[]>('/art');
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, signal } from '@angular/core';
|
import { Injectable, signal, inject } 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,15 +16,14 @@ 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
|
||||||
@@ -35,6 +34,7 @@ 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,9 +42,11 @@ 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);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -68,20 +70,45 @@ 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(['/']);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -89,12 +116,17 @@ 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 } from '@angular/core';
|
import { Injectable, inject } 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,7 +13,8 @@ import { Book, CreateBookDto, UpdateBookDto } from '@library/shared-types';
|
|||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class BooksService {
|
export class BooksService {
|
||||||
constructor(private api: ApiService) {}
|
private api = inject(ApiService);
|
||||||
|
|
||||||
|
|
||||||
getAllBooks(): Observable<Book[]> {
|
getAllBooks(): Observable<Book[]> {
|
||||||
return this.api.get<Book[]>('/books');
|
return this.api.get<Book[]>('/books');
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* @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 } from '@angular/core';
|
import { Injectable, inject } 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,7 +13,8 @@ import { Comment, CreateCommentDto } from '@library/shared-types';
|
|||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class CommentsService {
|
export class CommentsService {
|
||||||
constructor(private api: ApiService) {}
|
private api = inject(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`);
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* @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 } from '@angular/core';
|
import { Injectable, inject } 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,7 +13,8 @@ import { Game, CreateGameDto, UpdateGameDto } from '@library/shared-types';
|
|||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class GamesService {
|
export class GamesService {
|
||||||
constructor(private api: ApiService) {}
|
private api = inject(ApiService);
|
||||||
|
|
||||||
|
|
||||||
getAllGames(): Observable<Game[]> {
|
getAllGames(): Observable<Game[]> {
|
||||||
return this.api.get<Game[]>('/games');
|
return this.api.get<Game[]>('/games');
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* @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.';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,4 +8,5 @@ export { ApiService } from './api.service';
|
|||||||
export { AuthService } from './auth.service';
|
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 } from '@angular/core';
|
import { Injectable, inject } 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,7 +13,8 @@ import { Manga, CreateMangaDto, UpdateMangaDto } from '@library/shared-types';
|
|||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class MangaService {
|
export class MangaService {
|
||||||
constructor(private api: ApiService) {}
|
private api = inject(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 } from '@angular/core';
|
import { Injectable, inject } 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,7 +13,8 @@ import { Music, CreateMusicDto, UpdateMusicDto } from '@library/shared-types';
|
|||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class MusicService {
|
export class MusicService {
|
||||||
constructor(private api: ApiService) {}
|
private api = inject(ApiService);
|
||||||
|
|
||||||
|
|
||||||
getAllMusic(): Observable<Music[]> {
|
getAllMusic(): Observable<Music[]> {
|
||||||
return this.api.get<Music[]>('/music');
|
return this.api.get<Music[]>('/music');
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* @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 } from '@angular/core';
|
import { Injectable, inject } 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,7 +13,8 @@ import { Show, CreateShowDto, UpdateShowDto } from '@library/shared-types';
|
|||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class ShowsService {
|
export class ShowsService {
|
||||||
constructor(private api: ApiService) {}
|
private api = inject(ApiService);
|
||||||
|
|
||||||
|
|
||||||
getAllShows(): Observable<Show[]> {
|
getAllShows(): Observable<Show[]> {
|
||||||
return this.api.get<Show[]>('/shows');
|
return this.api.get<Show[]>('/shows');
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* @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,6 +9,49 @@ 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'
|
||||||
})
|
})
|
||||||
@@ -26,4 +69,16 @@ 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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 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"
|
||||||
+1
-6
@@ -1,8 +1,3 @@
|
|||||||
import nhcarrigan from '@nhcarrigan/eslint-config';
|
import nhcarrigan from '@nhcarrigan/eslint-config';
|
||||||
|
|
||||||
export default [
|
export default nhcarrigan;
|
||||||
...nhcarrigan,
|
|
||||||
{
|
|
||||||
ignores: ['**/dist', '**/out-tsc', 'node_modules'],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
+21
-15
@@ -3,8 +3,9 @@
|
|||||||
"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": "nx run-many --target=build --all",
|
"build": "pnpm db:gen && 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",
|
||||||
@@ -25,21 +26,26 @@
|
|||||||
"@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": {
|
||||||
@@ -65,10 +71,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",
|
||||||
|
|||||||
Generated
+87
-19
@@ -30,37 +30,52 @@ 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.2.0
|
version: 11.0.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
|
||||||
@@ -69,10 +84,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
|
||||||
@@ -145,16 +160,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
|
||||||
@@ -1611,8 +1626,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.2.0':
|
'@fastify/cors@11.0.0':
|
||||||
resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==}
|
resolution: {integrity: sha512-41Bx0LVGr2a6DnnhDN/SgfDlTRNZtEs8niPxyoymV6Hw09AIdz/9Rn/0Fpu+pBOs6kviwS44JY2mB8NcU2qSAA==}
|
||||||
|
|
||||||
'@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==}
|
||||||
@@ -1656,6 +1671,27 @@ 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==}
|
||||||
|
|
||||||
@@ -2488,6 +2524,9 @@ 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'}
|
||||||
@@ -6890,6 +6929,9 @@ 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==}
|
||||||
|
|
||||||
@@ -11558,10 +11600,10 @@ snapshots:
|
|||||||
cookie: 1.1.1
|
cookie: 1.1.1
|
||||||
fastify-plugin: 5.0.1
|
fastify-plugin: 5.0.1
|
||||||
|
|
||||||
'@fastify/cors@11.2.0':
|
'@fastify/cors@11.0.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
fastify-plugin: 5.0.1
|
fastify-plugin: 5.0.1
|
||||||
toad-cache: 3.7.0
|
mnemonist: 0.40.0
|
||||||
|
|
||||||
'@fastify/csrf-protection@7.1.0':
|
'@fastify/csrf-protection@7.1.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -11642,6 +11684,26 @@ 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
|
||||||
@@ -12722,6 +12784,8 @@ 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':
|
||||||
@@ -18229,6 +18293,10 @@ 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
|
||||||
|
|||||||
@@ -18,4 +18,7 @@ MOD_ROLE_ID="op://Environment Variables - Naomi/Library/mod role id"
|
|||||||
STAFF_ROLE_ID="op://Environment Variables - Naomi/Library/staff role id"
|
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"
|
||||||
@@ -3,19 +3,113 @@ 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: ['**/*.ts'],
|
files: ['shared-types/test/**/*.spec.ts'],
|
||||||
languageOptions: {
|
rules: {
|
||||||
parserOptions: {
|
'vitest/consistent-test-filename': 'off',
|
||||||
project: './tsconfig.lib.json',
|
'vitest/consistent-test-it': 'off',
|
||||||
tsconfigRootDir: __dirname,
|
'vitest/expect-expect': 'off',
|
||||||
},
|
'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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -5,5 +5,20 @@
|
|||||||
"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}"]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-11
@@ -1,17 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* @copyright 2026 NHCarrigan
|
* @copyright NHCarrigan
|
||||||
* @license Naomi's Public License
|
* @license Naomi's Public License
|
||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
export * from "./lib/game.types";
|
export type * from "./lib/art.types";
|
||||||
export * from "./lib/book.types";
|
|
||||||
export * from "./lib/music.types";
|
|
||||||
export * from "./lib/art.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/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 type * from "./lib/like.types";
|
||||||
|
export * from "./lib/manga.types";
|
||||||
|
export * from "./lib/music.types";
|
||||||
|
export * from "./lib/report.types";
|
||||||
|
export * from "./lib/show.types";
|
||||||
export * from "./lib/suggestion.types";
|
export * from "./lib/suggestion.types";
|
||||||
export * from "./lib/common.types";
|
|
||||||
export * from "./lib/like.types";
|
|
||||||
@@ -1,30 +1,33 @@
|
|||||||
/**
|
/**
|
||||||
* @copyright 2026 NHCarrigan
|
* @copyright NHCarrigan
|
||||||
* @license Naomi's Public License
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Link } from "./common.types";
|
import type { Link } from "./common.types";
|
||||||
|
|
||||||
export interface Art {
|
interface Art {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
artist: string;
|
artist: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
tags: string[];
|
tags: Array<string>;
|
||||||
links: Link[];
|
links: Array<Link>;
|
||||||
dateAdded: Date;
|
dateAdded: Date;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateArtDto {
|
interface CreateArtDto {
|
||||||
title: string;
|
title: string;
|
||||||
artist: string;
|
artist: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
tags?: string[];
|
tags?: Array<string>;
|
||||||
links?: Link[];
|
links?: Array<Link>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateArtDto extends Partial<CreateArtDto> {}
|
type UpdateArtDto = Partial<CreateArtDto>;
|
||||||
|
|
||||||
|
export type { Art, CreateArtDto, UpdateArtDto };
|
||||||
|
|||||||
@@ -1,58 +1,72 @@
|
|||||||
export enum AuditAction {
|
/**
|
||||||
LOGIN = "LOGIN",
|
* @copyright NHCarrigan
|
||||||
LOGOUT = "LOGOUT",
|
* @license Naomi's Public License
|
||||||
LOGIN_FAILED = "LOGIN_FAILED",
|
* @author Naomi Carrigan
|
||||||
COMMENT_CREATE = "COMMENT_CREATE",
|
*/
|
||||||
COMMENT_UPDATE = "COMMENT_UPDATE",
|
|
||||||
COMMENT_DELETE = "COMMENT_DELETE",
|
enum AuditAction {
|
||||||
ENTRY_CREATE = "ENTRY_CREATE",
|
login = "LOGIN",
|
||||||
ENTRY_UPDATE = "ENTRY_UPDATE",
|
logout = "LOGOUT",
|
||||||
ENTRY_DELETE = "ENTRY_DELETE",
|
loginFailed = "LOGIN_FAILED",
|
||||||
LIKE = "LIKE",
|
commentCreate = "COMMENT_CREATE",
|
||||||
UNLIKE = "UNLIKE",
|
commentUpdate = "COMMENT_UPDATE",
|
||||||
USER_BAN = "USER_BAN",
|
commentDelete = "COMMENT_DELETE",
|
||||||
USER_UNBAN = "USER_UNBAN",
|
entryCreate = "ENTRY_CREATE",
|
||||||
RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED",
|
entryUpdate = "ENTRY_UPDATE",
|
||||||
CSRF_VALIDATION_FAILED = "CSRF_VALIDATION_FAILED",
|
entryDelete = "ENTRY_DELETE",
|
||||||
UNAUTHORIZED_ACCESS = "UNAUTHORIZED_ACCESS",
|
like = "LIKE",
|
||||||
|
unlike = "UNLIKE",
|
||||||
|
userBan = "USER_BAN",
|
||||||
|
userUnban = "USER_UNBAN",
|
||||||
|
rateLimitExceeded = "RATE_LIMIT_EXCEEDED",
|
||||||
|
csrfValidationFailed = "CSRF_VALIDATION_FAILED",
|
||||||
|
unauthorizedAccess = "UNAUTHORIZED_ACCESS",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum AuditCategory {
|
enum AuditCategory {
|
||||||
AUTH = "AUTH",
|
auth = "AUTH",
|
||||||
CONTENT = "CONTENT",
|
content = "CONTENT",
|
||||||
ADMIN = "ADMIN",
|
admin = "ADMIN",
|
||||||
SECURITY = "SECURITY",
|
security = "SECURITY",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuditLogUser {
|
interface AuditLogUser {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuditLog {
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuditLogFilters {
|
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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -4,33 +4,47 @@
|
|||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
username: string;
|
username: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
discordId: string;
|
slug?: string;
|
||||||
isAdmin: boolean;
|
displayName?: string;
|
||||||
isBanned: boolean;
|
bio?: string;
|
||||||
inDiscord: boolean;
|
profilePublic: boolean;
|
||||||
isVip: boolean;
|
website?: string;
|
||||||
isMod: boolean;
|
discordServer?: string;
|
||||||
isStaff: boolean;
|
bluesky?: string;
|
||||||
|
github?: string;
|
||||||
|
linkedin?: string;
|
||||||
|
twitch?: string;
|
||||||
|
youtube?: string;
|
||||||
|
discordId: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
isBanned: boolean;
|
||||||
|
inDiscord: boolean;
|
||||||
|
isVip: boolean;
|
||||||
|
isMod: boolean;
|
||||||
|
isStaff: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JwtPayload {
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthResponse {
|
interface AuthResponse {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type { AuthResponse, JwtPayload, User };
|
||||||
|
|||||||
@@ -1,46 +1,53 @@
|
|||||||
/**
|
/**
|
||||||
* @copyright 2026 NHCarrigan
|
* @copyright NHCarrigan
|
||||||
* @copyright 2026 NHCarrigan
|
|
||||||
* @license Naomi's Public License
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export enum BookStatus {
|
import type { Link } from "./common.types";
|
||||||
|
|
||||||
|
enum BookStatus {
|
||||||
reading = "READING",
|
reading = "READING",
|
||||||
finished = "FINISHED",
|
finished = "FINISHED",
|
||||||
toRead = "TO_READ",
|
toRead = "TO_READ",
|
||||||
|
retired = "RETIRED",
|
||||||
}
|
}
|
||||||
|
|
||||||
import { Link } from "./common.types";
|
interface Book {
|
||||||
|
|
||||||
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: string[];
|
tags: Array<string>;
|
||||||
links: Link[];
|
links: Array<Link>;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateBookDto {
|
interface CreateBookDto {
|
||||||
title: string;
|
title: string;
|
||||||
author: string;
|
author: string;
|
||||||
isbn?: string;
|
isbn?: string;
|
||||||
status: BookStatus;
|
status: BookStatus;
|
||||||
rating?: number;
|
dateStarted?: Date;
|
||||||
notes?: string;
|
dateFinished?: Date;
|
||||||
coverImage?: string;
|
rating?: number;
|
||||||
tags?: string[];
|
notes?: string;
|
||||||
links?: Link[];
|
coverImage?: string;
|
||||||
|
tags?: Array<string>;
|
||||||
|
links?: Array<Link>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateBookDto extends Partial<CreateBookDto> {
|
interface UpdateBookDto extends Partial<CreateBookDto> {
|
||||||
|
dateStarted?: Date;
|
||||||
dateFinished?: Date;
|
dateFinished?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { type Book, BookStatus, type CreateBookDto, type UpdateBookDto };
|
||||||
|
|||||||
@@ -4,32 +4,35 @@
|
|||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface CommentUser {
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Comment {
|
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;
|
||||||
createdAt: Date;
|
hasPendingReports?: boolean;
|
||||||
updatedAt: Date;
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateCommentDto {
|
interface CreateCommentDto {
|
||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type { Comment, CommentUser, CreateCommentDto };
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
export interface Link {
|
export interface Link {
|
||||||
title: string;
|
title: string;
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +1,53 @@
|
|||||||
/**
|
/**
|
||||||
* @copyright 2026 NHCarrigan
|
* @copyright NHCarrigan
|
||||||
* @copyright 2026 NHCarrigan
|
|
||||||
* @license Naomi's Public License
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export enum GameStatus {
|
import type { Link } from "./common.types";
|
||||||
|
|
||||||
|
enum GameStatus {
|
||||||
playing = "PLAYING",
|
playing = "PLAYING",
|
||||||
completed = "COMPLETED",
|
completed = "COMPLETED",
|
||||||
backlog = "BACKLOG",
|
backlog = "BACKLOG",
|
||||||
|
retired = "RETIRED",
|
||||||
}
|
}
|
||||||
|
|
||||||
import { Link } from "./common.types";
|
interface Game {
|
||||||
|
|
||||||
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: string[];
|
tags: Array<string>;
|
||||||
links: Link[];
|
links: Array<Link>;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateGameDto {
|
interface CreateGameDto {
|
||||||
title: string;
|
title: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
status: GameStatus;
|
status: GameStatus;
|
||||||
rating?: number;
|
dateStarted?: Date;
|
||||||
notes?: string;
|
dateFinished?: Date;
|
||||||
coverImage?: string;
|
rating?: number;
|
||||||
tags?: string[];
|
notes?: string;
|
||||||
links?: Link[];
|
coverImage?: string;
|
||||||
|
tags?: Array<string>;
|
||||||
|
links?: Array<Link>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateGameDto extends Partial<CreateGameDto> {
|
interface UpdateGameDto extends Partial<CreateGameDto> {
|
||||||
|
dateStarted?: Date;
|
||||||
dateCompleted?: Date;
|
dateCompleted?: Date;
|
||||||
|
dateFinished?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { type CreateGameDto, type Game, GameStatus, type UpdateGameDto };
|
||||||
|
|||||||
@@ -1,32 +1,38 @@
|
|||||||
/**
|
/**
|
||||||
* @copyright 2026 NHCarrigan
|
* @copyright NHCarrigan
|
||||||
* @license Naomi's Public License
|
* @license Naomi's Public License
|
||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface Like {
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CreateLikeDto = Pick<Like, 'entityType' | 'entityId'>;
|
type CreateLikeDto = Pick<Like, "entityType" | "entityId">;
|
||||||
|
|
||||||
export interface LikeCountDto {
|
interface LikeCountDto {
|
||||||
entityId: string;
|
entityId: string;
|
||||||
entityType: string;
|
entityType: string;
|
||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LikedItemDto {
|
interface LikedItemDto {
|
||||||
like: Like;
|
like: Like;
|
||||||
item: any; // This will be the actual entity (Book, Game, etc.)
|
|
||||||
|
/**
|
||||||
|
* This will be the actual entity (Book, Game, etc.).
|
||||||
|
*/
|
||||||
|
item: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Response types
|
// Response types
|
||||||
export interface LikeResponse {
|
interface LikeResponse {
|
||||||
liked: boolean;
|
liked: boolean;
|
||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type { CreateLikeDto, Like, LikeCountDto, LikedItemDto, LikeResponse };
|
||||||
|
|||||||
@@ -1,44 +1,54 @@
|
|||||||
/**
|
/**
|
||||||
* @copyright 2026 NHCarrigan
|
* @copyright NHCarrigan
|
||||||
* @license Naomi's Public License
|
* @license Naomi's Public License
|
||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export enum MangaStatus {
|
import type { Link } from "./common.types";
|
||||||
|
|
||||||
|
enum MangaStatus {
|
||||||
reading = "READING",
|
reading = "READING",
|
||||||
completed = "COMPLETED",
|
completed = "COMPLETED",
|
||||||
wantToRead = "WANT_TO_READ",
|
wantToRead = "WANT_TO_READ",
|
||||||
|
retired = "RETIRED",
|
||||||
}
|
}
|
||||||
|
|
||||||
import { Link } from "./common.types";
|
interface Manga {
|
||||||
|
|
||||||
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: string[];
|
tags: Array<string>;
|
||||||
links: Link[];
|
links: Array<Link>;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateMangaDto {
|
interface CreateMangaDto {
|
||||||
title: string;
|
title: string;
|
||||||
author: string;
|
author: string;
|
||||||
status: MangaStatus;
|
status: MangaStatus;
|
||||||
rating?: number;
|
dateStarted?: Date;
|
||||||
notes?: string;
|
dateFinished?: Date;
|
||||||
coverImage?: string;
|
rating?: number;
|
||||||
tags?: string[];
|
notes?: string;
|
||||||
links?: Link[];
|
coverImage?: string;
|
||||||
|
tags?: Array<string>;
|
||||||
|
links?: Array<Link>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateMangaDto extends Partial<CreateMangaDto> {
|
interface UpdateMangaDto extends Partial<CreateMangaDto> {
|
||||||
|
dateStarted?: Date;
|
||||||
dateCompleted?: Date;
|
dateCompleted?: Date;
|
||||||
|
dateFinished?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { MangaStatus };
|
||||||
|
export type { CreateMangaDto, Manga, UpdateMangaDto };
|
||||||
|
|||||||
@@ -1,52 +1,62 @@
|
|||||||
/**
|
/**
|
||||||
* @copyright 2026 NHCarrigan
|
* @copyright NHCarrigan
|
||||||
* @copyright 2026 NHCarrigan
|
|
||||||
* @license Naomi's Public License
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export enum MusicType {
|
import type { Link } from "./common.types";
|
||||||
|
|
||||||
|
enum MusicType {
|
||||||
album = "ALBUM",
|
album = "ALBUM",
|
||||||
single = "SINGLE",
|
single = "SINGLE",
|
||||||
ep = "EP",
|
ep = "EP",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum MusicStatus {
|
enum MusicStatus {
|
||||||
listening = "LISTENING",
|
listening = "LISTENING",
|
||||||
completed = "COMPLETED",
|
completed = "COMPLETED",
|
||||||
wantToListen = "WANT_TO_LISTEN",
|
wantToListen = "WANT_TO_LISTEN",
|
||||||
|
retired = "RETIRED",
|
||||||
}
|
}
|
||||||
|
|
||||||
import { Link } from "./common.types";
|
interface Music {
|
||||||
|
|
||||||
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: string[];
|
tags: Array<string>;
|
||||||
links: Link[];
|
links: Array<Link>;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateMusicDto {
|
interface CreateMusicDto {
|
||||||
title: string;
|
title: string;
|
||||||
artist: string;
|
artist: string;
|
||||||
type: MusicType;
|
type: MusicType;
|
||||||
status: MusicStatus;
|
status: MusicStatus;
|
||||||
rating?: number;
|
dateStarted?: Date;
|
||||||
notes?: string;
|
dateFinished?: Date;
|
||||||
coverArt?: string;
|
rating?: number;
|
||||||
tags?: string[];
|
notes?: string;
|
||||||
links?: Link[];
|
coverArt?: string;
|
||||||
|
tags?: Array<string>;
|
||||||
|
links?: Array<Link>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateMusicDto extends Partial<CreateMusicDto> {
|
interface UpdateMusicDto extends Partial<CreateMusicDto> {
|
||||||
|
dateStarted?: Date;
|
||||||
dateCompleted?: Date;
|
dateCompleted?: Date;
|
||||||
|
dateFinished?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { MusicStatus, MusicType };
|
||||||
|
export type { CreateMusicDto, Music, UpdateMusicDto };
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,51 +1,61 @@
|
|||||||
/**
|
/**
|
||||||
* @copyright 2026 NHCarrigan
|
* @copyright NHCarrigan
|
||||||
* @license Naomi's Public License
|
* @license Naomi's Public License
|
||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export enum ShowType {
|
import type { Link } from "./common.types";
|
||||||
|
|
||||||
|
enum ShowType {
|
||||||
tvSeries = "TV_SERIES",
|
tvSeries = "TV_SERIES",
|
||||||
anime = "ANIME",
|
anime = "ANIME",
|
||||||
film = "FILM",
|
film = "FILM",
|
||||||
documentary = "DOCUMENTARY",
|
documentary = "DOCUMENTARY",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ShowStatus {
|
enum ShowStatus {
|
||||||
watching = "WATCHING",
|
watching = "WATCHING",
|
||||||
completed = "COMPLETED",
|
completed = "COMPLETED",
|
||||||
wantToWatch = "WANT_TO_WATCH",
|
wantToWatch = "WANT_TO_WATCH",
|
||||||
|
retired = "RETIRED",
|
||||||
}
|
}
|
||||||
|
|
||||||
import { Link } from "./common.types";
|
interface Show {
|
||||||
|
|
||||||
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: string[];
|
tags: Array<string>;
|
||||||
links: Link[];
|
links: Array<Link>;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateShowDto {
|
interface CreateShowDto {
|
||||||
title: string;
|
title: string;
|
||||||
type: ShowType;
|
type: ShowType;
|
||||||
status: ShowStatus;
|
status: ShowStatus;
|
||||||
rating?: number;
|
dateStarted?: Date;
|
||||||
notes?: string;
|
dateFinished?: Date;
|
||||||
coverImage?: string;
|
rating?: number;
|
||||||
tags?: string[];
|
notes?: string;
|
||||||
links?: Link[];
|
coverImage?: string;
|
||||||
|
tags?: Array<string>;
|
||||||
|
links?: Array<Link>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateShowDto extends Partial<CreateShowDto> {
|
interface UpdateShowDto extends Partial<CreateShowDto> {
|
||||||
|
dateStarted?: Date;
|
||||||
dateCompleted?: Date;
|
dateCompleted?: Date;
|
||||||
|
dateFinished?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { ShowStatus, ShowType };
|
||||||
|
export type { CreateShowDto, Show, UpdateShowDto };
|
||||||
|
|||||||
@@ -1,104 +1,110 @@
|
|||||||
import type { CreateGameDto } from "./game.types";
|
/**
|
||||||
import type { CreateBookDto } from "./book.types";
|
* @copyright NHCarrigan
|
||||||
import type { CreateMusicDto } from "./music.types";
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
import type { CreateArtDto } from "./art.types";
|
import type { CreateArtDto } from "./art.types";
|
||||||
import type { CreateShowDto } from "./show.types";
|
import type { CreateBookDto } from "./book.types";
|
||||||
|
import type { CreateGameDto } from "./game.types";
|
||||||
import type { CreateMangaDto } from "./manga.types";
|
import type { CreateMangaDto } from "./manga.types";
|
||||||
|
import type { CreateMusicDto } from "./music.types";
|
||||||
|
import type { CreateShowDto } from "./show.types";
|
||||||
|
|
||||||
export enum SuggestionEntity {
|
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",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum SuggestionStatus {
|
enum SuggestionStatus {
|
||||||
UNREVIEWED = "UNREVIEWED",
|
unreviewed = "UNREVIEWED",
|
||||||
ACCEPTED = "ACCEPTED",
|
accepted = "ACCEPTED",
|
||||||
DECLINED = "DECLINED",
|
declined = "DECLINED",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SuggestionUser {
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Suggestion {
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateGameSuggestionDto {
|
interface CreateGameSuggestionDto {
|
||||||
entityType: SuggestionEntity.GAME;
|
entityType: SuggestionEntity.game;
|
||||||
title: string;
|
title: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
coverImage?: string;
|
coverImage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateBookSuggestionDto {
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateMusicSuggestionDto {
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateArtSuggestionDto {
|
interface CreateArtSuggestionDto {
|
||||||
entityType: SuggestionEntity.ART;
|
entityType: SuggestionEntity.art;
|
||||||
title: string;
|
title: string;
|
||||||
artist: string;
|
artist: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateShowSuggestionDto {
|
interface CreateShowSuggestionDto {
|
||||||
entityType: SuggestionEntity.SHOW;
|
entityType: SuggestionEntity.show;
|
||||||
title: string;
|
title: string;
|
||||||
type: string;
|
type: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
coverImage?: string;
|
coverImage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateMangaSuggestionDto {
|
interface CreateMangaSuggestionDto {
|
||||||
entityType: SuggestionEntity.MANGA;
|
entityType: SuggestionEntity.manga;
|
||||||
title: string;
|
title: string;
|
||||||
author: string;
|
author: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
coverImage?: string;
|
coverImage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CreateSuggestionDto =
|
type CreateSuggestionDto =
|
||||||
| CreateGameSuggestionDto
|
| CreateGameSuggestionDto
|
||||||
| CreateBookSuggestionDto
|
| CreateBookSuggestionDto
|
||||||
| CreateMusicSuggestionDto
|
| CreateMusicSuggestionDto
|
||||||
@@ -106,22 +112,37 @@ export type CreateSuggestionDto =
|
|||||||
| CreateShowSuggestionDto
|
| CreateShowSuggestionDto
|
||||||
| CreateMangaSuggestionDto;
|
| CreateMangaSuggestionDto;
|
||||||
|
|
||||||
export interface DeclineSuggestionDto {
|
interface DeclineSuggestionDto {
|
||||||
reason?: string;
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AcceptWithEditsDto {
|
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?: string[];
|
tags?: Array<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,
|
||||||
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user