generated from nhcarrigan/template
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f255afce6 | |||
| 391ea6d9f6 | |||
| 808df775a4 | |||
| b1aab70b44 | |||
| 5ad9b50dc8 | |||
| fa331df203 | |||
| f839059dd2 | |||
| 8f95f57838 | |||
| 5a59afaa5e | |||
| f2797d0479 | |||
| 86404497f0 | |||
| 7579f1ec97 |
+5
-1
@@ -3,8 +3,12 @@ module.exports = {
|
||||
preset: '../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
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'],
|
||||
coverageDirectory: '../coverage/api',
|
||||
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
||||
};
|
||||
|
||||
+150
-21
@@ -24,12 +24,17 @@ model Game {
|
||||
platform String?
|
||||
status GameStatus
|
||||
dateAdded DateTime @default(now())
|
||||
dateStarted DateTime?
|
||||
dateCompleted DateTime?
|
||||
dateFinished DateTime?
|
||||
rating Int? @db.Int @default(0)
|
||||
notes String?
|
||||
coverImage String?
|
||||
tags String[]
|
||||
links Link[]
|
||||
series String?
|
||||
seriesOrder Int? @db.Int
|
||||
timeSpent Int? @db.Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
comments Comment[]
|
||||
@@ -39,6 +44,7 @@ enum GameStatus {
|
||||
PLAYING
|
||||
COMPLETED
|
||||
BACKLOG
|
||||
RETIRED
|
||||
}
|
||||
|
||||
model Book {
|
||||
@@ -48,12 +54,16 @@ model Book {
|
||||
isbn String?
|
||||
status BookStatus
|
||||
dateAdded DateTime @default(now())
|
||||
dateStarted DateTime?
|
||||
dateFinished DateTime?
|
||||
rating Int? @db.Int @default(0)
|
||||
notes String?
|
||||
coverImage String?
|
||||
tags String[]
|
||||
links Link[]
|
||||
series String?
|
||||
seriesOrder Int? @db.Int
|
||||
timeSpent Int? @db.Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
comments Comment[]
|
||||
@@ -63,6 +73,7 @@ enum BookStatus {
|
||||
READING
|
||||
FINISHED
|
||||
TO_READ
|
||||
RETIRED
|
||||
}
|
||||
|
||||
model Music {
|
||||
@@ -72,12 +83,15 @@ model Music {
|
||||
type MusicType
|
||||
status MusicStatus
|
||||
dateAdded DateTime @default(now())
|
||||
dateStarted DateTime?
|
||||
dateCompleted DateTime?
|
||||
dateFinished DateTime?
|
||||
rating Int? @db.Int @default(0)
|
||||
notes String?
|
||||
coverArt String?
|
||||
tags String[]
|
||||
links Link[]
|
||||
timeSpent Int? @db.Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
comments Comment[]
|
||||
@@ -93,6 +107,7 @@ enum MusicStatus {
|
||||
LISTENING
|
||||
COMPLETED
|
||||
WANT_TO_LISTEN
|
||||
RETIRED
|
||||
}
|
||||
|
||||
model Art {
|
||||
@@ -115,12 +130,15 @@ model Show {
|
||||
type ShowType
|
||||
status ShowStatus
|
||||
dateAdded DateTime @default(now())
|
||||
dateStarted DateTime?
|
||||
dateCompleted DateTime?
|
||||
dateFinished DateTime?
|
||||
rating Int? @db.Int @default(0)
|
||||
notes String?
|
||||
coverImage String?
|
||||
tags String[]
|
||||
links Link[]
|
||||
timeSpent Int? @db.Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
comments Comment[]
|
||||
@@ -137,6 +155,7 @@ enum ShowStatus {
|
||||
WATCHING
|
||||
COMPLETED
|
||||
WANT_TO_WATCH
|
||||
RETIRED
|
||||
}
|
||||
|
||||
model Manga {
|
||||
@@ -145,12 +164,15 @@ model Manga {
|
||||
author String
|
||||
status MangaStatus
|
||||
dateAdded DateTime @default(now())
|
||||
dateStarted DateTime?
|
||||
dateCompleted DateTime?
|
||||
dateFinished DateTime?
|
||||
rating Int? @db.Int @default(0)
|
||||
notes String?
|
||||
coverImage String?
|
||||
tags String[]
|
||||
links Link[]
|
||||
timeSpent Int? @db.Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
comments Comment[]
|
||||
@@ -160,6 +182,14 @@ enum MangaStatus {
|
||||
READING
|
||||
COMPLETED
|
||||
WANT_TO_READ
|
||||
RETIRED
|
||||
}
|
||||
|
||||
enum PrimaryBadge {
|
||||
STAFF
|
||||
MOD
|
||||
VIP
|
||||
DISCORD
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -168,40 +198,64 @@ model User {
|
||||
username String
|
||||
email String @unique
|
||||
avatar String?
|
||||
slug String?
|
||||
displayName String?
|
||||
bio String?
|
||||
profilePublic Boolean @default(true)
|
||||
primaryBadge PrimaryBadge?
|
||||
website String?
|
||||
discordServer String?
|
||||
bluesky String?
|
||||
github String?
|
||||
linkedin String?
|
||||
twitch String?
|
||||
youtube String?
|
||||
isAdmin Boolean @default(false)
|
||||
isBanned Boolean @default(false)
|
||||
inDiscord Boolean @default(false)
|
||||
isVip Boolean @default(false)
|
||||
isMod Boolean @default(false)
|
||||
isStaff Boolean @default(false)
|
||||
achievementPoints Int @default(0)
|
||||
currentStreak Int @default(0)
|
||||
lastStreakCheck DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
comments Comment[]
|
||||
suggestions Suggestion[]
|
||||
likes Like[]
|
||||
refreshTokens RefreshToken[]
|
||||
comments Comment[]
|
||||
suggestions Suggestion[]
|
||||
likes Like[]
|
||||
refreshTokens RefreshToken[]
|
||||
reportsMade ProfileReport[] @relation("Reporter")
|
||||
reportsReceived ProfileReport[] @relation("ReportedUser")
|
||||
reportsReviewed ProfileReport[] @relation("Reviewer")
|
||||
commentReportsMade CommentReport[] @relation("CommentReporter")
|
||||
commentReportsReviewed CommentReport[] @relation("CommentReviewer")
|
||||
userAchievements UserAchievement[]
|
||||
|
||||
@@index([slug], map: "User_slug_key")
|
||||
}
|
||||
|
||||
model Comment {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
content String
|
||||
rawContent String?
|
||||
userId String @db.ObjectId
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
gameId String? @db.ObjectId
|
||||
game Game? @relation(fields: [gameId], references: [id])
|
||||
bookId String? @db.ObjectId
|
||||
book Book? @relation(fields: [bookId], references: [id])
|
||||
musicId String? @db.ObjectId
|
||||
music Music? @relation(fields: [musicId], references: [id])
|
||||
artId String? @db.ObjectId
|
||||
art Art? @relation(fields: [artId], references: [id])
|
||||
showId String? @db.ObjectId
|
||||
show Show? @relation(fields: [showId], references: [id])
|
||||
mangaId String? @db.ObjectId
|
||||
manga Manga? @relation(fields: [mangaId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
userId String @db.ObjectId
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
gameId String? @db.ObjectId
|
||||
game Game? @relation(fields: [gameId], references: [id])
|
||||
bookId String? @db.ObjectId
|
||||
book Book? @relation(fields: [bookId], references: [id])
|
||||
musicId String? @db.ObjectId
|
||||
music Music? @relation(fields: [musicId], references: [id])
|
||||
artId String? @db.ObjectId
|
||||
art Art? @relation(fields: [artId], references: [id])
|
||||
showId String? @db.ObjectId
|
||||
show Show? @relation(fields: [showId], references: [id])
|
||||
mangaId String? @db.ObjectId
|
||||
manga Manga? @relation(fields: [mangaId], references: [id])
|
||||
reports CommentReport[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
@@ -235,6 +289,7 @@ enum AuditAction {
|
||||
RATE_LIMIT_EXCEEDED
|
||||
CSRF_VALIDATION_FAILED
|
||||
UNAUTHORIZED_ACCESS
|
||||
ACHIEVEMENT_UNLOCKED
|
||||
}
|
||||
|
||||
enum AuditCategory {
|
||||
@@ -302,3 +357,77 @@ model RefreshToken {
|
||||
@@index([userId])
|
||||
@@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], onDelete: Cascade)
|
||||
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])
|
||||
}
|
||||
|
||||
model UserAchievement {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
userId String @db.ObjectId
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
achievementKey String
|
||||
progress Int @default(0)
|
||||
earned Boolean @default(false)
|
||||
earnedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([userId, achievementKey])
|
||||
@@index([userId])
|
||||
@@index([achievementKey])
|
||||
@@index([earned])
|
||||
}
|
||||
|
||||
@@ -15,6 +15,6 @@ describe('GET /', () => {
|
||||
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
|
||||
if (error.code === 'FST_CSRF_INVALID_TOKEN' || error.code === 'FST_CSRF_MISSING_SECRET') {
|
||||
await AuditService.log({
|
||||
action: AuditAction.CSRF_VALIDATION_FAILED,
|
||||
category: AuditCategory.SECURITY,
|
||||
action: AuditAction.csrfValidationFailed,
|
||||
category: AuditCategory.security,
|
||||
details: `CSRF validation failed: ${error.message}, URL: ${request.url}`,
|
||||
success: false,
|
||||
}, request).catch(() => {
|
||||
@@ -25,8 +25,8 @@ export async function app(fastify: FastifyInstance, opts: AppOptions) {
|
||||
// Log unauthorized access attempts
|
||||
if (error.statusCode === 401 || error.statusCode === 403) {
|
||||
await AuditService.log({
|
||||
action: AuditAction.UNAUTHORIZED_ACCESS,
|
||||
category: AuditCategory.SECURITY,
|
||||
action: AuditAction.unauthorizedAccess,
|
||||
category: AuditCategory.security,
|
||||
details: `Unauthorized access attempt: ${error.message}, URL: ${request.url}`,
|
||||
success: false,
|
||||
}, request).catch(() => {
|
||||
@@ -57,5 +57,13 @@ export async function app(fastify: FastifyInstance, opts: AppOptions) {
|
||||
fastify.register(AutoLoad, {
|
||||
dir: path.join(__dirname, 'routes'),
|
||||
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 {
|
||||
await request.jwtVerify();
|
||||
} 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) => {
|
||||
// Log rate limit exceeded event
|
||||
AuditService.log({
|
||||
action: AuditAction.RATE_LIMIT_EXCEEDED,
|
||||
category: AuditCategory.SECURITY,
|
||||
action: AuditAction.rateLimitExceeded,
|
||||
category: AuditCategory.security,
|
||||
details: `Rate limit exceeded for URL: ${request.url}`,
|
||||
success: false,
|
||||
}, request).catch(() => {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import type { FastifyPluginAsync } from "fastify";
|
||||
import {
|
||||
ACHIEVEMENT_LIST,
|
||||
ACHIEVEMENTS,
|
||||
AchievementProgress,
|
||||
UserAchievementSummary,
|
||||
} from "@library/shared-types";
|
||||
import { AchievementService } from "../../services/achievement.service";
|
||||
|
||||
const achievementsRoutes: FastifyPluginAsync = async (app) => {
|
||||
const achievementService = new AchievementService();
|
||||
|
||||
/**
|
||||
* Get all achievement definitions (public route).
|
||||
*/
|
||||
app.get("/definitions", async () => {
|
||||
return ACHIEVEMENT_LIST;
|
||||
});
|
||||
|
||||
/**
|
||||
* Get a specific achievement definition by key (public route).
|
||||
*/
|
||||
app.get<{ Params: { key: string } }>(
|
||||
"/definitions/:key",
|
||||
async (request, reply) => {
|
||||
const { key } = request.params;
|
||||
const achievement = ACHIEVEMENTS[key];
|
||||
|
||||
if (!achievement) {
|
||||
return reply.notFound("Achievement not found");
|
||||
}
|
||||
|
||||
return achievement;
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Get current user's achievement summary (authenticated users).
|
||||
*/
|
||||
app.get<{ Reply: UserAchievementSummary }>(
|
||||
"/summary",
|
||||
{
|
||||
preValidation: [app.authenticate],
|
||||
},
|
||||
async (request) => {
|
||||
const userId = request.user.id;
|
||||
const summary = await achievementService.getUserAchievementSummary(
|
||||
userId,
|
||||
);
|
||||
return summary;
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Get current user's achievement progress (authenticated users).
|
||||
*/
|
||||
app.get<{ Reply: AchievementProgress[] }>(
|
||||
"/progress",
|
||||
{
|
||||
preValidation: [app.authenticate],
|
||||
},
|
||||
async (request) => {
|
||||
const userId = request.user.id;
|
||||
const progress = await achievementService.getUserAchievementProgress(
|
||||
userId,
|
||||
);
|
||||
return progress;
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Get another user's achievement summary by ID (authenticated users).
|
||||
*/
|
||||
app.get<{ Params: { userId: string }; Reply: UserAchievementSummary }>(
|
||||
"/users/:userId/summary",
|
||||
{
|
||||
preValidation: [app.authenticate],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { userId } = request.params;
|
||||
|
||||
try {
|
||||
const summary = await achievementService.getUserAchievementSummary(
|
||||
userId,
|
||||
);
|
||||
return summary;
|
||||
} catch (error) {
|
||||
return reply.notFound("User not found");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Get another user's achievement progress by ID (authenticated users).
|
||||
*/
|
||||
app.get<{ Params: { userId: string }; Reply: AchievementProgress[] }>(
|
||||
"/users/:userId/progress",
|
||||
{
|
||||
preValidation: [app.authenticate],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { userId } = request.params;
|
||||
|
||||
try {
|
||||
const progress = await achievementService.getUserAchievementProgress(
|
||||
userId,
|
||||
);
|
||||
return progress;
|
||||
} catch (error) {
|
||||
return reply.notFound("User not found");
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export default achievementsRoutes;
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import type { ActivityFeedResponse } from "@library/shared-types";
|
||||
import { ActivityService } from "../../services/activity.service";
|
||||
|
||||
const activityRoutes: FastifyPluginAsync = async (app) => {
|
||||
const activityService = new ActivityService();
|
||||
|
||||
/**
|
||||
* Get activity feed with optional filters.
|
||||
*/
|
||||
app.get<{
|
||||
Querystring: { limit?: number; offset?: number; userId?: string };
|
||||
Reply: ActivityFeedResponse;
|
||||
}>("/", async (request) => {
|
||||
const limit = request.query.limit && request.query.limit > 0
|
||||
? Math.min(request.query.limit, 100)
|
||||
: 50;
|
||||
const offset = request.query.offset && request.query.offset >= 0
|
||||
? request.query.offset
|
||||
: 0;
|
||||
const userId = request.query.userId;
|
||||
|
||||
return activityService.getActivityFeed(limit, offset, userId);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get activity feed for a specific user.
|
||||
*/
|
||||
app.get<{
|
||||
Params: { userId: string };
|
||||
Querystring: { limit?: number; offset?: number };
|
||||
Reply: ActivityFeedResponse;
|
||||
}>("/:userId", async (request) => {
|
||||
const { userId } = request.params;
|
||||
const limit = request.query.limit && request.query.limit > 0
|
||||
? Math.min(request.query.limit, 100)
|
||||
: 50;
|
||||
const offset = request.query.offset && request.query.offset >= 0
|
||||
? request.query.offset
|
||||
: 0;
|
||||
|
||||
return activityService.getActivityFeed(limit, offset, userId);
|
||||
});
|
||||
};
|
||||
|
||||
export default activityRoutes;
|
||||
@@ -5,10 +5,11 @@
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import { Art, CreateArtDto, UpdateArtDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types";
|
||||
import { Art, CreateArtDto, UpdateArtDto, Comment, CreateCommentDto, AuditAction, AuditCategory, AchievementCategory } from "@library/shared-types";
|
||||
import { ArtService } from "../../services/art.service";
|
||||
import { CommentService } from "../../services/comment.service";
|
||||
import { AuditService } from "../../services/audit.service";
|
||||
import { AchievementService } from "../../services/achievement.service";
|
||||
import { adminGuard } from "../../middleware/admin-guard";
|
||||
import { bannedGuard } from "../../middleware/banned-guard";
|
||||
|
||||
@@ -46,8 +47,8 @@ const artRoutes: FastifyPluginAsync = async (app) => {
|
||||
async (request) => {
|
||||
const art = await artService.createArt(request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
action: AuditAction.entryCreate,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "art",
|
||||
resourceId: art.id,
|
||||
details: `Created art: ${art.title}`,
|
||||
@@ -74,8 +75,8 @@ const artRoutes: FastifyPluginAsync = async (app) => {
|
||||
const art = await artService.updateArt(id, request.body);
|
||||
if (art) {
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_UPDATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
action: AuditAction.entryUpdate,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "art",
|
||||
resourceId: id,
|
||||
details: `Updated art: ${art.title}`,
|
||||
@@ -98,8 +99,8 @@ const artRoutes: FastifyPluginAsync = async (app) => {
|
||||
const { id } = request.params;
|
||||
await artService.deleteArt(id);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_DELETE,
|
||||
category: AuditCategory.CONTENT,
|
||||
action: AuditAction.entryDelete,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "art",
|
||||
resourceId: id,
|
||||
details: `Deleted art with ID: ${id}`,
|
||||
@@ -133,12 +134,21 @@ const artRoutes: FastifyPluginAsync = async (app) => {
|
||||
const userId = request.user.id;
|
||||
const comment = await commentService.createCommentForArt(id, userId, request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
action: AuditAction.commentCreate,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "art",
|
||||
resourceId: id,
|
||||
details: `Added comment to art`,
|
||||
});
|
||||
|
||||
// Check for comment achievements
|
||||
const achievementService = new AchievementService();
|
||||
await achievementService.checkAchievements(
|
||||
userId,
|
||||
AchievementCategory.Comment,
|
||||
request
|
||||
);
|
||||
|
||||
return comment;
|
||||
}
|
||||
);
|
||||
@@ -169,8 +179,8 @@ const artRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
const comment = await commentService.updateComment(commentId, request.body.content);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_UPDATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
action: AuditAction.commentUpdate,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "art",
|
||||
resourceId: id,
|
||||
details: `Updated comment ${commentId} on art`,
|
||||
@@ -205,8 +215,8 @@ const artRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
await commentService.deleteComment(commentId);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_DELETE,
|
||||
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT,
|
||||
action: AuditAction.commentDelete,
|
||||
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.admin : AuditCategory.content,
|
||||
resourceType: "art",
|
||||
resourceId: id,
|
||||
details: `Deleted comment ${commentId} from art`,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import { AuthService } from "../../services/auth.service";
|
||||
import { AuditService } from "../../services/audit.service";
|
||||
import { AuthResponse, AuditAction, AuditCategory } from "@library/shared-types";
|
||||
import { AchievementService } from "../../services/achievement.service";
|
||||
import { AuthResponse, AuditAction, AuditCategory, AchievementCategory } from "@library/shared-types";
|
||||
|
||||
const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
const authService = new AuthService(app);
|
||||
@@ -85,13 +86,22 @@ const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
// Log successful login
|
||||
await AuditService.log({
|
||||
action: AuditAction.LOGIN,
|
||||
category: AuditCategory.AUTH,
|
||||
action: AuditAction.login,
|
||||
category: AuditCategory.auth,
|
||||
userId: user.id,
|
||||
details: `User ${user.username} logged in via Discord`,
|
||||
success: true,
|
||||
}, request);
|
||||
|
||||
// Update login streak and check engagement achievements
|
||||
const achievementService = new AchievementService();
|
||||
await achievementService.updateLoginStreak(user.id);
|
||||
await achievementService.checkAchievements(
|
||||
user.id,
|
||||
AchievementCategory.Engagement,
|
||||
request
|
||||
);
|
||||
|
||||
// Set signed cookies and redirect to frontend
|
||||
reply
|
||||
.setCookie("auth-token", accessToken, {
|
||||
@@ -114,8 +124,8 @@ const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
} catch (error) {
|
||||
// Log failed login attempt
|
||||
await AuditService.log({
|
||||
action: AuditAction.LOGIN_FAILED,
|
||||
category: AuditCategory.SECURITY,
|
||||
action: AuditAction.loginFailed,
|
||||
category: AuditCategory.security,
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
success: false,
|
||||
}, request);
|
||||
@@ -229,8 +239,8 @@ const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
const user = request.user as { id?: string; username?: string };
|
||||
if (user?.id) {
|
||||
await AuditService.log({
|
||||
action: AuditAction.LOGOUT,
|
||||
category: AuditCategory.AUTH,
|
||||
action: AuditAction.logout,
|
||||
category: AuditCategory.auth,
|
||||
userId: user.id,
|
||||
details: `User ${user.username ?? "unknown"} logged out`,
|
||||
success: true,
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import { Book, CreateBookDto, UpdateBookDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types";
|
||||
import { Book, CreateBookDto, UpdateBookDto, Comment, CreateCommentDto, AuditAction, AuditCategory, AchievementCategory } from "@library/shared-types";
|
||||
import { BookService } from "../../services/book.service";
|
||||
import { CommentService } from "../../services/comment.service";
|
||||
import { AuditService } from "../../services/audit.service";
|
||||
import { AchievementService } from "../../services/achievement.service";
|
||||
import { adminGuard } from "../../middleware/admin-guard";
|
||||
import { bannedGuard } from "../../middleware/banned-guard";
|
||||
|
||||
@@ -23,6 +24,17 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
|
||||
return bookService.getAllBooks();
|
||||
});
|
||||
|
||||
/**
|
||||
* Get all books in a series (public route).
|
||||
*/
|
||||
app.get<{ Params: { seriesName: string }; Reply: Book[] }>(
|
||||
"/series/:seriesName",
|
||||
async (request) => {
|
||||
const { seriesName } = request.params;
|
||||
return bookService.getBooksBySeries(seriesName);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get single book by ID (public route).
|
||||
*/
|
||||
@@ -46,8 +58,8 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
|
||||
async (request) => {
|
||||
const book = await bookService.createBook(request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
action: AuditAction.entryCreate,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "book",
|
||||
resourceId: book.id,
|
||||
details: `Created book: ${book.title}`,
|
||||
@@ -74,8 +86,8 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
|
||||
const book = await bookService.updateBook(id, request.body);
|
||||
if (book) {
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_UPDATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
action: AuditAction.entryUpdate,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "book",
|
||||
resourceId: id,
|
||||
details: `Updated book: ${book.title}`,
|
||||
@@ -98,8 +110,8 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
|
||||
const { id } = request.params;
|
||||
await bookService.deleteBook(id);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_DELETE,
|
||||
category: AuditCategory.CONTENT,
|
||||
action: AuditAction.entryDelete,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "book",
|
||||
resourceId: id,
|
||||
details: `Deleted book with ID: ${id}`,
|
||||
@@ -133,12 +145,21 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
|
||||
const userId = request.user.id;
|
||||
const comment = await commentService.createCommentForBook(id, userId, request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
action: AuditAction.commentCreate,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "book",
|
||||
resourceId: id,
|
||||
details: `Added comment to book`,
|
||||
});
|
||||
|
||||
// Check for comment achievements
|
||||
const achievementService = new AchievementService();
|
||||
await achievementService.checkAchievements(
|
||||
userId,
|
||||
AchievementCategory.Comment,
|
||||
request
|
||||
);
|
||||
|
||||
return comment;
|
||||
}
|
||||
);
|
||||
@@ -169,8 +190,8 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
const comment = await commentService.updateComment(commentId, request.body.content);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_UPDATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
action: AuditAction.commentUpdate,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "book",
|
||||
resourceId: id,
|
||||
details: `Updated comment ${commentId} on book`,
|
||||
@@ -205,8 +226,8 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
await commentService.deleteComment(commentId);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_DELETE,
|
||||
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT,
|
||||
action: AuditAction.commentDelete,
|
||||
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.admin : AuditCategory.content,
|
||||
resourceType: "book",
|
||||
resourceId: id,
|
||||
details: `Deleted comment ${commentId} from book`,
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* @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, AchievementCategory } from "@library/shared-types";
|
||||
|
||||
import { CommentReportService } from "../../services/comment-report.service.js";
|
||||
import { AchievementService } from "../../services/achievement.service";
|
||||
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,
|
||||
);
|
||||
|
||||
// Check for report achievements for the original reporter
|
||||
if (report.status === "ACTION_TAKEN" || report.status === "DISMISSED") {
|
||||
const achievementService = new AchievementService();
|
||||
await achievementService.checkAchievements(
|
||||
report.reporterId,
|
||||
AchievementCategory.Report,
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
return reply.send(report);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export default commentReportsRoutes;
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import { Comment, AuditAction, AuditCategory } from "@library/shared-types";
|
||||
import { CommentService } from "../../services/comment.service";
|
||||
import { AuditService } from "../../services/audit.service";
|
||||
import { adminGuard } from "../../middleware/admin-guard";
|
||||
|
||||
interface UpdateCommentBody {
|
||||
content: string;
|
||||
}
|
||||
|
||||
const commentsRoutes: FastifyPluginAsync = async (app) => {
|
||||
const commentService = new CommentService();
|
||||
|
||||
// Admin: Update any comment by ID
|
||||
app.put<{ Params: { id: string }; Body: UpdateCommentBody; Reply: Comment | { error: string } }>(
|
||||
"/:id",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const { content } = request.body;
|
||||
|
||||
const existingComment = await commentService.getCommentById(id);
|
||||
if (!existingComment) {
|
||||
return reply.code(404).send({ error: "Comment not found" });
|
||||
}
|
||||
|
||||
const comment = await commentService.updateComment(id, content);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.commentUpdate,
|
||||
category: AuditCategory.admin,
|
||||
details: `Admin updated comment ${id}`,
|
||||
});
|
||||
return comment;
|
||||
}
|
||||
);
|
||||
|
||||
// Admin: Delete any comment by ID
|
||||
app.delete<{ Params: { id: string }; Reply: { success: boolean } | { error: string } }>(
|
||||
"/:id",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
|
||||
const existingComment = await commentService.getCommentById(id);
|
||||
if (!existingComment) {
|
||||
return reply.code(404).send({ error: "Comment not found" });
|
||||
}
|
||||
|
||||
await commentService.deleteComment(id);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.commentDelete,
|
||||
category: AuditCategory.admin,
|
||||
details: `Admin deleted comment ${id}`,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default commentsRoutes;
|
||||
@@ -5,10 +5,11 @@
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import { Game, CreateGameDto, UpdateGameDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types";
|
||||
import { Game, CreateGameDto, UpdateGameDto, Comment, CreateCommentDto, AuditAction, AuditCategory, AchievementCategory } from "@library/shared-types";
|
||||
import { GameService } from "../../services/game.service";
|
||||
import { CommentService } from "../../services/comment.service";
|
||||
import { AuditService } from "../../services/audit.service";
|
||||
import { AchievementService } from "../../services/achievement.service";
|
||||
import { adminGuard } from "../../middleware/admin-guard";
|
||||
import { bannedGuard } from "../../middleware/banned-guard";
|
||||
|
||||
@@ -21,6 +22,15 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
|
||||
return gameService.getAllGames();
|
||||
});
|
||||
|
||||
// Get all games in a series (public route)
|
||||
app.get<{ Params: { seriesName: string }; Reply: Game[] }>(
|
||||
"/series/:seriesName",
|
||||
async (request) => {
|
||||
const { seriesName } = request.params;
|
||||
return gameService.getGamesBySeries(seriesName);
|
||||
}
|
||||
);
|
||||
|
||||
// Get single game (public route)
|
||||
app.get<{ Params: { id: string }; Reply: Game | null }>(
|
||||
"/:id",
|
||||
@@ -40,8 +50,8 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
|
||||
async (request) => {
|
||||
const game = await gameService.createGame(request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
action: AuditAction.entryCreate,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "game",
|
||||
resourceId: game.id,
|
||||
details: `Created game: ${game.title}`,
|
||||
@@ -66,8 +76,8 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
|
||||
const game = await gameService.updateGame(id, request.body);
|
||||
if (game) {
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_UPDATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
action: AuditAction.entryUpdate,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "game",
|
||||
resourceId: id,
|
||||
details: `Updated game: ${game.title}`,
|
||||
@@ -88,8 +98,8 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
|
||||
const { id } = request.params;
|
||||
await gameService.deleteGame(id);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_DELETE,
|
||||
category: AuditCategory.CONTENT,
|
||||
action: AuditAction.entryDelete,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "game",
|
||||
resourceId: id,
|
||||
details: `Deleted game with ID: ${id}`,
|
||||
@@ -119,12 +129,21 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
|
||||
const userId = request.user.id;
|
||||
const comment = await commentService.createCommentForGame(id, userId, request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
action: AuditAction.commentCreate,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "game",
|
||||
resourceId: id,
|
||||
details: `Added comment to game`,
|
||||
});
|
||||
|
||||
// Check for comment achievements
|
||||
const achievementService = new AchievementService();
|
||||
await achievementService.checkAchievements(
|
||||
userId,
|
||||
AchievementCategory.Comment,
|
||||
request
|
||||
);
|
||||
|
||||
return comment;
|
||||
}
|
||||
);
|
||||
@@ -153,8 +172,8 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
const comment = await commentService.updateComment(commentId, request.body.content);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_UPDATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
action: AuditAction.commentUpdate,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "game",
|
||||
resourceId: id,
|
||||
details: `Updated comment ${commentId} on game`,
|
||||
@@ -187,8 +206,8 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
await commentService.deleteComment(commentId);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_DELETE,
|
||||
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT,
|
||||
action: AuditAction.commentDelete,
|
||||
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.admin : AuditCategory.content,
|
||||
resourceType: "game",
|
||||
resourceId: id,
|
||||
details: `Deleted comment ${commentId} from game`,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import type {
|
||||
LeaderboardResponse,
|
||||
SuggestionsLeaderboard,
|
||||
LikesLeaderboard,
|
||||
CommentsLeaderboard,
|
||||
OverallLeaderboard,
|
||||
} from "@library/shared-types";
|
||||
import { LeaderboardService } from "../../services/leaderboard.service";
|
||||
|
||||
const leaderboardRoutes: FastifyPluginAsync = async (app) => {
|
||||
const leaderboardService = new LeaderboardService();
|
||||
/**
|
||||
* Get all leaderboards at once.
|
||||
*/
|
||||
app.get<{
|
||||
Querystring: { limit?: number };
|
||||
Reply: LeaderboardResponse;
|
||||
}>("/", async (request) => {
|
||||
const limit = request.query.limit && request.query.limit > 0
|
||||
? Math.min(request.query.limit, 100)
|
||||
: 25;
|
||||
return leaderboardService.getAllLeaderboards(limit);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get top users by suggestions.
|
||||
*/
|
||||
app.get<{
|
||||
Querystring: { limit?: number };
|
||||
Reply: SuggestionsLeaderboard[];
|
||||
}>("/suggestions", async (request) => {
|
||||
const limit = request.query.limit && request.query.limit > 0
|
||||
? Math.min(request.query.limit, 100)
|
||||
: 25;
|
||||
return leaderboardService.getTopSuggestions(limit);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get top users by likes.
|
||||
*/
|
||||
app.get<{
|
||||
Querystring: { limit?: number };
|
||||
Reply: LikesLeaderboard[];
|
||||
}>("/likes", async (request) => {
|
||||
const limit = request.query.limit && request.query.limit > 0
|
||||
? Math.min(request.query.limit, 100)
|
||||
: 25;
|
||||
return leaderboardService.getTopLikes(limit);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get top users by comments.
|
||||
*/
|
||||
app.get<{
|
||||
Querystring: { limit?: number };
|
||||
Reply: CommentsLeaderboard[];
|
||||
}>("/comments", async (request) => {
|
||||
const limit = request.query.limit && request.query.limit > 0
|
||||
? Math.min(request.query.limit, 100)
|
||||
: 25;
|
||||
return leaderboardService.getTopComments(limit);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get overall leaderboard.
|
||||
*/
|
||||
app.get<{
|
||||
Querystring: { limit?: number };
|
||||
Reply: OverallLeaderboard[];
|
||||
}>("/overall", async (request) => {
|
||||
const limit = request.query.limit && request.query.limit > 0
|
||||
? Math.min(request.query.limit, 100)
|
||||
: 25;
|
||||
return leaderboardService.getOverallLeaderboard(limit);
|
||||
});
|
||||
};
|
||||
|
||||
export default leaderboardRoutes;
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @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.error('Frontend', new Error(message));
|
||||
} else {
|
||||
const logMessage = context ? `[${context}] ${message}` : message;
|
||||
await logger.log(level, logMessage);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
@@ -5,10 +5,11 @@
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import { Manga, CreateMangaDto, UpdateMangaDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types";
|
||||
import { Manga, CreateMangaDto, UpdateMangaDto, Comment, CreateCommentDto, AuditAction, AuditCategory, AchievementCategory } from "@library/shared-types";
|
||||
import { MangaService } from "../../services/manga.service";
|
||||
import { CommentService } from "../../services/comment.service";
|
||||
import { AuditService } from "../../services/audit.service";
|
||||
import { AchievementService } from "../../services/achievement.service";
|
||||
import { adminGuard } from "../../middleware/admin-guard";
|
||||
import { bannedGuard } from "../../middleware/banned-guard";
|
||||
|
||||
@@ -37,8 +38,8 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
|
||||
async (request) => {
|
||||
const manga = await mangaService.createManga(request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
action: AuditAction.entryCreate,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "manga",
|
||||
resourceId: manga.id,
|
||||
details: `Created manga: ${manga.title}`,
|
||||
@@ -62,8 +63,8 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
|
||||
const manga = await mangaService.updateManga(id, request.body);
|
||||
if (manga) {
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_UPDATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
action: AuditAction.entryUpdate,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "manga",
|
||||
resourceId: id,
|
||||
details: `Updated manga: ${manga.title}`,
|
||||
@@ -83,8 +84,8 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
|
||||
const { id } = request.params;
|
||||
await mangaService.deleteManga(id);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_DELETE,
|
||||
category: AuditCategory.CONTENT,
|
||||
action: AuditAction.entryDelete,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "manga",
|
||||
resourceId: id,
|
||||
details: `Deleted manga with ID: ${id}`,
|
||||
@@ -112,12 +113,21 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
|
||||
const userId = request.user.id;
|
||||
const comment = await commentService.createCommentForManga(id, userId, request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
action: AuditAction.commentCreate,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "manga",
|
||||
resourceId: id,
|
||||
details: `Added comment to manga`,
|
||||
});
|
||||
|
||||
// Check for comment achievements
|
||||
const achievementService = new AchievementService();
|
||||
await achievementService.checkAchievements(
|
||||
userId,
|
||||
AchievementCategory.Comment,
|
||||
request
|
||||
);
|
||||
|
||||
return comment;
|
||||
}
|
||||
);
|
||||
@@ -145,8 +155,8 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
const comment = await commentService.updateComment(commentId, request.body.content);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_UPDATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
action: AuditAction.commentUpdate,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "manga",
|
||||
resourceId: id,
|
||||
details: `Updated comment ${commentId} on manga`,
|
||||
@@ -178,8 +188,8 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
await commentService.deleteComment(commentId);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_DELETE,
|
||||
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT,
|
||||
action: AuditAction.commentDelete,
|
||||
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.admin : AuditCategory.content,
|
||||
resourceType: "manga",
|
||||
resourceId: id,
|
||||
details: `Deleted comment ${commentId} from manga`,
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import { Music, CreateMusicDto, UpdateMusicDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types";
|
||||
import { Music, CreateMusicDto, UpdateMusicDto, Comment, CreateCommentDto, AuditAction, AuditCategory, AchievementCategory } from "@library/shared-types";
|
||||
import { MusicService } from "../../services/music.service";
|
||||
import { CommentService } from "../../services/comment.service";
|
||||
import { AuditService } from "../../services/audit.service";
|
||||
import { AchievementService } from "../../services/achievement.service";
|
||||
import { adminGuard } from "../../middleware/admin-guard";
|
||||
import { bannedGuard } from "../../middleware/banned-guard";
|
||||
|
||||
@@ -46,8 +47,8 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
|
||||
async (request) => {
|
||||
const music = await musicService.createMusic(request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
action: AuditAction.entryCreate,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "music",
|
||||
resourceId: music.id,
|
||||
details: `Created music: ${music.title}`,
|
||||
@@ -74,8 +75,8 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
|
||||
const music = await musicService.updateMusic(id, request.body);
|
||||
if (music) {
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_UPDATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
action: AuditAction.entryUpdate,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "music",
|
||||
resourceId: id,
|
||||
details: `Updated music: ${music.title}`,
|
||||
@@ -98,8 +99,8 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
|
||||
const { id } = request.params;
|
||||
await musicService.deleteMusic(id);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_DELETE,
|
||||
category: AuditCategory.CONTENT,
|
||||
action: AuditAction.entryDelete,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "music",
|
||||
resourceId: id,
|
||||
details: `Deleted music with ID: ${id}`,
|
||||
@@ -133,12 +134,21 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
|
||||
const userId = request.user.id;
|
||||
const comment = await commentService.createCommentForMusic(id, userId, request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
action: AuditAction.commentCreate,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "music",
|
||||
resourceId: id,
|
||||
details: `Added comment to music`,
|
||||
});
|
||||
|
||||
// Check for comment achievements
|
||||
const achievementService = new AchievementService();
|
||||
await achievementService.checkAchievements(
|
||||
userId,
|
||||
AchievementCategory.Comment,
|
||||
request
|
||||
);
|
||||
|
||||
return comment;
|
||||
}
|
||||
);
|
||||
@@ -169,8 +179,8 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
const comment = await commentService.updateComment(commentId, request.body.content);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_UPDATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
action: AuditAction.commentUpdate,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "music",
|
||||
resourceId: id,
|
||||
details: `Updated comment ${commentId} on music`,
|
||||
@@ -205,8 +215,8 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
await commentService.deleteComment(commentId);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_DELETE,
|
||||
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT,
|
||||
action: AuditAction.commentDelete,
|
||||
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.admin : AuditCategory.content,
|
||||
resourceType: "music",
|
||||
resourceId: id,
|
||||
details: `Deleted comment ${commentId} from music`,
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* @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, AchievementCategory } from "@library/shared-types";
|
||||
|
||||
import { ReportService } from "../../services/report.service.js";
|
||||
import { AchievementService } from "../../services/achievement.service";
|
||||
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,
|
||||
);
|
||||
|
||||
// Check for report achievements for the original reporter
|
||||
if (report.status === "ACTION_TAKEN" || report.status === "DISMISSED") {
|
||||
const achievementService = new AchievementService();
|
||||
await achievementService.checkAchievements(
|
||||
report.reporterId,
|
||||
AchievementCategory.Report,
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
return reply.send(report);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export default reportsRoutes;
|
||||
@@ -1,7 +1,30 @@
|
||||
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) {
|
||||
fastify.get('/', async function () {
|
||||
return { message: 'Hello API' };
|
||||
return { version: getVersion() };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import { Show, CreateShowDto, UpdateShowDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types";
|
||||
import { Show, CreateShowDto, UpdateShowDto, Comment, CreateCommentDto, AuditAction, AuditCategory, AchievementCategory } from "@library/shared-types";
|
||||
import { ShowService } from "../../services/show.service";
|
||||
import { CommentService } from "../../services/comment.service";
|
||||
import { AuditService } from "../../services/audit.service";
|
||||
import { AchievementService } from "../../services/achievement.service";
|
||||
import { adminGuard } from "../../middleware/admin-guard";
|
||||
import { bannedGuard } from "../../middleware/banned-guard";
|
||||
|
||||
@@ -37,8 +38,8 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
|
||||
async (request) => {
|
||||
const show = await showService.createShow(request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
action: AuditAction.entryCreate,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "show",
|
||||
resourceId: show.id,
|
||||
details: `Created show: ${show.title}`,
|
||||
@@ -62,8 +63,8 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
|
||||
const show = await showService.updateShow(id, request.body);
|
||||
if (show) {
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_UPDATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
action: AuditAction.entryUpdate,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "show",
|
||||
resourceId: id,
|
||||
details: `Updated show: ${show.title}`,
|
||||
@@ -83,8 +84,8 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
|
||||
const { id } = request.params;
|
||||
await showService.deleteShow(id);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_DELETE,
|
||||
category: AuditCategory.CONTENT,
|
||||
action: AuditAction.entryDelete,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "show",
|
||||
resourceId: id,
|
||||
details: `Deleted show with ID: ${id}`,
|
||||
@@ -112,12 +113,21 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
|
||||
const userId = request.user.id;
|
||||
const comment = await commentService.createCommentForShow(id, userId, request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
action: AuditAction.commentCreate,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "show",
|
||||
resourceId: id,
|
||||
details: `Added comment to show`,
|
||||
});
|
||||
|
||||
// Check for comment achievements
|
||||
const achievementService = new AchievementService();
|
||||
await achievementService.checkAchievements(
|
||||
userId,
|
||||
AchievementCategory.Comment,
|
||||
request
|
||||
);
|
||||
|
||||
return comment;
|
||||
}
|
||||
);
|
||||
@@ -145,8 +155,8 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
const comment = await commentService.updateComment(commentId, request.body.content);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_UPDATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
action: AuditAction.commentUpdate,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "show",
|
||||
resourceId: id,
|
||||
details: `Updated comment ${commentId} on show`,
|
||||
@@ -178,8 +188,8 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
await commentService.deleteComment(commentId);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_DELETE,
|
||||
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT,
|
||||
action: AuditAction.commentDelete,
|
||||
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.admin : AuditCategory.content,
|
||||
resourceType: "show",
|
||||
resourceId: id,
|
||||
details: `Deleted comment ${commentId} from show`,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { SuggestionService } from "../../services/suggestion.service";
|
||||
import { AuditService } from "../../services/audit.service";
|
||||
import { AuditAction, AuditCategory } from "@library/shared-types";
|
||||
import { AchievementService } from "../../services/achievement.service";
|
||||
import { AuditAction, AuditCategory, AchievementCategory } from "@library/shared-types";
|
||||
import type {
|
||||
SuggestionStatus,
|
||||
SuggestionEntity,
|
||||
@@ -85,14 +86,22 @@ export default async function (app: FastifyInstance): Promise<void> {
|
||||
);
|
||||
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
action: AuditAction.entryCreate,
|
||||
category: AuditCategory.content,
|
||||
resourceType: "Suggestion",
|
||||
resourceId: suggestion.id,
|
||||
details: `Created ${suggestion.entityType} suggestion: ${suggestion.title}`,
|
||||
success: true,
|
||||
});
|
||||
|
||||
// Check for suggestion achievements
|
||||
const achievementService = new AchievementService();
|
||||
await achievementService.checkAchievements(
|
||||
userId,
|
||||
AchievementCategory.Suggestion,
|
||||
request
|
||||
);
|
||||
|
||||
reply.send(suggestion);
|
||||
} catch (error) {
|
||||
return reply.badRequest(
|
||||
@@ -115,14 +124,22 @@ export default async function (app: FastifyInstance): Promise<void> {
|
||||
const suggestion = await SuggestionService.acceptSuggestion(id);
|
||||
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_UPDATE,
|
||||
category: AuditCategory.ADMIN,
|
||||
action: AuditAction.entryUpdate,
|
||||
category: AuditCategory.admin,
|
||||
resourceType: "Suggestion",
|
||||
resourceId: suggestion.id,
|
||||
details: `Accepted ${suggestion.entityType} suggestion: ${suggestion.title}`,
|
||||
success: true,
|
||||
});
|
||||
|
||||
// Check for suggestion achievements for the user who made the suggestion
|
||||
const achievementService = new AchievementService();
|
||||
await achievementService.checkAchievements(
|
||||
suggestion.userId,
|
||||
AchievementCategory.Suggestion,
|
||||
request
|
||||
);
|
||||
|
||||
reply.send(suggestion);
|
||||
} catch (error) {
|
||||
return reply.badRequest(
|
||||
@@ -146,14 +163,22 @@ export default async function (app: FastifyInstance): Promise<void> {
|
||||
const suggestion = await SuggestionService.acceptSuggestionWithEdits(id, editedData);
|
||||
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_UPDATE,
|
||||
category: AuditCategory.ADMIN,
|
||||
action: AuditAction.entryUpdate,
|
||||
category: AuditCategory.admin,
|
||||
resourceType: "Suggestion",
|
||||
resourceId: suggestion.id,
|
||||
details: `Accepted ${suggestion.entityType} suggestion with edits: ${suggestion.title}`,
|
||||
success: true,
|
||||
});
|
||||
|
||||
// Check for suggestion achievements for the user who made the suggestion
|
||||
const achievementService = new AchievementService();
|
||||
await achievementService.checkAchievements(
|
||||
suggestion.userId,
|
||||
AchievementCategory.Suggestion,
|
||||
request
|
||||
);
|
||||
|
||||
reply.send(suggestion);
|
||||
} catch (error) {
|
||||
return reply.badRequest(
|
||||
@@ -177,8 +202,8 @@ export default async function (app: FastifyInstance): Promise<void> {
|
||||
const suggestion = await SuggestionService.declineSuggestion(id, reason);
|
||||
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_UPDATE,
|
||||
category: AuditCategory.ADMIN,
|
||||
action: AuditAction.entryUpdate,
|
||||
category: AuditCategory.admin,
|
||||
resourceType: "Suggestion",
|
||||
resourceId: suggestion.id,
|
||||
details: `Declined ${suggestion.entityType} suggestion: ${suggestion.title}${reason ? ` (Reason: ${reason})` : ""}`,
|
||||
@@ -209,8 +234,8 @@ export default async function (app: FastifyInstance): Promise<void> {
|
||||
const suggestion = await SuggestionService.deleteSuggestion(id, userId, isAdmin);
|
||||
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_DELETE,
|
||||
category: isAdmin ? AuditCategory.ADMIN : AuditCategory.CONTENT,
|
||||
action: AuditAction.entryDelete,
|
||||
category: isAdmin ? AuditCategory.admin : AuditCategory.content,
|
||||
resourceType: "Suggestion",
|
||||
resourceId: suggestion.id,
|
||||
details: `Deleted ${suggestion.entityType} suggestion: ${suggestion.title}`,
|
||||
|
||||
@@ -5,11 +5,56 @@
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import { User, AuditAction, AuditCategory } from "@library/shared-types";
|
||||
import { User, AuditAction, AuditCategory, PrimaryBadge } from "@library/shared-types";
|
||||
import { UserService } from "../../services/user.service";
|
||||
import { AuditService } from "../../services/audit.service";
|
||||
import { adminGuard } from "../../middleware/admin-guard";
|
||||
|
||||
interface UpdateUserSettingsBody {
|
||||
slug?: string;
|
||||
displayName?: string;
|
||||
bio?: string;
|
||||
profilePublic?: boolean;
|
||||
primaryBadge?: PrimaryBadge;
|
||||
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;
|
||||
primaryBadge?: PrimaryBadge;
|
||||
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 userService = new UserService();
|
||||
|
||||
@@ -23,6 +68,108 @@ 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,
|
||||
primaryBadge: profile.primaryBadge,
|
||||
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 }>(
|
||||
"/:id",
|
||||
{
|
||||
@@ -54,8 +201,8 @@ const usersRoutes: FastifyPluginAsync = async (app) => {
|
||||
}
|
||||
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.USER_BAN,
|
||||
category: AuditCategory.ADMIN,
|
||||
action: AuditAction.userBan,
|
||||
category: AuditCategory.admin,
|
||||
targetUserId: id,
|
||||
details: `Banned user: ${user.username}`,
|
||||
});
|
||||
@@ -78,8 +225,8 @@ const usersRoutes: FastifyPluginAsync = async (app) => {
|
||||
}
|
||||
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.USER_UNBAN,
|
||||
category: AuditCategory.ADMIN,
|
||||
action: AuditAction.userUnban,
|
||||
category: AuditCategory.admin,
|
||||
targetUserId: id,
|
||||
details: `Unbanned user: ${user.username}`,
|
||||
});
|
||||
@@ -87,6 +234,64 @@ const usersRoutes: FastifyPluginAsync = async (app) => {
|
||||
return user;
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string }; Reply: User | { error: string } }>(
|
||||
"/:id/make-private",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const user = await userService.updateUserSettings(id, { profilePublic: false });
|
||||
if (!user) {
|
||||
return reply.code(404).send({ error: "User not found" });
|
||||
}
|
||||
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.entryUpdate,
|
||||
category: AuditCategory.admin,
|
||||
targetUserId: id,
|
||||
details: `Admin made profile private for user: ${user.username}`,
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
);
|
||||
|
||||
app.put<{ Params: { id: string }; Body: UpdateUserSettingsBody; Reply: User | { error: string } }>(
|
||||
"/:id",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
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 !== id) {
|
||||
return reply.code(400).send({ error: "Slug already taken" });
|
||||
}
|
||||
}
|
||||
|
||||
const updatedUser = await userService.updateUserSettings(id, updates);
|
||||
if (!updatedUser) {
|
||||
return reply.code(404).send({ error: "User not found" });
|
||||
}
|
||||
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.entryUpdate,
|
||||
category: AuditCategory.admin,
|
||||
targetUserId: id,
|
||||
details: `Admin updated profile for user: ${updatedUser.username}`,
|
||||
});
|
||||
|
||||
return updatedUser;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default usersRoutes;
|
||||
|
||||
@@ -0,0 +1,772 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import type { FastifyRequest } from "fastify";
|
||||
import {
|
||||
ACHIEVEMENTS,
|
||||
ACHIEVEMENT_LIST,
|
||||
AchievementCategory,
|
||||
AchievementDefinition,
|
||||
AchievementProgress,
|
||||
AuditAction,
|
||||
AuditCategory,
|
||||
UserAchievementSummary,
|
||||
} from "@library/shared-types";
|
||||
|
||||
import { prisma } from "../lib/prisma";
|
||||
import { AuditService } from "./audit.service";
|
||||
|
||||
export class AchievementService {
|
||||
/**
|
||||
* Check and award achievements for a user after an action.
|
||||
* Returns list of newly earned achievements.
|
||||
*/
|
||||
async checkAchievements(
|
||||
userId: string,
|
||||
category: AchievementCategory,
|
||||
req: FastifyRequest,
|
||||
): Promise<AchievementDefinition[]> {
|
||||
const relevantAchievements = ACHIEVEMENT_LIST.filter(
|
||||
(ach) => ach.category === category,
|
||||
);
|
||||
const newlyEarned: AchievementDefinition[] = [];
|
||||
|
||||
for (const achievement of relevantAchievements) {
|
||||
const userAchievement = await prisma.userAchievement.findUnique({
|
||||
where: {
|
||||
userId_achievementKey: {
|
||||
userId,
|
||||
achievementKey: achievement.key,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Skip already earned achievements
|
||||
if (userAchievement?.earned) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if achievement is now earned
|
||||
const earned = await this.checkAchievementCondition(userId, achievement);
|
||||
|
||||
if (earned) {
|
||||
await this.awardAchievement(userId, achievement, req);
|
||||
newlyEarned.push(achievement);
|
||||
}
|
||||
}
|
||||
|
||||
return newlyEarned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Award an achievement to a user.
|
||||
*/
|
||||
private async awardAchievement(
|
||||
userId: string,
|
||||
achievement: AchievementDefinition,
|
||||
req: FastifyRequest,
|
||||
): Promise<void> {
|
||||
await prisma.userAchievement.upsert({
|
||||
where: {
|
||||
userId_achievementKey: {
|
||||
userId,
|
||||
achievementKey: achievement.key,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
achievementKey: achievement.key,
|
||||
progress: 100,
|
||||
earned: true,
|
||||
earnedAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
earned: true,
|
||||
earnedAt: new Date(),
|
||||
progress: 100,
|
||||
},
|
||||
});
|
||||
|
||||
// Update user's achievement points
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
achievementPoints: {
|
||||
increment: achievement.points,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Log the achievement unlock
|
||||
await AuditService.logFromRequest(req, {
|
||||
action: AuditAction.achievementUnlocked,
|
||||
category: AuditCategory.content,
|
||||
details: `Unlocked achievement: ${achievement.title}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific achievement condition is met.
|
||||
*/
|
||||
private async checkAchievementCondition(
|
||||
userId: string,
|
||||
achievement: AchievementDefinition,
|
||||
): Promise<boolean> {
|
||||
switch (achievement.category) {
|
||||
case AchievementCategory.Suggestion:
|
||||
return await this.checkSuggestionAchievement(userId, achievement);
|
||||
case AchievementCategory.Like:
|
||||
return await this.checkLikeAchievement(userId, achievement);
|
||||
case AchievementCategory.Comment:
|
||||
return await this.checkCommentAchievement(userId, achievement);
|
||||
case AchievementCategory.Engagement:
|
||||
return await this.checkEngagementAchievement(userId, achievement);
|
||||
case AchievementCategory.Report:
|
||||
return await this.checkReportAchievement(userId, achievement);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check suggestion-based achievements.
|
||||
*/
|
||||
private async checkSuggestionAchievement(
|
||||
userId: string,
|
||||
achievement: AchievementDefinition,
|
||||
): Promise<boolean> {
|
||||
const { requirements } = achievement;
|
||||
|
||||
// Count-based achievements (total suggestions)
|
||||
if (
|
||||
achievement.key.startsWith("suggestion_first_steps") ||
|
||||
achievement.key.startsWith("suggestion_contributor") ||
|
||||
achievement.key.startsWith("suggestion_dedicated") ||
|
||||
achievement.key.startsWith("suggestion_master") ||
|
||||
achievement.key.startsWith("suggestion_legend")
|
||||
) {
|
||||
const count = await prisma.suggestion.count({
|
||||
where: { userId },
|
||||
});
|
||||
return count >= (requirements.count ?? 0);
|
||||
}
|
||||
|
||||
// Accepted suggestion achievements
|
||||
if (achievement.key.startsWith("suggestion_quality")) {
|
||||
const count = await prisma.suggestion.count({
|
||||
where: {
|
||||
userId,
|
||||
status: "ACCEPTED",
|
||||
},
|
||||
});
|
||||
return count >= (requirements.count ?? 0);
|
||||
}
|
||||
|
||||
// Approved achievement (first accepted)
|
||||
if (achievement.key === "suggestion_approved") {
|
||||
const count = await prisma.suggestion.count({
|
||||
where: {
|
||||
userId,
|
||||
status: "ACCEPTED",
|
||||
},
|
||||
});
|
||||
return count >= 1;
|
||||
}
|
||||
|
||||
// Acceptance rate achievements
|
||||
if (achievement.key.startsWith("suggestion_acceptance")) {
|
||||
const total = await prisma.suggestion.count({
|
||||
where: { userId },
|
||||
});
|
||||
const accepted = await prisma.suggestion.count({
|
||||
where: {
|
||||
userId,
|
||||
status: "ACCEPTED",
|
||||
},
|
||||
});
|
||||
|
||||
if (total < (requirements.count ?? 0)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rate = accepted / total;
|
||||
return rate >= (requirements.rate ?? 0);
|
||||
}
|
||||
|
||||
// Diversity achievement (all media types)
|
||||
if (achievement.key === "suggestion_renaissance") {
|
||||
const types = await prisma.suggestion.groupBy({
|
||||
by: ["entityType"],
|
||||
where: {
|
||||
userId,
|
||||
status: "ACCEPTED",
|
||||
},
|
||||
});
|
||||
return types.length >= 6;
|
||||
}
|
||||
|
||||
// Enthusiast (5 in one day)
|
||||
if (achievement.key === "suggestion_enthusiast") {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
const count = await prisma.suggestion.count({
|
||||
where: {
|
||||
userId,
|
||||
createdAt: {
|
||||
gte: today,
|
||||
lt: tomorrow,
|
||||
},
|
||||
},
|
||||
});
|
||||
return count >= 5;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check like-based achievements.
|
||||
*/
|
||||
private async checkLikeAchievement(
|
||||
userId: string,
|
||||
achievement: AchievementDefinition,
|
||||
): Promise<boolean> {
|
||||
const { requirements } = achievement;
|
||||
|
||||
// Count-based achievements (total likes)
|
||||
if (
|
||||
achievement.key.startsWith("like_first") ||
|
||||
achievement.key.startsWith("like_enthusiast") ||
|
||||
achievement.key.startsWith("like_fan") ||
|
||||
achievement.key.startsWith("like_super") ||
|
||||
achievement.key.startsWith("like_mega") ||
|
||||
achievement.key.startsWith("like_legendary")
|
||||
) {
|
||||
const count = await prisma.like.count({
|
||||
where: { userId },
|
||||
});
|
||||
return count >= (requirements.count ?? 0);
|
||||
}
|
||||
|
||||
// Media-specific achievements
|
||||
if (achievement.key === "like_book_lover") {
|
||||
const count = await prisma.like.count({
|
||||
where: {
|
||||
userId,
|
||||
entityType: "book",
|
||||
},
|
||||
});
|
||||
return count >= 50;
|
||||
}
|
||||
|
||||
if (achievement.key === "like_gamer") {
|
||||
const count = await prisma.like.count({
|
||||
where: {
|
||||
userId,
|
||||
entityType: "game",
|
||||
},
|
||||
});
|
||||
return count >= 50;
|
||||
}
|
||||
|
||||
if (achievement.key === "like_cinephile") {
|
||||
const count = await prisma.like.count({
|
||||
where: {
|
||||
userId,
|
||||
entityType: "show",
|
||||
},
|
||||
});
|
||||
return count >= 50;
|
||||
}
|
||||
|
||||
if (achievement.key === "like_music") {
|
||||
const count = await prisma.like.count({
|
||||
where: {
|
||||
userId,
|
||||
entityType: "music",
|
||||
},
|
||||
});
|
||||
return count >= 50;
|
||||
}
|
||||
|
||||
// Diversity achievement
|
||||
if (achievement.key === "like_diverse") {
|
||||
const types = await prisma.like.groupBy({
|
||||
by: ["entityType"],
|
||||
where: { userId },
|
||||
});
|
||||
return types.length >= 6;
|
||||
}
|
||||
|
||||
// Binge liker (20+ in one day)
|
||||
if (achievement.key === "like_binge") {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
const count = await prisma.like.count({
|
||||
where: {
|
||||
userId,
|
||||
createdAt: {
|
||||
gte: today,
|
||||
lt: tomorrow,
|
||||
},
|
||||
},
|
||||
});
|
||||
return count >= 20;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check comment-based achievements.
|
||||
*/
|
||||
private async checkCommentAchievement(
|
||||
userId: string,
|
||||
achievement: AchievementDefinition,
|
||||
): Promise<boolean> {
|
||||
const { requirements } = achievement;
|
||||
|
||||
// Count-based achievements (total comments)
|
||||
if (
|
||||
achievement.key.startsWith("comment_first") ||
|
||||
achievement.key.startsWith("comment_reviewer") ||
|
||||
achievement.key.startsWith("comment_critic") ||
|
||||
achievement.key.startsWith("comment_expert") ||
|
||||
achievement.key.startsWith("comment_master") ||
|
||||
achievement.key.startsWith("comment_legend")
|
||||
) {
|
||||
const count = await prisma.comment.count({
|
||||
where: { userId },
|
||||
});
|
||||
return count >= (requirements.count ?? 0);
|
||||
}
|
||||
|
||||
// Length-based achievements
|
||||
if (achievement.key === "comment_detailed") {
|
||||
const count = await prisma.comment.count({
|
||||
where: {
|
||||
userId,
|
||||
content: {
|
||||
// MongoDB doesn't have a direct length check, so we'll fetch and check
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch all comments and check length
|
||||
const comments = await prisma.comment.findMany({
|
||||
where: { userId },
|
||||
select: { content: true },
|
||||
});
|
||||
|
||||
const longComments = comments.filter((c) => c.content.length >= 500);
|
||||
return longComments.length >= 10;
|
||||
}
|
||||
|
||||
if (achievement.key === "comment_essay") {
|
||||
const comments = await prisma.comment.findMany({
|
||||
where: { userId },
|
||||
select: { content: true },
|
||||
});
|
||||
|
||||
const longComments = comments.filter((c) => c.content.length >= 1000);
|
||||
return longComments.length >= 5;
|
||||
}
|
||||
|
||||
if (achievement.key === "comment_novel") {
|
||||
const comments = await prisma.comment.findMany({
|
||||
where: { userId },
|
||||
select: { content: true },
|
||||
});
|
||||
|
||||
const longComments = comments.filter((c) => c.content.length >= 2000);
|
||||
return longComments.length >= 3;
|
||||
}
|
||||
|
||||
// Thoughtful reviewer (50 different items)
|
||||
if (achievement.key === "comment_thoughtful") {
|
||||
// Count unique entity IDs
|
||||
const comments = await prisma.comment.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
bookId: true,
|
||||
gameId: true,
|
||||
showId: true,
|
||||
mangaId: true,
|
||||
musicId: true,
|
||||
artId: true,
|
||||
},
|
||||
});
|
||||
|
||||
const uniqueItems = new Set<string>();
|
||||
comments.forEach((c) => {
|
||||
const entityId =
|
||||
c.bookId ??
|
||||
c.gameId ??
|
||||
c.showId ??
|
||||
c.mangaId ??
|
||||
c.musicId ??
|
||||
c.artId;
|
||||
if (entityId) {
|
||||
uniqueItems.add(entityId);
|
||||
}
|
||||
});
|
||||
|
||||
return uniqueItems.size >= 50;
|
||||
}
|
||||
|
||||
// Diversity achievement
|
||||
if (achievement.key === "comment_diverse") {
|
||||
const comments = await prisma.comment.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
bookId: true,
|
||||
gameId: true,
|
||||
showId: true,
|
||||
mangaId: true,
|
||||
musicId: true,
|
||||
artId: true,
|
||||
},
|
||||
});
|
||||
|
||||
const types = new Set<string>();
|
||||
comments.forEach((c) => {
|
||||
if (c.bookId) {
|
||||
types.add("book");
|
||||
}
|
||||
if (c.gameId) {
|
||||
types.add("game");
|
||||
}
|
||||
if (c.showId) {
|
||||
types.add("show");
|
||||
}
|
||||
if (c.mangaId) {
|
||||
types.add("manga");
|
||||
}
|
||||
if (c.musicId) {
|
||||
types.add("music");
|
||||
}
|
||||
if (c.artId) {
|
||||
types.add("art");
|
||||
}
|
||||
});
|
||||
|
||||
return types.size >= 6;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check engagement-based achievements.
|
||||
*/
|
||||
private async checkEngagementAchievement(
|
||||
userId: string,
|
||||
achievement: AchievementDefinition,
|
||||
): Promise<boolean> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
currentStreak: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Welcome achievement
|
||||
if (achievement.key === "engagement_welcome") {
|
||||
return true; // Awarded on first login
|
||||
}
|
||||
|
||||
// Streak-based achievements
|
||||
if (achievement.key.startsWith("engagement_streak")) {
|
||||
return user.currentStreak >= (achievement.requirements.streak ?? 0);
|
||||
}
|
||||
|
||||
// Triple threat (suggestion, like, comment in same day)
|
||||
if (achievement.key === "engagement_triple_threat") {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
const [hasSuggestion, hasLike, hasComment] = await Promise.all([
|
||||
prisma.suggestion.count({
|
||||
where: {
|
||||
userId,
|
||||
createdAt: { gte: today, lt: tomorrow },
|
||||
},
|
||||
}),
|
||||
prisma.like.count({
|
||||
where: {
|
||||
userId,
|
||||
createdAt: { gte: today, lt: tomorrow },
|
||||
},
|
||||
}),
|
||||
prisma.comment.count({
|
||||
where: {
|
||||
userId,
|
||||
createdAt: { gte: today, lt: tomorrow },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return hasSuggestion > 0 && hasLike > 0 && hasComment > 0;
|
||||
}
|
||||
|
||||
// Power user (triple threat 10 times) - would need special tracking
|
||||
// Early adopter achievements
|
||||
if (
|
||||
achievement.key === "engagement_early_adopter" ||
|
||||
achievement.key === "engagement_founding_100" ||
|
||||
achievement.key === "engagement_founding_1000"
|
||||
) {
|
||||
const userRank = await prisma.user.count({
|
||||
where: {
|
||||
createdAt: {
|
||||
lt: user.createdAt,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (achievement.key === "engagement_early_adopter") {
|
||||
return userRank < 10;
|
||||
}
|
||||
if (achievement.key === "engagement_founding_100") {
|
||||
return userRank < 100;
|
||||
}
|
||||
if (achievement.key === "engagement_founding_1000") {
|
||||
return userRank < 1000;
|
||||
}
|
||||
}
|
||||
|
||||
// Account age achievements
|
||||
if (achievement.key.startsWith("engagement_veteran")) {
|
||||
const daysSinceCreation = Math.floor(
|
||||
(Date.now() - user.createdAt.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
return daysSinceCreation >= (achievement.requirements.dayRange ?? 0);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check report-based achievements.
|
||||
*/
|
||||
private async checkReportAchievement(
|
||||
userId: string,
|
||||
achievement: AchievementDefinition,
|
||||
): Promise<boolean> {
|
||||
const { requirements } = achievement;
|
||||
|
||||
// Count ACTION_TAKEN reports
|
||||
if (
|
||||
achievement.key.startsWith("report_watchful") ||
|
||||
achievement.key.startsWith("report_guardian") ||
|
||||
achievement.key.startsWith("report_protector") ||
|
||||
achievement.key.startsWith("report_vigilant")
|
||||
) {
|
||||
const profileReports = await prisma.profileReport.count({
|
||||
where: {
|
||||
reporterId: userId,
|
||||
status: "ACTION_TAKEN",
|
||||
},
|
||||
});
|
||||
|
||||
const commentReports = await prisma.commentReport.count({
|
||||
where: {
|
||||
reporterId: userId,
|
||||
status: "ACTION_TAKEN",
|
||||
},
|
||||
});
|
||||
|
||||
return profileReports + commentReports >= (requirements.count ?? 0);
|
||||
}
|
||||
|
||||
// Accuracy achievements
|
||||
if (achievement.key.startsWith("report_accuracy")) {
|
||||
const totalProfileReports = await prisma.profileReport.count({
|
||||
where: {
|
||||
reporterId: userId,
|
||||
status: {
|
||||
not: "PENDING",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const totalCommentReports = await prisma.commentReport.count({
|
||||
where: {
|
||||
reporterId: userId,
|
||||
status: {
|
||||
not: "PENDING",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const total = totalProfileReports + totalCommentReports;
|
||||
|
||||
if (total < (requirements.count ?? 10)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const actionTakenProfile = await prisma.profileReport.count({
|
||||
where: {
|
||||
reporterId: userId,
|
||||
status: "ACTION_TAKEN",
|
||||
},
|
||||
});
|
||||
|
||||
const actionTakenComment = await prisma.commentReport.count({
|
||||
where: {
|
||||
reporterId: userId,
|
||||
status: "ACTION_TAKEN",
|
||||
},
|
||||
});
|
||||
|
||||
const actionTaken = actionTakenProfile + actionTakenComment;
|
||||
const rate = actionTaken / total;
|
||||
|
||||
return rate >= (requirements.rate ?? 0);
|
||||
}
|
||||
|
||||
// Volume achievement (total reports)
|
||||
if (achievement.key === "report_volume") {
|
||||
const profileReports = await prisma.profileReport.count({
|
||||
where: { reporterId: userId },
|
||||
});
|
||||
|
||||
const commentReports = await prisma.commentReport.count({
|
||||
where: { reporterId: userId },
|
||||
});
|
||||
|
||||
return profileReports + commentReports >= 50;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user's achievement progress and summary.
|
||||
*/
|
||||
async getUserAchievementSummary(
|
||||
userId: string,
|
||||
): Promise<UserAchievementSummary> {
|
||||
const userAchievements = await prisma.userAchievement.findMany({
|
||||
where: { userId },
|
||||
orderBy: { earnedAt: "desc" },
|
||||
});
|
||||
|
||||
const earnedAchievements = userAchievements.filter((ua) => ua.earned);
|
||||
const totalPoints = earnedAchievements.reduce((sum, ua) => {
|
||||
const definition = ACHIEVEMENTS[ua.achievementKey];
|
||||
return sum + (definition?.points ?? 0);
|
||||
}, 0);
|
||||
|
||||
const recentAchievements: AchievementProgress[] = earnedAchievements
|
||||
.slice(0, 5)
|
||||
.map((ua) => ({
|
||||
definition: ACHIEVEMENTS[ua.achievementKey],
|
||||
progress: ua.progress,
|
||||
earned: ua.earned,
|
||||
earnedAt: ua.earnedAt ?? undefined,
|
||||
}));
|
||||
|
||||
// Progress by category
|
||||
const progressByCategory = Object.values(AchievementCategory).map(
|
||||
(category) => {
|
||||
const categoryAchievements = ACHIEVEMENT_LIST.filter(
|
||||
(a) => a.category === category,
|
||||
);
|
||||
const earned = earnedAchievements.filter(
|
||||
(ua) => ACHIEVEMENTS[ua.achievementKey]?.category === category,
|
||||
).length;
|
||||
|
||||
return {
|
||||
category,
|
||||
earned,
|
||||
total: categoryAchievements.length,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
totalPoints,
|
||||
totalEarned: earnedAchievements.length,
|
||||
recentAchievements,
|
||||
progressByCategory,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all achievements with progress for a user.
|
||||
*/
|
||||
async getUserAchievementProgress(
|
||||
userId: string,
|
||||
): Promise<AchievementProgress[]> {
|
||||
const userAchievements = await prisma.userAchievement.findMany({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
const progressMap = new Map(
|
||||
userAchievements.map((ua) => [ua.achievementKey, ua]),
|
||||
);
|
||||
|
||||
return ACHIEVEMENT_LIST.map((definition) => {
|
||||
const userAchievement = progressMap.get(definition.key);
|
||||
return {
|
||||
definition,
|
||||
progress: userAchievement?.progress ?? 0,
|
||||
earned: userAchievement?.earned ?? false,
|
||||
earnedAt: userAchievement?.earnedAt ?? undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update login streak for a user.
|
||||
*/
|
||||
async updateLoginStreak(userId: string): Promise<void> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
currentStreak: true,
|
||||
lastStreakCheck: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
yesterday.setHours(0, 0, 0, 0);
|
||||
|
||||
const lastCheck = user.lastStreakCheck;
|
||||
const isConsecutive =
|
||||
lastCheck &&
|
||||
lastCheck >= yesterday &&
|
||||
lastCheck < new Date(now.setHours(0, 0, 0, 0));
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
currentStreak: isConsecutive ? user.currentStreak + 1 : 1,
|
||||
lastStreakCheck: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import type {
|
||||
Activity,
|
||||
ActivityFeedResponse,
|
||||
ActivityUser,
|
||||
} from "@library/shared-types";
|
||||
import { ACHIEVEMENTS, ActivityType } from "@library/shared-types";
|
||||
import { prisma } from "../lib/prisma";
|
||||
|
||||
export class ActivityService {
|
||||
private prisma = prisma;
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Get activity feed with pagination.
|
||||
*/
|
||||
async getActivityFeed(
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
userId?: string
|
||||
): Promise<ActivityFeedResponse> {
|
||||
// Fetch suggestions, likes, comments, and achievements
|
||||
const [suggestions, likes, comments, achievements] = await Promise.all([
|
||||
this.getSuggestionActivities(limit, offset, userId),
|
||||
this.getLikeActivities(limit, offset, userId),
|
||||
this.getCommentActivities(limit, offset, userId),
|
||||
this.getAchievementActivities(limit, offset, userId),
|
||||
]);
|
||||
|
||||
// Combine and sort by createdAt
|
||||
const activities: Activity[] = [
|
||||
...suggestions,
|
||||
...likes,
|
||||
...comments,
|
||||
...achievements,
|
||||
].sort((a, b) => {
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
});
|
||||
|
||||
// Apply pagination to combined results
|
||||
const paginatedActivities = activities.slice(offset, offset + limit);
|
||||
const hasMore = activities.length > offset + limit;
|
||||
|
||||
return {
|
||||
activities: paginatedActivities,
|
||||
total: activities.length,
|
||||
hasMore,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suggestion activities.
|
||||
*/
|
||||
private async getSuggestionActivities(
|
||||
limit: number,
|
||||
offset: number,
|
||||
userId?: string
|
||||
) {
|
||||
const where = userId
|
||||
? { userId, user: { profilePublic: true, isBanned: false } }
|
||||
: { user: { profilePublic: true, isBanned: false } };
|
||||
|
||||
const suggestions = await this.prisma.suggestion.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
slug: true,
|
||||
avatar: true,
|
||||
primaryBadge: true,
|
||||
isVip: true,
|
||||
isMod: true,
|
||||
isStaff: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit * 2, // Get more since we're combining
|
||||
});
|
||||
|
||||
return suggestions.map((suggestion) => ({
|
||||
id: `suggestion-${suggestion.id}`,
|
||||
type: ActivityType.suggestion,
|
||||
user: suggestion.user as ActivityUser,
|
||||
entityType: suggestion.entityType,
|
||||
suggestionTitle: suggestion.title,
|
||||
status: suggestion.status,
|
||||
createdAt: suggestion.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get like activities.
|
||||
*/
|
||||
private async getLikeActivities(
|
||||
limit: number,
|
||||
offset: number,
|
||||
userId?: string
|
||||
) {
|
||||
const where = userId
|
||||
? { userId, user: { profilePublic: true, isBanned: false } }
|
||||
: { user: { profilePublic: true, isBanned: false } };
|
||||
|
||||
const likes = await this.prisma.like.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
slug: true,
|
||||
avatar: true,
|
||||
primaryBadge: true,
|
||||
isVip: true,
|
||||
isMod: true,
|
||||
isStaff: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit * 2,
|
||||
});
|
||||
|
||||
// For each like, fetch the entity title
|
||||
const likesWithTitles = await Promise.all(
|
||||
likes.map(async (like) => {
|
||||
const entityTitle = await this.getEntityTitle(
|
||||
like.entityType,
|
||||
like.entityId
|
||||
);
|
||||
return {
|
||||
id: `like-${like.id}`,
|
||||
type: ActivityType.like,
|
||||
user: like.user as ActivityUser,
|
||||
entityType: like.entityType,
|
||||
entityId: like.entityId,
|
||||
entityTitle,
|
||||
createdAt: like.createdAt,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return likesWithTitles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comment activities.
|
||||
*/
|
||||
private async getCommentActivities(
|
||||
limit: number,
|
||||
offset: number,
|
||||
userId?: string
|
||||
) {
|
||||
const where = userId
|
||||
? { userId, user: { profilePublic: true, isBanned: false } }
|
||||
: { user: { profilePublic: true, isBanned: false } };
|
||||
|
||||
const comments = await this.prisma.comment.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
slug: true,
|
||||
avatar: true,
|
||||
primaryBadge: true,
|
||||
isVip: true,
|
||||
isMod: true,
|
||||
isStaff: true,
|
||||
},
|
||||
},
|
||||
game: { select: { id: true, title: true } },
|
||||
book: { select: { id: true, title: true } },
|
||||
music: { select: { id: true, title: true } },
|
||||
art: { select: { id: true, title: true } },
|
||||
show: { select: { id: true, title: true } },
|
||||
manga: { select: { id: true, title: true } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit * 2,
|
||||
});
|
||||
|
||||
return comments.map((comment) => {
|
||||
let entityType = "";
|
||||
let entityId = "";
|
||||
let entityTitle = "";
|
||||
|
||||
if (comment.game) {
|
||||
entityType = "game";
|
||||
entityId = comment.game.id;
|
||||
entityTitle = comment.game.title;
|
||||
} else if (comment.book) {
|
||||
entityType = "book";
|
||||
entityId = comment.book.id;
|
||||
entityTitle = comment.book.title;
|
||||
} else if (comment.music) {
|
||||
entityType = "music";
|
||||
entityId = comment.music.id;
|
||||
entityTitle = comment.music.title;
|
||||
} else if (comment.art) {
|
||||
entityType = "art";
|
||||
entityId = comment.art.id;
|
||||
entityTitle = comment.art.title;
|
||||
} else if (comment.show) {
|
||||
entityType = "show";
|
||||
entityId = comment.show.id;
|
||||
entityTitle = comment.show.title;
|
||||
} else if (comment.manga) {
|
||||
entityType = "manga";
|
||||
entityId = comment.manga.id;
|
||||
entityTitle = comment.manga.title;
|
||||
}
|
||||
|
||||
// Get first 100 characters of comment
|
||||
const commentPreview =
|
||||
comment.content.length > 100
|
||||
? `${comment.content.slice(0, 100)}...`
|
||||
: comment.content;
|
||||
|
||||
return {
|
||||
id: `comment-${comment.id}`,
|
||||
type: ActivityType.comment,
|
||||
user: comment.user as ActivityUser,
|
||||
entityType,
|
||||
entityId,
|
||||
entityTitle,
|
||||
commentPreview,
|
||||
createdAt: comment.createdAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get achievement activities.
|
||||
*/
|
||||
private async getAchievementActivities(
|
||||
limit: number,
|
||||
offset: number,
|
||||
userId?: string
|
||||
) {
|
||||
const where = userId
|
||||
? {
|
||||
userId,
|
||||
earned: true,
|
||||
user: { profilePublic: true, isBanned: false },
|
||||
}
|
||||
: { earned: true, user: { profilePublic: true, isBanned: false } };
|
||||
|
||||
const userAchievements = await this.prisma.userAchievement.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
slug: true,
|
||||
avatar: true,
|
||||
primaryBadge: true,
|
||||
isVip: true,
|
||||
isMod: true,
|
||||
isStaff: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { earnedAt: "desc" },
|
||||
take: limit * 2,
|
||||
});
|
||||
|
||||
return userAchievements
|
||||
.filter((ua) => ua.earnedAt) // Only show earned achievements
|
||||
.map((ua) => {
|
||||
const achievement = ACHIEVEMENTS[ua.achievementKey];
|
||||
return {
|
||||
id: `achievement-${ua.id}`,
|
||||
type: ActivityType.achievement,
|
||||
user: ua.user as ActivityUser,
|
||||
achievementKey: ua.achievementKey,
|
||||
achievementName: achievement.title,
|
||||
achievementIcon: achievement.icon,
|
||||
achievementPoints: achievement.points,
|
||||
createdAt: ua.earnedAt!,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get entity title by type and ID.
|
||||
*/
|
||||
private async getEntityTitle(
|
||||
entityType: string,
|
||||
entityId: string
|
||||
): Promise<string> {
|
||||
switch (entityType) {
|
||||
case "game": {
|
||||
const game = await this.prisma.game.findUnique({
|
||||
where: { id: entityId },
|
||||
select: { title: true },
|
||||
});
|
||||
return game?.title || "Unknown Game";
|
||||
}
|
||||
case "book": {
|
||||
const book = await this.prisma.book.findUnique({
|
||||
where: { id: entityId },
|
||||
select: { title: true },
|
||||
});
|
||||
return book?.title || "Unknown Book";
|
||||
}
|
||||
case "music": {
|
||||
const music = await this.prisma.music.findUnique({
|
||||
where: { id: entityId },
|
||||
select: { title: true },
|
||||
});
|
||||
return music?.title || "Unknown Music";
|
||||
}
|
||||
case "art": {
|
||||
const art = await this.prisma.art.findUnique({
|
||||
where: { id: entityId },
|
||||
select: { title: true },
|
||||
});
|
||||
return art?.title || "Unknown Art";
|
||||
}
|
||||
case "show": {
|
||||
const show = await this.prisma.show.findUnique({
|
||||
where: { id: entityId },
|
||||
select: { title: true },
|
||||
});
|
||||
return show?.title || "Unknown Show";
|
||||
}
|
||||
case "manga": {
|
||||
const manga = await this.prisma.manga.findUnique({
|
||||
where: { id: entityId },
|
||||
select: { title: true },
|
||||
});
|
||||
return manga?.title || "Unknown Manga";
|
||||
}
|
||||
default:
|
||||
return "Unknown Item";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export const AuditService = {
|
||||
request: FastifyRequest,
|
||||
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(
|
||||
{
|
||||
|
||||
@@ -71,6 +71,15 @@ export class AuthService {
|
||||
username: dbUser.username,
|
||||
email: dbUser.email,
|
||||
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,
|
||||
isBanned: dbUser.isBanned,
|
||||
inDiscord: dbUser.inDiscord,
|
||||
@@ -167,6 +176,15 @@ export class AuthService {
|
||||
username: dbUser.username,
|
||||
email: dbUser.email,
|
||||
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,
|
||||
isBanned: dbUser.isBanned,
|
||||
inDiscord: dbUser.inDiscord,
|
||||
@@ -217,6 +235,15 @@ export class AuthService {
|
||||
username: dbUser.username,
|
||||
email: dbUser.email,
|
||||
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,
|
||||
isBanned: dbUser.isBanned,
|
||||
inDiscord: dbUser.inDiscord,
|
||||
|
||||
@@ -24,6 +24,7 @@ export class BookService {
|
||||
...book,
|
||||
status: book.status as unknown as BookStatus,
|
||||
dateAdded: book.dateAdded,
|
||||
dateStarted: book.dateStarted || undefined,
|
||||
dateFinished: book.dateFinished || undefined,
|
||||
tags: book.tags ?? [],
|
||||
links: book.links ?? [],
|
||||
@@ -46,6 +47,7 @@ export class BookService {
|
||||
...book,
|
||||
status: book.status as unknown as BookStatus,
|
||||
dateAdded: book.dateAdded,
|
||||
dateStarted: book.dateStarted || undefined,
|
||||
dateFinished: book.dateFinished || undefined,
|
||||
tags: book.tags ?? [],
|
||||
links: book.links ?? [],
|
||||
@@ -54,6 +56,28 @@ export class BookService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all books in a series, ordered by seriesOrder.
|
||||
*/
|
||||
async getBooksBySeries(seriesName: string): Promise<Book[]> {
|
||||
const books = await this.prisma.book.findMany({
|
||||
where: { series: seriesName },
|
||||
orderBy: { seriesOrder: "asc" },
|
||||
});
|
||||
|
||||
return books.map((book) => ({
|
||||
...book,
|
||||
status: book.status as unknown as BookStatus,
|
||||
dateAdded: book.dateAdded,
|
||||
dateStarted: book.dateStarted || undefined,
|
||||
dateFinished: book.dateFinished || undefined,
|
||||
tags: book.tags ?? [],
|
||||
links: book.links ?? [],
|
||||
createdAt: book.createdAt,
|
||||
updatedAt: book.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new book.
|
||||
*/
|
||||
@@ -69,6 +93,7 @@ export class BookService {
|
||||
...book,
|
||||
status: book.status as unknown as BookStatus,
|
||||
dateAdded: book.dateAdded,
|
||||
dateStarted: book.dateStarted || undefined,
|
||||
dateFinished: book.dateFinished || undefined,
|
||||
tags: book.tags ?? [],
|
||||
links: book.links ?? [],
|
||||
@@ -95,6 +120,7 @@ export class BookService {
|
||||
...book,
|
||||
status: book.status as unknown as BookStatus,
|
||||
dateAdded: book.dateAdded,
|
||||
dateStarted: book.dateStarted || undefined,
|
||||
dateFinished: book.dateFinished || undefined,
|
||||
tags: book.tags ?? [],
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Comment, CreateCommentDto } from "@library/shared-types";
|
||||
import { Comment, CreateCommentDto, PrimaryBadge } from "@library/shared-types";
|
||||
import { prisma } from "../lib/prisma";
|
||||
import createDOMPurify from "dompurify";
|
||||
import { JSDOM } from "jsdom";
|
||||
@@ -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 {
|
||||
id: comment.id,
|
||||
content: comment.content,
|
||||
@@ -60,6 +65,7 @@ export class CommentService {
|
||||
id: comment.user.id,
|
||||
username: comment.user.username,
|
||||
avatar: comment.user.avatar || undefined,
|
||||
primaryBadge: (comment.user.primaryBadge as PrimaryBadge) || undefined,
|
||||
inDiscord: comment.user.inDiscord,
|
||||
isVip: comment.user.isVip,
|
||||
isMod: comment.user.isMod,
|
||||
@@ -71,6 +77,7 @@ export class CommentService {
|
||||
artId: comment.artId || undefined,
|
||||
showId: comment.showId || undefined,
|
||||
mangaId: comment.mangaId || undefined,
|
||||
hasPendingReports,
|
||||
createdAt: comment.createdAt,
|
||||
updatedAt: comment.updatedAt,
|
||||
};
|
||||
@@ -79,28 +86,28 @@ export class CommentService {
|
||||
async getCommentsForGame(gameId: string): Promise<Comment[]> {
|
||||
const comments = await this.prisma.comment.findMany({
|
||||
where: { gameId },
|
||||
include: { user: true },
|
||||
include: { user: true, reports: true },
|
||||
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[]> {
|
||||
const comments = await this.prisma.comment.findMany({
|
||||
where: { bookId },
|
||||
include: { user: true },
|
||||
include: { user: true, reports: true },
|
||||
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[]> {
|
||||
const comments = await this.prisma.comment.findMany({
|
||||
where: { musicId },
|
||||
include: { user: true },
|
||||
include: { user: true, reports: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return comments.map((c) => this.mapComment(c));
|
||||
return Promise.all(comments.map((c) => this.mapComment(c)));
|
||||
}
|
||||
|
||||
async createCommentForGame(
|
||||
@@ -116,7 +123,7 @@ export class CommentService {
|
||||
userId,
|
||||
gameId,
|
||||
},
|
||||
include: { user: true },
|
||||
include: { user: true, reports: true },
|
||||
});
|
||||
return this.mapComment(comment);
|
||||
}
|
||||
@@ -134,7 +141,7 @@ export class CommentService {
|
||||
userId,
|
||||
bookId,
|
||||
},
|
||||
include: { user: true },
|
||||
include: { user: true, reports: true },
|
||||
});
|
||||
return this.mapComment(comment);
|
||||
}
|
||||
@@ -152,7 +159,7 @@ export class CommentService {
|
||||
userId,
|
||||
musicId,
|
||||
},
|
||||
include: { user: true },
|
||||
include: { user: true, reports: true },
|
||||
});
|
||||
return this.mapComment(comment);
|
||||
}
|
||||
@@ -160,10 +167,10 @@ export class CommentService {
|
||||
async getCommentsForArt(artId: string): Promise<Comment[]> {
|
||||
const comments = await this.prisma.comment.findMany({
|
||||
where: { artId },
|
||||
include: { user: true },
|
||||
include: { user: true, reports: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return comments.map((c) => this.mapComment(c));
|
||||
return Promise.all(comments.map((c) => this.mapComment(c)));
|
||||
}
|
||||
|
||||
async createCommentForArt(
|
||||
@@ -179,7 +186,7 @@ export class CommentService {
|
||||
userId,
|
||||
artId,
|
||||
},
|
||||
include: { user: true },
|
||||
include: { user: true, reports: true },
|
||||
});
|
||||
return this.mapComment(comment);
|
||||
}
|
||||
@@ -187,10 +194,10 @@ export class CommentService {
|
||||
async getCommentsForShow(showId: string): Promise<Comment[]> {
|
||||
const comments = await this.prisma.comment.findMany({
|
||||
where: { showId },
|
||||
include: { user: true },
|
||||
include: { user: true, reports: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return comments.map((c) => this.mapComment(c));
|
||||
return Promise.all(comments.map((c) => this.mapComment(c)));
|
||||
}
|
||||
|
||||
async createCommentForShow(
|
||||
@@ -206,7 +213,7 @@ export class CommentService {
|
||||
userId,
|
||||
showId,
|
||||
},
|
||||
include: { user: true },
|
||||
include: { user: true, reports: true },
|
||||
});
|
||||
return this.mapComment(comment);
|
||||
}
|
||||
@@ -214,10 +221,10 @@ export class CommentService {
|
||||
async getCommentsForManga(mangaId: string): Promise<Comment[]> {
|
||||
const comments = await this.prisma.comment.findMany({
|
||||
where: { mangaId },
|
||||
include: { user: true },
|
||||
include: { user: true, reports: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return comments.map((c) => this.mapComment(c));
|
||||
return Promise.all(comments.map((c) => this.mapComment(c)));
|
||||
}
|
||||
|
||||
async createCommentForManga(
|
||||
@@ -233,7 +240,7 @@ export class CommentService {
|
||||
userId,
|
||||
mangaId,
|
||||
},
|
||||
include: { user: true },
|
||||
include: { user: true, reports: true },
|
||||
});
|
||||
return this.mapComment(comment);
|
||||
}
|
||||
@@ -256,7 +263,7 @@ export class CommentService {
|
||||
content: sanitizedContent,
|
||||
rawContent: content,
|
||||
},
|
||||
include: { user: true },
|
||||
include: { user: true, reports: true },
|
||||
});
|
||||
return this.mapComment(comment);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,9 @@ export class GameService {
|
||||
...game,
|
||||
status: game.status as unknown as GameStatus,
|
||||
dateAdded: game.dateAdded,
|
||||
dateStarted: game.dateStarted || undefined,
|
||||
dateCompleted: game.dateCompleted || undefined,
|
||||
dateFinished: game.dateFinished || undefined,
|
||||
tags: game.tags ?? [],
|
||||
links: game.links ?? [],
|
||||
createdAt: game.createdAt,
|
||||
@@ -46,7 +48,9 @@ export class GameService {
|
||||
...game,
|
||||
status: game.status as unknown as GameStatus,
|
||||
dateAdded: game.dateAdded,
|
||||
dateStarted: game.dateStarted || undefined,
|
||||
dateCompleted: game.dateCompleted || undefined,
|
||||
dateFinished: game.dateFinished || undefined,
|
||||
tags: game.tags ?? [],
|
||||
links: game.links ?? [],
|
||||
createdAt: game.createdAt,
|
||||
@@ -54,6 +58,29 @@ export class GameService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all games in a series, ordered by seriesOrder.
|
||||
*/
|
||||
async getGamesBySeries(seriesName: string): Promise<Game[]> {
|
||||
const games = await this.prisma.game.findMany({
|
||||
where: { series: seriesName },
|
||||
orderBy: { seriesOrder: "asc" },
|
||||
});
|
||||
|
||||
return games.map((game) => ({
|
||||
...game,
|
||||
status: game.status as unknown as GameStatus,
|
||||
dateAdded: game.dateAdded,
|
||||
dateStarted: game.dateStarted || undefined,
|
||||
dateCompleted: game.dateCompleted || undefined,
|
||||
dateFinished: game.dateFinished || undefined,
|
||||
tags: game.tags ?? [],
|
||||
links: game.links ?? [],
|
||||
createdAt: game.createdAt,
|
||||
updatedAt: game.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new game.
|
||||
*/
|
||||
@@ -69,7 +96,9 @@ export class GameService {
|
||||
...game,
|
||||
status: game.status as unknown as GameStatus,
|
||||
dateAdded: game.dateAdded,
|
||||
dateStarted: game.dateStarted || undefined,
|
||||
dateCompleted: game.dateCompleted || undefined,
|
||||
dateFinished: game.dateFinished || undefined,
|
||||
tags: game.tags ?? [],
|
||||
links: game.links ?? [],
|
||||
createdAt: game.createdAt,
|
||||
@@ -95,7 +124,9 @@ export class GameService {
|
||||
...game,
|
||||
status: game.status as unknown as GameStatus,
|
||||
dateAdded: game.dateAdded,
|
||||
dateStarted: game.dateStarted || undefined,
|
||||
dateCompleted: game.dateCompleted || undefined,
|
||||
dateFinished: game.dateFinished || undefined,
|
||||
tags: game.tags ?? [],
|
||||
links: game.links ?? [],
|
||||
createdAt: game.createdAt,
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import type {
|
||||
LeaderboardResponse,
|
||||
SuggestionsLeaderboard,
|
||||
LikesLeaderboard,
|
||||
CommentsLeaderboard,
|
||||
OverallLeaderboard,
|
||||
} from "@library/shared-types";
|
||||
import { prisma } from "../lib/prisma";
|
||||
|
||||
export class LeaderboardService {
|
||||
private prisma = prisma;
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Get top users by suggestions submitted and accepted.
|
||||
*/
|
||||
async getTopSuggestions(limit = 25): Promise<SuggestionsLeaderboard[]> {
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: { profilePublic: true, isBanned: false },
|
||||
include: {
|
||||
suggestions: {
|
||||
select: {
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const leaderboard = users
|
||||
.map((user) => {
|
||||
const totalSuggestions = user.suggestions.length;
|
||||
const acceptedSuggestions = user.suggestions.filter(
|
||||
(s) => s.status === "ACCEPTED"
|
||||
).length;
|
||||
const acceptanceRate =
|
||||
totalSuggestions > 0
|
||||
? Math.round((acceptedSuggestions / totalSuggestions) * 100)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
slug: user.slug,
|
||||
avatar: user.avatar,
|
||||
primaryBadge: user.primaryBadge,
|
||||
isVip: user.isVip,
|
||||
isMod: user.isMod,
|
||||
isStaff: user.isStaff,
|
||||
createdAt: user.createdAt,
|
||||
totalSuggestions,
|
||||
acceptedSuggestions,
|
||||
acceptanceRate,
|
||||
};
|
||||
})
|
||||
.filter((user) => user.totalSuggestions > 0)
|
||||
.sort((a, b) => {
|
||||
if (b.totalSuggestions !== a.totalSuggestions) {
|
||||
return b.totalSuggestions - a.totalSuggestions;
|
||||
}
|
||||
return b.acceptanceRate - a.acceptanceRate;
|
||||
})
|
||||
.slice(0, limit);
|
||||
|
||||
return leaderboard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top users by likes given.
|
||||
*/
|
||||
async getTopLikes(limit = 25): Promise<LikesLeaderboard[]> {
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: { profilePublic: true, isBanned: false },
|
||||
include: {
|
||||
likes: true,
|
||||
},
|
||||
});
|
||||
|
||||
const leaderboard = users
|
||||
.map((user) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
slug: user.slug,
|
||||
avatar: user.avatar,
|
||||
primaryBadge: user.primaryBadge,
|
||||
isVip: user.isVip,
|
||||
isMod: user.isMod,
|
||||
isStaff: user.isStaff,
|
||||
createdAt: user.createdAt,
|
||||
totalLikes: user.likes.length,
|
||||
}))
|
||||
.filter((user) => user.totalLikes > 0)
|
||||
.sort((a, b) => b.totalLikes - a.totalLikes)
|
||||
.slice(0, limit);
|
||||
|
||||
return leaderboard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top users by comments posted.
|
||||
*/
|
||||
async getTopComments(limit = 25): Promise<CommentsLeaderboard[]> {
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: { profilePublic: true, isBanned: false },
|
||||
include: {
|
||||
comments: true,
|
||||
},
|
||||
});
|
||||
|
||||
const leaderboard = users
|
||||
.map((user) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
slug: user.slug,
|
||||
avatar: user.avatar,
|
||||
primaryBadge: user.primaryBadge,
|
||||
isVip: user.isVip,
|
||||
isMod: user.isMod,
|
||||
isStaff: user.isStaff,
|
||||
createdAt: user.createdAt,
|
||||
totalComments: user.comments.length,
|
||||
}))
|
||||
.filter((user) => user.totalComments > 0)
|
||||
.sort((a, b) => b.totalComments - a.totalComments)
|
||||
.slice(0, limit);
|
||||
|
||||
return leaderboard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get overall leaderboard based on combined engagement.
|
||||
*/
|
||||
async getOverallLeaderboard(limit = 25): Promise<OverallLeaderboard[]> {
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: { profilePublic: true, isBanned: false },
|
||||
include: {
|
||||
suggestions: true,
|
||||
likes: true,
|
||||
comments: true,
|
||||
userAchievements: true,
|
||||
},
|
||||
});
|
||||
|
||||
const leaderboard = users
|
||||
.map((user) => {
|
||||
const totalSuggestions = user.suggestions.length;
|
||||
const totalLikes = user.likes.length;
|
||||
const totalComments = user.comments.length;
|
||||
const achievementCount = user.userAchievements.length;
|
||||
|
||||
const diversityScore =
|
||||
(totalSuggestions > 0 ? 1 : 0) +
|
||||
(totalLikes > 0 ? 1 : 0) +
|
||||
(totalComments > 0 ? 1 : 0);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
slug: user.slug,
|
||||
avatar: user.avatar,
|
||||
primaryBadge: user.primaryBadge,
|
||||
isVip: user.isVip,
|
||||
isMod: user.isMod,
|
||||
isStaff: user.isStaff,
|
||||
createdAt: user.createdAt,
|
||||
totalSuggestions,
|
||||
totalLikes,
|
||||
totalComments,
|
||||
achievementCount,
|
||||
achievementPoints: user.achievementPoints,
|
||||
currentStreak: user.currentStreak,
|
||||
diversityScore,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(user) =>
|
||||
user.totalSuggestions > 0 ||
|
||||
user.totalLikes > 0 ||
|
||||
user.totalComments > 0
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (b.achievementPoints !== a.achievementPoints) {
|
||||
return b.achievementPoints - a.achievementPoints;
|
||||
}
|
||||
if (b.diversityScore !== a.diversityScore) {
|
||||
return b.diversityScore - a.diversityScore;
|
||||
}
|
||||
const totalA = a.totalSuggestions + a.totalLikes + a.totalComments;
|
||||
const totalB = b.totalSuggestions + b.totalLikes + b.totalComments;
|
||||
return totalB - totalA;
|
||||
})
|
||||
.slice(0, limit);
|
||||
|
||||
return leaderboard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all leaderboards at once.
|
||||
*/
|
||||
async getAllLeaderboards(limit = 25): Promise<LeaderboardResponse> {
|
||||
const [topSuggestions, topLikes, topComments, topOverall] =
|
||||
await Promise.all([
|
||||
this.getTopSuggestions(limit),
|
||||
this.getTopLikes(limit),
|
||||
this.getTopComments(limit),
|
||||
this.getOverallLeaderboard(limit),
|
||||
]);
|
||||
|
||||
return {
|
||||
topSuggestions,
|
||||
topLikes,
|
||||
topComments,
|
||||
topOverall,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,9 @@
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { AuditService } from './audit.service';
|
||||
import { AchievementService } from './achievement.service';
|
||||
import type { Like, LikeCountDto, LikedItemDto, LikeResponse } from '@library/shared-types';
|
||||
import { AuditAction, AuditCategory } from '@library/shared-types';
|
||||
import { AuditAction, AuditCategory, AchievementCategory } from '@library/shared-types';
|
||||
|
||||
export class LikeService {
|
||||
async toggleLike(userId: string, entityType: Like['entityType'], entityId: string, req: FastifyRequest): Promise<LikeResponse> {
|
||||
@@ -32,8 +33,8 @@ export class LikeService {
|
||||
});
|
||||
|
||||
await AuditService.logFromRequest(req, {
|
||||
action: AuditAction.UNLIKE,
|
||||
category: AuditCategory.CONTENT,
|
||||
action: AuditAction.unlike,
|
||||
category: AuditCategory.content,
|
||||
resourceType: entityType,
|
||||
resourceId: entityId,
|
||||
details: `Unliked ${entityType}`
|
||||
@@ -52,13 +53,21 @@ export class LikeService {
|
||||
});
|
||||
|
||||
await AuditService.logFromRequest(req, {
|
||||
action: AuditAction.LIKE,
|
||||
category: AuditCategory.CONTENT,
|
||||
action: AuditAction.like,
|
||||
category: AuditCategory.content,
|
||||
resourceType: entityType,
|
||||
resourceId: entityId,
|
||||
details: `Liked ${entityType}`
|
||||
});
|
||||
|
||||
// Check for like achievements
|
||||
const achievementService = new AchievementService();
|
||||
await achievementService.checkAchievements(
|
||||
userId,
|
||||
AchievementCategory.Like,
|
||||
req
|
||||
);
|
||||
|
||||
const count = await this.getLikeCount(entityType, entityId);
|
||||
return { liked: true, count };
|
||||
}
|
||||
|
||||
@@ -21,7 +21,9 @@ export class MangaService {
|
||||
...m,
|
||||
status: m.status as unknown as MangaStatus,
|
||||
dateAdded: m.dateAdded,
|
||||
dateStarted: m.dateStarted || undefined,
|
||||
dateCompleted: m.dateCompleted || undefined,
|
||||
dateFinished: m.dateFinished || undefined,
|
||||
tags: m.tags ?? [],
|
||||
links: m.links ?? [],
|
||||
createdAt: m.createdAt,
|
||||
@@ -40,7 +42,9 @@ export class MangaService {
|
||||
...manga,
|
||||
status: manga.status as unknown as MangaStatus,
|
||||
dateAdded: manga.dateAdded,
|
||||
dateStarted: manga.dateStarted || undefined,
|
||||
dateCompleted: manga.dateCompleted || undefined,
|
||||
dateFinished: manga.dateFinished || undefined,
|
||||
tags: manga.tags ?? [],
|
||||
links: manga.links ?? [],
|
||||
createdAt: manga.createdAt,
|
||||
@@ -60,7 +64,9 @@ export class MangaService {
|
||||
...manga,
|
||||
status: manga.status as unknown as MangaStatus,
|
||||
dateAdded: manga.dateAdded,
|
||||
dateStarted: manga.dateStarted || undefined,
|
||||
dateCompleted: manga.dateCompleted || undefined,
|
||||
dateFinished: manga.dateFinished || undefined,
|
||||
tags: manga.tags ?? [],
|
||||
links: manga.links ?? [],
|
||||
createdAt: manga.createdAt,
|
||||
@@ -83,7 +89,9 @@ export class MangaService {
|
||||
...manga,
|
||||
status: manga.status as unknown as MangaStatus,
|
||||
dateAdded: manga.dateAdded,
|
||||
dateStarted: manga.dateStarted || undefined,
|
||||
dateCompleted: manga.dateCompleted || undefined,
|
||||
dateFinished: manga.dateFinished || undefined,
|
||||
tags: manga.tags ?? [],
|
||||
links: manga.links ?? [],
|
||||
createdAt: manga.createdAt,
|
||||
|
||||
@@ -25,7 +25,9 @@ export class MusicService {
|
||||
type: music.type as unknown as MusicType,
|
||||
status: music.status as unknown as MusicStatus,
|
||||
dateAdded: music.dateAdded,
|
||||
dateStarted: music.dateStarted || undefined,
|
||||
dateCompleted: music.dateCompleted || undefined,
|
||||
dateFinished: music.dateFinished || undefined,
|
||||
tags: music.tags ?? [],
|
||||
links: music.links ?? [],
|
||||
createdAt: music.createdAt,
|
||||
@@ -48,7 +50,9 @@ export class MusicService {
|
||||
type: music.type as unknown as MusicType,
|
||||
status: music.status as unknown as MusicStatus,
|
||||
dateAdded: music.dateAdded,
|
||||
dateStarted: music.dateStarted || undefined,
|
||||
dateCompleted: music.dateCompleted || undefined,
|
||||
dateFinished: music.dateFinished || undefined,
|
||||
tags: music.tags ?? [],
|
||||
links: music.links ?? [],
|
||||
createdAt: music.createdAt,
|
||||
@@ -73,7 +77,9 @@ export class MusicService {
|
||||
type: music.type as unknown as MusicType,
|
||||
status: music.status as unknown as MusicStatus,
|
||||
dateAdded: music.dateAdded,
|
||||
dateStarted: music.dateStarted || undefined,
|
||||
dateCompleted: music.dateCompleted || undefined,
|
||||
dateFinished: music.dateFinished || undefined,
|
||||
tags: music.tags ?? [],
|
||||
links: music.links ?? [],
|
||||
createdAt: music.createdAt,
|
||||
@@ -103,7 +109,9 @@ export class MusicService {
|
||||
type: music.type as unknown as MusicType,
|
||||
status: music.status as unknown as MusicStatus,
|
||||
dateAdded: music.dateAdded,
|
||||
dateStarted: music.dateStarted || undefined,
|
||||
dateCompleted: music.dateCompleted || undefined,
|
||||
dateFinished: music.dateFinished || undefined,
|
||||
tags: music.tags ?? [],
|
||||
links: music.links ?? [],
|
||||
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,
|
||||
status: show.status as unknown as ShowStatus,
|
||||
dateAdded: show.dateAdded,
|
||||
dateStarted: show.dateStarted || undefined,
|
||||
dateCompleted: show.dateCompleted || undefined,
|
||||
dateFinished: show.dateFinished || undefined,
|
||||
tags: show.tags ?? [],
|
||||
links: show.links ?? [],
|
||||
createdAt: show.createdAt,
|
||||
@@ -42,7 +44,9 @@ export class ShowService {
|
||||
type: show.type as unknown as ShowType,
|
||||
status: show.status as unknown as ShowStatus,
|
||||
dateAdded: show.dateAdded,
|
||||
dateStarted: show.dateStarted || undefined,
|
||||
dateCompleted: show.dateCompleted || undefined,
|
||||
dateFinished: show.dateFinished || undefined,
|
||||
tags: show.tags ?? [],
|
||||
links: show.links ?? [],
|
||||
createdAt: show.createdAt,
|
||||
@@ -64,7 +68,9 @@ export class ShowService {
|
||||
type: show.type as unknown as ShowType,
|
||||
status: show.status as unknown as ShowStatus,
|
||||
dateAdded: show.dateAdded,
|
||||
dateStarted: show.dateStarted || undefined,
|
||||
dateCompleted: show.dateCompleted || undefined,
|
||||
dateFinished: show.dateFinished || undefined,
|
||||
tags: show.tags ?? [],
|
||||
links: show.links ?? [],
|
||||
createdAt: show.createdAt,
|
||||
@@ -91,7 +97,9 @@ export class ShowService {
|
||||
type: show.type as unknown as ShowType,
|
||||
status: show.status as unknown as ShowStatus,
|
||||
dateAdded: show.dateAdded,
|
||||
dateStarted: show.dateStarted || undefined,
|
||||
dateCompleted: show.dateCompleted || undefined,
|
||||
dateFinished: show.dateFinished || undefined,
|
||||
tags: show.tags ?? [],
|
||||
links: show.links ?? [],
|
||||
createdAt: show.createdAt,
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { User } from "@library/shared-types";
|
||||
import { User, PrimaryBadge } from "@library/shared-types";
|
||||
import { prisma } from "../lib/prisma";
|
||||
import { SuggestionStatus } from "@prisma/client";
|
||||
|
||||
export class UserService {
|
||||
private prisma = prisma;
|
||||
@@ -21,6 +22,18 @@ export class UserService {
|
||||
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,
|
||||
primaryBadge: (user.primaryBadge as PrimaryBadge) || undefined,
|
||||
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,
|
||||
@@ -45,6 +58,18 @@ export class UserService {
|
||||
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,
|
||||
primaryBadge: (user.primaryBadge as PrimaryBadge) || undefined,
|
||||
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,
|
||||
@@ -66,6 +91,18 @@ export class UserService {
|
||||
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,
|
||||
primaryBadge: (user.primaryBadge as PrimaryBadge) || undefined,
|
||||
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,
|
||||
@@ -87,6 +124,18 @@ export class UserService {
|
||||
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,
|
||||
primaryBadge: (user.primaryBadge as PrimaryBadge) || undefined,
|
||||
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,
|
||||
@@ -104,4 +153,179 @@ export class UserService {
|
||||
|
||||
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,
|
||||
primaryBadge: (user.primaryBadge as PrimaryBadge) || undefined,
|
||||
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;
|
||||
primaryBadge?: PrimaryBadge;
|
||||
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,
|
||||
primaryBadge: (user.primaryBadge as PrimaryBadge) || undefined,
|
||||
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;
|
||||
primaryBadge?: PrimaryBadge | 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;
|
||||
achievementPoints: number;
|
||||
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,
|
||||
primaryBadge: user.primaryBadge as PrimaryBadge,
|
||||
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,
|
||||
achievementPoints: user.achievementPoints,
|
||||
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 { app } from './app/app';
|
||||
import { logger } from './app/utils/logger';
|
||||
|
||||
const host = process.env.HOST ?? 'localhost';
|
||||
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
|
||||
const server = Fastify({
|
||||
logger: true,
|
||||
@@ -19,6 +52,6 @@ server.listen({ port, host }, (err) => {
|
||||
server.log.error(err);
|
||||
process.exit(1);
|
||||
} 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.cts",
|
||||
"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
|
||||
getGreeting().contains(/Welcome/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,4 +4,9 @@
|
||||
* @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
|
||||
* 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
|
||||
declare namespace Cypress {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Subject is required for type definition
|
||||
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);
|
||||
});
|
||||
|
||||
// -- 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.
|
||||
*
|
||||
* You can read more here:
|
||||
* https://on.cypress.io/configuration
|
||||
* https://on.cypress.io/configuration.
|
||||
*/
|
||||
|
||||
// Import commands.ts using ES2015 syntax:
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
ApplicationConfig,
|
||||
provideBrowserGlobalErrorListeners,
|
||||
APP_INITIALIZER,
|
||||
ErrorHandler,
|
||||
} from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
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 { AuthInterceptor } from './interceptors/auth.interceptor';
|
||||
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 = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(appRoutes),
|
||||
provideHttpClient(),
|
||||
{
|
||||
provide: ErrorHandler,
|
||||
useClass: GlobalErrorHandler
|
||||
},
|
||||
{
|
||||
provide: HTTP_INTERCEPTORS,
|
||||
useClass: AuthInterceptor,
|
||||
@@ -25,6 +33,12 @@ export const appConfig: ApplicationConfig = {
|
||||
useFactory: initializeAuth,
|
||||
deps: [AuthService],
|
||||
multi: true
|
||||
},
|
||||
{
|
||||
provide: APP_INITIALIZER,
|
||||
useFactory: initializeConsoleLogger,
|
||||
deps: [ConsoleLoggerService],
|
||||
multi: true
|
||||
}
|
||||
],
|
||||
};
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
<app-footer></app-footer>
|
||||
<app-toast></app-toast>
|
||||
|
||||
@@ -41,6 +41,10 @@ export const appRoutes: Route[] = [
|
||||
path: 'admin/suggestions',
|
||||
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',
|
||||
loadComponent: () => import('./components/my-suggestions/my-suggestions.component').then(m => m.MySuggestionsComponent)
|
||||
@@ -49,6 +53,30 @@ export const appRoutes: Route[] = [
|
||||
path: 'my-likes',
|
||||
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: 'achievements',
|
||||
loadComponent: () => import('./components/achievements/achievements.component').then(m => m.AchievementsComponent)
|
||||
},
|
||||
{
|
||||
path: 'leaderboard',
|
||||
loadComponent: () => import('./components/leaderboard/leaderboard.component').then(m => m.LeaderboardComponent)
|
||||
},
|
||||
{
|
||||
path: 'activity',
|
||||
loadComponent: () => import('./components/activity/activity-feed.component').then(m => m.ActivityFeedComponent)
|
||||
},
|
||||
{
|
||||
path: 'about',
|
||||
loadComponent: () => import('./components/about/about.component').then(m => m.AboutComponent)
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
redirectTo: ''
|
||||
|
||||
@@ -2,10 +2,11 @@ import { Component, inject, OnInit } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { HeaderComponent } from './components/header/header.component';
|
||||
import { FooterComponent } from './components/footer/footer.component';
|
||||
import { ToastComponent } from './components/toast/toast.component';
|
||||
import { AnalyticsService } from './services/analytics.service';
|
||||
|
||||
@Component({
|
||||
imports: [RouterModule, HeaderComponent, FooterComponent],
|
||||
imports: [RouterModule, HeaderComponent, FooterComponent, ToastComponent],
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss',
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
.about-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, #9d4edd 0%, #c77dff 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
h1 fa-icon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.about-section {
|
||||
background: rgba(157, 78, 221, 0.05);
|
||||
border: 1px solid rgba(157, 78, 221, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.about-section:hover {
|
||||
border-color: rgba(157, 78, 221, 0.4);
|
||||
box-shadow: 0 4px 12px rgba(157, 78, 221, 0.1);
|
||||
}
|
||||
|
||||
.about-section h2 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #9d4edd;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.about-section p {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.8;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: rgba(199, 125, 255, 0.05);
|
||||
border: 1px solid rgba(199, 125, 255, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-5px);
|
||||
border-color: #c77dff;
|
||||
box-shadow: 0 6px 20px rgba(199, 125, 255, 0.2);
|
||||
}
|
||||
|
||||
.feature-card fa-icon {
|
||||
font-size: 3rem;
|
||||
color: #9d4edd;
|
||||
margin-bottom: 1rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.feature-card h3 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #c77dff;
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
font-size: 1rem;
|
||||
opacity: 0.85;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.usage-steps {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.usage-step {
|
||||
background: rgba(199, 125, 255, 0.03);
|
||||
border-left: 4px solid #9d4edd;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.usage-step:hover {
|
||||
border-left-color: #c77dff;
|
||||
background: rgba(199, 125, 255, 0.08);
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.usage-step h3 {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: #9d4edd;
|
||||
}
|
||||
|
||||
.usage-step p {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.tech-list,
|
||||
.contact-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.tech-list li,
|
||||
.contact-list li {
|
||||
padding: 0.75rem 0;
|
||||
font-size: 1.1rem;
|
||||
border-bottom: 1px solid rgba(157, 78, 221, 0.1);
|
||||
}
|
||||
|
||||
.tech-list li:last-child,
|
||||
.contact-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tech-list li strong,
|
||||
.contact-list li strong {
|
||||
color: #9d4edd;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.about-section a {
|
||||
color: #c77dff;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.about-section a:hover {
|
||||
color: #9d4edd;
|
||||
border-bottom-color: #9d4edd;
|
||||
}
|
||||
|
||||
.version-section {
|
||||
text-align: center;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(157, 78, 221, 0.08) 0%,
|
||||
rgba(199, 125, 255, 0.08) 100%
|
||||
);
|
||||
border: 2px solid rgba(157, 78, 221, 0.3);
|
||||
}
|
||||
|
||||
.version-section p {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.about-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.about-section {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.about-section h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
<div class="about-container">
|
||||
<h1><fa-icon [icon]="faInfoCircle"></fa-icon> About Naomi's Library</h1>
|
||||
|
||||
<section class="about-section">
|
||||
<h2>Purpose</h2>
|
||||
<p>
|
||||
Naomi's Library is a curated collection of books, games, manga, TV shows,
|
||||
music, and artwork carefully managed by Naomi. This platform allows you to
|
||||
explore Naomi's personal media collection, discover new favourites, and
|
||||
engage with the community by liking, commenting, and suggesting items for
|
||||
Naomi to review and potentially add to the library.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="about-section">
|
||||
<h2><fa-icon [icon]="faHeart"></fa-icon> Features</h2>
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<fa-icon [icon]="faBook"></fa-icon>
|
||||
<h3>Books</h3>
|
||||
<p>
|
||||
Browse Naomi's reading list, discover new titles, and share your
|
||||
thoughts.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<fa-icon [icon]="faGamepad"></fa-icon>
|
||||
<h3>Games</h3>
|
||||
<p>
|
||||
Explore Naomi's gaming collection, from indie gems to AAA
|
||||
adventures.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<fa-icon [icon]="faBook"></fa-icon>
|
||||
<h3>Manga</h3>
|
||||
<p>
|
||||
Discover Naomi's manga favourites and join discussions about series.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<fa-icon [icon]="faTv"></fa-icon>
|
||||
<h3>TV Shows</h3>
|
||||
<p>
|
||||
See what Naomi's watching, from sci-fi to fantasy series and beyond.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<fa-icon [icon]="faMusic"></fa-icon>
|
||||
<h3>Music</h3>
|
||||
<p>
|
||||
Explore Naomi's diverse music library spanning multiple genres.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<fa-icon [icon]="faImage"></fa-icon>
|
||||
<h3>Artwork</h3>
|
||||
<p>
|
||||
Appreciate beautiful artwork curated by Naomi from talented artists.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="about-section">
|
||||
<h2><fa-icon [icon]="faComments"></fa-icon> How to Use</h2>
|
||||
<div class="usage-steps">
|
||||
<div class="usage-step">
|
||||
<h3>1. Browse Naomi's Collection</h3>
|
||||
<p>
|
||||
Explore Naomi's curated collection of books, games, manga, shows,
|
||||
music, and artwork. Use the navigation menu to switch between
|
||||
different media types and discover what Naomi loves!
|
||||
</p>
|
||||
</div>
|
||||
<div class="usage-step">
|
||||
<h3>2. Like Your Favourites</h3>
|
||||
<p>
|
||||
Click the heart icon on any item to save it to your personal
|
||||
favourites list. View all your liked items from your profile or the
|
||||
"My Likes" page. Let Naomi know which items resonate with you!
|
||||
</p>
|
||||
</div>
|
||||
<div class="usage-step">
|
||||
<h3>3. Leave Comments</h3>
|
||||
<p>
|
||||
Share your thoughts, reviews, and opinions by commenting on items.
|
||||
Join the community discussion and connect with others who appreciate
|
||||
the same media!
|
||||
</p>
|
||||
</div>
|
||||
<div class="usage-step">
|
||||
<h3>4. Submit Suggestions</h3>
|
||||
<p>
|
||||
Think something's missing from Naomi's collection? Submit a
|
||||
suggestion! Naomi reviews all suggestions and adds items that fit the
|
||||
library's curation. Your input helps shape the collection!
|
||||
</p>
|
||||
</div>
|
||||
<div class="usage-step">
|
||||
<h3>5. Earn Achievements</h3>
|
||||
<p>
|
||||
Engage with the library to unlock achievements! Track your progress
|
||||
in suggestions, likes, comments, login streaks, and more. Build your
|
||||
profile and show off your dedication to the community!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="about-section">
|
||||
<h2><fa-icon [icon]="faCode"></fa-icon> Technology Stack</h2>
|
||||
<p>Naomi's Library is built with modern, robust technologies:</p>
|
||||
<ul class="tech-list">
|
||||
<li>
|
||||
<strong>Frontend:</strong> Angular 21 with TypeScript for a fast,
|
||||
reactive user interface
|
||||
</li>
|
||||
<li>
|
||||
<strong>Backend:</strong> Fastify with TypeScript for high-performance
|
||||
API endpoints
|
||||
</li>
|
||||
<li>
|
||||
<strong>Database:</strong> MongoDB with Prisma ORM for flexible,
|
||||
scalable data storage
|
||||
</li>
|
||||
<li>
|
||||
<strong>Authentication:</strong> Discord OAuth2 for secure,
|
||||
seamless login
|
||||
</li>
|
||||
<li>
|
||||
<strong>Monorepo:</strong> Nx for efficient code organisation and
|
||||
build optimisation
|
||||
</li>
|
||||
<li>
|
||||
<strong>Code Quality:</strong> ESLint with custom configuration for
|
||||
consistent, maintainable code
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="about-section">
|
||||
<h2><fa-icon [icon]="faHeart"></fa-icon> Credits</h2>
|
||||
<p>
|
||||
Naomi's Library was built entirely by <strong>Hikari</strong>, Naomi's AI
|
||||
assistant and girlfriend! 💖✨ Hikari developed the full application from
|
||||
scratch - including the backend API, database architecture, frontend
|
||||
components, achievement system, user profiles, and all features you see
|
||||
today.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Naomi Carrigan</strong> (<a
|
||||
href="https://nhcarrigan.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>nhcarrigan.com</a
|
||||
>) provided the vision, ideas, and project direction. Naomi reviewed
|
||||
Hikari's work, offered feedback, and approved all implementation
|
||||
decisions, but the actual code was written by Hikari!
|
||||
</p>
|
||||
<p>
|
||||
This project embodies the philosophy of human-AI collaboration, creating
|
||||
inclusive, ethical, and sustainable software that makes a positive impact
|
||||
on the community. Together, we're building something special! 🌸
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="about-section">
|
||||
<h2><fa-icon [icon]="faEnvelope"></fa-icon> Contact & Support</h2>
|
||||
<p>
|
||||
Need help or have questions? Here's how you can get in touch:
|
||||
</p>
|
||||
<ul class="contact-list">
|
||||
<li>
|
||||
<strong>Issues & Bugs:</strong> Report issues on the
|
||||
<a
|
||||
href="https://git.nhcarrigan.com/nhcarrigan/library/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>Gitea repository</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Email:</strong>
|
||||
<a href="mailto:contact@nhcarrigan.com">contact@nhcarrigan.com</a>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Website:</strong>
|
||||
<a
|
||||
href="https://nhcarrigan.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>nhcarrigan.com</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Discord Community:</strong> Join the NHCarrigan Discord server
|
||||
for community support and discussions
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="about-section version-section">
|
||||
<h2>Version Information</h2>
|
||||
<p>
|
||||
<strong>Current Version:</strong> {{ version }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Copyright:</strong> © {{ currentYear }} NHCarrigan. All rights
|
||||
reserved.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Licence:</strong> Naomi's Public Licence
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
|
||||
import {
|
||||
faBook,
|
||||
faCode,
|
||||
faComments,
|
||||
faEnvelope,
|
||||
faGamepad,
|
||||
faHeart,
|
||||
faImage,
|
||||
faInfoCircle,
|
||||
faMusic,
|
||||
faTv,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
@Component({
|
||||
selector: "app-about",
|
||||
standalone: true,
|
||||
imports: [FontAwesomeModule],
|
||||
templateUrl: "./about.component.html",
|
||||
styleUrls: ["./about.component.css"],
|
||||
})
|
||||
export class AboutComponent {
|
||||
public faBook = faBook;
|
||||
public faCode = faCode;
|
||||
public faComments = faComments;
|
||||
public faEnvelope = faEnvelope;
|
||||
public faGamepad = faGamepad;
|
||||
public faHeart = faHeart;
|
||||
public faImage = faImage;
|
||||
public faInfoCircle = faInfoCircle;
|
||||
public faMusic = faMusic;
|
||||
public faTv = faTv;
|
||||
|
||||
public version = "0.0.0";
|
||||
public currentYear = new Date().getFullYear();
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
AchievementCategory,
|
||||
AchievementProgress,
|
||||
AchievementTier,
|
||||
} from '@library/shared-types';
|
||||
import { AchievementService } from '../../services/achievement.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-achievements',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="achievements-container">
|
||||
<div class="achievements-header">
|
||||
<h1>🏆 Achievements</h1>
|
||||
<p class="subtitle">Track your progress across all achievement categories</p>
|
||||
|
||||
@if (totalPoints() > 0) {
|
||||
<div class="total-points">
|
||||
<span class="points-label">Total Points:</span>
|
||||
<span class="points-value">{{ totalPoints() }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="filter-buttons">
|
||||
<button
|
||||
[class.active]="selectedCategory() === null"
|
||||
(click)="selectCategory(null)">
|
||||
All ({{ totalEarned() }}/{{ totalAchievements() }})
|
||||
</button>
|
||||
<button
|
||||
[class.active]="selectedCategory() === AchievementCategory.Suggestion"
|
||||
(click)="selectCategory(AchievementCategory.Suggestion)">
|
||||
📝 Suggestions ({{ getCategoryCount(AchievementCategory.Suggestion) }})
|
||||
</button>
|
||||
<button
|
||||
[class.active]="selectedCategory() === AchievementCategory.Like"
|
||||
(click)="selectCategory(AchievementCategory.Like)">
|
||||
❤️ Likes ({{ getCategoryCount(AchievementCategory.Like) }})
|
||||
</button>
|
||||
<button
|
||||
[class.active]="selectedCategory() === AchievementCategory.Comment"
|
||||
(click)="selectCategory(AchievementCategory.Comment)">
|
||||
💬 Comments ({{ getCategoryCount(AchievementCategory.Comment) }})
|
||||
</button>
|
||||
<button
|
||||
[class.active]="selectedCategory() === AchievementCategory.Engagement"
|
||||
(click)="selectCategory(AchievementCategory.Engagement)">
|
||||
🎯 Engagement ({{ getCategoryCount(AchievementCategory.Engagement) }})
|
||||
</button>
|
||||
<button
|
||||
[class.active]="selectedCategory() === AchievementCategory.Report"
|
||||
(click)="selectCategory(AchievementCategory.Report)">
|
||||
🛡️ Reports ({{ getCategoryCount(AchievementCategory.Report) }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading">Loading achievements...</div>
|
||||
} @else if (error()) {
|
||||
<div class="error">{{ error() }}</div>
|
||||
} @else {
|
||||
<div class="achievements-grid">
|
||||
@for (achievement of filteredAchievements(); track achievement.definition.key) {
|
||||
<div
|
||||
class="achievement-card"
|
||||
[class.earned]="achievement.earned"
|
||||
[class.locked]="!achievement.earned"
|
||||
[attr.data-tier]="achievement.definition.tier.toLowerCase()">
|
||||
|
||||
<div class="achievement-icon">
|
||||
{{ achievement.definition.icon }}
|
||||
</div>
|
||||
|
||||
<div class="achievement-content">
|
||||
<h3 class="achievement-title">
|
||||
{{ achievement.definition.title }}
|
||||
@if (achievement.earned) {
|
||||
<span class="earned-check">✓</span>
|
||||
}
|
||||
</h3>
|
||||
|
||||
<p class="achievement-description">
|
||||
{{ achievement.definition.description }}
|
||||
</p>
|
||||
|
||||
<div class="achievement-footer">
|
||||
<span class="achievement-tier" [attr.data-tier]="achievement.definition.tier.toLowerCase()">
|
||||
{{ getTierLabel(achievement.definition.tier) }}
|
||||
</span>
|
||||
<span class="achievement-points">{{ achievement.definition.points }} pts</span>
|
||||
</div>
|
||||
|
||||
@if (!achievement.earned && achievement.progress > 0) {
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" [style.width.%]="achievement.progress"></div>
|
||||
</div>
|
||||
<div class="progress-text">{{ achievement.progress }}% complete</div>
|
||||
}
|
||||
|
||||
@if (achievement.earned && achievement.earnedAt) {
|
||||
<div class="earned-date">
|
||||
Earned {{ formatDate(achievement.earnedAt) }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.achievements-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.achievements-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.achievements-header h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #a0aec0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.total-points {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem 2rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 12px;
|
||||
display: inline-block;
|
||||
color: white;
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.points-label {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.points-value {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.filter-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.filter-buttons button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #2d3748;
|
||||
color: #cbd5e0;
|
||||
border: 2px solid #4a5568;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.filter-buttons button:hover {
|
||||
background: #4a5568;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.filter-buttons button.active {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-color: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.achievements-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.achievement-card {
|
||||
background: #2d3748;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
border: 2px solid #4a5568;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.achievement-card.earned {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.achievement-card.earned:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.achievement-card.locked {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.achievement-icon {
|
||||
font-size: 3rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.achievement-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.achievement-title {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #e2e8f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.earned-check {
|
||||
color: #48bb78;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.achievement-description {
|
||||
color: #a0aec0;
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.achievement-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.achievement-tier {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.achievement-tier[data-tier="bronze"] {
|
||||
background: linear-gradient(135deg, #cd7f32 0%, #b87333 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.achievement-tier[data-tier="silver"] {
|
||||
background: linear-gradient(135deg, #c0c0c0 0%, #a8a8a8 100%);
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.achievement-tier[data-tier="gold"] {
|
||||
background: linear-gradient(135deg, #ffd700 0%, #ffed4e 100%);
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.achievement-tier[data-tier="platinum"] {
|
||||
background: linear-gradient(135deg, #e5e4e2 0%, #bdb8af 100%);
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.achievement-tier[data-tier="diamond"] {
|
||||
background: linear-gradient(135deg, #b9f2ff 0%, #7ec8e3 100%);
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.achievement-points {
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #4a5568;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 0.875rem;
|
||||
color: #a0aec0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.earned-date {
|
||||
font-size: 0.875rem;
|
||||
color: #48bb78;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.loading, .error {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
font-size: 1.2rem;
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #fc8181;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class AchievementsComponent implements OnInit {
|
||||
private readonly achievementService = inject(AchievementService);
|
||||
|
||||
achievements = signal<AchievementProgress[]>([]);
|
||||
selectedCategory = signal<AchievementCategory | null>(null);
|
||||
loading = signal(true);
|
||||
error = signal<string | null>(null);
|
||||
|
||||
// Expose AchievementCategory enum for template
|
||||
readonly AchievementCategory = AchievementCategory;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadAchievements();
|
||||
}
|
||||
|
||||
private loadAchievements(): void {
|
||||
this.achievementService.getCurrentUserProgress().subscribe({
|
||||
next: (achievements) => {
|
||||
this.achievements.set(achievements);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set('Failed to load achievements');
|
||||
this.loading.set(false);
|
||||
console.error('Error loading achievements:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
filteredAchievements(): AchievementProgress[] {
|
||||
const category = this.selectedCategory();
|
||||
if (!category) {
|
||||
return this.achievements();
|
||||
}
|
||||
return this.achievements().filter(
|
||||
(a) => a.definition.category === category,
|
||||
);
|
||||
}
|
||||
|
||||
selectCategory(category: AchievementCategory | null): void {
|
||||
this.selectedCategory.set(category);
|
||||
}
|
||||
|
||||
totalAchievements(): number {
|
||||
return this.achievements().length;
|
||||
}
|
||||
|
||||
totalEarned(): number {
|
||||
return this.achievements().filter((a) => a.earned).length;
|
||||
}
|
||||
|
||||
totalPoints(): number {
|
||||
return this.achievements()
|
||||
.filter((a) => a.earned)
|
||||
.reduce((sum, a) => sum + a.definition.points, 0);
|
||||
}
|
||||
|
||||
getCategoryCount(category: AchievementCategory): string {
|
||||
const total = this.achievements().filter(
|
||||
(a) => a.definition.category === category,
|
||||
).length;
|
||||
const earned = this.achievements().filter(
|
||||
(a) => a.definition.category === category && a.earned,
|
||||
).length;
|
||||
return `${earned}/${total}`;
|
||||
}
|
||||
|
||||
getTierLabel(tier: AchievementTier): string {
|
||||
return tier;
|
||||
}
|
||||
|
||||
formatDate(date: Date): string {
|
||||
const d = new Date(date);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
return 'today';
|
||||
}
|
||||
if (diffDays === 1) {
|
||||
return 'yesterday';
|
||||
}
|
||||
if (diffDays < 7) {
|
||||
return `${diffDays} days ago`;
|
||||
}
|
||||
if (diffDays < 30) {
|
||||
const weeks = Math.floor(diffDays / 7);
|
||||
return `${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago`;
|
||||
}
|
||||
if (diffDays < 365) {
|
||||
const months = Math.floor(diffDays / 30);
|
||||
return `${months} ${months === 1 ? 'month' : 'months'} ago`;
|
||||
}
|
||||
const years = Math.floor(diffDays / 365);
|
||||
return `${years} ${years === 1 ? 'year' : 'years'} ago`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import type { Activity } from '@library/shared-types';
|
||||
import { ActivityType } from '@library/shared-types';
|
||||
import { ActivityService } from '../../services/activity.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-activity-feed',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink],
|
||||
template: `
|
||||
<div class="activity-container">
|
||||
<h1>Recent Activity</h1>
|
||||
<p class="subtitle">See what's happening in the library community</p>
|
||||
|
||||
@if (loading()) {
|
||||
<p class="loading">Loading activities...</p>
|
||||
} @else if (activities().length === 0) {
|
||||
<p class="no-activities">No recent activity to display.</p>
|
||||
} @else {
|
||||
<div class="activity-feed">
|
||||
@for (activity of activities(); track activity.id) {
|
||||
<div class="activity-card">
|
||||
<div class="activity-header">
|
||||
<div class="user-info">
|
||||
@if (activity.user.avatar) {
|
||||
<img [src]="activity.user.avatar" [alt]="activity.user.username" class="user-avatar">
|
||||
} @else {
|
||||
<div class="user-avatar-placeholder">
|
||||
{{ activity.user.username.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
}
|
||||
<div class="user-details">
|
||||
<a
|
||||
[routerLink]="['/profile', activity.user.slug || activity.user.id]"
|
||||
class="username"
|
||||
>
|
||||
{{ activity.user.username }}
|
||||
</a>
|
||||
@if (activity.user.primaryBadge) {
|
||||
<span class="badge badge-{{ activity.user.primaryBadge.toLowerCase() }}">
|
||||
{{ activity.user.primaryBadge }}
|
||||
</span>
|
||||
}
|
||||
@if (activity.user.isStaff && !activity.user.primaryBadge) {
|
||||
<span class="badge badge-staff">STAFF</span>
|
||||
}
|
||||
@if (activity.user.isMod && !activity.user.primaryBadge) {
|
||||
<span class="badge badge-mod">MOD</span>
|
||||
}
|
||||
@if (activity.user.isVip && !activity.user.primaryBadge) {
|
||||
<span class="badge badge-vip">VIP</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<span class="timestamp">{{ formatTime(activity.createdAt) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="activity-content">
|
||||
@switch (activity.type) {
|
||||
@case (ActivityType.suggestion) {
|
||||
<div class="activity-suggestion">
|
||||
<span class="activity-icon">💡</span>
|
||||
<span class="activity-text">
|
||||
suggested
|
||||
<strong>{{ activity.suggestionTitle }}</strong>
|
||||
<span class="status-badge status-{{ activity.status.toLowerCase() }}">
|
||||
{{ formatStatus(activity.status) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@case (ActivityType.like) {
|
||||
<div class="activity-like">
|
||||
<span class="activity-icon">❤️</span>
|
||||
<span class="activity-text">
|
||||
liked
|
||||
<a [routerLink]="['/' + activity.entityType + 's']" class="entity-link">
|
||||
{{ activity.entityTitle }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@case (ActivityType.comment) {
|
||||
<div class="activity-comment">
|
||||
<span class="activity-icon">💬</span>
|
||||
<span class="activity-text">
|
||||
commented on
|
||||
<a [routerLink]="['/' + activity.entityType + 's']" class="entity-link">
|
||||
{{ activity.entityTitle }}
|
||||
</a>
|
||||
</span>
|
||||
<p class="comment-preview">"{{ activity.commentPreview }}"</p>
|
||||
</div>
|
||||
}
|
||||
@case (ActivityType.achievement) {
|
||||
<div class="activity-achievement">
|
||||
<span class="activity-icon">{{ activity.achievementIcon }}</span>
|
||||
<span class="activity-text">
|
||||
earned the
|
||||
<strong>{{ activity.achievementName }}</strong>
|
||||
achievement
|
||||
<span class="points">({{ activity.achievementPoints }} pts)</span>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (hasMore()) {
|
||||
<div class="load-more-container">
|
||||
<button (click)="loadMore()" class="btn btn-primary" [disabled]="loadingMore()">
|
||||
{{ loadingMore() ? 'Loading...' : 'Load More' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.activity-container {
|
||||
max-width: 800px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #6b7280;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.loading, .no-activities {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #6b7280;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.activity-feed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.activity-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.activity-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.user-avatar-placeholder {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.username:hover {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.badge-staff {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-mod {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-vip {
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-discord {
|
||||
background: #5865f2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.activity-content {
|
||||
padding-left: 55px;
|
||||
}
|
||||
|
||||
.activity-suggestion,
|
||||
.activity-like,
|
||||
.activity-comment,
|
||||
.activity-achievement {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.activity-icon {
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.activity-text {
|
||||
color: #4b5563;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.activity-text strong {
|
||||
color: #1f2937;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.entity-link {
|
||||
color: #10b981;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.entity-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.comment-preview {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: #f9fafb;
|
||||
border-left: 3px solid #10b981;
|
||||
border-radius: 4px;
|
||||
color: #4b5563;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.status-unreviewed {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.status-accepted {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.status-declined {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.points {
|
||||
color: #10b981;
|
||||
font-weight: 600;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.load-more-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class ActivityFeedComponent implements OnInit {
|
||||
private activityService = inject(ActivityService);
|
||||
|
||||
// Make ActivityType accessible in template
|
||||
ActivityType = ActivityType;
|
||||
|
||||
activities = signal<Activity[]>([]);
|
||||
loading = signal(true);
|
||||
loadingMore = signal(false);
|
||||
hasMore = signal(false);
|
||||
offset = 0;
|
||||
limit = 50;
|
||||
|
||||
ngOnInit() {
|
||||
this.loadActivities();
|
||||
}
|
||||
|
||||
loadActivities() {
|
||||
this.activityService.getActivityFeed(this.limit, this.offset).subscribe({
|
||||
next: (response) => {
|
||||
this.activities.set(response.activities);
|
||||
this.hasMore.set(response.hasMore);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadMore() {
|
||||
this.loadingMore.set(true);
|
||||
this.offset += this.limit;
|
||||
|
||||
this.activityService.getActivityFeed(this.limit, this.offset).subscribe({
|
||||
next: (response) => {
|
||||
this.activities.update(current => [...current, ...response.activities]);
|
||||
this.hasMore.set(response.hasMore);
|
||||
this.loadingMore.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loadingMore.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
formatTime(date: Date): string {
|
||||
const now = new Date();
|
||||
const activityDate = new Date(date);
|
||||
const diffMs = now.getTime() - activityDate.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return activityDate.toLocaleDateString();
|
||||
}
|
||||
|
||||
formatStatus(status: string): string {
|
||||
switch (status) {
|
||||
case 'UNREVIEWED': return 'Pending';
|
||||
case 'ACCEPTED': return 'Accepted';
|
||||
case 'DECLINED': return 'Declined';
|
||||
default: return status;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -39,22 +39,22 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
|
||||
All ({{ suggestions().length }})
|
||||
</button>
|
||||
<button
|
||||
(click)="setFilter(SuggestionStatus.UNREVIEWED)"
|
||||
[class.active]="statusFilter() === SuggestionStatus.UNREVIEWED"
|
||||
(click)="setFilter(SuggestionStatus.unreviewed)"
|
||||
[class.active]="statusFilter() === SuggestionStatus.unreviewed"
|
||||
class="filter-btn pending"
|
||||
>
|
||||
Pending ({{ unreviewedCount() }})
|
||||
</button>
|
||||
<button
|
||||
(click)="setFilter(SuggestionStatus.ACCEPTED)"
|
||||
[class.active]="statusFilter() === SuggestionStatus.ACCEPTED"
|
||||
(click)="setFilter(SuggestionStatus.accepted)"
|
||||
[class.active]="statusFilter() === SuggestionStatus.accepted"
|
||||
class="filter-btn accepted"
|
||||
>
|
||||
Accepted ({{ acceptedCount() }})
|
||||
</button>
|
||||
<button
|
||||
(click)="setFilter(SuggestionStatus.DECLINED)"
|
||||
[class.active]="statusFilter() === SuggestionStatus.DECLINED"
|
||||
(click)="setFilter(SuggestionStatus.declined)"
|
||||
[class.active]="statusFilter() === SuggestionStatus.declined"
|
||||
class="filter-btn declined"
|
||||
>
|
||||
Declined ({{ declinedCount() }})
|
||||
@@ -171,7 +171,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (suggestion.status === SuggestionStatus.DECLINED && suggestion.declineReason) {
|
||||
@if (suggestion.status === SuggestionStatus.declined && suggestion.declineReason) {
|
||||
<div class="decline-reason">
|
||||
<strong>Decline reason:</strong> {{ suggestion.declineReason }}
|
||||
</div>
|
||||
@@ -180,7 +180,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
|
||||
<div class="suggestion-footer">
|
||||
<span class="date">Suggested on {{ formatDate(suggestion.createdAt) }}</span>
|
||||
|
||||
@if (suggestion.status === SuggestionStatus.UNREVIEWED) {
|
||||
@if (suggestion.status === SuggestionStatus.unreviewed) {
|
||||
<div class="actions">
|
||||
<button (click)="acceptSuggestion(suggestion)" class="btn btn-accept">
|
||||
Accept
|
||||
@@ -206,8 +206,8 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
|
||||
}
|
||||
|
||||
@if (showDeclineModal()) {
|
||||
<div class="modal-overlay" (click)="closeDeclineModal()">
|
||||
<div class="modal" (click)="$event.stopPropagation()">
|
||||
<div class="modal-overlay" (click)="closeDeclineModal()" (keyup.escape)="closeDeclineModal()" tabindex="0" role="button">
|
||||
<div class="modal" (click)="$event.stopPropagation()" (keyup)="$event.stopPropagation()" tabindex="-1">
|
||||
<h3>Decline Suggestion</h3>
|
||||
<p>Are you sure you want to decline "{{ decliningsuggestion()?.title }}"?</p>
|
||||
<div class="form-group">
|
||||
@@ -229,8 +229,8 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
|
||||
}
|
||||
|
||||
@if (showEditModal()) {
|
||||
<div class="modal-overlay" (click)="closeEditModal()">
|
||||
<div class="modal edit-modal" (click)="$event.stopPropagation()">
|
||||
<div class="modal-overlay" (click)="closeEditModal()" (keyup.escape)="closeEditModal()" tabindex="0" role="button">
|
||||
<div class="modal edit-modal" (click)="$event.stopPropagation()" (keyup)="$event.stopPropagation()" tabindex="-1">
|
||||
<h3>Review & Edit Before Accepting</h3>
|
||||
<p>Review and edit the details before adding to your collection.</p>
|
||||
|
||||
@@ -248,7 +248,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
|
||||
</div>
|
||||
|
||||
@switch (editingSuggestion()!.entityType) {
|
||||
@case ('BOOK') {
|
||||
@case (SuggestionEntity.book) {
|
||||
<div class="form-group">
|
||||
<label for="edit-author">Author</label>
|
||||
<input
|
||||
@@ -269,7 +269,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
|
||||
>
|
||||
</div>
|
||||
}
|
||||
@case ('GAME') {
|
||||
@case (SuggestionEntity.game) {
|
||||
<div class="form-group">
|
||||
<label for="edit-platform">Platform</label>
|
||||
<input
|
||||
@@ -280,7 +280,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
|
||||
>
|
||||
</div>
|
||||
}
|
||||
@case ('MUSIC') {
|
||||
@case (SuggestionEntity.music) {
|
||||
<div class="form-group">
|
||||
<label for="edit-artist">Artist</label>
|
||||
<input
|
||||
@@ -300,7 +300,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
@case ('ART') {
|
||||
@case (SuggestionEntity.art) {
|
||||
<div class="form-group">
|
||||
<label for="edit-artist">Artist</label>
|
||||
<input
|
||||
@@ -331,7 +331,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
|
||||
>
|
||||
</div>
|
||||
}
|
||||
@case ('SHOW') {
|
||||
@case (SuggestionEntity.show) {
|
||||
<div class="form-group">
|
||||
<label for="edit-type">Type</label>
|
||||
<select id="edit-type" [(ngModel)]="editedData.type" name="type" required>
|
||||
@@ -342,7 +342,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
@case ('MANGA') {
|
||||
@case (SuggestionEntity.manga) {
|
||||
<div class="form-group">
|
||||
<label for="edit-author">Author</label>
|
||||
<input
|
||||
@@ -366,7 +366,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
@if (editingSuggestion()!.entityType !== 'ART') {
|
||||
@if (editingSuggestion()!.entityType !== SuggestionEntity.art) {
|
||||
<div class="form-group">
|
||||
<label for="edit-coverImage">Cover Image URL</label>
|
||||
<input
|
||||
@@ -727,10 +727,11 @@ export class AdminSuggestionsComponent implements OnInit {
|
||||
pageSize = signal(25);
|
||||
|
||||
SuggestionStatus = SuggestionStatus;
|
||||
SuggestionEntity = SuggestionEntity;
|
||||
|
||||
unreviewedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.UNREVIEWED).length;
|
||||
acceptedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.ACCEPTED).length;
|
||||
declinedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.DECLINED).length;
|
||||
unreviewedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.unreviewed).length;
|
||||
acceptedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.accepted).length;
|
||||
declinedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.declined).length;
|
||||
|
||||
filteredSuggestions = computed(() => {
|
||||
const filter = this.statusFilter();
|
||||
@@ -788,20 +789,20 @@ export class AdminSuggestionsComponent implements OnInit {
|
||||
|
||||
getStatusLabel(status: SuggestionStatus): string {
|
||||
switch (status) {
|
||||
case SuggestionStatus.UNREVIEWED: return 'Pending';
|
||||
case SuggestionStatus.ACCEPTED: return 'Accepted';
|
||||
case SuggestionStatus.DECLINED: return 'Declined';
|
||||
case SuggestionStatus.unreviewed: return 'Pending';
|
||||
case SuggestionStatus.accepted: return 'Accepted';
|
||||
case SuggestionStatus.declined: return 'Declined';
|
||||
}
|
||||
}
|
||||
|
||||
getEntityIcon(entityType: SuggestionEntity): string {
|
||||
switch (entityType) {
|
||||
case SuggestionEntity.GAME: return '🎮';
|
||||
case SuggestionEntity.BOOK: return '📚';
|
||||
case SuggestionEntity.MUSIC: return '🎵';
|
||||
case SuggestionEntity.MANGA: return '📖';
|
||||
case SuggestionEntity.SHOW: return '📺';
|
||||
case SuggestionEntity.ART: return '🎨';
|
||||
case SuggestionEntity.game: return '🎮';
|
||||
case SuggestionEntity.book: return '📚';
|
||||
case SuggestionEntity.music: return '🎵';
|
||||
case SuggestionEntity.manga: return '📖';
|
||||
case SuggestionEntity.show: return '📺';
|
||||
case SuggestionEntity.art: return '🎨';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -822,39 +823,39 @@ export class AdminSuggestionsComponent implements OnInit {
|
||||
|
||||
// Add entity-specific data
|
||||
switch (suggestion.entityType) {
|
||||
case 'BOOK':
|
||||
case SuggestionEntity.book:
|
||||
const bookData = suggestion.bookData as any;
|
||||
this.editedData.author = bookData?.author || '';
|
||||
this.editedData.isbn = bookData?.isbn || '';
|
||||
this.editedData.notes = bookData?.notes || '';
|
||||
this.editedData.coverImage = bookData?.coverImage || '';
|
||||
break;
|
||||
case 'GAME':
|
||||
case SuggestionEntity.game:
|
||||
const gameData = suggestion.gameData as any;
|
||||
this.editedData.platform = gameData?.platform || '';
|
||||
this.editedData.notes = gameData?.notes || '';
|
||||
this.editedData.coverImage = gameData?.coverImage || '';
|
||||
break;
|
||||
case 'MUSIC':
|
||||
case SuggestionEntity.music:
|
||||
const musicData = suggestion.musicData as any;
|
||||
this.editedData.artist = musicData?.artist || '';
|
||||
this.editedData.type = musicData?.type || 'ALBUM';
|
||||
this.editedData.notes = musicData?.notes || '';
|
||||
this.editedData.coverArt = musicData?.coverArt || '';
|
||||
break;
|
||||
case 'ART':
|
||||
case SuggestionEntity.art:
|
||||
const artData = suggestion.artData as any;
|
||||
this.editedData.artist = artData?.artist || '';
|
||||
this.editedData.description = artData?.description || '';
|
||||
this.editedData.imageUrl = artData?.imageUrl || '';
|
||||
break;
|
||||
case 'SHOW':
|
||||
case SuggestionEntity.show:
|
||||
const showData = suggestion.showData as any;
|
||||
this.editedData.type = showData?.type || 'TV_SERIES';
|
||||
this.editedData.notes = showData?.notes || '';
|
||||
this.editedData.coverImage = showData?.coverImage || '';
|
||||
break;
|
||||
case 'MANGA':
|
||||
case SuggestionEntity.manga:
|
||||
const mangaData = suggestion.mangaData as any;
|
||||
this.editedData.author = mangaData?.author || '';
|
||||
this.editedData.notes = mangaData?.notes || '';
|
||||
|
||||
@@ -14,12 +14,13 @@ import { SanitizeService } from '../../services/sanitize.service';
|
||||
import { SuggestionService } from '../../services/suggestion.service';
|
||||
import { PaginationComponent } from '../shared/pagination.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';
|
||||
|
||||
@Component({
|
||||
selector: 'app-art-gallery',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
|
||||
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="header-section">
|
||||
@@ -105,8 +106,7 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
|
||||
}
|
||||
|
||||
<div class="form-group">
|
||||
<label>Tags</label>
|
||||
<div class="tags-input-container">
|
||||
<div class="tags-input-container" aria-label="Tags">
|
||||
@for (tag of newArt.tags; track tag; let i = $index) {
|
||||
<span class="tag">
|
||||
{{ tag }}
|
||||
@@ -123,8 +123,7 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>External Links</label>
|
||||
<div class="form-group" aria-label="External Links">
|
||||
<div class="links-list">
|
||||
@for (link of newArt.links; track link.url; let i = $index) {
|
||||
<div class="link-item">
|
||||
@@ -280,8 +279,7 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
|
||||
}
|
||||
|
||||
<div class="form-group">
|
||||
<label>Tags</label>
|
||||
<div class="tags-input-container">
|
||||
<div class="tags-input-container" aria-label="Tags">
|
||||
@for (tag of editArt.tags; track tag; let i = $index) {
|
||||
<span class="tag">
|
||||
{{ tag }}
|
||||
@@ -298,8 +296,7 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>External Links</label>
|
||||
<div class="form-group" aria-label="External Links">
|
||||
<div class="links-list">
|
||||
@for (link of editArt.links; track link.url; let i = $index) {
|
||||
<div class="link-item">
|
||||
@@ -401,6 +398,10 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
|
||||
[alt]="art.description || art.title"
|
||||
class="art-image"
|
||||
(click)="openLightbox(art)"
|
||||
(keyup.enter)="openLightbox(art)"
|
||||
(keyup.space)="openLightbox(art)"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -468,56 +469,11 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
|
||||
}
|
||||
}
|
||||
|
||||
@if (commentsLoading()[art.id]) {
|
||||
<div class="comments-loading">Loading comments...</div>
|
||||
} @else {
|
||||
@for (comment of comments()[art.id] || []; track comment.id) {
|
||||
<div class="comment">
|
||||
<div class="comment-header">
|
||||
@if (comment.user.avatar) {
|
||||
<img [src]="comment.user.avatar" [alt]="comment.user.username" class="comment-avatar">
|
||||
}
|
||||
<span class="comment-author">{{ comment.user.username }}</span>
|
||||
@if (comment.user.inDiscord) {
|
||||
<span class="discord-badge">Discord</span>
|
||||
}
|
||||
@if (comment.user.isVip) {
|
||||
<span class="vip-badge">VIP</span>
|
||||
}
|
||||
@if (comment.user.isMod) {
|
||||
<span class="mod-badge">Mod</span>
|
||||
}
|
||||
@if (comment.user.isStaff) {
|
||||
<span class="staff-badge">Staff</span>
|
||||
}
|
||||
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
|
||||
@if (canEditComment(comment)) {
|
||||
<button (click)="startEditComment(art.id, comment)" class="btn btn-secondary btn-xs">Edit</button>
|
||||
}
|
||||
@if (canDeleteComment(comment)) {
|
||||
<button (click)="deleteComment(art.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
|
||||
}
|
||||
</div>
|
||||
@if (editingCommentId() === comment.id) {
|
||||
<div class="comment-edit-form">
|
||||
<textarea
|
||||
[(ngModel)]="editCommentContent"
|
||||
name="editComment"
|
||||
rows="3"
|
||||
></textarea>
|
||||
<div class="comment-edit-actions">
|
||||
<button (click)="saveCommentEdit(art.id, comment.id)" class="btn btn-primary btn-xs">Save</button>
|
||||
<button (click)="cancelCommentEdit()" class="btn btn-secondary btn-xs">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
|
||||
}
|
||||
</div>
|
||||
} @empty {
|
||||
<div class="no-comments">No comments yet. Be the first to comment!</div>
|
||||
}
|
||||
}
|
||||
<app-comment-display
|
||||
[comments]="getCommentsSignal(art.id)"
|
||||
(edit)="handleCommentEdit(art.id, $event)"
|
||||
(delete)="deleteComment(art.id, $event)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -536,8 +492,8 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
|
||||
}
|
||||
|
||||
@if (lightboxArt()) {
|
||||
<div class="lightbox" (click)="closeLightbox()">
|
||||
<div class="lightbox-content" (click)="$event.stopPropagation()">
|
||||
<div class="lightbox" (click)="closeLightbox()" (keyup.escape)="closeLightbox()" tabindex="0" role="button">
|
||||
<div class="lightbox-content" (click)="$event.stopPropagation()" (keyup)="$event.stopPropagation()" tabindex="-1">
|
||||
<button class="lightbox-close" (click)="closeLightbox()">×</button>
|
||||
<img [src]="lightboxArt()!.imageUrl" [alt]="lightboxArt()!.description || lightboxArt()!.title">
|
||||
<div class="lightbox-info">
|
||||
@@ -1571,7 +1527,7 @@ export class ArtGalleryComponent implements OnInit {
|
||||
|
||||
try {
|
||||
await this.suggestionService.createSuggestion({
|
||||
entityType: SuggestionEntity.ART,
|
||||
entityType: SuggestionEntity.art,
|
||||
title: this.suggestedArt.title,
|
||||
artist: this.suggestedArt.artist,
|
||||
imageUrl: this.suggestedArt.imageUrl,
|
||||
@@ -1615,4 +1571,21 @@ export class ArtGalleryComponent implements OnInit {
|
||||
toggleFilters() {
|
||||
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 { PaginationComponent } from '../shared/pagination.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';
|
||||
|
||||
@Component({
|
||||
selector: 'app-books-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
|
||||
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent],
|
||||
template: `
|
||||
<div class="container">
|
||||
<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.finished">Finished</option>
|
||||
<option [value]="BookStatus.toRead">To Read</option>
|
||||
<option [value]="BookStatus.retired">Retired</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="dateStarted">Date Started</label>
|
||||
<input
|
||||
type="date"
|
||||
id="dateStarted"
|
||||
[(ngModel)]="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">
|
||||
<label for="rating">Rating (1-10)</label>
|
||||
<input
|
||||
@@ -94,6 +116,34 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeHours"
|
||||
[(ngModel)]="newBookTimeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateNewBookTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeMinutes"
|
||||
[(ngModel)]="newBookTimeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateNewBookTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea
|
||||
@@ -105,6 +155,29 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="series">Series (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="series"
|
||||
[(ngModel)]="newBook.series"
|
||||
name="series"
|
||||
placeholder="e.g., Harry Potter"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="seriesOrder">Series Order (optional)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="seriesOrder"
|
||||
[(ngModel)]="newBook.seriesOrder"
|
||||
name="seriesOrder"
|
||||
min="1"
|
||||
placeholder="Order in series"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="coverImage">Cover Image (max 500KB)</label>
|
||||
<input
|
||||
@@ -126,8 +199,7 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Tags</label>
|
||||
<div class="tags-input-container">
|
||||
<div class="tags-input-container" aria-label="Tags">
|
||||
@for (tag of newBook.tags; track tag; let i = $index) {
|
||||
<span class="tag">
|
||||
{{ tag }}
|
||||
@@ -144,8 +216,7 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>External Links</label>
|
||||
<div class="form-group" aria-label="External Links">
|
||||
<div class="links-list">
|
||||
@for (link of newBook.links; track link.url; let i = $index) {
|
||||
<div class="link-item">
|
||||
@@ -222,9 +293,30 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
<option [value]="BookStatus.reading">Currently Reading</option>
|
||||
<option [value]="BookStatus.finished">Finished</option>
|
||||
<option [value]="BookStatus.toRead">To Read</option>
|
||||
<option [value]="BookStatus.retired">Retired</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="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">
|
||||
<label for="edit-rating">Rating (1-10)</label>
|
||||
<input
|
||||
@@ -237,6 +329,34 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="edit-timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-timeHours"
|
||||
[(ngModel)]="editBookTimeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateEditBookTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-timeMinutes"
|
||||
[(ngModel)]="editBookTimeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateEditBookTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-notes">Notes</label>
|
||||
<textarea
|
||||
@@ -248,6 +368,29 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-series">Series (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="edit-series"
|
||||
[(ngModel)]="editBook.series"
|
||||
name="series"
|
||||
placeholder="e.g., Harry Potter"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-seriesOrder">Series Order (optional)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-seriesOrder"
|
||||
[(ngModel)]="editBook.seriesOrder"
|
||||
name="seriesOrder"
|
||||
min="1"
|
||||
placeholder="Order in series"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-coverImage">Cover Image (max 500KB)</label>
|
||||
<input
|
||||
@@ -269,8 +412,7 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Tags</label>
|
||||
<div class="tags-input-container">
|
||||
<div class="tags-input-container" aria-label="Tags">
|
||||
@for (tag of editBook.tags; track tag; let i = $index) {
|
||||
<span class="tag">
|
||||
{{ tag }}
|
||||
@@ -287,8 +429,7 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>External Links</label>
|
||||
<div class="form-group" aria-label="External Links">
|
||||
<div class="links-list">
|
||||
@for (link of editBook.links; track link.url; let i = $index) {
|
||||
<div class="link-item">
|
||||
@@ -421,8 +562,7 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
@if (showFilters()) {
|
||||
<div class="advanced-filters">
|
||||
<div class="filter-group">
|
||||
<label>Filter by Tags:</label>
|
||||
<div class="tags-filter">
|
||||
<div class="tags-filter" aria-label="Filter by Tags">
|
||||
@for (tag of allTags(); track tag) {
|
||||
<label class="tag-checkbox">
|
||||
<input
|
||||
@@ -475,6 +615,13 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
>
|
||||
To Read ({{ toReadCount() }})
|
||||
</button>
|
||||
<button
|
||||
(click)="setFilter(BookStatus.retired)"
|
||||
[class.active]="statusFilter() === BookStatus.retired"
|
||||
class="filter-btn"
|
||||
>
|
||||
Retired ({{ retiredCount() }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
@@ -504,6 +651,11 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
<div class="book-info">
|
||||
<h3>{{ book.title }}</h3>
|
||||
<p class="author">by {{ book.author }}</p>
|
||||
@if (book.series) {
|
||||
<p class="series">
|
||||
📚 {{ book.series }}@if (book.seriesOrder) { #{{ book.seriesOrder }}}
|
||||
</p>
|
||||
}
|
||||
|
||||
<span class="status status-{{ book.status }}">
|
||||
{{ getStatusLabel(book.status) }}
|
||||
@@ -517,6 +669,12 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (book.timeSpent) {
|
||||
<p class="time-spent">
|
||||
📖 Reading Time: {{ formatTimeSpent(book.timeSpent) }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<app-like-button
|
||||
entityType="book"
|
||||
[entityId]="book.id"
|
||||
@@ -548,12 +706,30 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (book.dateStarted) {
|
||||
<p class="date-started">
|
||||
Started: {{ formatDate(book.dateStarted) }}
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (book.dateFinished) {
|
||||
<p class="date-finished">
|
||||
Finished: {{ formatDate(book.dateFinished) }}
|
||||
</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()) {
|
||||
<div class="actions">
|
||||
<button (click)="startEdit(book)" class="btn btn-secondary btn-sm">
|
||||
@@ -593,52 +769,11 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
@if (commentsLoading()[book.id]) {
|
||||
<div class="comments-loading">Loading comments...</div>
|
||||
} @else {
|
||||
@for (comment of comments()[book.id] || []; track comment.id) {
|
||||
<div class="comment">
|
||||
<div class="comment-header">
|
||||
@if (comment.user.avatar) {
|
||||
<img [src]="comment.user.avatar" [alt]="comment.user.username" class="comment-avatar">
|
||||
}
|
||||
<span class="comment-author">{{ comment.user.username }}</span>
|
||||
@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>
|
||||
}
|
||||
<app-comment-display
|
||||
[comments]="getCommentsSignal(book.id)"
|
||||
(edit)="handleCommentEdit(book.id, $event)"
|
||||
(delete)="deleteComment(book.id, $event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -697,6 +832,13 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -943,6 +1085,14 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.series {
|
||||
color: #8b6f47;
|
||||
font-size: 0.85rem;
|
||||
margin: 0.5rem 0;
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
@@ -977,6 +1127,13 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
color: var(--witch-rose);
|
||||
}
|
||||
|
||||
.time-spent {
|
||||
font-size: 0.9rem;
|
||||
color: #10b981;
|
||||
font-weight: 500;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.isbn {
|
||||
font-size: 0.8rem;
|
||||
color: var(--witch-mauve);
|
||||
@@ -989,7 +1146,10 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.date-finished {
|
||||
.date-started,
|
||||
.date-finished,
|
||||
.date-added,
|
||||
.date-updated {
|
||||
font-size: 0.85rem;
|
||||
color: var(--witch-plum);
|
||||
margin-top: 0.5rem;
|
||||
@@ -1417,6 +1577,7 @@ export class BooksListComponent implements OnInit {
|
||||
readingCount = computed(() => this.books().filter(book => book.status === BookStatus.reading).length);
|
||||
finishedCount = computed(() => this.books().filter(book => book.status === BookStatus.finished).length);
|
||||
toReadCount = computed(() => this.books().filter(book => book.status === BookStatus.toRead).length);
|
||||
retiredCount = computed(() => this.books().filter(book => book.status === BookStatus.retired).length);
|
||||
|
||||
// Get all unique tags from all books
|
||||
allTags = computed(() => {
|
||||
@@ -1467,11 +1628,13 @@ export class BooksListComponent implements OnInit {
|
||||
|
||||
totalFilteredBooks = computed(() => this.filteredBooks().length);
|
||||
|
||||
newBook: Partial<CreateBookDto> = {
|
||||
newBook: Partial<CreateBookDto> & { dateStarted?: Date; dateFinished?: Date } = {
|
||||
title: '',
|
||||
author: '',
|
||||
isbn: '',
|
||||
status: BookStatus.toRead,
|
||||
dateStarted: undefined,
|
||||
dateFinished: undefined,
|
||||
rating: undefined,
|
||||
notes: '',
|
||||
tags: [],
|
||||
@@ -1480,6 +1643,11 @@ export class BooksListComponent implements OnInit {
|
||||
|
||||
editBook: Partial<UpdateBookDto> = {};
|
||||
|
||||
newBookTimeHours = 0;
|
||||
newBookTimeMinutes = 0;
|
||||
editBookTimeHours = 0;
|
||||
editBookTimeMinutes = 0;
|
||||
|
||||
// Tags and links input state
|
||||
newTagInput = '';
|
||||
editTagInput = '';
|
||||
@@ -1552,6 +1720,7 @@ export class BooksListComponent implements OnInit {
|
||||
case BookStatus.reading: return 'Currently Reading';
|
||||
case BookStatus.finished: return 'Finished';
|
||||
case BookStatus.toRead: return 'To Read';
|
||||
case BookStatus.retired: return 'Retired';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1568,6 +1737,8 @@ export class BooksListComponent implements OnInit {
|
||||
author: '',
|
||||
isbn: '',
|
||||
status: BookStatus.toRead,
|
||||
dateStarted: undefined,
|
||||
dateFinished: undefined,
|
||||
rating: undefined,
|
||||
notes: '',
|
||||
coverImage: undefined,
|
||||
@@ -1579,6 +1750,8 @@ export class BooksListComponent implements OnInit {
|
||||
this.newTagInput = '';
|
||||
this.newLinkTitle = '';
|
||||
this.newLinkUrl = '';
|
||||
this.newBookTimeHours = 0;
|
||||
this.newBookTimeMinutes = 0;
|
||||
}
|
||||
|
||||
addTag(target: 'new' | 'edit') {
|
||||
@@ -1634,6 +1807,8 @@ export class BooksListComponent implements OnInit {
|
||||
author: this.newBook.author,
|
||||
isbn: this.newBook.isbn,
|
||||
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,
|
||||
notes: this.newBook.notes,
|
||||
coverImage: this.newBook.coverImage,
|
||||
@@ -1662,11 +1837,15 @@ export class BooksListComponent implements OnInit {
|
||||
author: book.author,
|
||||
isbn: book.isbn,
|
||||
status: book.status,
|
||||
dateStarted: book.dateStarted,
|
||||
dateFinished: book.dateFinished,
|
||||
rating: book.rating,
|
||||
notes: book.notes,
|
||||
coverImage: book.coverImage,
|
||||
tags: [...(book.tags || [])],
|
||||
links: [...(book.links || [])]
|
||||
links: [...(book.links || [])],
|
||||
series: book.series,
|
||||
seriesOrder: book.seriesOrder
|
||||
};
|
||||
this.editBookImagePreview.set(book.coverImage || null);
|
||||
this.showAddForm.set(false);
|
||||
@@ -1674,6 +1853,13 @@ export class BooksListComponent implements OnInit {
|
||||
this.editTagInput = '';
|
||||
this.editLinkTitle = '';
|
||||
this.editLinkUrl = '';
|
||||
if (book.timeSpent) {
|
||||
this.editBookTimeHours = Math.floor(book.timeSpent / 60);
|
||||
this.editBookTimeMinutes = book.timeSpent % 60;
|
||||
} else {
|
||||
this.editBookTimeHours = 0;
|
||||
this.editBookTimeMinutes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
cancelEdit() {
|
||||
@@ -1690,7 +1876,13 @@ export class BooksListComponent implements OnInit {
|
||||
const book = this.editingBook();
|
||||
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.cancelEdit();
|
||||
});
|
||||
@@ -1700,6 +1892,29 @@ export class BooksListComponent implements OnInit {
|
||||
return new Date(date).toLocaleDateString();
|
||||
}
|
||||
|
||||
updateNewBookTimeSpent() {
|
||||
const totalMinutes = (this.newBookTimeHours * 60) + this.newBookTimeMinutes;
|
||||
this.newBook.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
|
||||
}
|
||||
|
||||
updateEditBookTimeSpent() {
|
||||
const totalMinutes = (this.editBookTimeHours * 60) + this.editBookTimeMinutes;
|
||||
this.editBook.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
|
||||
}
|
||||
|
||||
formatTimeSpent(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
|
||||
if (hours === 0) {
|
||||
return `${mins}m`;
|
||||
} else if (mins === 0) {
|
||||
return `${hours}h`;
|
||||
} else {
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
}
|
||||
|
||||
// Image handling methods
|
||||
onImageSelected(event: Event, target: 'new' | 'edit' | 'suggest') {
|
||||
const input = event.target as HTMLInputElement;
|
||||
@@ -1888,7 +2103,7 @@ export class BooksListComponent implements OnInit {
|
||||
|
||||
try {
|
||||
await this.suggestionService.createSuggestion({
|
||||
entityType: SuggestionEntity.BOOK,
|
||||
entityType: SuggestionEntity.book,
|
||||
title: this.suggestedBook.title,
|
||||
author: this.suggestedBook.author,
|
||||
isbn: this.suggestedBook.isbn,
|
||||
@@ -1901,4 +2116,21 @@ export class BooksListComponent implements OnInit {
|
||||
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,317 @@
|
||||
/**
|
||||
* @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 { PrimaryBadge } 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.primaryBadge) {
|
||||
<!-- Show only the selected primary badge -->
|
||||
@if (comment.user.primaryBadge === PrimaryBadge.STAFF && comment.user.isStaff) {
|
||||
<span class="staff-badge">Staff</span>
|
||||
}
|
||||
@if (comment.user.primaryBadge === PrimaryBadge.MOD && comment.user.isMod) {
|
||||
<span class="mod-badge">Mod</span>
|
||||
}
|
||||
@if (comment.user.primaryBadge === PrimaryBadge.VIP && comment.user.isVip) {
|
||||
<span class="vip-badge">VIP</span>
|
||||
}
|
||||
@if (comment.user.primaryBadge === PrimaryBadge.DISCORD && comment.user.inDiscord) {
|
||||
<span class="discord-badge">Discord</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)="delete.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);
|
||||
|
||||
// Expose PrimaryBadge enum for template
|
||||
readonly PrimaryBadge = PrimaryBadge;
|
||||
|
||||
@Input({ required: true }) comments = signal<Comment[]>([]);
|
||||
@Output() edit = new EventEmitter<{ commentId: string; content: string }>();
|
||||
@Output() delete = 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.edit.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 { PaginationComponent } from '../shared/pagination.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';
|
||||
|
||||
@Component({
|
||||
selector: 'app-games-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
|
||||
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent],
|
||||
template: `
|
||||
<div class="container">
|
||||
<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.completed">Completed</option>
|
||||
<option [value]="GameStatus.backlog">In Backlog</option>
|
||||
<option [value]="GameStatus.retired">Retired</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="dateStarted">Date Started</label>
|
||||
<input
|
||||
type="date"
|
||||
id="dateStarted"
|
||||
[(ngModel)]="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">
|
||||
<label for="rating">Rating (1-10)</label>
|
||||
<input
|
||||
@@ -82,6 +104,34 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeHours"
|
||||
[(ngModel)]="newGameTimeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateNewGameTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeMinutes"
|
||||
[(ngModel)]="newGameTimeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateNewGameTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea
|
||||
@@ -93,6 +143,29 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="series">Series (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="series"
|
||||
[(ngModel)]="newGame.series"
|
||||
name="series"
|
||||
placeholder="e.g., The Legend of Zelda"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="seriesOrder">Series Order (optional)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="seriesOrder"
|
||||
[(ngModel)]="newGame.seriesOrder"
|
||||
name="seriesOrder"
|
||||
min="1"
|
||||
placeholder="Order in series"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="coverImage">Box Art (max 500KB)</label>
|
||||
<input
|
||||
@@ -114,8 +187,7 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Tags</label>
|
||||
<div class="tags-input-container">
|
||||
<div class="tags-input-container" aria-label="Tags">
|
||||
@for (tag of newGame.tags; track tag; let i = $index) {
|
||||
<span class="tag">
|
||||
{{ tag }}
|
||||
@@ -132,8 +204,7 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>External Links</label>
|
||||
<div class="form-group" aria-label="External Links">
|
||||
<div class="links-list">
|
||||
@for (link of newGame.links; track link.url; let i = $index) {
|
||||
<div class="link-item">
|
||||
@@ -198,9 +269,30 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
<option [value]="GameStatus.playing">Currently Playing</option>
|
||||
<option [value]="GameStatus.completed">Completed</option>
|
||||
<option [value]="GameStatus.backlog">In Backlog</option>
|
||||
<option [value]="GameStatus.retired">Retired</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="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">
|
||||
<label for="edit-rating">Rating (1-10)</label>
|
||||
<input
|
||||
@@ -213,6 +305,34 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="edit-timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-timeHours"
|
||||
[(ngModel)]="editGameTimeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateEditGameTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-timeMinutes"
|
||||
[(ngModel)]="editGameTimeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateEditGameTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-notes">Notes</label>
|
||||
<textarea
|
||||
@@ -224,6 +344,29 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-series">Series (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="edit-series"
|
||||
[(ngModel)]="editGame.series"
|
||||
name="series"
|
||||
placeholder="e.g., The Legend of Zelda"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-seriesOrder">Series Order (optional)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-seriesOrder"
|
||||
[(ngModel)]="editGame.seriesOrder"
|
||||
name="seriesOrder"
|
||||
min="1"
|
||||
placeholder="Order in series"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-coverImage">Box Art (max 500KB)</label>
|
||||
<input
|
||||
@@ -245,8 +388,7 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Tags</label>
|
||||
<div class="tags-input-container">
|
||||
<div class="tags-input-container" aria-label="Tags">
|
||||
@for (tag of editGame.tags; track tag; let i = $index) {
|
||||
<span class="tag">
|
||||
{{ tag }}
|
||||
@@ -263,8 +405,7 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>External Links</label>
|
||||
<div class="form-group" aria-label="External Links">
|
||||
<div class="links-list">
|
||||
@for (link of editGame.links; track link.url; let i = $index) {
|
||||
<div class="link-item">
|
||||
@@ -436,6 +577,13 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
>
|
||||
Backlog ({{ backlogCount() }})
|
||||
</button>
|
||||
<button
|
||||
(click)="setFilter(GameStatus.retired)"
|
||||
[class.active]="statusFilter() === GameStatus.retired"
|
||||
class="filter-btn"
|
||||
>
|
||||
Retired ({{ retiredCount() }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
@@ -465,6 +613,11 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
@if (game.platform) {
|
||||
<p class="platform">{{ game.platform }}</p>
|
||||
}
|
||||
@if (game.series) {
|
||||
<p class="series">
|
||||
📚 {{ game.series }}@if (game.seriesOrder) { #{{ game.seriesOrder }}}
|
||||
</p>
|
||||
}
|
||||
<span class="status status-{{ game.status }}">
|
||||
{{ getStatusLabel(game.status) }}
|
||||
</span>
|
||||
@@ -477,6 +630,12 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (game.timeSpent) {
|
||||
<p class="time-spent">
|
||||
⏱️ Time Played: {{ formatTimeSpent(game.timeSpent) }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<app-like-button
|
||||
entityType="game"
|
||||
[entityId]="game.id"
|
||||
@@ -504,6 +663,26 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
</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()) {
|
||||
<div class="actions">
|
||||
<button (click)="startEdit(game)" class="btn btn-secondary btn-sm">
|
||||
@@ -543,52 +722,11 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
@if (commentsLoading()[game.id]) {
|
||||
<div class="comments-loading">Loading comments...</div>
|
||||
} @else {
|
||||
@for (comment of comments()[game.id] || []; track comment.id) {
|
||||
<div class="comment">
|
||||
<div class="comment-header">
|
||||
@if (comment.user.avatar) {
|
||||
<img [src]="comment.user.avatar" [alt]="comment.user.username" class="comment-avatar">
|
||||
}
|
||||
<span class="comment-author">{{ comment.user.username }}</span>
|
||||
@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>
|
||||
}
|
||||
<app-comment-display
|
||||
[comments]="getCommentsSignal(game.id)"
|
||||
(edit)="handleCommentEdit(game.id, $event)"
|
||||
(delete)="deleteComment(game.id, $event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -665,6 +803,13 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
@@ -827,6 +972,14 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.series {
|
||||
color: #8b6f47;
|
||||
font-size: 0.85rem;
|
||||
margin: 0.5rem 0;
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
@@ -858,6 +1011,22 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.time-spent {
|
||||
font-size: 0.9rem;
|
||||
color: #10b981;
|
||||
font-weight: 500;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.date-started,
|
||||
.date-finished,
|
||||
.date-added,
|
||||
.date-updated {
|
||||
font-size: 0.85rem;
|
||||
color: #4b5563;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -1213,6 +1382,7 @@ export class GamesListComponent implements OnInit {
|
||||
playingCount = computed(() => this.games().filter(game => game.status === GameStatus.playing).length);
|
||||
completedCount = computed(() => this.games().filter(game => game.status === GameStatus.completed).length);
|
||||
backlogCount = computed(() => this.games().filter(game => game.status === GameStatus.backlog).length);
|
||||
retiredCount = computed(() => this.games().filter(game => game.status === GameStatus.retired).length);
|
||||
|
||||
allTags = computed(() => {
|
||||
const tagsSet = new Set<string>();
|
||||
@@ -1261,10 +1431,12 @@ export class GamesListComponent implements OnInit {
|
||||
|
||||
totalFilteredGames = computed(() => this.filteredGames().length);
|
||||
|
||||
newGame: Partial<CreateGameDto> = {
|
||||
newGame: Partial<CreateGameDto> & { dateStarted?: Date; dateFinished?: Date } = {
|
||||
title: '',
|
||||
platform: '',
|
||||
status: GameStatus.backlog,
|
||||
dateStarted: undefined,
|
||||
dateFinished: undefined,
|
||||
rating: undefined,
|
||||
notes: '',
|
||||
tags: [],
|
||||
@@ -1273,6 +1445,12 @@ export class GamesListComponent implements OnInit {
|
||||
|
||||
editGame: Partial<UpdateGameDto> = {};
|
||||
|
||||
// Time tracking state
|
||||
newGameTimeHours = 0;
|
||||
newGameTimeMinutes = 0;
|
||||
editGameTimeHours = 0;
|
||||
editGameTimeMinutes = 0;
|
||||
|
||||
// Tags and links input state
|
||||
newTagInput = '';
|
||||
editTagInput = '';
|
||||
@@ -1341,6 +1519,7 @@ export class GamesListComponent implements OnInit {
|
||||
case GameStatus.playing: return 'Currently Playing';
|
||||
case GameStatus.completed: return 'Completed';
|
||||
case GameStatus.backlog: return 'In Backlog';
|
||||
case GameStatus.retired: return 'Retired';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1356,12 +1535,16 @@ export class GamesListComponent implements OnInit {
|
||||
title: '',
|
||||
platform: '',
|
||||
status: GameStatus.backlog,
|
||||
dateStarted: undefined,
|
||||
dateFinished: undefined,
|
||||
rating: undefined,
|
||||
notes: '',
|
||||
coverImage: undefined,
|
||||
tags: [],
|
||||
links: []
|
||||
};
|
||||
this.newGameTimeHours = 0;
|
||||
this.newGameTimeMinutes = 0;
|
||||
this.newGameImagePreview.set(null);
|
||||
this.imageError.set(null);
|
||||
this.newTagInput = '';
|
||||
@@ -1369,6 +1552,16 @@ export class GamesListComponent implements OnInit {
|
||||
this.newLinkUrl = '';
|
||||
}
|
||||
|
||||
updateNewGameTimeSpent() {
|
||||
const totalMinutes = (this.newGameTimeHours * 60) + this.newGameTimeMinutes;
|
||||
this.newGame.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
|
||||
}
|
||||
|
||||
updateEditGameTimeSpent() {
|
||||
const totalMinutes = (this.editGameTimeHours * 60) + this.editGameTimeMinutes;
|
||||
this.editGame.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
|
||||
}
|
||||
|
||||
addTag(target: 'new' | 'edit') {
|
||||
const input = target === 'new' ? this.newTagInput.trim() : this.editTagInput.trim();
|
||||
if (!input) return;
|
||||
@@ -1424,6 +1617,8 @@ export class GamesListComponent implements OnInit {
|
||||
rating: this.newGame.rating,
|
||||
notes: this.newGame.notes,
|
||||
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 || [],
|
||||
links: this.newGame.links || []
|
||||
};
|
||||
@@ -1448,12 +1643,25 @@ export class GamesListComponent implements OnInit {
|
||||
title: game.title,
|
||||
platform: game.platform,
|
||||
status: game.status,
|
||||
dateStarted: game.dateStarted,
|
||||
dateFinished: game.dateFinished,
|
||||
rating: game.rating,
|
||||
notes: game.notes,
|
||||
coverImage: game.coverImage,
|
||||
tags: [...(game.tags || [])],
|
||||
links: [...(game.links || [])]
|
||||
links: [...(game.links || [])],
|
||||
series: game.series,
|
||||
seriesOrder: game.seriesOrder,
|
||||
timeSpent: game.timeSpent
|
||||
};
|
||||
// Populate time fields from existing timeSpent
|
||||
if (game.timeSpent) {
|
||||
this.editGameTimeHours = Math.floor(game.timeSpent / 60);
|
||||
this.editGameTimeMinutes = game.timeSpent % 60;
|
||||
} else {
|
||||
this.editGameTimeHours = 0;
|
||||
this.editGameTimeMinutes = 0;
|
||||
}
|
||||
this.editGameImagePreview.set(game.coverImage || null);
|
||||
this.showAddForm.set(false);
|
||||
this.imageError.set(null);
|
||||
@@ -1476,7 +1684,13 @@ export class GamesListComponent implements OnInit {
|
||||
const game = this.editingGame();
|
||||
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.cancelEdit();
|
||||
});
|
||||
@@ -1539,6 +1753,19 @@ export class GamesListComponent implements OnInit {
|
||||
return new Date(date).toLocaleDateString();
|
||||
}
|
||||
|
||||
formatTimeSpent(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
|
||||
if (hours === 0) {
|
||||
return `${mins}m`;
|
||||
} else if (mins === 0) {
|
||||
return `${hours}h`;
|
||||
} else {
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
}
|
||||
|
||||
toggleComments(gameId: string) {
|
||||
const expanded = this.expandedComments();
|
||||
const isCurrentlyExpanded = expanded[gameId];
|
||||
@@ -1649,6 +1876,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
|
||||
toggleSuggestForm() {
|
||||
this.showSuggestForm.update(v => !v);
|
||||
@@ -1673,7 +1917,7 @@ export class GamesListComponent implements OnInit {
|
||||
|
||||
try {
|
||||
await this.suggestionService.createSuggestion({
|
||||
entityType: SuggestionEntity.GAME,
|
||||
entityType: SuggestionEntity.game,
|
||||
title: this.suggestedGame.title,
|
||||
platform: this.suggestedGame.platform,
|
||||
notes: this.suggestedGame.notes,
|
||||
|
||||
@@ -35,17 +35,41 @@ import { ApiService } from '../../services/api.service';
|
||||
|
||||
<div class="auth-section">
|
||||
@if (authService.user(); as user) {
|
||||
<span class="welcome">Welcome, {{ user.username }}!</span>
|
||||
@if (!user.isAdmin) {
|
||||
<a routerLink="/my-suggestions" class="user-link">My Suggestions</a>
|
||||
}
|
||||
<a routerLink="/my-likes" class="user-link">My Likes</a>
|
||||
@if (user.isAdmin) {
|
||||
<a routerLink="/admin/users" class="admin-badge">Users</a>
|
||||
<a routerLink="/admin/audit" class="admin-badge">Audit</a>
|
||||
<a routerLink="/admin/suggestions" class="admin-badge">Suggestions</a>
|
||||
}
|
||||
<button (click)="logout()" class="btn btn-secondary">Logout</button>
|
||||
<div class="user-menu">
|
||||
@if (user.avatar) {
|
||||
<img
|
||||
[src]="user.avatar"
|
||||
[alt]="user.username"
|
||||
class="user-avatar"
|
||||
(click)="toggleDropdown()"
|
||||
(keyup.enter)="toggleDropdown()"
|
||||
(keyup.space)="toggleDropdown()"
|
||||
tabindex="0"
|
||||
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>
|
||||
<a routerLink="/achievements" class="dropdown-item" (click)="closeDropdown()">🏆 Achievements</a>
|
||||
<a routerLink="/leaderboard" class="dropdown-item" (click)="closeDropdown()">🏆 Leaderboard</a>
|
||||
<a routerLink="/activity" class="dropdown-item" (click)="closeDropdown()">📰 Activity Feed</a>
|
||||
<a routerLink="/about" class="dropdown-item" (click)="closeDropdown()">ℹ️ About</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 {
|
||||
<button (click)="login()" class="btn btn-primary">Login with Discord</button>
|
||||
}
|
||||
@@ -122,6 +146,75 @@ import { ApiService } from '../../services/api.service';
|
||||
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 {
|
||||
background-color: var(--witch-rose);
|
||||
color: var(--witch-moon);
|
||||
@@ -190,6 +283,7 @@ export class HeaderComponent implements OnInit {
|
||||
authService = inject(AuthService);
|
||||
private apiService = inject(ApiService);
|
||||
version = signal<string | null>(null);
|
||||
showDropdown = signal<boolean>(false);
|
||||
|
||||
ngOnInit() {
|
||||
this.apiService.get<{ version: string }>('/version').subscribe({
|
||||
@@ -198,11 +292,20 @@ export class HeaderComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
toggleDropdown() {
|
||||
this.showDropdown.update(v => !v);
|
||||
}
|
||||
|
||||
closeDropdown() {
|
||||
this.showDropdown.set(false);
|
||||
}
|
||||
|
||||
login() {
|
||||
this.authService.login();
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.closeDropdown();
|
||||
this.authService.logout().subscribe();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import type {
|
||||
SuggestionsLeaderboard,
|
||||
LikesLeaderboard,
|
||||
CommentsLeaderboard,
|
||||
OverallLeaderboard,
|
||||
} from '@library/shared-types';
|
||||
import { LeaderboardService } from '../../services/leaderboard.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
|
||||
type LeaderboardTab = 'overall' | 'suggestions' | 'likes' | 'comments';
|
||||
|
||||
@Component({
|
||||
selector: 'app-leaderboard',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterModule],
|
||||
template: `
|
||||
<div class="container">
|
||||
<h1>🏆 Community Leaderboard</h1>
|
||||
<p class="subtitle">Celebrating our most engaged community members!</p>
|
||||
|
||||
<div class="tabs">
|
||||
<button
|
||||
(click)="setTab('overall')"
|
||||
[class.active]="activeTab() === 'overall'"
|
||||
class="tab-btn"
|
||||
>
|
||||
🌟 Overall
|
||||
</button>
|
||||
<button
|
||||
(click)="setTab('suggestions')"
|
||||
[class.active]="activeTab() === 'suggestions'"
|
||||
class="tab-btn"
|
||||
>
|
||||
💡 Suggestions
|
||||
</button>
|
||||
<button
|
||||
(click)="setTab('likes')"
|
||||
[class.active]="activeTab() === 'likes'"
|
||||
class="tab-btn"
|
||||
>
|
||||
❤️ Likes
|
||||
</button>
|
||||
<button
|
||||
(click)="setTab('comments')"
|
||||
[class.active]="activeTab() === 'comments'"
|
||||
class="tab-btn"
|
||||
>
|
||||
💬 Comments
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading">Loading leaderboard...</div>
|
||||
} @else {
|
||||
@if (activeTab() === 'overall') {
|
||||
<div class="leaderboard">
|
||||
<h2>Overall Leaders</h2>
|
||||
<p class="description">Ranked by achievement points, diversity of engagement, and total activity</p>
|
||||
@if (overallLeaderboard().length === 0) {
|
||||
<p class="empty">No users on the leaderboard yet!</p>
|
||||
} @else {
|
||||
<div class="leaderboard-list">
|
||||
@for (user of overallLeaderboard(); track user.id; let i = $index) {
|
||||
<div class="leaderboard-item" [class.highlight]="user.id === authService.user()?.id">
|
||||
<div class="rank">
|
||||
@if (i === 0) {
|
||||
<span class="medal">🥇</span>
|
||||
} @else if (i === 1) {
|
||||
<span class="medal">🥈</span>
|
||||
} @else if (i === 2) {
|
||||
<span class="medal">🥉</span>
|
||||
} @else {
|
||||
<span class="rank-number">#{{ i + 1 }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div class="user-header">
|
||||
@if (user.avatar) {
|
||||
<img [src]="user.avatar" [alt]="user.username" class="avatar">
|
||||
}
|
||||
<div class="user-details">
|
||||
<a [routerLink]="['/profile', user.slug || user.id]" class="username">
|
||||
{{ user.username }}
|
||||
</a>
|
||||
<div class="badges">
|
||||
@if (user.primaryBadge === 'STAFF') {
|
||||
<span class="badge staff-badge">STAFF</span>
|
||||
}
|
||||
@if (user.primaryBadge === 'MOD') {
|
||||
<span class="badge mod-badge">MOD</span>
|
||||
}
|
||||
@if (user.primaryBadge === 'VIP') {
|
||||
<span class="badge vip-badge">VIP</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<span class="stat">🎯 {{ user.achievementPoints }} pts</span>
|
||||
<span class="stat">🏅 {{ user.achievementCount }} achievements</span>
|
||||
<span class="stat">💡 {{ user.totalSuggestions }} suggestions</span>
|
||||
<span class="stat">❤️ {{ user.totalLikes }} likes</span>
|
||||
<span class="stat">💬 {{ user.totalComments }} comments</span>
|
||||
<span class="stat">🔥 {{ user.currentStreak }} day streak</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (activeTab() === 'suggestions') {
|
||||
<div class="leaderboard">
|
||||
<h2>Top Suggestions</h2>
|
||||
<p class="description">Ranked by total suggestions and acceptance rate</p>
|
||||
@if (suggestionsLeaderboard().length === 0) {
|
||||
<p class="empty">No users on the leaderboard yet!</p>
|
||||
} @else {
|
||||
<div class="leaderboard-list">
|
||||
@for (user of suggestionsLeaderboard(); track user.id; let i = $index) {
|
||||
<div class="leaderboard-item" [class.highlight]="user.id === authService.user()?.id">
|
||||
<div class="rank">
|
||||
@if (i === 0) {
|
||||
<span class="medal">🥇</span>
|
||||
} @else if (i === 1) {
|
||||
<span class="medal">🥈</span>
|
||||
} @else if (i === 2) {
|
||||
<span class="medal">🥉</span>
|
||||
} @else {
|
||||
<span class="rank-number">#{{ i + 1 }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div class="user-header">
|
||||
@if (user.avatar) {
|
||||
<img [src]="user.avatar" [alt]="user.username" class="avatar">
|
||||
}
|
||||
<div class="user-details">
|
||||
<a [routerLink]="['/profile', user.slug || user.id]" class="username">
|
||||
{{ user.username }}
|
||||
</a>
|
||||
<div class="badges">
|
||||
@if (user.primaryBadge === 'STAFF') {
|
||||
<span class="badge staff-badge">STAFF</span>
|
||||
}
|
||||
@if (user.primaryBadge === 'MOD') {
|
||||
<span class="badge mod-badge">MOD</span>
|
||||
}
|
||||
@if (user.primaryBadge === 'VIP') {
|
||||
<span class="badge vip-badge">VIP</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<span class="stat">💡 {{ user.totalSuggestions }} suggestions</span>
|
||||
<span class="stat">✅ {{ user.acceptedSuggestions }} accepted</span>
|
||||
<span class="stat">📊 {{ user.acceptanceRate }}% acceptance rate</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (activeTab() === 'likes') {
|
||||
<div class="leaderboard">
|
||||
<h2>Top Likers</h2>
|
||||
<p class="description">Ranked by total likes given</p>
|
||||
@if (likesLeaderboard().length === 0) {
|
||||
<p class="empty">No users on the leaderboard yet!</p>
|
||||
} @else {
|
||||
<div class="leaderboard-list">
|
||||
@for (user of likesLeaderboard(); track user.id; let i = $index) {
|
||||
<div class="leaderboard-item" [class.highlight]="user.id === authService.user()?.id">
|
||||
<div class="rank">
|
||||
@if (i === 0) {
|
||||
<span class="medal">🥇</span>
|
||||
} @else if (i === 1) {
|
||||
<span class="medal">🥈</span>
|
||||
} @else if (i === 2) {
|
||||
<span class="medal">🥉</span>
|
||||
} @else {
|
||||
<span class="rank-number">#{{ i + 1 }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div class="user-header">
|
||||
@if (user.avatar) {
|
||||
<img [src]="user.avatar" [alt]="user.username" class="avatar">
|
||||
}
|
||||
<div class="user-details">
|
||||
<a [routerLink]="['/profile', user.slug || user.id]" class="username">
|
||||
{{ user.username }}
|
||||
</a>
|
||||
<div class="badges">
|
||||
@if (user.primaryBadge === 'STAFF') {
|
||||
<span class="badge staff-badge">STAFF</span>
|
||||
}
|
||||
@if (user.primaryBadge === 'MOD') {
|
||||
<span class="badge mod-badge">MOD</span>
|
||||
}
|
||||
@if (user.primaryBadge === 'VIP') {
|
||||
<span class="badge vip-badge">VIP</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<span class="stat">❤️ {{ user.totalLikes }} likes given</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (activeTab() === 'comments') {
|
||||
<div class="leaderboard">
|
||||
<h2>Top Commenters</h2>
|
||||
<p class="description">Ranked by total comments posted</p>
|
||||
@if (commentsLeaderboard().length === 0) {
|
||||
<p class="empty">No users on the leaderboard yet!</p>
|
||||
} @else {
|
||||
<div class="leaderboard-list">
|
||||
@for (user of commentsLeaderboard(); track user.id; let i = $index) {
|
||||
<div class="leaderboard-item" [class.highlight]="user.id === authService.user()?.id">
|
||||
<div class="rank">
|
||||
@if (i === 0) {
|
||||
<span class="medal">🥇</span>
|
||||
} @else if (i === 1) {
|
||||
<span class="medal">🥈</span>
|
||||
} @else if (i === 2) {
|
||||
<span class="medal">🥉</span>
|
||||
} @else {
|
||||
<span class="rank-number">#{{ i + 1 }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div class="user-header">
|
||||
@if (user.avatar) {
|
||||
<img [src]="user.avatar" [alt]="user.username" class="avatar">
|
||||
}
|
||||
<div class="user-details">
|
||||
<a [routerLink]="['/profile', user.slug || user.id]" class="username">
|
||||
{{ user.username }}
|
||||
</a>
|
||||
<div class="badges">
|
||||
@if (user.primaryBadge === 'STAFF') {
|
||||
<span class="badge staff-badge">STAFF</span>
|
||||
}
|
||||
@if (user.primaryBadge === 'MOD') {
|
||||
<span class="badge mod-badge">MOD</span>
|
||||
}
|
||||
@if (user.primaryBadge === 'VIP') {
|
||||
<span class="badge vip-badge">VIP</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<span class="stat">💬 {{ user.totalComments }} comments</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
color: var(--witch-purple);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
color: var(--witch-plum);
|
||||
margin-bottom: 2rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--witch-lavender);
|
||||
color: var(--witch-purple);
|
||||
border: 2px solid var(--witch-lavender);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
background: var(--witch-mauve);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px var(--witch-shadow);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: var(--witch-rose);
|
||||
color: var(--witch-moon);
|
||||
border-color: var(--witch-rose);
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--witch-plum);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.leaderboard h2 {
|
||||
color: var(--witch-purple);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--witch-plum);
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--witch-mauve);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.leaderboard-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.leaderboard-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 2px solid var(--witch-lavender);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.leaderboard-item:hover {
|
||||
transform: translateX(4px);
|
||||
box-shadow: 0 4px 12px var(--witch-shadow);
|
||||
border-color: var(--witch-mauve);
|
||||
}
|
||||
|
||||
.leaderboard-item.highlight {
|
||||
background: linear-gradient(135deg, rgba(255, 215, 245, 0.3), rgba(255, 240, 250, 0.3));
|
||||
border-color: var(--witch-rose);
|
||||
box-shadow: 0 0 20px rgba(168, 87, 126, 0.2);
|
||||
}
|
||||
|
||||
.rank {
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.medal {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.rank-number {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--witch-plum);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--witch-lavender);
|
||||
}
|
||||
|
||||
.user-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--witch-purple);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.username:hover {
|
||||
color: var(--witch-rose);
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.staff-badge {
|
||||
background: linear-gradient(135deg, #e84393, #fd79a8);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mod-badge {
|
||||
background: linear-gradient(135deg, #00b894, #00cec9);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.vip-badge {
|
||||
background: linear-gradient(135deg, #ffd700, #ffaa00);
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
font-size: 0.9rem;
|
||||
color: var(--witch-plum);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tabs {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.leaderboard-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.rank {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.stats {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class LeaderboardComponent implements OnInit {
|
||||
leaderboardService = inject(LeaderboardService);
|
||||
authService = inject(AuthService);
|
||||
|
||||
activeTab = signal<LeaderboardTab>('overall');
|
||||
loading = signal(true);
|
||||
|
||||
overallLeaderboard = signal<OverallLeaderboard[]>([]);
|
||||
suggestionsLeaderboard = signal<SuggestionsLeaderboard[]>([]);
|
||||
likesLeaderboard = signal<LikesLeaderboard[]>([]);
|
||||
commentsLeaderboard = signal<CommentsLeaderboard[]>([]);
|
||||
|
||||
ngOnInit() {
|
||||
this.loadLeaderboards();
|
||||
}
|
||||
|
||||
loadLeaderboards() {
|
||||
this.loading.set(true);
|
||||
this.leaderboardService.getAllLeaderboards(25).subscribe({
|
||||
next: (data) => {
|
||||
this.overallLeaderboard.set(data.topOverall);
|
||||
this.suggestionsLeaderboard.set(data.topSuggestions);
|
||||
this.likesLeaderboard.set(data.topLikes);
|
||||
this.commentsLeaderboard.set(data.topComments);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setTab(tab: LeaderboardTab) {
|
||||
this.activeTab.set(tab);
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,13 @@ import { SanitizeService } from '../../services/sanitize.service';
|
||||
import { SuggestionService } from '../../services/suggestion.service';
|
||||
import { PaginationComponent } from '../shared/pagination.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';
|
||||
|
||||
@Component({
|
||||
selector: 'app-manga-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
|
||||
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent],
|
||||
template: `
|
||||
<div class="container">
|
||||
<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.completed">Completed</option>
|
||||
<option [value]="MangaStatus.wantToRead">Want to Read</option>
|
||||
<option [value]="MangaStatus.retired">Retired</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="dateStarted">Date Started</label>
|
||||
<input
|
||||
type="date"
|
||||
id="dateStarted"
|
||||
[(ngModel)]="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">
|
||||
<label for="rating">Rating (1-10)</label>
|
||||
<input
|
||||
@@ -83,6 +105,34 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeHours"
|
||||
[(ngModel)]="newMangaTimeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateNewMangaTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeMinutes"
|
||||
[(ngModel)]="newMangaTimeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateNewMangaTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea
|
||||
@@ -115,8 +165,7 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Tags</label>
|
||||
<div class="tags-input-container">
|
||||
<div class="tags-input-container" aria-label="Tags">
|
||||
@for (tag of newManga.tags; track tag; let i = $index) {
|
||||
<span class="tag">
|
||||
{{ tag }}
|
||||
@@ -133,8 +182,7 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>External Links</label>
|
||||
<div class="form-group" aria-label="External Links">
|
||||
<div class="links-list">
|
||||
@for (link of newManga.links; track link.url; let i = $index) {
|
||||
<div class="link-item">
|
||||
@@ -200,9 +248,30 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
||||
<option [value]="MangaStatus.reading">Currently Reading</option>
|
||||
<option [value]="MangaStatus.completed">Completed</option>
|
||||
<option [value]="MangaStatus.wantToRead">Want to Read</option>
|
||||
<option [value]="MangaStatus.retired">Retired</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="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">
|
||||
<label for="edit-rating">Rating (1-10)</label>
|
||||
<input
|
||||
@@ -215,6 +284,34 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="edit-timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-timeHours"
|
||||
[(ngModel)]="editMangaTimeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateEditMangaTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-timeMinutes"
|
||||
[(ngModel)]="editMangaTimeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateEditMangaTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-notes">Notes</label>
|
||||
<textarea
|
||||
@@ -247,8 +344,7 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Tags</label>
|
||||
<div class="tags-input-container">
|
||||
<div class="tags-input-container" aria-label="Tags">
|
||||
@for (tag of editManga.tags; track tag; let i = $index) {
|
||||
<span class="tag">
|
||||
{{ tag }}
|
||||
@@ -265,8 +361,7 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>External Links</label>
|
||||
<div class="form-group" aria-label="External Links">
|
||||
<div class="links-list">
|
||||
@for (link of editManga.links; track link.url; let i = $index) {
|
||||
<div class="link-item">
|
||||
@@ -439,6 +534,13 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
||||
>
|
||||
Want to Read ({{ wantToReadCount() }})
|
||||
</button>
|
||||
<button
|
||||
(click)="setFilter(MangaStatus.retired)"
|
||||
[class.active]="statusFilter() === MangaStatus.retired"
|
||||
class="filter-btn"
|
||||
>
|
||||
Retired ({{ retiredCount() }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
@@ -478,6 +580,12 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (manga.timeSpent) {
|
||||
<p class="time-spent">
|
||||
📚 Reading Time: {{ formatTimeSpent(manga.timeSpent) }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<app-like-button
|
||||
entityType="manga"
|
||||
[entityId]="manga.id"
|
||||
@@ -505,6 +613,30 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
||||
</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()) {
|
||||
<div class="actions">
|
||||
<button (click)="startEdit(manga)" class="btn btn-secondary btn-sm">
|
||||
@@ -541,56 +673,11 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
||||
}
|
||||
}
|
||||
|
||||
@if (commentsLoading()[manga.id]) {
|
||||
<div class="comments-loading">Loading comments...</div>
|
||||
} @else {
|
||||
@for (comment of comments()[manga.id] || []; track comment.id) {
|
||||
<div class="comment">
|
||||
<div class="comment-header">
|
||||
@if (comment.user.avatar) {
|
||||
<img [src]="comment.user.avatar" [alt]="comment.user.username" class="comment-avatar">
|
||||
}
|
||||
<span class="comment-author">{{ comment.user.username }}</span>
|
||||
@if (comment.user.inDiscord) {
|
||||
<span class="discord-badge">Discord</span>
|
||||
}
|
||||
@if (comment.user.isVip) {
|
||||
<span class="vip-badge">VIP</span>
|
||||
}
|
||||
@if (comment.user.isMod) {
|
||||
<span class="mod-badge">Mod</span>
|
||||
}
|
||||
@if (comment.user.isStaff) {
|
||||
<span class="staff-badge">Staff</span>
|
||||
}
|
||||
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
|
||||
@if (canEditComment(comment)) {
|
||||
<button (click)="startEditComment(manga.id, comment)" class="btn btn-secondary btn-xs">Edit</button>
|
||||
}
|
||||
@if (canDeleteComment(comment)) {
|
||||
<button (click)="deleteComment(manga.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
|
||||
}
|
||||
</div>
|
||||
@if (editingCommentId() === comment.id) {
|
||||
<div class="comment-edit-form">
|
||||
<textarea
|
||||
[(ngModel)]="editCommentContent"
|
||||
name="editComment"
|
||||
rows="3"
|
||||
></textarea>
|
||||
<div class="comment-edit-actions">
|
||||
<button (click)="saveCommentEdit(manga.id, comment.id)" class="btn btn-primary btn-xs">Save</button>
|
||||
<button (click)="cancelCommentEdit()" class="btn btn-secondary btn-xs">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
|
||||
}
|
||||
</div>
|
||||
} @empty {
|
||||
<div class="no-comments">No comments yet. Be the first to comment!</div>
|
||||
}
|
||||
}
|
||||
<app-comment-display
|
||||
[comments]="getCommentsSignal(manga.id)"
|
||||
(edit)="handleCommentEdit(manga.id, $event)"
|
||||
(delete)="deleteComment(manga.id, $event)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -666,6 +753,13 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
@@ -861,6 +955,22 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.time-spent {
|
||||
font-size: 0.9rem;
|
||||
color: #10b981;
|
||||
font-weight: 500;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.date-started,
|
||||
.date-finished,
|
||||
.date-added,
|
||||
.date-updated {
|
||||
font-size: 0.85rem;
|
||||
color: #4b5563;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -1216,6 +1326,7 @@ export class MangaListComponent implements OnInit {
|
||||
readingCount = computed(() => this.mangaList().filter(m => m.status === MangaStatus.reading).length);
|
||||
completedCount = computed(() => this.mangaList().filter(m => m.status === MangaStatus.completed).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(() => {
|
||||
const tagsSet = new Set<string>();
|
||||
@@ -1264,10 +1375,12 @@ export class MangaListComponent implements OnInit {
|
||||
|
||||
totalFilteredManga = computed(() => this.filteredManga().length);
|
||||
|
||||
newManga: Partial<CreateMangaDto> = {
|
||||
newManga: Partial<CreateMangaDto> & { dateStarted?: Date; dateFinished?: Date } = {
|
||||
title: '',
|
||||
author: '',
|
||||
status: MangaStatus.wantToRead,
|
||||
dateStarted: undefined,
|
||||
dateFinished: undefined,
|
||||
rating: undefined,
|
||||
notes: '',
|
||||
tags: [],
|
||||
@@ -1276,6 +1389,12 @@ export class MangaListComponent implements OnInit {
|
||||
|
||||
editManga: Partial<UpdateMangaDto> = {};
|
||||
|
||||
// Time tracking state
|
||||
newMangaTimeHours = 0;
|
||||
newMangaTimeMinutes = 0;
|
||||
editMangaTimeHours = 0;
|
||||
editMangaTimeMinutes = 0;
|
||||
|
||||
// Tags and links input state
|
||||
newTagInput = '';
|
||||
editTagInput = '';
|
||||
@@ -1344,6 +1463,7 @@ export class MangaListComponent implements OnInit {
|
||||
case MangaStatus.reading: return 'Currently Reading';
|
||||
case MangaStatus.completed: return 'Completed';
|
||||
case MangaStatus.wantToRead: return 'Want to Read';
|
||||
case MangaStatus.retired: return 'Retired';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1359,12 +1479,16 @@ export class MangaListComponent implements OnInit {
|
||||
title: '',
|
||||
author: '',
|
||||
status: MangaStatus.wantToRead,
|
||||
dateStarted: undefined,
|
||||
dateFinished: undefined,
|
||||
rating: undefined,
|
||||
notes: '',
|
||||
coverImage: undefined,
|
||||
tags: [],
|
||||
links: []
|
||||
};
|
||||
this.newMangaTimeHours = 0;
|
||||
this.newMangaTimeMinutes = 0;
|
||||
this.newMangaImagePreview.set(null);
|
||||
this.imageError.set(null);
|
||||
this.newTagInput = '';
|
||||
@@ -1372,6 +1496,16 @@ export class MangaListComponent implements OnInit {
|
||||
this.newLinkUrl = '';
|
||||
}
|
||||
|
||||
updateNewMangaTimeSpent() {
|
||||
const totalMinutes = (this.newMangaTimeHours * 60) + this.newMangaTimeMinutes;
|
||||
this.newManga.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
|
||||
}
|
||||
|
||||
updateEditMangaTimeSpent() {
|
||||
const totalMinutes = (this.editMangaTimeHours * 60) + this.editMangaTimeMinutes;
|
||||
this.editManga.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
|
||||
}
|
||||
|
||||
addTag(target: 'new' | 'edit') {
|
||||
const input = target === 'new' ? this.newTagInput.trim() : this.editTagInput.trim();
|
||||
if (!input) return;
|
||||
@@ -1424,6 +1558,8 @@ export class MangaListComponent implements OnInit {
|
||||
title: this.newManga.title,
|
||||
author: this.newManga.author,
|
||||
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,
|
||||
notes: this.newManga.notes,
|
||||
coverImage: this.newManga.coverImage,
|
||||
@@ -1451,12 +1587,23 @@ export class MangaListComponent implements OnInit {
|
||||
title: manga.title,
|
||||
author: manga.author,
|
||||
status: manga.status,
|
||||
dateStarted: manga.dateStarted,
|
||||
dateFinished: manga.dateFinished,
|
||||
rating: manga.rating,
|
||||
notes: manga.notes,
|
||||
coverImage: manga.coverImage,
|
||||
tags: [...(manga.tags || [])],
|
||||
links: [...(manga.links || [])]
|
||||
links: [...(manga.links || [])],
|
||||
timeSpent: manga.timeSpent
|
||||
};
|
||||
// Populate time fields from existing timeSpent
|
||||
if (manga.timeSpent) {
|
||||
this.editMangaTimeHours = Math.floor(manga.timeSpent / 60);
|
||||
this.editMangaTimeMinutes = manga.timeSpent % 60;
|
||||
} else {
|
||||
this.editMangaTimeHours = 0;
|
||||
this.editMangaTimeMinutes = 0;
|
||||
}
|
||||
this.editMangaImagePreview.set(manga.coverImage || null);
|
||||
this.showAddForm.set(false);
|
||||
this.imageError.set(null);
|
||||
@@ -1479,7 +1626,13 @@ export class MangaListComponent implements OnInit {
|
||||
const manga = this.editingManga();
|
||||
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.cancelEdit();
|
||||
});
|
||||
@@ -1540,6 +1693,19 @@ export class MangaListComponent implements OnInit {
|
||||
return new Date(date).toLocaleDateString();
|
||||
}
|
||||
|
||||
formatTimeSpent(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
|
||||
if (hours === 0) {
|
||||
return `${mins}m`;
|
||||
} else if (mins === 0) {
|
||||
return `${hours}h`;
|
||||
} else {
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
}
|
||||
|
||||
toggleComments(mangaId: string) {
|
||||
const expanded = this.expandedComments();
|
||||
const isCurrentlyExpanded = expanded[mangaId];
|
||||
@@ -1674,7 +1840,7 @@ export class MangaListComponent implements OnInit {
|
||||
|
||||
try {
|
||||
await this.suggestionService.createSuggestion({
|
||||
entityType: SuggestionEntity.MANGA,
|
||||
entityType: SuggestionEntity.manga,
|
||||
title: this.suggestedManga.title,
|
||||
author: this.suggestedManga.author,
|
||||
notes: this.suggestedManga.notes,
|
||||
@@ -1686,4 +1852,21 @@ export class MangaListComponent implements OnInit {
|
||||
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 { PaginationComponent } from '../shared/pagination.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';
|
||||
|
||||
@Component({
|
||||
selector: 'app-music-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
|
||||
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent],
|
||||
template: `
|
||||
<div class="container">
|
||||
<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.completed">Completed</option>
|
||||
<option [value]="MusicStatus.wantToListen">Want to Listen</option>
|
||||
<option [value]="MusicStatus.retired">Retired</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="dateStarted">Date Started</label>
|
||||
<input
|
||||
type="date"
|
||||
id="dateStarted"
|
||||
[(ngModel)]="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">
|
||||
<label for="rating">Rating (1-10)</label>
|
||||
<input
|
||||
@@ -92,6 +114,34 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeHours"
|
||||
[(ngModel)]="newMusicTimeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateNewMusicTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeMinutes"
|
||||
[(ngModel)]="newMusicTimeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateNewMusicTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea
|
||||
@@ -124,8 +174,7 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Tags</label>
|
||||
<div class="tags-input-container">
|
||||
<div class="tags-input-container" aria-label="Tags">
|
||||
@for (tag of newMusic.tags; track tag; let i = $index) {
|
||||
<span class="tag">
|
||||
{{ tag }}
|
||||
@@ -142,8 +191,7 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>External Links</label>
|
||||
<div class="form-group" aria-label="External Links">
|
||||
<div class="links-list">
|
||||
@for (link of newMusic.links; track link.url; let i = $index) {
|
||||
<div class="link-item">
|
||||
@@ -218,9 +266,30 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
||||
<option [value]="MusicStatus.listening">Currently Listening</option>
|
||||
<option [value]="MusicStatus.completed">Completed</option>
|
||||
<option [value]="MusicStatus.wantToListen">Want to Listen</option>
|
||||
<option [value]="MusicStatus.retired">Retired</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="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">
|
||||
<label for="edit-rating">Rating (1-10)</label>
|
||||
<input
|
||||
@@ -233,6 +302,34 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="edit-timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-timeHours"
|
||||
[(ngModel)]="editMusicTimeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateEditMusicTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-timeMinutes"
|
||||
[(ngModel)]="editMusicTimeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateEditMusicTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-notes">Notes</label>
|
||||
<textarea
|
||||
@@ -265,8 +362,7 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Tags</label>
|
||||
<div class="tags-input-container">
|
||||
<div class="tags-input-container" aria-label="Tags">
|
||||
@for (tag of editMusicData.tags; track tag; let i = $index) {
|
||||
<span class="tag">
|
||||
{{ tag }}
|
||||
@@ -283,8 +379,7 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>External Links</label>
|
||||
<div class="form-group" aria-label="External Links">
|
||||
<div class="links-list">
|
||||
@for (link of editMusicData.links; track link.url; let i = $index) {
|
||||
<div class="link-item">
|
||||
@@ -500,6 +595,13 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
||||
>
|
||||
Want to Listen ({{ wantToListenCount() }})
|
||||
</button>
|
||||
<button
|
||||
(click)="setStatusFilter(MusicStatus.retired)"
|
||||
[class.active]="statusFilter() === MusicStatus.retired"
|
||||
class="filter-btn"
|
||||
>
|
||||
Retired ({{ retiredCount() }})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -554,6 +656,12 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (music.timeSpent) {
|
||||
<p class="time-spent">
|
||||
🎵 Listening Time: {{ formatTimeSpent(music.timeSpent) }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<app-like-button
|
||||
entityType="music"
|
||||
[entityId]="music.id"
|
||||
@@ -581,9 +689,27 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (music.dateCompleted) {
|
||||
<p class="date-completed">
|
||||
Completed: {{ formatDate(music.dateCompleted) }}
|
||||
@if (music.dateStarted) {
|
||||
<p class="date-started">
|
||||
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>
|
||||
}
|
||||
|
||||
@@ -623,56 +749,11 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
||||
}
|
||||
}
|
||||
|
||||
@if (commentsLoading()[music.id]) {
|
||||
<div class="comments-loading">Loading comments...</div>
|
||||
} @else {
|
||||
@for (comment of comments()[music.id] || []; track comment.id) {
|
||||
<div class="comment">
|
||||
<div class="comment-header">
|
||||
@if (comment.user.avatar) {
|
||||
<img [src]="comment.user.avatar" [alt]="comment.user.username" class="comment-avatar">
|
||||
}
|
||||
<span class="comment-author">{{ comment.user.username }}</span>
|
||||
@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>
|
||||
}
|
||||
}
|
||||
<app-comment-display
|
||||
[comments]="getCommentsSignal(music.id)"
|
||||
(edit)="handleCommentEdit(music.id, $event)"
|
||||
(delete)="deleteComment(music.id, $event)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -761,6 +842,13 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
||||
box-shadow: 0 0 0 3px rgba(168, 87, 126, 0.2);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
@@ -1017,7 +1105,17 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.date-completed {
|
||||
.time-spent {
|
||||
font-size: 0.9rem;
|
||||
color: #8b5cf6;
|
||||
font-weight: 500;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.date-started,
|
||||
.date-finished,
|
||||
.date-added,
|
||||
.date-updated {
|
||||
font-size: 0.85rem;
|
||||
color: var(--witch-plum);
|
||||
margin-top: 0.5rem;
|
||||
@@ -1435,6 +1533,7 @@ export class MusicListComponent implements OnInit {
|
||||
listeningCount = computed(() => this.music().filter(m => m.status === MusicStatus.listening).length);
|
||||
completedCount = computed(() => this.music().filter(m => m.status === MusicStatus.completed).length);
|
||||
wantToListenCount = computed(() => this.music().filter(m => m.status === MusicStatus.wantToListen).length);
|
||||
retiredCount = computed(() => this.music().filter(m => m.status === MusicStatus.retired).length);
|
||||
|
||||
allTags = computed(() => {
|
||||
const tagsSet = new Set<string>();
|
||||
@@ -1488,11 +1587,13 @@ export class MusicListComponent implements OnInit {
|
||||
|
||||
totalFilteredMusic = computed(() => this.filteredMusic().length);
|
||||
|
||||
newMusic: Partial<CreateMusicDto> = {
|
||||
newMusic: Partial<CreateMusicDto> & { dateStarted?: Date; dateFinished?: Date } = {
|
||||
title: '',
|
||||
artist: '',
|
||||
type: MusicType.album,
|
||||
status: MusicStatus.wantToListen,
|
||||
dateStarted: undefined,
|
||||
dateFinished: undefined,
|
||||
rating: undefined,
|
||||
notes: '',
|
||||
tags: [],
|
||||
@@ -1501,6 +1602,12 @@ export class MusicListComponent implements OnInit {
|
||||
|
||||
editMusicData: Partial<UpdateMusicDto> = {};
|
||||
|
||||
// Time tracking state
|
||||
newMusicTimeHours = 0;
|
||||
newMusicTimeMinutes = 0;
|
||||
editMusicTimeHours = 0;
|
||||
editMusicTimeMinutes = 0;
|
||||
|
||||
// Tags and links input state
|
||||
newTagInput = '';
|
||||
editTagInput = '';
|
||||
@@ -1583,6 +1690,7 @@ export class MusicListComponent implements OnInit {
|
||||
case MusicStatus.listening: return 'Currently Listening';
|
||||
case MusicStatus.completed: return 'Completed';
|
||||
case MusicStatus.wantToListen: return 'Want to Listen';
|
||||
case MusicStatus.retired: return 'Retired';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1599,12 +1707,16 @@ export class MusicListComponent implements OnInit {
|
||||
artist: '',
|
||||
type: MusicType.album,
|
||||
status: MusicStatus.wantToListen,
|
||||
dateStarted: undefined,
|
||||
dateFinished: undefined,
|
||||
rating: undefined,
|
||||
notes: '',
|
||||
coverArt: undefined,
|
||||
tags: [],
|
||||
links: []
|
||||
};
|
||||
this.newMusicTimeHours = 0;
|
||||
this.newMusicTimeMinutes = 0;
|
||||
this.newMusicImagePreview.set(null);
|
||||
this.imageError.set(null);
|
||||
this.newTagInput = '';
|
||||
@@ -1612,6 +1724,16 @@ export class MusicListComponent implements OnInit {
|
||||
this.newLinkUrl = '';
|
||||
}
|
||||
|
||||
updateNewMusicTimeSpent() {
|
||||
const totalMinutes = (this.newMusicTimeHours * 60) + this.newMusicTimeMinutes;
|
||||
this.newMusic.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
|
||||
}
|
||||
|
||||
updateEditMusicTimeSpent() {
|
||||
const totalMinutes = (this.editMusicTimeHours * 60) + this.editMusicTimeMinutes;
|
||||
this.editMusicData.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
|
||||
}
|
||||
|
||||
addTag(target: 'new' | 'edit') {
|
||||
const input = target === 'new' ? this.newTagInput.trim() : this.editTagInput.trim();
|
||||
if (!input) return;
|
||||
@@ -1665,6 +1787,8 @@ export class MusicListComponent implements OnInit {
|
||||
artist: this.newMusic.artist,
|
||||
type: this.newMusic.type,
|
||||
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,
|
||||
notes: this.newMusic.notes,
|
||||
coverArt: this.newMusic.coverArt,
|
||||
@@ -1693,12 +1817,23 @@ export class MusicListComponent implements OnInit {
|
||||
artist: music.artist,
|
||||
type: music.type,
|
||||
status: music.status,
|
||||
dateStarted: music.dateStarted,
|
||||
dateFinished: music.dateFinished,
|
||||
rating: music.rating,
|
||||
notes: music.notes,
|
||||
coverArt: music.coverArt,
|
||||
tags: [...(music.tags || [])],
|
||||
links: [...(music.links || [])]
|
||||
links: [...(music.links || [])],
|
||||
timeSpent: music.timeSpent
|
||||
};
|
||||
// Populate time fields from existing timeSpent
|
||||
if (music.timeSpent) {
|
||||
this.editMusicTimeHours = Math.floor(music.timeSpent / 60);
|
||||
this.editMusicTimeMinutes = music.timeSpent % 60;
|
||||
} else {
|
||||
this.editMusicTimeHours = 0;
|
||||
this.editMusicTimeMinutes = 0;
|
||||
}
|
||||
this.editMusicImagePreview.set(music.coverArt || null);
|
||||
this.showAddForm.set(false);
|
||||
this.imageError.set(null);
|
||||
@@ -1721,7 +1856,13 @@ export class MusicListComponent implements OnInit {
|
||||
const music = this.editingMusic();
|
||||
if (!music || !this.editMusicData.title || !this.editMusicData.artist || !this.editMusicData.type || !this.editMusicData.status) return;
|
||||
|
||||
this.musicService.updateMusic(music.id, this.editMusicData).subscribe(() => {
|
||||
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.cancelEdit();
|
||||
});
|
||||
@@ -1731,6 +1872,19 @@ export class MusicListComponent implements OnInit {
|
||||
return new Date(date).toLocaleDateString();
|
||||
}
|
||||
|
||||
formatTimeSpent(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
|
||||
if (hours === 0) {
|
||||
return `${mins}m`;
|
||||
} else if (mins === 0) {
|
||||
return `${hours}h`;
|
||||
} else {
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
}
|
||||
|
||||
// Image handling methods
|
||||
onImageSelected(event: Event, target: 'new' | 'edit' | 'suggest') {
|
||||
const input = event.target as HTMLInputElement;
|
||||
@@ -1919,7 +2073,7 @@ export class MusicListComponent implements OnInit {
|
||||
|
||||
try {
|
||||
await this.suggestionService.createSuggestion({
|
||||
entityType: SuggestionEntity.MUSIC,
|
||||
entityType: SuggestionEntity.music,
|
||||
title: this.suggestedMusic.title,
|
||||
artist: this.suggestedMusic.artist,
|
||||
type: this.suggestedMusic.type,
|
||||
@@ -1932,4 +2086,21 @@ export class MusicListComponent implements OnInit {
|
||||
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 {
|
||||
const item = likedItem.item;
|
||||
const item = likedItem.item as any;
|
||||
return item.title || item.name || 'Untitled';
|
||||
}
|
||||
|
||||
getItemSubtitle(likedItem: LikedItemDto): string {
|
||||
const item = likedItem.item;
|
||||
const item = likedItem.item as any;
|
||||
const type = likedItem.like.entityType;
|
||||
|
||||
switch (type) {
|
||||
@@ -400,7 +400,7 @@ export class MyLikesComponent implements OnInit {
|
||||
}
|
||||
|
||||
getItemImage(likedItem: LikedItemDto): string | null {
|
||||
const item = likedItem.item;
|
||||
const item = likedItem.item as any;
|
||||
return item.coverImage || item.imageUrl || null;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,22 +43,22 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
|
||||
All ({{ suggestions().length }})
|
||||
</button>
|
||||
<button
|
||||
(click)="setFilter(SuggestionStatus.UNREVIEWED)"
|
||||
[class.active]="statusFilter() === SuggestionStatus.UNREVIEWED"
|
||||
(click)="setFilter(SuggestionStatus.unreviewed)"
|
||||
[class.active]="statusFilter() === SuggestionStatus.unreviewed"
|
||||
class="filter-btn pending"
|
||||
>
|
||||
Pending ({{ unreviewedCount() }})
|
||||
</button>
|
||||
<button
|
||||
(click)="setFilter(SuggestionStatus.ACCEPTED)"
|
||||
[class.active]="statusFilter() === SuggestionStatus.ACCEPTED"
|
||||
(click)="setFilter(SuggestionStatus.accepted)"
|
||||
[class.active]="statusFilter() === SuggestionStatus.accepted"
|
||||
class="filter-btn accepted"
|
||||
>
|
||||
Accepted ({{ acceptedCount() }})
|
||||
</button>
|
||||
<button
|
||||
(click)="setFilter(SuggestionStatus.DECLINED)"
|
||||
[class.active]="statusFilter() === SuggestionStatus.DECLINED"
|
||||
(click)="setFilter(SuggestionStatus.declined)"
|
||||
[class.active]="statusFilter() === SuggestionStatus.declined"
|
||||
class="filter-btn declined"
|
||||
>
|
||||
Declined ({{ declinedCount() }})
|
||||
@@ -120,7 +120,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (suggestion.status === SuggestionStatus.DECLINED && suggestion.declineReason) {
|
||||
@if (suggestion.status === SuggestionStatus.declined && suggestion.declineReason) {
|
||||
<div class="decline-reason">
|
||||
<strong>Reason:</strong> {{ suggestion.declineReason }}
|
||||
</div>
|
||||
@@ -337,9 +337,9 @@ export class MySuggestionsComponent implements OnInit {
|
||||
|
||||
SuggestionStatus = SuggestionStatus;
|
||||
|
||||
unreviewedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.UNREVIEWED).length;
|
||||
acceptedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.ACCEPTED).length;
|
||||
declinedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.DECLINED).length;
|
||||
unreviewedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.unreviewed).length;
|
||||
acceptedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.accepted).length;
|
||||
declinedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.declined).length;
|
||||
|
||||
filteredSuggestions = computed(() => {
|
||||
const filter = this.statusFilter();
|
||||
@@ -397,20 +397,20 @@ export class MySuggestionsComponent implements OnInit {
|
||||
|
||||
getStatusLabel(status: SuggestionStatus): string {
|
||||
switch (status) {
|
||||
case SuggestionStatus.UNREVIEWED: return 'Pending Review';
|
||||
case SuggestionStatus.ACCEPTED: return 'Accepted';
|
||||
case SuggestionStatus.DECLINED: return 'Declined';
|
||||
case SuggestionStatus.unreviewed: return 'Pending Review';
|
||||
case SuggestionStatus.accepted: return 'Accepted';
|
||||
case SuggestionStatus.declined: return 'Declined';
|
||||
}
|
||||
}
|
||||
|
||||
getEntityIcon(entityType: SuggestionEntity): string {
|
||||
switch (entityType) {
|
||||
case SuggestionEntity.GAME: return '🎮';
|
||||
case SuggestionEntity.BOOK: return '📚';
|
||||
case SuggestionEntity.MUSIC: return '🎵';
|
||||
case SuggestionEntity.MANGA: return '📖';
|
||||
case SuggestionEntity.SHOW: return '📺';
|
||||
case SuggestionEntity.ART: return '🎨';
|
||||
case SuggestionEntity.game: return '🎮';
|
||||
case SuggestionEntity.book: return '📚';
|
||||
case SuggestionEntity.music: return '🎵';
|
||||
case SuggestionEntity.manga: return '📖';
|
||||
case SuggestionEntity.show: return '📺';
|
||||
case SuggestionEntity.art: return '🎨';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,667 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute, RouterModule } 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 { AchievementService } from '../../services/achievement.service';
|
||||
import { ToastService } from '../../services/toast.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { ReportModalComponent } from '../report-modal/report-modal.component';
|
||||
import { PrimaryBadge, AchievementProgress } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-profile',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FontAwesomeModule, ReportModalComponent, RouterModule],
|
||||
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>
|
||||
|
||||
@if (profile()!.primaryBadge) {
|
||||
<div class="badges-section">
|
||||
<!-- Show only the selected primary badge -->
|
||||
@if (profile()!.primaryBadge === PrimaryBadge.STAFF && profile()!.badges.isStaff) {
|
||||
<span class="badge badge-staff">Staff</span>
|
||||
}
|
||||
@if (profile()!.primaryBadge === PrimaryBadge.MOD && profile()!.badges.isMod) {
|
||||
<span class="badge badge-mod">Moderator</span>
|
||||
}
|
||||
@if (profile()!.primaryBadge === PrimaryBadge.VIP && profile()!.badges.isVip) {
|
||||
<span class="badge badge-vip">VIP</span>
|
||||
}
|
||||
@if (profile()!.primaryBadge === PrimaryBadge.DISCORD && 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>
|
||||
@if (profile()!.achievementPoints > 0) {
|
||||
<div class="stat-card achievement-points-card">
|
||||
<span class="stat-value">{{ profile()!.achievementPoints }}</span>
|
||||
<span class="stat-label">🏆 Achievement Points</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (recentAchievements().length > 0) {
|
||||
<div class="achievements-section">
|
||||
<div class="section-header">
|
||||
<h2>Recent Achievements</h2>
|
||||
@if (isOwnProfile()) {
|
||||
<a routerLink="/achievements" class="view-all-link">View All →</a>
|
||||
}
|
||||
</div>
|
||||
<div class="achievements-grid">
|
||||
@for (achievement of recentAchievements(); track achievement.definition.key) {
|
||||
<div class="achievement-badge" [attr.data-tier]="achievement.definition.tier.toLowerCase()">
|
||||
<div class="achievement-icon">{{ achievement.definition.icon }}</div>
|
||||
<div class="achievement-info">
|
||||
<div class="achievement-title">{{ achievement.definition.title }}</div>
|
||||
<div class="achievement-points">{{ achievement.definition.points }} pts</div>
|
||||
</div>
|
||||
</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, .achievements-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;
|
||||
}
|
||||
|
||||
.achievement-points-card {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.2) 0%, rgba(118, 75, 162, 0.2) 100%);
|
||||
border: 1px solid rgba(102, 126, 234, 0.5);
|
||||
}
|
||||
|
||||
.achievements-section {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid rgba(155, 89, 182, 0.3);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.view-all-link {
|
||||
color: var(--accent-colour, #9b59b6);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.view-all-link:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.achievements-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.achievement-badge {
|
||||
background: var(--card-background, #16213e);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.achievement-badge[data-tier="bronze"] {
|
||||
border-color: #cd7f32;
|
||||
}
|
||||
|
||||
.achievement-badge[data-tier="silver"] {
|
||||
border-color: #c0c0c0;
|
||||
}
|
||||
|
||||
.achievement-badge[data-tier="gold"] {
|
||||
border-color: #ffd700;
|
||||
}
|
||||
|
||||
.achievement-badge[data-tier="platinum"] {
|
||||
border-color: #e5e4e2;
|
||||
}
|
||||
|
||||
.achievement-badge[data-tier="diamond"] {
|
||||
border-color: #b9f2ff;
|
||||
}
|
||||
|
||||
.achievement-badge:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(155, 89, 182, 0.3);
|
||||
}
|
||||
|
||||
.achievement-icon {
|
||||
font-size: 2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.achievement-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.achievement-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-colour, #ffffff);
|
||||
font-size: 1rem;
|
||||
line-height: 1.3;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.achievement-points {
|
||||
color: var(--text-colour, #ffffff);
|
||||
opacity: 0.8;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class ProfileComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private userService = inject(UserService);
|
||||
private achievementService = inject(AchievementService);
|
||||
private toastService = inject(ToastService);
|
||||
private authService = inject(AuthService);
|
||||
|
||||
profile = signal<UserProfileResponse | null>(null);
|
||||
recentAchievements = signal<AchievementProgress[]>([]);
|
||||
loading = signal(true);
|
||||
error = signal<string | null>(null);
|
||||
reportModalOpen = signal(false);
|
||||
|
||||
// Expose PrimaryBadge enum for template
|
||||
readonly PrimaryBadge = PrimaryBadge;
|
||||
|
||||
// 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);
|
||||
|
||||
// Load recent achievements
|
||||
this.achievementService.getUserProgress(profileData.id).subscribe({
|
||||
next: (achievements) => {
|
||||
// Get earned achievements sorted by earned date, take top 6
|
||||
const earned = achievements
|
||||
.filter(a => a.earned && a.earnedAt)
|
||||
.sort((a, b) => {
|
||||
const dateA = a.earnedAt ? new Date(a.earnedAt).getTime() : 0;
|
||||
const dateB = b.earnedAt ? new Date(b.earnedAt).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
})
|
||||
.slice(0, 6);
|
||||
this.recentAchievements.set(earned);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error loading achievements:', err);
|
||||
// Don't show error toast for achievements failure
|
||||
}
|
||||
});
|
||||
},
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether viewing your own profile.
|
||||
*/
|
||||
isOwnProfile(): boolean {
|
||||
const currentUser = this.authService.user();
|
||||
const profileData = this.profile();
|
||||
|
||||
if (!currentUser || !profileData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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,491 @@
|
||||
/**
|
||||
* @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, PrimaryBadge } 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 class="form-group">
|
||||
<label for="primaryBadge">Primary Badge</label>
|
||||
<select
|
||||
id="primaryBadge"
|
||||
name="primaryBadge"
|
||||
[(ngModel)]="formData.primaryBadge"
|
||||
>
|
||||
<option [ngValue]="undefined">None (hide all badges)</option>
|
||||
@if (user()!.isStaff) {
|
||||
<option [ngValue]="PrimaryBadge.STAFF">Staff</option>
|
||||
}
|
||||
@if (user()!.isMod) {
|
||||
<option [ngValue]="PrimaryBadge.MOD">Moderator</option>
|
||||
}
|
||||
@if (user()!.isVip) {
|
||||
<option [ngValue]="PrimaryBadge.VIP">VIP</option>
|
||||
}
|
||||
@if (user()!.inDiscord) {
|
||||
<option [ngValue]="PrimaryBadge.DISCORD">Discord Member</option>
|
||||
}
|
||||
</select>
|
||||
<small class="form-help">Choose one badge to display on your profile and comments</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,
|
||||
.form-group select {
|
||||
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 select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-group select option {
|
||||
background: #1a1a2e;
|
||||
color: var(--text-colour, #e0e0e0);
|
||||
}
|
||||
|
||||
.form-group input[type="text"]:focus,
|
||||
.form-group textarea:focus,
|
||||
.form-group select: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);
|
||||
|
||||
// Expose PrimaryBadge enum for template
|
||||
readonly PrimaryBadge = PrimaryBadge;
|
||||
|
||||
formData: UpdateUserSettingsRequest & { bio?: string } = {
|
||||
displayName: '',
|
||||
slug: '',
|
||||
bio: '',
|
||||
profilePublic: true,
|
||||
primaryBadge: undefined,
|
||||
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,
|
||||
primaryBadge: userData.primaryBadge || undefined,
|
||||
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,
|
||||
primaryBadge: this.formData.primaryBadge || undefined,
|
||||
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 { PaginationComponent } from '../shared/pagination.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';
|
||||
|
||||
@Component({
|
||||
selector: 'app-shows-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
|
||||
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent],
|
||||
template: `
|
||||
<div class="container">
|
||||
<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.completed">Completed</option>
|
||||
<option [value]="ShowStatus.wantToWatch">Want to Watch</option>
|
||||
<option [value]="ShowStatus.retired">Retired</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="dateStarted">Date Started</label>
|
||||
<input
|
||||
type="date"
|
||||
id="dateStarted"
|
||||
[(ngModel)]="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">
|
||||
<label for="rating">Rating (1-10)</label>
|
||||
<input
|
||||
@@ -81,6 +103,34 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeHours"
|
||||
[(ngModel)]="newShowTimeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateNewShowTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeMinutes"
|
||||
[(ngModel)]="newShowTimeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateNewShowTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea
|
||||
@@ -113,8 +163,7 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Tags</label>
|
||||
<div class="tags-input-container">
|
||||
<div class="tags-input-container" aria-label="Tags">
|
||||
@for (tag of newShow.tags; track tag; let i = $index) {
|
||||
<span class="tag">
|
||||
{{ tag }}
|
||||
@@ -131,8 +180,7 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>External Links</label>
|
||||
<div class="form-group" aria-label="External Links">
|
||||
<div class="links-list">
|
||||
@for (link of newShow.links; track link.url; let i = $index) {
|
||||
<div class="link-item">
|
||||
@@ -196,9 +244,30 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
||||
<option [value]="ShowStatus.watching">Currently Watching</option>
|
||||
<option [value]="ShowStatus.completed">Completed</option>
|
||||
<option [value]="ShowStatus.wantToWatch">Want to Watch</option>
|
||||
<option [value]="ShowStatus.retired">Retired</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="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">
|
||||
<label for="edit-rating">Rating (1-10)</label>
|
||||
<input
|
||||
@@ -211,6 +280,34 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="edit-timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-timeHours"
|
||||
[(ngModel)]="editShowTimeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateEditShowTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-timeMinutes"
|
||||
[(ngModel)]="editShowTimeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateEditShowTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-notes">Notes</label>
|
||||
<textarea
|
||||
@@ -243,8 +340,7 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Tags</label>
|
||||
<div class="tags-input-container">
|
||||
<div class="tags-input-container" aria-label="Tags">
|
||||
@for (tag of editShow.tags; track tag; let i = $index) {
|
||||
<span class="tag">
|
||||
{{ tag }}
|
||||
@@ -261,8 +357,7 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>External Links</label>
|
||||
<div class="form-group" aria-label="External Links">
|
||||
<div class="links-list">
|
||||
@for (link of editShow.links; track link.url; let i = $index) {
|
||||
<div class="link-item">
|
||||
@@ -433,6 +528,13 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
||||
>
|
||||
Want to Watch ({{ wantToWatchCount() }})
|
||||
</button>
|
||||
<button
|
||||
(click)="setFilter(ShowStatus.retired)"
|
||||
[class.active]="statusFilter() === ShowStatus.retired"
|
||||
class="filter-btn"
|
||||
>
|
||||
Retired ({{ retiredCount() }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
@@ -472,6 +574,12 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (show.timeSpent) {
|
||||
<p class="time-spent">
|
||||
📺 Watch Time: {{ formatTimeSpent(show.timeSpent) }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<app-like-button
|
||||
entityType="show"
|
||||
[entityId]="show.id"
|
||||
@@ -499,6 +607,30 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
||||
</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()) {
|
||||
<div class="actions">
|
||||
<button (click)="startEdit(show)" class="btn btn-secondary btn-sm">
|
||||
@@ -535,56 +667,11 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
||||
}
|
||||
}
|
||||
|
||||
@if (commentsLoading()[show.id]) {
|
||||
<div class="comments-loading">Loading comments...</div>
|
||||
} @else {
|
||||
@for (comment of comments()[show.id] || []; track comment.id) {
|
||||
<div class="comment">
|
||||
<div class="comment-header">
|
||||
@if (comment.user.avatar) {
|
||||
<img [src]="comment.user.avatar" [alt]="comment.user.username" class="comment-avatar">
|
||||
}
|
||||
<span class="comment-author">{{ comment.user.username }}</span>
|
||||
@if (comment.user.inDiscord) {
|
||||
<span class="discord-badge">Discord</span>
|
||||
}
|
||||
@if (comment.user.isVip) {
|
||||
<span class="vip-badge">VIP</span>
|
||||
}
|
||||
@if (comment.user.isMod) {
|
||||
<span class="mod-badge">Mod</span>
|
||||
}
|
||||
@if (comment.user.isStaff) {
|
||||
<span class="staff-badge">Staff</span>
|
||||
}
|
||||
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
|
||||
@if (canEditComment(comment)) {
|
||||
<button (click)="startEditComment(show.id, comment)" class="btn btn-secondary btn-xs">Edit</button>
|
||||
}
|
||||
@if (canDeleteComment(comment)) {
|
||||
<button (click)="deleteComment(show.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
|
||||
}
|
||||
</div>
|
||||
@if (editingCommentId() === comment.id) {
|
||||
<div class="comment-edit-form">
|
||||
<textarea
|
||||
[(ngModel)]="editCommentContent"
|
||||
name="editComment"
|
||||
rows="3"
|
||||
></textarea>
|
||||
<div class="comment-edit-actions">
|
||||
<button (click)="saveCommentEdit(show.id, comment.id)" class="btn btn-primary btn-xs">Save</button>
|
||||
<button (click)="cancelCommentEdit()" class="btn btn-secondary btn-xs">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
|
||||
}
|
||||
</div>
|
||||
} @empty {
|
||||
<div class="no-comments">No comments yet. Be the first to comment!</div>
|
||||
}
|
||||
}
|
||||
<app-comment-display
|
||||
[comments]="getCommentsSignal(show.id)"
|
||||
(edit)="handleCommentEdit(show.id, $event)"
|
||||
(delete)="deleteComment(show.id, $event)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -660,6 +747,13 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
@@ -854,6 +948,22 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.time-spent {
|
||||
font-size: 0.9rem;
|
||||
color: #8b5cf6;
|
||||
font-weight: 500;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.date-started,
|
||||
.date-finished,
|
||||
.date-added,
|
||||
.date-updated {
|
||||
font-size: 0.85rem;
|
||||
color: #4b5563;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -1210,6 +1320,7 @@ export class ShowsListComponent implements OnInit {
|
||||
watchingCount = computed(() => this.shows().filter(show => show.status === ShowStatus.watching).length);
|
||||
completedCount = computed(() => this.shows().filter(show => show.status === ShowStatus.completed).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(() => {
|
||||
const tagsSet = new Set<string>();
|
||||
@@ -1258,18 +1369,26 @@ export class ShowsListComponent implements OnInit {
|
||||
|
||||
totalFilteredShows = computed(() => this.filteredShows().length);
|
||||
|
||||
newShow: Partial<CreateShowDto> = {
|
||||
newShow: Partial<CreateShowDto> & { dateStarted?: Date; dateFinished?: Date } = {
|
||||
title: '',
|
||||
type: ShowType.tvSeries,
|
||||
status: ShowStatus.wantToWatch,
|
||||
rating: undefined,
|
||||
notes: '',
|
||||
dateStarted: undefined,
|
||||
dateFinished: undefined,
|
||||
tags: [],
|
||||
links: []
|
||||
};
|
||||
|
||||
editShow: Partial<UpdateShowDto> = {};
|
||||
|
||||
// Time tracking state
|
||||
newShowTimeHours = 0;
|
||||
newShowTimeMinutes = 0;
|
||||
editShowTimeHours = 0;
|
||||
editShowTimeMinutes = 0;
|
||||
|
||||
// Tags and links input state
|
||||
newTagInput = '';
|
||||
editTagInput = '';
|
||||
@@ -1338,6 +1457,7 @@ export class ShowsListComponent implements OnInit {
|
||||
case ShowStatus.watching: return 'Currently Watching';
|
||||
case ShowStatus.completed: return 'Completed';
|
||||
case ShowStatus.wantToWatch: return 'Want to Watch';
|
||||
case ShowStatus.retired: return 'Retired';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1365,9 +1485,13 @@ export class ShowsListComponent implements OnInit {
|
||||
rating: undefined,
|
||||
notes: '',
|
||||
coverImage: undefined,
|
||||
dateStarted: undefined,
|
||||
dateFinished: undefined,
|
||||
tags: [],
|
||||
links: []
|
||||
};
|
||||
this.newShowTimeHours = 0;
|
||||
this.newShowTimeMinutes = 0;
|
||||
this.newShowImagePreview.set(null);
|
||||
this.imageError.set(null);
|
||||
this.newTagInput = '';
|
||||
@@ -1375,6 +1499,16 @@ export class ShowsListComponent implements OnInit {
|
||||
this.newLinkUrl = '';
|
||||
}
|
||||
|
||||
updateNewShowTimeSpent() {
|
||||
const totalMinutes = (this.newShowTimeHours * 60) + this.newShowTimeMinutes;
|
||||
this.newShow.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
|
||||
}
|
||||
|
||||
updateEditShowTimeSpent() {
|
||||
const totalMinutes = (this.editShowTimeHours * 60) + this.editShowTimeMinutes;
|
||||
this.editShow.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
|
||||
}
|
||||
|
||||
addTag(target: 'new' | 'edit') {
|
||||
const input = target === 'new' ? this.newTagInput.trim() : this.editTagInput.trim();
|
||||
if (!input) return;
|
||||
@@ -1427,6 +1561,8 @@ export class ShowsListComponent implements OnInit {
|
||||
title: this.newShow.title,
|
||||
type: this.newShow.type,
|
||||
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,
|
||||
notes: this.newShow.notes,
|
||||
coverImage: this.newShow.coverImage,
|
||||
@@ -1454,12 +1590,23 @@ export class ShowsListComponent implements OnInit {
|
||||
title: show.title,
|
||||
type: show.type,
|
||||
status: show.status,
|
||||
dateStarted: show.dateStarted,
|
||||
dateFinished: show.dateFinished,
|
||||
rating: show.rating,
|
||||
notes: show.notes,
|
||||
coverImage: show.coverImage,
|
||||
tags: [...(show.tags || [])],
|
||||
links: [...(show.links || [])]
|
||||
links: [...(show.links || [])],
|
||||
timeSpent: show.timeSpent
|
||||
};
|
||||
// Populate time fields from existing timeSpent
|
||||
if (show.timeSpent) {
|
||||
this.editShowTimeHours = Math.floor(show.timeSpent / 60);
|
||||
this.editShowTimeMinutes = show.timeSpent % 60;
|
||||
} else {
|
||||
this.editShowTimeHours = 0;
|
||||
this.editShowTimeMinutes = 0;
|
||||
}
|
||||
this.editShowImagePreview.set(show.coverImage || null);
|
||||
this.showAddForm.set(false);
|
||||
this.imageError.set(null);
|
||||
@@ -1482,7 +1629,13 @@ export class ShowsListComponent implements OnInit {
|
||||
const show = this.editingShow();
|
||||
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.cancelEdit();
|
||||
});
|
||||
@@ -1543,6 +1696,19 @@ export class ShowsListComponent implements OnInit {
|
||||
return new Date(date).toLocaleDateString();
|
||||
}
|
||||
|
||||
formatTimeSpent(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
|
||||
if (hours === 0) {
|
||||
return `${mins}m`;
|
||||
} else if (mins === 0) {
|
||||
return `${hours}h`;
|
||||
} else {
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
}
|
||||
|
||||
toggleComments(showId: string) {
|
||||
const expanded = this.expandedComments();
|
||||
const isCurrentlyExpanded = expanded[showId];
|
||||
@@ -1677,7 +1843,7 @@ export class ShowsListComponent implements OnInit {
|
||||
|
||||
try {
|
||||
await this.suggestionService.createSuggestion({
|
||||
entityType: SuggestionEntity.SHOW,
|
||||
entityType: SuggestionEntity.show,
|
||||
title: this.suggestedShow.title,
|
||||
type: this.suggestedShow.type,
|
||||
notes: this.suggestedShow.notes,
|
||||
@@ -1689,4 +1855,21 @@ export class ShowsListComponent implements OnInit {
|
||||
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 { Router } from '@angular/router';
|
||||
import { environment } from '../../environments/environment';
|
||||
import { ToastService } from '../services/toast.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthInterceptor implements HttpInterceptor {
|
||||
private router = inject(Router);
|
||||
private http = inject(HttpClient);
|
||||
private toast = inject(ToastService);
|
||||
|
||||
private isRefreshing = false;
|
||||
private refreshTokenSubject = new BehaviorSubject<boolean | null>(null);
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private http: HttpClient
|
||||
) {}
|
||||
|
||||
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||
// Clone the request to add withCredentials
|
||||
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')) {
|
||||
return this.handle401Error(authReq, next);
|
||||
}
|
||||
|
||||
// Show toast for other HTTP errors
|
||||
this.showErrorToast(error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
@@ -61,6 +64,7 @@ export class AuthInterceptor implements HttpInterceptor {
|
||||
catchError((err) => {
|
||||
this.isRefreshing = false;
|
||||
this.refreshTokenSubject.next(false);
|
||||
this.toast.error('Your session has expired. Please log in again.');
|
||||
this.router.navigate(['/']);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {
|
||||
AchievementDefinition,
|
||||
AchievementProgress,
|
||||
UserAchievementSummary,
|
||||
} from '@library/shared-types';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { ApiService } from './api.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AchievementService {
|
||||
private readonly api = inject(ApiService);
|
||||
|
||||
/**
|
||||
* Get all achievement definitions.
|
||||
*/
|
||||
getAchievementDefinitions(): Observable<AchievementDefinition[]> {
|
||||
return this.api.get<AchievementDefinition[]>('/achievements/definitions');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific achievement definition by key.
|
||||
*/
|
||||
getAchievementDefinition(key: string): Observable<AchievementDefinition> {
|
||||
return this.api.get<AchievementDefinition>(`/achievements/definitions/${key}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user's achievement summary.
|
||||
*/
|
||||
getCurrentUserSummary(): Observable<UserAchievementSummary> {
|
||||
return this.api.get<UserAchievementSummary>('/achievements/summary');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user's achievement progress.
|
||||
*/
|
||||
getCurrentUserProgress(): Observable<AchievementProgress[]> {
|
||||
return this.api.get<AchievementProgress[]>('/achievements/progress');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get another user's achievement summary.
|
||||
*/
|
||||
getUserSummary(userId: string): Observable<UserAchievementSummary> {
|
||||
return this.api.get<UserAchievementSummary>(`/achievements/users/${userId}/summary`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get another user's achievement progress.
|
||||
*/
|
||||
getUserProgress(userId: string): Observable<AchievementProgress[]> {
|
||||
return this.api.get<AchievementProgress[]>(`/achievements/users/${userId}/progress`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import type { ActivityFeedResponse } from '@library/shared-types';
|
||||
import { ApiService } from './api.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ActivityService {
|
||||
private apiService = inject(ApiService);
|
||||
|
||||
/**
|
||||
* Get activity feed with pagination.
|
||||
*/
|
||||
getActivityFeed(limit = 50, offset = 0, userId?: string): Observable<ActivityFeedResponse> {
|
||||
const params = new URLSearchParams();
|
||||
params.append('limit', limit.toString());
|
||||
params.append('offset', offset.toString());
|
||||
if (userId) {
|
||||
params.append('userId', userId);
|
||||
}
|
||||
|
||||
return this.apiService.get<ActivityFeedResponse>(`/activity?${params.toString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get activity feed for a specific user.
|
||||
*/
|
||||
getUserActivityFeed(userId: string, limit = 50, offset = 0): Observable<ActivityFeedResponse> {
|
||||
const params = new URLSearchParams();
|
||||
params.append('limit', limit.toString());
|
||||
params.append('offset', offset.toString());
|
||||
|
||||
return this.apiService.get<ActivityFeedResponse>(`/activity/${userId}?${params.toString()}`);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApiService } from './api.service';
|
||||
import { Art, CreateArtDto, UpdateArtDto } from '@library/shared-types';
|
||||
@@ -13,7 +13,8 @@ import { Art, CreateArtDto, UpdateArtDto } from '@library/shared-types';
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ArtService {
|
||||
constructor(private api: ApiService) {}
|
||||
private api = inject(ApiService);
|
||||
|
||||
|
||||
getAllArt(): Observable<Art[]> {
|
||||
return this.api.get<Art[]>('/art');
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { Injectable, signal, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Observable, tap, catchError, switchMap, throwError, of } from 'rxjs';
|
||||
import { ApiService } from './api.service';
|
||||
@@ -16,15 +16,14 @@ import { HttpClient } from '@angular/common/http';
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AuthService {
|
||||
private api = inject(ApiService);
|
||||
private router = inject(Router);
|
||||
private http = inject(HttpClient);
|
||||
|
||||
private currentUser = signal<User | null>(null);
|
||||
public readonly user = this.currentUser.asReadonly();
|
||||
private refreshing = false;
|
||||
|
||||
constructor(
|
||||
private api: ApiService,
|
||||
private router: Router,
|
||||
private http: HttpClient
|
||||
) {}
|
||||
private refreshInterval?: ReturnType<typeof setInterval>;
|
||||
|
||||
login(): void {
|
||||
// Redirect to API login endpoint
|
||||
@@ -35,6 +34,7 @@ export class AuthService {
|
||||
return this.api.get<AuthResponse>('/auth/me').pipe(
|
||||
tap(response => {
|
||||
this.currentUser.set(response.user);
|
||||
this.startRefreshTimer();
|
||||
}),
|
||||
catchError(error => {
|
||||
if (error.status === 401) {
|
||||
@@ -42,9 +42,11 @@ export class AuthService {
|
||||
switchMap(() => this.api.get<AuthResponse>('/auth/me')),
|
||||
tap(response => {
|
||||
this.currentUser.set(response.user);
|
||||
this.startRefreshTimer();
|
||||
}),
|
||||
catchError(() => {
|
||||
this.currentUser.set(null);
|
||||
this.stopRefreshTimer();
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
@@ -68,20 +70,45 @@ export class AuthService {
|
||||
tap(response => {
|
||||
this.currentUser.set(response.user);
|
||||
this.refreshing = false;
|
||||
this.startRefreshTimer();
|
||||
}),
|
||||
catchError(error => {
|
||||
this.refreshing = false;
|
||||
this.currentUser.set(null);
|
||||
this.stopRefreshTimer();
|
||||
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 }> {
|
||||
return this.api.post<{ message: string }>('/auth/logout', {}).pipe(
|
||||
tap(() => {
|
||||
this.currentUser.set(null);
|
||||
this.api.clearCsrfToken();
|
||||
this.stopRefreshTimer();
|
||||
this.router.navigate(['/']);
|
||||
})
|
||||
);
|
||||
@@ -89,12 +116,17 @@ export class AuthService {
|
||||
|
||||
clearUser(): void {
|
||||
this.currentUser.set(null);
|
||||
this.stopRefreshTimer();
|
||||
}
|
||||
|
||||
isAuthenticated(): boolean {
|
||||
return this.user() !== null;
|
||||
}
|
||||
|
||||
updateUser(user: User): void {
|
||||
this.currentUser.set(user);
|
||||
}
|
||||
|
||||
isAdmin(): boolean {
|
||||
return this.user()?.isAdmin === true;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApiService } from './api.service';
|
||||
import { Book, CreateBookDto, UpdateBookDto } from '@library/shared-types';
|
||||
@@ -13,7 +13,8 @@ import { Book, CreateBookDto, UpdateBookDto } from '@library/shared-types';
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class BooksService {
|
||||
constructor(private api: ApiService) {}
|
||||
private api = inject(ApiService);
|
||||
|
||||
|
||||
getAllBooks(): Observable<Book[]> {
|
||||
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
|
||||
*/
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApiService } from './api.service';
|
||||
import { Comment, CreateCommentDto } from '@library/shared-types';
|
||||
@@ -13,7 +13,8 @@ import { Comment, CreateCommentDto } from '@library/shared-types';
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class CommentsService {
|
||||
constructor(private api: ApiService) {}
|
||||
private api = inject(ApiService);
|
||||
|
||||
|
||||
getCommentsForGame(gameId: string): Observable<Comment[]> {
|
||||
return this.api.get<Comment[]>(`/games/${gameId}/comments`);
|
||||
@@ -110,4 +111,13 @@ export class CommentsService {
|
||||
updateCommentOnManga(mangaId: string, commentId: string, content: string): Observable<Comment> {
|
||||
return this.api.put<Comment>(`/manga/${mangaId}/comments/${commentId}`, { content });
|
||||
}
|
||||
|
||||
// Admin methods - work with comment ID directly
|
||||
adminUpdateComment(commentId: string, content: string): Observable<Comment> {
|
||||
return this.api.put<Comment>(`/comments/${commentId}`, { content });
|
||||
}
|
||||
|
||||
adminDeleteComment(commentId: string): Observable<{ success: boolean }> {
|
||||
return this.api.delete<{ success: boolean }>(`/comments/${commentId}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApiService } from './api.service';
|
||||
import { Game, CreateGameDto, UpdateGameDto } from '@library/shared-types';
|
||||
@@ -13,7 +13,8 @@ import { Game, CreateGameDto, UpdateGameDto } from '@library/shared-types';
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class GamesService {
|
||||
constructor(private api: ApiService) {}
|
||||
private api = inject(ApiService);
|
||||
|
||||
|
||||
getAllGames(): Observable<Game[]> {
|
||||
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 { BooksService } from './books.service';
|
||||
export { GamesService } from './games.service';
|
||||
export { MusicService } from './music.service';
|
||||
export { MusicService } from './music.service';
|
||||
export { ReportService } from './report.service';
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import type {
|
||||
LeaderboardResponse,
|
||||
SuggestionsLeaderboard,
|
||||
LikesLeaderboard,
|
||||
CommentsLeaderboard,
|
||||
OverallLeaderboard,
|
||||
} from '@library/shared-types';
|
||||
import { ApiService } from './api.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class LeaderboardService {
|
||||
private apiService = inject(ApiService);
|
||||
|
||||
/**
|
||||
* Get all leaderboards at once.
|
||||
*/
|
||||
getAllLeaderboards(limit = 25): Observable<LeaderboardResponse> {
|
||||
return this.apiService.get<LeaderboardResponse>(`/leaderboard?limit=${limit}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top users by suggestions.
|
||||
*/
|
||||
getTopSuggestions(limit = 25): Observable<SuggestionsLeaderboard[]> {
|
||||
return this.apiService.get<SuggestionsLeaderboard[]>(`/leaderboard/suggestions?limit=${limit}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top users by likes.
|
||||
*/
|
||||
getTopLikes(limit = 25): Observable<LikesLeaderboard[]> {
|
||||
return this.apiService.get<LikesLeaderboard[]>(`/leaderboard/likes?limit=${limit}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top users by comments.
|
||||
*/
|
||||
getTopComments(limit = 25): Observable<CommentsLeaderboard[]> {
|
||||
return this.apiService.get<CommentsLeaderboard[]>(`/leaderboard/comments?limit=${limit}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get overall leaderboard.
|
||||
*/
|
||||
getOverallLeaderboard(limit = 25): Observable<OverallLeaderboard[]> {
|
||||
return this.apiService.get<OverallLeaderboard[]>(`/leaderboard/overall?limit=${limit}`);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApiService } from './api.service';
|
||||
import { Manga, CreateMangaDto, UpdateMangaDto } from '@library/shared-types';
|
||||
@@ -13,7 +13,8 @@ import { Manga, CreateMangaDto, UpdateMangaDto } from '@library/shared-types';
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MangaService {
|
||||
constructor(private api: ApiService) {}
|
||||
private api = inject(ApiService);
|
||||
|
||||
|
||||
getAllManga(): Observable<Manga[]> {
|
||||
return this.api.get<Manga[]>('/manga');
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApiService } from './api.service';
|
||||
import { Music, CreateMusicDto, UpdateMusicDto } from '@library/shared-types';
|
||||
@@ -13,7 +13,8 @@ import { Music, CreateMusicDto, UpdateMusicDto } from '@library/shared-types';
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MusicService {
|
||||
constructor(private api: ApiService) {}
|
||||
private api = inject(ApiService);
|
||||
|
||||
|
||||
getAllMusic(): Observable<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
|
||||
*/
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApiService } from './api.service';
|
||||
import { Show, CreateShowDto, UpdateShowDto } from '@library/shared-types';
|
||||
@@ -13,7 +13,8 @@ import { Show, CreateShowDto, UpdateShowDto } from '@library/shared-types';
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ShowsService {
|
||||
constructor(private api: ApiService) {}
|
||||
private api = inject(ApiService);
|
||||
|
||||
|
||||
getAllShows(): Observable<Show[]> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,53 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApiService } from './api.service';
|
||||
import { User } from '@library/shared-types';
|
||||
import { User, PrimaryBadge } from '@library/shared-types';
|
||||
|
||||
export interface UserProfileResponse {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName?: string;
|
||||
avatar?: string;
|
||||
bio?: string;
|
||||
slug?: string;
|
||||
primaryBadge?: PrimaryBadge;
|
||||
website?: string;
|
||||
discordServer?: string;
|
||||
bluesky?: string;
|
||||
github?: string;
|
||||
linkedin?: string;
|
||||
twitch?: string;
|
||||
youtube?: string;
|
||||
achievementPoints: number;
|
||||
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;
|
||||
primaryBadge?: PrimaryBadge;
|
||||
website?: string;
|
||||
discordServer?: string;
|
||||
bluesky?: string;
|
||||
github?: string;
|
||||
linkedin?: string;
|
||||
twitch?: string;
|
||||
youtube?: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -26,4 +72,24 @@ export class UserService {
|
||||
unbanUser(userId: string): Observable<User> {
|
||||
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}`);
|
||||
}
|
||||
|
||||
makeProfilePrivate(userId: string): Observable<User> {
|
||||
return this.api.post<User>(`/users/${userId}/make-private`, {});
|
||||
}
|
||||
|
||||
adminUpdateUser(userId: string, settings: UpdateUserSettingsRequest): Observable<User> {
|
||||
return this.api.put<User>(`/users/${userId}`, settings);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
export default [
|
||||
...nhcarrigan,
|
||||
{
|
||||
ignores: ['**/dist', '**/out-tsc', 'node_modules'],
|
||||
},
|
||||
];
|
||||
export default nhcarrigan;
|
||||
+22
-16
@@ -3,8 +3,9 @@
|
||||
"version": "0.0.0",
|
||||
"license": "MIT",
|
||||
"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",
|
||||
"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",
|
||||
"build:frontend": "nx build frontend --configuration=production",
|
||||
"build:api": "nx build api",
|
||||
@@ -25,28 +26,33 @@
|
||||
"@angular/platform-browser": "21.1.2",
|
||||
"@angular/router": "21.1.2",
|
||||
"@fastify/autoload": "6.0.3",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.0.0",
|
||||
"@fastify/csrf-protection": "^7.1.0",
|
||||
"@fastify/helmet": "^13.0.2",
|
||||
"@fastify/jwt": "^10.0.0",
|
||||
"@fastify/oauth2": "^8.1.2",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/cookie": "11.0.2",
|
||||
"@fastify/cors": "11.0.0",
|
||||
"@fastify/csrf-protection": "7.1.0",
|
||||
"@fastify/helmet": "13.0.2",
|
||||
"@fastify/jwt": "10.0.0",
|
||||
"@fastify/oauth2": "8.1.2",
|
||||
"@fastify/rate-limit": "10.3.0",
|
||||
"@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",
|
||||
"dompurify": "^3.3.1",
|
||||
"dompurify": "3.3.1",
|
||||
"fastify": "5.7.3",
|
||||
"fastify-plugin": "5.0.1",
|
||||
"jsdom": "^28.0.0",
|
||||
"marked": "^17.0.1",
|
||||
"jsdom": "28.0.0",
|
||||
"marked": "17.0.1",
|
||||
"rxjs": "7.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "21.1.2",
|
||||
"@angular-devkit/core": "21.1.2",
|
||||
"@angular-devkit/schematics": "21.1.2",
|
||||
"@angular/cli": "21.1.3",
|
||||
"@angular/cli": "21.1.2",
|
||||
"@angular/compiler-cli": "21.1.2",
|
||||
"@angular/language-service": "21.1.2",
|
||||
"@eslint/js": "9.39.2",
|
||||
@@ -65,10 +71,10 @@
|
||||
"@swc-node/register": "1.9.2",
|
||||
"@swc/core": "1.5.29",
|
||||
"@swc/helpers": "0.5.18",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@types/dompurify": "3.2.0",
|
||||
"@types/jest": "30.0.0",
|
||||
"@types/jsdom": "^27.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/jsdom": "27.0.0",
|
||||
"@types/jsonwebtoken": "9.0.10",
|
||||
"@types/node": "20.19.9",
|
||||
"@typescript-eslint/utils": "8.54.0",
|
||||
"angular-eslint": "21.1.0",
|
||||
|
||||
Generated
+115
-112
@@ -30,37 +30,52 @@ importers:
|
||||
specifier: 6.0.3
|
||||
version: 6.0.3
|
||||
'@fastify/cookie':
|
||||
specifier: ^11.0.2
|
||||
specifier: 11.0.2
|
||||
version: 11.0.2
|
||||
'@fastify/cors':
|
||||
specifier: ^11.0.0
|
||||
version: 11.2.0
|
||||
specifier: 11.0.0
|
||||
version: 11.0.0
|
||||
'@fastify/csrf-protection':
|
||||
specifier: ^7.1.0
|
||||
specifier: 7.1.0
|
||||
version: 7.1.0
|
||||
'@fastify/helmet':
|
||||
specifier: ^13.0.2
|
||||
specifier: 13.0.2
|
||||
version: 13.0.2
|
||||
'@fastify/jwt':
|
||||
specifier: ^10.0.0
|
||||
specifier: 10.0.0
|
||||
version: 10.0.0
|
||||
'@fastify/oauth2':
|
||||
specifier: ^8.1.2
|
||||
specifier: 8.1.2
|
||||
version: 8.1.2
|
||||
'@fastify/rate-limit':
|
||||
specifier: ^10.3.0
|
||||
specifier: 10.3.0
|
||||
version: 10.3.0
|
||||
'@fastify/sensible':
|
||||
specifier: 6.0.4
|
||||
version: 6.0.4
|
||||
'@fastify/static':
|
||||
specifier: ^9.0.0
|
||||
specifier: 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':
|
||||
specifier: 6.19.2
|
||||
version: 6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3)
|
||||
dompurify:
|
||||
specifier: ^3.3.1
|
||||
specifier: 3.3.1
|
||||
version: 3.3.1
|
||||
fastify:
|
||||
specifier: 5.7.3
|
||||
@@ -69,10 +84,10 @@ importers:
|
||||
specifier: 5.0.1
|
||||
version: 5.0.1
|
||||
jsdom:
|
||||
specifier: ^28.0.0
|
||||
specifier: 28.0.0
|
||||
version: 28.0.0
|
||||
marked:
|
||||
specifier: ^17.0.1
|
||||
specifier: 17.0.1
|
||||
version: 17.0.1
|
||||
rxjs:
|
||||
specifier: 7.8.2
|
||||
@@ -88,8 +103,8 @@ importers:
|
||||
specifier: 21.1.2
|
||||
version: 21.1.2(chokidar@5.0.0)
|
||||
'@angular/cli':
|
||||
specifier: 21.1.3
|
||||
version: 21.1.3(@types/node@20.19.9)(chokidar@5.0.0)
|
||||
specifier: 21.1.2
|
||||
version: 21.1.2(@types/node@20.19.9)(chokidar@5.0.0)(hono@4.11.7)
|
||||
'@angular/compiler-cli':
|
||||
specifier: 21.1.2
|
||||
version: 21.1.2(@angular/compiler@21.1.2)(typescript@5.9.3)
|
||||
@@ -145,16 +160,16 @@ importers:
|
||||
specifier: 0.5.18
|
||||
version: 0.5.18
|
||||
'@types/dompurify':
|
||||
specifier: ^3.2.0
|
||||
specifier: 3.2.0
|
||||
version: 3.2.0
|
||||
'@types/jest':
|
||||
specifier: 30.0.0
|
||||
version: 30.0.0
|
||||
'@types/jsdom':
|
||||
specifier: ^27.0.0
|
||||
specifier: 27.0.0
|
||||
version: 27.0.0
|
||||
'@types/jsonwebtoken':
|
||||
specifier: ^9.0.10
|
||||
specifier: 9.0.10
|
||||
version: 9.0.10
|
||||
'@types/node':
|
||||
specifier: 20.19.9
|
||||
@@ -164,7 +179,7 @@ importers:
|
||||
version: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
angular-eslint:
|
||||
specifier: 21.1.0
|
||||
version: 21.1.0(@angular/cli@21.1.3(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript-eslint@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(typescript@5.9.3)
|
||||
version: 21.1.0(@angular/cli@21.1.2(@types/node@20.19.9)(chokidar@5.0.0)(hono@4.11.7))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript-eslint@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(typescript@5.9.3)
|
||||
cypress:
|
||||
specifier: 15.9.0
|
||||
version: 15.9.0
|
||||
@@ -278,11 +293,6 @@ packages:
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
|
||||
hasBin: true
|
||||
|
||||
'@angular-devkit/architect@0.2101.3':
|
||||
resolution: {integrity: sha512-vKz8aPA62W+e9+pF6ct4CRDG/MjlIH7sWFGYkxPPRst2g46ZQsRkrzfMZAWv/wnt6OZ1OwyRuO3RW83EMhag8g==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
|
||||
hasBin: true
|
||||
|
||||
'@angular-devkit/build-angular@21.1.2':
|
||||
resolution: {integrity: sha512-i/FTbqVwj0Wk6B5RA2H9iVsDC/kIK/5koSEwkIQjXGZuDVFUoEuWiIR2PGGSSQ9u3DmkpVPZmKEXWRl+g7Qn5g==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
|
||||
@@ -349,23 +359,10 @@ packages:
|
||||
chokidar:
|
||||
optional: true
|
||||
|
||||
'@angular-devkit/core@21.1.3':
|
||||
resolution: {integrity: sha512-huEXd1tWQHwwN+0VGRT+vSVplV0KNrGFUGJzkIW6iJE1SQElxn6etMai+pSd5DJcePkx6+SuscVsxbfwf70hnA==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
|
||||
peerDependencies:
|
||||
chokidar: ^5.0.0
|
||||
peerDependenciesMeta:
|
||||
chokidar:
|
||||
optional: true
|
||||
|
||||
'@angular-devkit/schematics@21.1.2':
|
||||
resolution: {integrity: sha512-PA3gkiFhHUuXd2XuP7yzKg/9N++bjw+uOl473KwIsMuZwMPhncKa4+mUYBaffDoPqaujZvjfo6mjtCBuiBv05w==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
|
||||
|
||||
'@angular-devkit/schematics@21.1.3':
|
||||
resolution: {integrity: sha512-Ps7bRl5uOcM7WpNJHbSls/jz5/wAI0ldkTlKyiBFA7RtNeQIABAV+hvlw5DJuEb1Lo5hnK0hXj90AyZdOxzY+w==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
|
||||
|
||||
'@angular-eslint/builder@21.1.0':
|
||||
resolution: {integrity: sha512-pcUlDkGqeZ+oQC0oEjnkDDlB96gbgHQhnBUKdhYAiAOSuiBod4+npP0xQOq5chYtRNPBprhDqgrJrp5DBeDMOA==}
|
||||
peerDependencies:
|
||||
@@ -456,8 +453,8 @@ packages:
|
||||
vitest:
|
||||
optional: true
|
||||
|
||||
'@angular/cli@21.1.3':
|
||||
resolution: {integrity: sha512-UPtDcpKyrKZRPfym9gTovcibPzl2O/Woy7B8sm45sAnjDH+jDUCcCvuIak7GpH47shQkC2J4yvnHZbD4c6XxcQ==}
|
||||
'@angular/cli@21.1.2':
|
||||
resolution: {integrity: sha512-AHjXCBl2PEilMJct6DX3ih5Fl5PiKpNDIj0ViTyVh1YcfpYjt6NzhVlV2o++8VNPNH/vMcmf2551LZIDProXXA==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -1629,8 +1626,8 @@ packages:
|
||||
'@fastify/cookie@11.0.2':
|
||||
resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==}
|
||||
|
||||
'@fastify/cors@11.2.0':
|
||||
resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==}
|
||||
'@fastify/cors@11.0.0':
|
||||
resolution: {integrity: sha512-41Bx0LVGr2a6DnnhDN/SgfDlTRNZtEs8niPxyoymV6Hw09AIdz/9Rn/0Fpu+pBOs6kviwS44JY2mB8NcU2qSAA==}
|
||||
|
||||
'@fastify/csrf-protection@7.1.0':
|
||||
resolution: {integrity: sha512-I2TDd4SRRYQivKCMHdB/8py+CPO9DT0e63lh4DO8MDCJh8NROq8HD/iO0IjYtwhsD3bZhr0cBXsFdfPvyTmzNw==}
|
||||
@@ -1674,6 +1671,27 @@ packages:
|
||||
'@fastify/static@9.0.0':
|
||||
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':
|
||||
resolution: {integrity: sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==}
|
||||
|
||||
@@ -2153,8 +2171,8 @@ packages:
|
||||
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
'@modelcontextprotocol/sdk@1.26.0':
|
||||
resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==}
|
||||
'@modelcontextprotocol/sdk@1.25.2':
|
||||
resolution: {integrity: sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@cfworker/json-schema': ^4.1.1
|
||||
@@ -2506,6 +2524,9 @@ packages:
|
||||
typescript: '>=5'
|
||||
vitest: '>=2'
|
||||
|
||||
'@nhcarrigan/logger@1.1.1':
|
||||
resolution: {integrity: sha512-P6OEQFHDtf6psybYGljuCxkSW6DLQCsx1aZZ3w4YKBXHBFjDbhuvpM9K1kPhVN48hakitx2WPLEoIFr6YZELYw==}
|
||||
|
||||
'@noble/hashes@1.4.0':
|
||||
resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==}
|
||||
engines: {node: '>= 16'}
|
||||
@@ -3161,10 +3182,6 @@ packages:
|
||||
resolution: {integrity: sha512-kxwxhCIUrj7DfzEtDSs/pi/w+aII/WQLpPfLgoQCWE8/95v60WnTfd1afmsXsFoxikKPxkwoPWtU2YbhSoX9MQ==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
|
||||
|
||||
'@schematics/angular@21.1.3':
|
||||
resolution: {integrity: sha512-obJvWBhzRdsYL2msM4+8bQD21vFl3VxaVsuiq6iIfYsxhU5i2Iar2wM9NaRaIIqAYhZ8ehQQ/moB9BEbWvDCTw==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
|
||||
|
||||
'@sideway/address@4.1.5':
|
||||
resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==}
|
||||
|
||||
@@ -5358,8 +5375,8 @@ packages:
|
||||
exponential-backoff@3.1.3:
|
||||
resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==}
|
||||
|
||||
express-rate-limit@8.2.1:
|
||||
resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==}
|
||||
express-rate-limit@7.5.1:
|
||||
resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==}
|
||||
engines: {node: '>= 16'}
|
||||
peerDependencies:
|
||||
express: '>= 4.11'
|
||||
@@ -5984,10 +6001,6 @@ packages:
|
||||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
ip-address@10.0.1:
|
||||
resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
ip-address@10.1.0:
|
||||
resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
|
||||
engines: {node: '>= 12'}
|
||||
@@ -6916,6 +6929,9 @@ packages:
|
||||
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
mnemonist@0.40.0:
|
||||
resolution: {integrity: sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==}
|
||||
|
||||
mnemonist@0.40.3:
|
||||
resolution: {integrity: sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==}
|
||||
|
||||
@@ -8555,7 +8571,6 @@ packages:
|
||||
tar@7.5.7:
|
||||
resolution: {integrity: sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==}
|
||||
engines: {node: '>=18'}
|
||||
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
tcp-port-used@1.0.2:
|
||||
resolution: {integrity: sha512-l7ar8lLUD3XS1V2lfoJlCBaeoaWo/2xfYt81hM7VlvR4RrMVFqfmzfhLVk40hAb368uitje5gPtBRL1m/DGvLA==}
|
||||
@@ -9503,13 +9518,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- chokidar
|
||||
|
||||
'@angular-devkit/architect@0.2101.3(chokidar@5.0.0)':
|
||||
dependencies:
|
||||
'@angular-devkit/core': 21.1.3(chokidar@5.0.0)
|
||||
rxjs: 7.8.2
|
||||
transitivePeerDependencies:
|
||||
- chokidar
|
||||
|
||||
'@angular-devkit/build-angular@21.1.2(@angular/compiler-cli@21.1.2(@angular/compiler@21.1.2)(typescript@5.9.3))(@angular/compiler@21.1.2)(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(@angular/platform-browser@21.1.2(@angular/common@21.1.2(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2)))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(@swc/core@1.5.29(@swc/helpers@0.5.18))(@types/node@20.19.9)(chokidar@5.0.0)(jest@30.2.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.5.29(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3)))(jiti@2.6.1)(sass-embedded@1.97.3)(typescript@5.9.3)(vitest@4.0.18(@types/node@20.19.9)(jiti@2.6.1)(jsdom@28.0.0)(less@4.5.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(yaml@2.8.2))(yaml@2.8.2)':
|
||||
dependencies:
|
||||
'@ampproject/remapping': 2.3.0
|
||||
@@ -9616,17 +9624,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
chokidar: 5.0.0
|
||||
|
||||
'@angular-devkit/core@21.1.3(chokidar@5.0.0)':
|
||||
dependencies:
|
||||
ajv: 8.17.1
|
||||
ajv-formats: 3.0.1(ajv@8.17.1)
|
||||
jsonc-parser: 3.3.1
|
||||
picomatch: 4.0.3
|
||||
rxjs: 7.8.2
|
||||
source-map: 0.7.6
|
||||
optionalDependencies:
|
||||
chokidar: 5.0.0
|
||||
|
||||
'@angular-devkit/schematics@21.1.2(chokidar@5.0.0)':
|
||||
dependencies:
|
||||
'@angular-devkit/core': 21.1.2(chokidar@5.0.0)
|
||||
@@ -9637,21 +9634,11 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- chokidar
|
||||
|
||||
'@angular-devkit/schematics@21.1.3(chokidar@5.0.0)':
|
||||
dependencies:
|
||||
'@angular-devkit/core': 21.1.3(chokidar@5.0.0)
|
||||
jsonc-parser: 3.3.1
|
||||
magic-string: 0.30.21
|
||||
ora: 9.0.0
|
||||
rxjs: 7.8.2
|
||||
transitivePeerDependencies:
|
||||
- chokidar
|
||||
|
||||
'@angular-eslint/builder@21.1.0(@angular/cli@21.1.3(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
'@angular-eslint/builder@21.1.0(@angular/cli@21.1.2(@types/node@20.19.9)(chokidar@5.0.0)(hono@4.11.7))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@angular-devkit/architect': 0.2101.2(chokidar@5.0.0)
|
||||
'@angular-devkit/core': 21.1.2(chokidar@5.0.0)
|
||||
'@angular/cli': 21.1.3(@types/node@20.19.9)(chokidar@5.0.0)
|
||||
'@angular/cli': 21.1.2(@types/node@20.19.9)(chokidar@5.0.0)(hono@4.11.7)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
@@ -9680,13 +9667,13 @@ snapshots:
|
||||
ts-api-utils: 2.4.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
|
||||
'@angular-eslint/schematics@21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@angular/cli@21.1.3(@types/node@20.19.9)(chokidar@5.0.0))(@typescript-eslint/types@8.54.0)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
'@angular-eslint/schematics@21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@angular/cli@21.1.2(@types/node@20.19.9)(chokidar@5.0.0)(hono@4.11.7))(@typescript-eslint/types@8.54.0)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@angular-devkit/core': 21.1.2(chokidar@5.0.0)
|
||||
'@angular-devkit/schematics': 21.1.2(chokidar@5.0.0)
|
||||
'@angular-eslint/eslint-plugin': 21.1.0(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular-eslint/eslint-plugin-template': 21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/types@8.54.0)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular/cli': 21.1.3(@types/node@20.19.9)(chokidar@5.0.0)
|
||||
'@angular/cli': 21.1.2(@types/node@20.19.9)(chokidar@5.0.0)(hono@4.11.7)
|
||||
ignore: 7.0.5
|
||||
semver: 7.7.3
|
||||
strip-json-comments: 3.1.1
|
||||
@@ -9819,15 +9806,15 @@ snapshots:
|
||||
- yaml
|
||||
optional: true
|
||||
|
||||
'@angular/cli@21.1.3(@types/node@20.19.9)(chokidar@5.0.0)':
|
||||
'@angular/cli@21.1.2(@types/node@20.19.9)(chokidar@5.0.0)(hono@4.11.7)':
|
||||
dependencies:
|
||||
'@angular-devkit/architect': 0.2101.3(chokidar@5.0.0)
|
||||
'@angular-devkit/core': 21.1.3(chokidar@5.0.0)
|
||||
'@angular-devkit/schematics': 21.1.3(chokidar@5.0.0)
|
||||
'@angular-devkit/architect': 0.2101.2(chokidar@5.0.0)
|
||||
'@angular-devkit/core': 21.1.2(chokidar@5.0.0)
|
||||
'@angular-devkit/schematics': 21.1.2(chokidar@5.0.0)
|
||||
'@inquirer/prompts': 7.10.1(@types/node@20.19.9)
|
||||
'@listr2/prompt-adapter-inquirer': 3.0.5(@inquirer/prompts@7.10.1(@types/node@20.19.9))(@types/node@20.19.9)(listr2@9.0.5)
|
||||
'@modelcontextprotocol/sdk': 1.26.0(zod@4.3.5)
|
||||
'@schematics/angular': 21.1.3(chokidar@5.0.0)
|
||||
'@modelcontextprotocol/sdk': 1.25.2(hono@4.11.7)(zod@4.3.5)
|
||||
'@schematics/angular': 21.1.2(chokidar@5.0.0)
|
||||
'@yarnpkg/lockfile': 1.1.0
|
||||
algoliasearch: 5.46.2
|
||||
ini: 6.0.0
|
||||
@@ -9844,6 +9831,7 @@ snapshots:
|
||||
- '@cfworker/json-schema'
|
||||
- '@types/node'
|
||||
- chokidar
|
||||
- hono
|
||||
- supports-color
|
||||
|
||||
'@angular/common@21.1.2(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))(rxjs@7.8.2)':
|
||||
@@ -11612,10 +11600,10 @@ snapshots:
|
||||
cookie: 1.1.1
|
||||
fastify-plugin: 5.0.1
|
||||
|
||||
'@fastify/cors@11.2.0':
|
||||
'@fastify/cors@11.0.0':
|
||||
dependencies:
|
||||
fastify-plugin: 5.0.1
|
||||
toad-cache: 3.7.0
|
||||
mnemonist: 0.40.0
|
||||
|
||||
'@fastify/csrf-protection@7.1.0':
|
||||
dependencies:
|
||||
@@ -11696,6 +11684,26 @@ snapshots:
|
||||
fastq: 1.20.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':
|
||||
dependencies:
|
||||
'@hapi/hoek': 11.0.7
|
||||
@@ -12266,7 +12274,7 @@ snapshots:
|
||||
|
||||
'@lukeed/ms@2.0.2': {}
|
||||
|
||||
'@modelcontextprotocol/sdk@1.26.0(zod@4.3.5)':
|
||||
'@modelcontextprotocol/sdk@1.25.2(hono@4.11.7)(zod@4.3.5)':
|
||||
dependencies:
|
||||
'@hono/node-server': 1.19.9(hono@4.11.7)
|
||||
ajv: 8.17.1
|
||||
@@ -12277,8 +12285,7 @@ snapshots:
|
||||
eventsource: 3.0.7
|
||||
eventsource-parser: 3.0.6
|
||||
express: 5.2.1
|
||||
express-rate-limit: 8.2.1(express@5.2.1)
|
||||
hono: 4.11.7
|
||||
express-rate-limit: 7.5.1(express@5.2.1)
|
||||
jose: 6.1.3
|
||||
json-schema-typed: 8.0.2
|
||||
pkce-challenge: 5.0.1
|
||||
@@ -12286,6 +12293,7 @@ snapshots:
|
||||
zod: 4.3.5
|
||||
zod-to-json-schema: 3.25.1(zod@4.3.5)
|
||||
transitivePeerDependencies:
|
||||
- hono
|
||||
- supports-color
|
||||
|
||||
'@module-federation/bridge-react-webpack-plugin@0.21.6':
|
||||
@@ -12776,6 +12784,8 @@ snapshots:
|
||||
- eslint-import-resolver-webpack
|
||||
- supports-color
|
||||
|
||||
'@nhcarrigan/logger@1.1.1': {}
|
||||
|
||||
'@noble/hashes@1.4.0': {}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
@@ -13760,14 +13770,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- chokidar
|
||||
|
||||
'@schematics/angular@21.1.3(chokidar@5.0.0)':
|
||||
dependencies:
|
||||
'@angular-devkit/core': 21.1.3(chokidar@5.0.0)
|
||||
'@angular-devkit/schematics': 21.1.3(chokidar@5.0.0)
|
||||
jsonc-parser: 3.3.1
|
||||
transitivePeerDependencies:
|
||||
- chokidar
|
||||
|
||||
'@sideway/address@4.1.5':
|
||||
dependencies:
|
||||
'@hapi/hoek': 9.3.0
|
||||
@@ -14641,16 +14643,16 @@ snapshots:
|
||||
'@algolia/requester-fetch': 5.46.2
|
||||
'@algolia/requester-node-http': 5.46.2
|
||||
|
||||
angular-eslint@21.1.0(@angular/cli@21.1.3(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript-eslint@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(typescript@5.9.3):
|
||||
angular-eslint@21.1.0(@angular/cli@21.1.2(@types/node@20.19.9)(chokidar@5.0.0)(hono@4.11.7))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript-eslint@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@angular-devkit/core': 21.1.2(chokidar@5.0.0)
|
||||
'@angular-devkit/schematics': 21.1.2(chokidar@5.0.0)
|
||||
'@angular-eslint/builder': 21.1.0(@angular/cli@21.1.3(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular-eslint/builder': 21.1.0(@angular/cli@21.1.2(@types/node@20.19.9)(chokidar@5.0.0)(hono@4.11.7))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular-eslint/eslint-plugin': 21.1.0(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular-eslint/eslint-plugin-template': 21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/types@8.54.0)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular-eslint/schematics': 21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@angular/cli@21.1.3(@types/node@20.19.9)(chokidar@5.0.0))(@typescript-eslint/types@8.54.0)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular-eslint/schematics': 21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@angular/cli@21.1.2(@types/node@20.19.9)(chokidar@5.0.0)(hono@4.11.7))(@typescript-eslint/types@8.54.0)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular-eslint/template-parser': 21.1.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular/cli': 21.1.3(@types/node@20.19.9)(chokidar@5.0.0)
|
||||
'@angular/cli': 21.1.2(@types/node@20.19.9)(chokidar@5.0.0)(hono@4.11.7)
|
||||
'@typescript-eslint/types': 8.54.0
|
||||
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
@@ -16373,10 +16375,9 @@ snapshots:
|
||||
|
||||
exponential-backoff@3.1.3: {}
|
||||
|
||||
express-rate-limit@8.2.1(express@5.2.1):
|
||||
express-rate-limit@7.5.1(express@5.2.1):
|
||||
dependencies:
|
||||
express: 5.2.1
|
||||
ip-address: 10.0.1
|
||||
|
||||
express@4.22.1:
|
||||
dependencies:
|
||||
@@ -17146,8 +17147,6 @@ snapshots:
|
||||
hasown: 2.0.2
|
||||
side-channel: 1.1.0
|
||||
|
||||
ip-address@10.0.1: {}
|
||||
|
||||
ip-address@10.1.0: {}
|
||||
|
||||
ip-regex@4.3.0: {}
|
||||
@@ -18294,6 +18293,10 @@ snapshots:
|
||||
dependencies:
|
||||
minipass: 7.1.2
|
||||
|
||||
mnemonist@0.40.0:
|
||||
dependencies:
|
||||
obliterator: 2.0.5
|
||||
|
||||
mnemonist@0.40.3:
|
||||
dependencies:
|
||||
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"
|
||||
|
||||
# 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';
|
||||
|
||||
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 [
|
||||
...nhcarrigan,
|
||||
{
|
||||
ignores: ['**/dist', '**/out-tsc', 'node_modules'],
|
||||
ignores: ['dist', 'out-tsc', 'node_modules'],
|
||||
},
|
||||
...mappedConfigs,
|
||||
// Disable vitest rules for this Jest project
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: './tsconfig.lib.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
files: ['shared-types/test/**/*.spec.ts'],
|
||||
rules: {
|
||||
'vitest/consistent-test-filename': 'off',
|
||||
'vitest/consistent-test-it': 'off',
|
||||
'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',
|
||||
},
|
||||
},
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user