generated from nhcarrigan/template
feat: implement user profiles with achievements and primary badge system (#58)
## Summary This PR implements comprehensive user profile enhancements including: - User profile pages showing stats, badges, social links, and bio - Achievement system with 62 achievements across 5 categories - Primary badge selection allowing users to display their preferred badge - Admin profile editing capabilities ## Changes ### User Profiles (#45) - **Frontend**: User profile pages with stats display - Profile cards showing avatar, display name, username, and bio - Social links section (Website, GitHub, Bluesky, LinkedIn, Twitch, YouTube, Discord) - Stats display (suggestions, accepted suggestions, likes, comments) - Recent achievements section - Badge display - Report button for other users' profiles - **Backend**: Profile API endpoints - Get user profile by username or ID - Profile includes stats, badges, and achievement points ### Achievement System (#48) - **Database**: UserAchievement model for tracking progress - **62 Total Achievements** across 5 categories: - **Suggestions (15)**: First suggestion through ultimate curator - **Likes (12)**: First like through legendary fan - **Comments (12)**: First comment through review legend - **Engagement (15)**: Login streaks and activity milestones - **Reports (8)**: Valid reports and accuracy tracking - **Backend**: AchievementService with real-time checking - Integrated into all user interaction points - API endpoints for achievement data - Progress tracking to avoid recalculation - **Frontend**: Achievements page and profile integration - Full achievements page with category filtering - Tier-based styling (Bronze, Silver, Gold, Platinum, Diamond) - Progress indicators for in-progress achievements - Recent achievements on profile pages ### Primary Badge System (#49) - **Database**: Add primaryBadge field to User model - **Backend**: Update profile endpoints to include primary badge - **Frontend**: Primary badge selection in settings - Only shows badges the user has earned - Displayed on profile page - Displayed in comments (next to username) - Falls back to no badge if selection is invalid - **Admin Features**: Admin can edit any user's primary badge ### Admin Enhancements - Comprehensive profile editing modal for admins - Edit display name, bio, slug, social links - Set primary badge for users - Visual feedback for save/error states - Admin action buttons in report review modals - Ban user, delete comment, edit profile - Integrated with report workflow ### Quality Improvements - Improved dropdown option contrast for readability - Hide all badges when no primary badge is selected - "View All" achievements link only shown on own profile - Improved achievement text readability ## Testing - ✅ User profiles display correctly with stats and badges - ✅ Achievement checking works for all interaction types - ✅ Primary badge selection persists and displays correctly - ✅ Admin profile editing saves successfully - ✅ Report workflow integrated with admin actions - ✅ Achievements page shows all 62 achievements with filtering - ✅ Text readability improved across components Closes #45 Closes #48 Closes #49 Co-authored-by: Hikari <hikari@nhcarrigan.com> Reviewed-on: #58 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #58.
This commit is contained in:
+127
-21
@@ -176,46 +176,77 @@ enum MangaStatus {
|
|||||||
RETIRED
|
RETIRED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum PrimaryBadge {
|
||||||
|
STAFF
|
||||||
|
MOD
|
||||||
|
VIP
|
||||||
|
DISCORD
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
discordId String @unique
|
discordId String @unique
|
||||||
username String
|
username String
|
||||||
email String @unique
|
email String @unique
|
||||||
avatar String?
|
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)
|
isAdmin Boolean @default(false)
|
||||||
isBanned Boolean @default(false)
|
isBanned Boolean @default(false)
|
||||||
inDiscord Boolean @default(false)
|
inDiscord Boolean @default(false)
|
||||||
isVip Boolean @default(false)
|
isVip Boolean @default(false)
|
||||||
isMod Boolean @default(false)
|
isMod Boolean @default(false)
|
||||||
isStaff Boolean @default(false)
|
isStaff Boolean @default(false)
|
||||||
|
achievementPoints Int @default(0)
|
||||||
|
currentStreak Int @default(0)
|
||||||
|
lastStreakCheck DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
suggestions Suggestion[]
|
suggestions Suggestion[]
|
||||||
likes Like[]
|
likes Like[]
|
||||||
refreshTokens RefreshToken[]
|
refreshTokens RefreshToken[]
|
||||||
|
reportsMade ProfileReport[] @relation("Reporter")
|
||||||
|
reportsReceived ProfileReport[] @relation("ReportedUser")
|
||||||
|
reportsReviewed ProfileReport[] @relation("Reviewer")
|
||||||
|
commentReportsMade CommentReport[] @relation("CommentReporter")
|
||||||
|
commentReportsReviewed CommentReport[] @relation("CommentReviewer")
|
||||||
|
userAchievements UserAchievement[]
|
||||||
|
|
||||||
|
@@index([slug], map: "User_slug_key")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Comment {
|
model Comment {
|
||||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
content String
|
content String
|
||||||
rawContent String?
|
rawContent String?
|
||||||
userId String @db.ObjectId
|
userId String @db.ObjectId
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
gameId String? @db.ObjectId
|
gameId String? @db.ObjectId
|
||||||
game Game? @relation(fields: [gameId], references: [id])
|
game Game? @relation(fields: [gameId], references: [id])
|
||||||
bookId String? @db.ObjectId
|
bookId String? @db.ObjectId
|
||||||
book Book? @relation(fields: [bookId], references: [id])
|
book Book? @relation(fields: [bookId], references: [id])
|
||||||
musicId String? @db.ObjectId
|
musicId String? @db.ObjectId
|
||||||
music Music? @relation(fields: [musicId], references: [id])
|
music Music? @relation(fields: [musicId], references: [id])
|
||||||
artId String? @db.ObjectId
|
artId String? @db.ObjectId
|
||||||
art Art? @relation(fields: [artId], references: [id])
|
art Art? @relation(fields: [artId], references: [id])
|
||||||
showId String? @db.ObjectId
|
showId String? @db.ObjectId
|
||||||
show Show? @relation(fields: [showId], references: [id])
|
show Show? @relation(fields: [showId], references: [id])
|
||||||
mangaId String? @db.ObjectId
|
mangaId String? @db.ObjectId
|
||||||
manga Manga? @relation(fields: [mangaId], references: [id])
|
manga Manga? @relation(fields: [mangaId], references: [id])
|
||||||
createdAt DateTime @default(now())
|
reports CommentReport[]
|
||||||
updatedAt DateTime @updatedAt
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
model AuditLog {
|
model AuditLog {
|
||||||
@@ -249,6 +280,7 @@ enum AuditAction {
|
|||||||
RATE_LIMIT_EXCEEDED
|
RATE_LIMIT_EXCEEDED
|
||||||
CSRF_VALIDATION_FAILED
|
CSRF_VALIDATION_FAILED
|
||||||
UNAUTHORIZED_ACCESS
|
UNAUTHORIZED_ACCESS
|
||||||
|
ACHIEVEMENT_UNLOCKED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AuditCategory {
|
enum AuditCategory {
|
||||||
@@ -316,3 +348,77 @@ model RefreshToken {
|
|||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([expiresAt])
|
@@index([expiresAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ReportReason {
|
||||||
|
INAPPROPRIATE_CONTENT
|
||||||
|
HARASSMENT
|
||||||
|
SPAM
|
||||||
|
IMPERSONATION
|
||||||
|
OFFENSIVE_NAME
|
||||||
|
MALICIOUS_LINKS
|
||||||
|
OTHER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ReportStatus {
|
||||||
|
PENDING
|
||||||
|
REVIEWED
|
||||||
|
DISMISSED
|
||||||
|
ACTION_TAKEN
|
||||||
|
}
|
||||||
|
|
||||||
|
model ProfileReport {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
reportedUserId String @db.ObjectId
|
||||||
|
reportedUser User @relation("ReportedUser", fields: [reportedUserId], references: [id])
|
||||||
|
reporterId String @db.ObjectId
|
||||||
|
reporter User @relation("Reporter", fields: [reporterId], references: [id])
|
||||||
|
reason ReportReason
|
||||||
|
details String
|
||||||
|
status ReportStatus @default(PENDING)
|
||||||
|
reviewedBy String? @db.ObjectId
|
||||||
|
reviewer User? @relation("Reviewer", fields: [reviewedBy], references: [id])
|
||||||
|
reviewNotes String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([reportedUserId])
|
||||||
|
@@index([reporterId])
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
|
||||||
|
model CommentReport {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
reportedCommentId String @db.ObjectId
|
||||||
|
reportedComment Comment @relation(fields: [reportedCommentId], references: [id], 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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -5,10 +5,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { FastifyPluginAsync } from "fastify";
|
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 { ArtService } from "../../services/art.service";
|
||||||
import { CommentService } from "../../services/comment.service";
|
import { CommentService } from "../../services/comment.service";
|
||||||
import { AuditService } from "../../services/audit.service";
|
import { AuditService } from "../../services/audit.service";
|
||||||
|
import { AchievementService } from "../../services/achievement.service";
|
||||||
import { adminGuard } from "../../middleware/admin-guard";
|
import { adminGuard } from "../../middleware/admin-guard";
|
||||||
import { bannedGuard } from "../../middleware/banned-guard";
|
import { bannedGuard } from "../../middleware/banned-guard";
|
||||||
|
|
||||||
@@ -139,6 +140,15 @@ const artRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
resourceId: id,
|
resourceId: id,
|
||||||
details: `Added comment to art`,
|
details: `Added comment to art`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check for comment achievements
|
||||||
|
const achievementService = new AchievementService();
|
||||||
|
await achievementService.checkAchievements(
|
||||||
|
userId,
|
||||||
|
AchievementCategory.Comment,
|
||||||
|
request
|
||||||
|
);
|
||||||
|
|
||||||
return comment;
|
return comment;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { FastifyPluginAsync } from "fastify";
|
import { FastifyPluginAsync } from "fastify";
|
||||||
import { AuthService } from "../../services/auth.service";
|
import { AuthService } from "../../services/auth.service";
|
||||||
import { AuditService } from "../../services/audit.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 authRoutes: FastifyPluginAsync = async (app) => {
|
||||||
const authService = new AuthService(app);
|
const authService = new AuthService(app);
|
||||||
@@ -92,6 +93,15 @@ const authRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
success: true,
|
success: true,
|
||||||
}, request);
|
}, 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
|
// Set signed cookies and redirect to frontend
|
||||||
reply
|
reply
|
||||||
.setCookie("auth-token", accessToken, {
|
.setCookie("auth-token", accessToken, {
|
||||||
|
|||||||
@@ -5,10 +5,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { FastifyPluginAsync } from "fastify";
|
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 { BookService } from "../../services/book.service";
|
||||||
import { CommentService } from "../../services/comment.service";
|
import { CommentService } from "../../services/comment.service";
|
||||||
import { AuditService } from "../../services/audit.service";
|
import { AuditService } from "../../services/audit.service";
|
||||||
|
import { AchievementService } from "../../services/achievement.service";
|
||||||
import { adminGuard } from "../../middleware/admin-guard";
|
import { adminGuard } from "../../middleware/admin-guard";
|
||||||
import { bannedGuard } from "../../middleware/banned-guard";
|
import { bannedGuard } from "../../middleware/banned-guard";
|
||||||
|
|
||||||
@@ -139,6 +140,15 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
resourceId: id,
|
resourceId: id,
|
||||||
details: `Added comment to book`,
|
details: `Added comment to book`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check for comment achievements
|
||||||
|
const achievementService = new AchievementService();
|
||||||
|
await achievementService.checkAchievements(
|
||||||
|
userId,
|
||||||
|
AchievementCategory.Comment,
|
||||||
|
request
|
||||||
|
);
|
||||||
|
|
||||||
return comment;
|
return comment;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 { 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 { GameService } from "../../services/game.service";
|
||||||
import { CommentService } from "../../services/comment.service";
|
import { CommentService } from "../../services/comment.service";
|
||||||
import { AuditService } from "../../services/audit.service";
|
import { AuditService } from "../../services/audit.service";
|
||||||
|
import { AchievementService } from "../../services/achievement.service";
|
||||||
import { adminGuard } from "../../middleware/admin-guard";
|
import { adminGuard } from "../../middleware/admin-guard";
|
||||||
import { bannedGuard } from "../../middleware/banned-guard";
|
import { bannedGuard } from "../../middleware/banned-guard";
|
||||||
|
|
||||||
@@ -125,6 +126,15 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
resourceId: id,
|
resourceId: id,
|
||||||
details: `Added comment to game`,
|
details: `Added comment to game`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check for comment achievements
|
||||||
|
const achievementService = new AchievementService();
|
||||||
|
await achievementService.checkAchievements(
|
||||||
|
userId,
|
||||||
|
AchievementCategory.Comment,
|
||||||
|
request
|
||||||
|
);
|
||||||
|
|
||||||
return comment;
|
return comment;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ interface LogBody {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function (fastify: FastifyInstance) {
|
export default async function (fastify: FastifyInstance) {
|
||||||
fastify.post('/log', async function (request: FastifyRequest<{ Body: LogBody }>) {
|
fastify.post('/', async function (request: FastifyRequest<{ Body: LogBody }>) {
|
||||||
const { level, message, context, error } = request.body;
|
const { level, message, context, error } = request.body;
|
||||||
|
|
||||||
if (level === 'error' && error) {
|
if (level === 'error' && error) {
|
||||||
@@ -30,9 +30,10 @@ export default async function (fastify: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
await logger.error(context || 'Frontend', errorObj);
|
await logger.error(context || 'Frontend', errorObj);
|
||||||
} else if (level === 'error') {
|
} else if (level === 'error') {
|
||||||
await logger.log('warn', `[Frontend Error] ${message}`);
|
await logger.error('Frontend', new Error(message));
|
||||||
} else {
|
} else {
|
||||||
await logger.log(level, `[Frontend] ${message}`);
|
const logMessage = context ? `[${context}] ${message}` : message;
|
||||||
|
await logger.log(level, logMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|||||||
@@ -5,10 +5,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { FastifyPluginAsync } from "fastify";
|
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 { MangaService } from "../../services/manga.service";
|
||||||
import { CommentService } from "../../services/comment.service";
|
import { CommentService } from "../../services/comment.service";
|
||||||
import { AuditService } from "../../services/audit.service";
|
import { AuditService } from "../../services/audit.service";
|
||||||
|
import { AchievementService } from "../../services/achievement.service";
|
||||||
import { adminGuard } from "../../middleware/admin-guard";
|
import { adminGuard } from "../../middleware/admin-guard";
|
||||||
import { bannedGuard } from "../../middleware/banned-guard";
|
import { bannedGuard } from "../../middleware/banned-guard";
|
||||||
|
|
||||||
@@ -118,6 +119,15 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
resourceId: id,
|
resourceId: id,
|
||||||
details: `Added comment to manga`,
|
details: `Added comment to manga`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check for comment achievements
|
||||||
|
const achievementService = new AchievementService();
|
||||||
|
await achievementService.checkAchievements(
|
||||||
|
userId,
|
||||||
|
AchievementCategory.Comment,
|
||||||
|
request
|
||||||
|
);
|
||||||
|
|
||||||
return comment;
|
return comment;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,10 +5,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { FastifyPluginAsync } from "fastify";
|
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 { MusicService } from "../../services/music.service";
|
||||||
import { CommentService } from "../../services/comment.service";
|
import { CommentService } from "../../services/comment.service";
|
||||||
import { AuditService } from "../../services/audit.service";
|
import { AuditService } from "../../services/audit.service";
|
||||||
|
import { AchievementService } from "../../services/achievement.service";
|
||||||
import { adminGuard } from "../../middleware/admin-guard";
|
import { adminGuard } from "../../middleware/admin-guard";
|
||||||
import { bannedGuard } from "../../middleware/banned-guard";
|
import { bannedGuard } from "../../middleware/banned-guard";
|
||||||
|
|
||||||
@@ -139,6 +140,15 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
resourceId: id,
|
resourceId: id,
|
||||||
details: `Added comment to music`,
|
details: `Added comment to music`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check for comment achievements
|
||||||
|
const achievementService = new AchievementService();
|
||||||
|
await achievementService.checkAchievements(
|
||||||
|
userId,
|
||||||
|
AchievementCategory.Comment,
|
||||||
|
request
|
||||||
|
);
|
||||||
|
|
||||||
return comment;
|
return comment;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -5,10 +5,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { FastifyPluginAsync } from "fastify";
|
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 { ShowService } from "../../services/show.service";
|
||||||
import { CommentService } from "../../services/comment.service";
|
import { CommentService } from "../../services/comment.service";
|
||||||
import { AuditService } from "../../services/audit.service";
|
import { AuditService } from "../../services/audit.service";
|
||||||
|
import { AchievementService } from "../../services/achievement.service";
|
||||||
import { adminGuard } from "../../middleware/admin-guard";
|
import { adminGuard } from "../../middleware/admin-guard";
|
||||||
import { bannedGuard } from "../../middleware/banned-guard";
|
import { bannedGuard } from "../../middleware/banned-guard";
|
||||||
|
|
||||||
@@ -118,6 +119,15 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
resourceId: id,
|
resourceId: id,
|
||||||
details: `Added comment to show`,
|
details: `Added comment to show`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check for comment achievements
|
||||||
|
const achievementService = new AchievementService();
|
||||||
|
await achievementService.checkAchievements(
|
||||||
|
userId,
|
||||||
|
AchievementCategory.Comment,
|
||||||
|
request
|
||||||
|
);
|
||||||
|
|
||||||
return comment;
|
return comment;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { SuggestionService } from "../../services/suggestion.service";
|
import { SuggestionService } from "../../services/suggestion.service";
|
||||||
import { AuditService } from "../../services/audit.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 {
|
import type {
|
||||||
SuggestionStatus,
|
SuggestionStatus,
|
||||||
SuggestionEntity,
|
SuggestionEntity,
|
||||||
@@ -93,6 +94,14 @@ export default async function (app: FastifyInstance): Promise<void> {
|
|||||||
success: true,
|
success: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check for suggestion achievements
|
||||||
|
const achievementService = new AchievementService();
|
||||||
|
await achievementService.checkAchievements(
|
||||||
|
userId,
|
||||||
|
AchievementCategory.Suggestion,
|
||||||
|
request
|
||||||
|
);
|
||||||
|
|
||||||
reply.send(suggestion);
|
reply.send(suggestion);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return reply.badRequest(
|
return reply.badRequest(
|
||||||
@@ -123,6 +132,14 @@ export default async function (app: FastifyInstance): Promise<void> {
|
|||||||
success: true,
|
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);
|
reply.send(suggestion);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return reply.badRequest(
|
return reply.badRequest(
|
||||||
@@ -154,6 +171,14 @@ export default async function (app: FastifyInstance): Promise<void> {
|
|||||||
success: true,
|
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);
|
reply.send(suggestion);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return reply.badRequest(
|
return reply.badRequest(
|
||||||
|
|||||||
@@ -5,11 +5,56 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { FastifyPluginAsync } from "fastify";
|
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 { UserService } from "../../services/user.service";
|
||||||
import { AuditService } from "../../services/audit.service";
|
import { AuditService } from "../../services/audit.service";
|
||||||
import { adminGuard } from "../../middleware/admin-guard";
|
import { adminGuard } from "../../middleware/admin-guard";
|
||||||
|
|
||||||
|
interface UpdateUserSettingsBody {
|
||||||
|
slug?: string;
|
||||||
|
displayName?: string;
|
||||||
|
bio?: string;
|
||||||
|
profilePublic?: boolean;
|
||||||
|
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 usersRoutes: FastifyPluginAsync = async (app) => {
|
||||||
const userService = new UserService();
|
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 }>(
|
app.get<{ Params: { id: string }; Reply: User | null }>(
|
||||||
"/:id",
|
"/:id",
|
||||||
{
|
{
|
||||||
@@ -87,6 +234,64 @@ const usersRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
return user;
|
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;
|
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(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,15 @@ export class AuthService {
|
|||||||
username: dbUser.username,
|
username: dbUser.username,
|
||||||
email: dbUser.email,
|
email: dbUser.email,
|
||||||
avatar: dbUser.avatar || undefined,
|
avatar: dbUser.avatar || undefined,
|
||||||
|
slug: dbUser.slug || undefined,
|
||||||
|
displayName: dbUser.displayName || undefined,
|
||||||
|
bio: dbUser.bio || undefined,
|
||||||
|
profilePublic: dbUser.profilePublic,
|
||||||
|
website: dbUser.website || undefined,
|
||||||
|
discordServer: dbUser.discordServer || undefined,
|
||||||
|
bluesky: dbUser.bluesky || undefined,
|
||||||
|
github: dbUser.github || undefined,
|
||||||
|
linkedin: dbUser.linkedin || undefined,
|
||||||
isAdmin: dbUser.isAdmin,
|
isAdmin: dbUser.isAdmin,
|
||||||
isBanned: dbUser.isBanned,
|
isBanned: dbUser.isBanned,
|
||||||
inDiscord: dbUser.inDiscord,
|
inDiscord: dbUser.inDiscord,
|
||||||
@@ -167,6 +176,15 @@ export class AuthService {
|
|||||||
username: dbUser.username,
|
username: dbUser.username,
|
||||||
email: dbUser.email,
|
email: dbUser.email,
|
||||||
avatar: dbUser.avatar || undefined,
|
avatar: dbUser.avatar || undefined,
|
||||||
|
slug: dbUser.slug || undefined,
|
||||||
|
displayName: dbUser.displayName || undefined,
|
||||||
|
bio: dbUser.bio || undefined,
|
||||||
|
profilePublic: dbUser.profilePublic,
|
||||||
|
website: dbUser.website || undefined,
|
||||||
|
discordServer: dbUser.discordServer || undefined,
|
||||||
|
bluesky: dbUser.bluesky || undefined,
|
||||||
|
github: dbUser.github || undefined,
|
||||||
|
linkedin: dbUser.linkedin || undefined,
|
||||||
isAdmin: dbUser.isAdmin,
|
isAdmin: dbUser.isAdmin,
|
||||||
isBanned: dbUser.isBanned,
|
isBanned: dbUser.isBanned,
|
||||||
inDiscord: dbUser.inDiscord,
|
inDiscord: dbUser.inDiscord,
|
||||||
@@ -217,6 +235,15 @@ export class AuthService {
|
|||||||
username: dbUser.username,
|
username: dbUser.username,
|
||||||
email: dbUser.email,
|
email: dbUser.email,
|
||||||
avatar: dbUser.avatar || undefined,
|
avatar: dbUser.avatar || undefined,
|
||||||
|
slug: dbUser.slug || undefined,
|
||||||
|
displayName: dbUser.displayName || undefined,
|
||||||
|
bio: dbUser.bio || undefined,
|
||||||
|
profilePublic: dbUser.profilePublic,
|
||||||
|
website: dbUser.website || undefined,
|
||||||
|
discordServer: dbUser.discordServer || undefined,
|
||||||
|
bluesky: dbUser.bluesky || undefined,
|
||||||
|
github: dbUser.github || undefined,
|
||||||
|
linkedin: dbUser.linkedin || undefined,
|
||||||
isAdmin: dbUser.isAdmin,
|
isAdmin: dbUser.isAdmin,
|
||||||
isBanned: dbUser.isBanned,
|
isBanned: dbUser.isBanned,
|
||||||
inDiscord: dbUser.inDiscord,
|
inDiscord: dbUser.inDiscord,
|
||||||
|
|||||||
@@ -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
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Comment, CreateCommentDto } from "@library/shared-types";
|
import { Comment, CreateCommentDto, PrimaryBadge } from "@library/shared-types";
|
||||||
import { prisma } from "../lib/prisma";
|
import { prisma } from "../lib/prisma";
|
||||||
import createDOMPurify from "dompurify";
|
import createDOMPurify from "dompurify";
|
||||||
import { JSDOM } from "jsdom";
|
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 {
|
return {
|
||||||
id: comment.id,
|
id: comment.id,
|
||||||
content: comment.content,
|
content: comment.content,
|
||||||
@@ -60,6 +65,7 @@ export class CommentService {
|
|||||||
id: comment.user.id,
|
id: comment.user.id,
|
||||||
username: comment.user.username,
|
username: comment.user.username,
|
||||||
avatar: comment.user.avatar || undefined,
|
avatar: comment.user.avatar || undefined,
|
||||||
|
primaryBadge: (comment.user.primaryBadge as PrimaryBadge) || undefined,
|
||||||
inDiscord: comment.user.inDiscord,
|
inDiscord: comment.user.inDiscord,
|
||||||
isVip: comment.user.isVip,
|
isVip: comment.user.isVip,
|
||||||
isMod: comment.user.isMod,
|
isMod: comment.user.isMod,
|
||||||
@@ -71,6 +77,7 @@ export class CommentService {
|
|||||||
artId: comment.artId || undefined,
|
artId: comment.artId || undefined,
|
||||||
showId: comment.showId || undefined,
|
showId: comment.showId || undefined,
|
||||||
mangaId: comment.mangaId || undefined,
|
mangaId: comment.mangaId || undefined,
|
||||||
|
hasPendingReports,
|
||||||
createdAt: comment.createdAt,
|
createdAt: comment.createdAt,
|
||||||
updatedAt: comment.updatedAt,
|
updatedAt: comment.updatedAt,
|
||||||
};
|
};
|
||||||
@@ -79,28 +86,28 @@ export class CommentService {
|
|||||||
async getCommentsForGame(gameId: string): Promise<Comment[]> {
|
async getCommentsForGame(gameId: string): Promise<Comment[]> {
|
||||||
const comments = await this.prisma.comment.findMany({
|
const comments = await this.prisma.comment.findMany({
|
||||||
where: { gameId },
|
where: { gameId },
|
||||||
include: { user: true },
|
include: { user: true, reports: true },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
return comments.map((c) => this.mapComment(c));
|
return Promise.all(comments.map((c) => this.mapComment(c)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCommentsForBook(bookId: string): Promise<Comment[]> {
|
async getCommentsForBook(bookId: string): Promise<Comment[]> {
|
||||||
const comments = await this.prisma.comment.findMany({
|
const comments = await this.prisma.comment.findMany({
|
||||||
where: { bookId },
|
where: { bookId },
|
||||||
include: { user: true },
|
include: { user: true, reports: true },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
return comments.map((c) => this.mapComment(c));
|
return Promise.all(comments.map((c) => this.mapComment(c)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCommentsForMusic(musicId: string): Promise<Comment[]> {
|
async getCommentsForMusic(musicId: string): Promise<Comment[]> {
|
||||||
const comments = await this.prisma.comment.findMany({
|
const comments = await this.prisma.comment.findMany({
|
||||||
where: { musicId },
|
where: { musicId },
|
||||||
include: { user: true },
|
include: { user: true, reports: true },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
return comments.map((c) => this.mapComment(c));
|
return Promise.all(comments.map((c) => this.mapComment(c)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async createCommentForGame(
|
async createCommentForGame(
|
||||||
@@ -116,7 +123,7 @@ export class CommentService {
|
|||||||
userId,
|
userId,
|
||||||
gameId,
|
gameId,
|
||||||
},
|
},
|
||||||
include: { user: true },
|
include: { user: true, reports: true },
|
||||||
});
|
});
|
||||||
return this.mapComment(comment);
|
return this.mapComment(comment);
|
||||||
}
|
}
|
||||||
@@ -134,7 +141,7 @@ export class CommentService {
|
|||||||
userId,
|
userId,
|
||||||
bookId,
|
bookId,
|
||||||
},
|
},
|
||||||
include: { user: true },
|
include: { user: true, reports: true },
|
||||||
});
|
});
|
||||||
return this.mapComment(comment);
|
return this.mapComment(comment);
|
||||||
}
|
}
|
||||||
@@ -152,7 +159,7 @@ export class CommentService {
|
|||||||
userId,
|
userId,
|
||||||
musicId,
|
musicId,
|
||||||
},
|
},
|
||||||
include: { user: true },
|
include: { user: true, reports: true },
|
||||||
});
|
});
|
||||||
return this.mapComment(comment);
|
return this.mapComment(comment);
|
||||||
}
|
}
|
||||||
@@ -160,10 +167,10 @@ export class CommentService {
|
|||||||
async getCommentsForArt(artId: string): Promise<Comment[]> {
|
async getCommentsForArt(artId: string): Promise<Comment[]> {
|
||||||
const comments = await this.prisma.comment.findMany({
|
const comments = await this.prisma.comment.findMany({
|
||||||
where: { artId },
|
where: { artId },
|
||||||
include: { user: true },
|
include: { user: true, reports: true },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
return comments.map((c) => this.mapComment(c));
|
return Promise.all(comments.map((c) => this.mapComment(c)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async createCommentForArt(
|
async createCommentForArt(
|
||||||
@@ -179,7 +186,7 @@ export class CommentService {
|
|||||||
userId,
|
userId,
|
||||||
artId,
|
artId,
|
||||||
},
|
},
|
||||||
include: { user: true },
|
include: { user: true, reports: true },
|
||||||
});
|
});
|
||||||
return this.mapComment(comment);
|
return this.mapComment(comment);
|
||||||
}
|
}
|
||||||
@@ -187,10 +194,10 @@ export class CommentService {
|
|||||||
async getCommentsForShow(showId: string): Promise<Comment[]> {
|
async getCommentsForShow(showId: string): Promise<Comment[]> {
|
||||||
const comments = await this.prisma.comment.findMany({
|
const comments = await this.prisma.comment.findMany({
|
||||||
where: { showId },
|
where: { showId },
|
||||||
include: { user: true },
|
include: { user: true, reports: true },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
return comments.map((c) => this.mapComment(c));
|
return Promise.all(comments.map((c) => this.mapComment(c)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async createCommentForShow(
|
async createCommentForShow(
|
||||||
@@ -206,7 +213,7 @@ export class CommentService {
|
|||||||
userId,
|
userId,
|
||||||
showId,
|
showId,
|
||||||
},
|
},
|
||||||
include: { user: true },
|
include: { user: true, reports: true },
|
||||||
});
|
});
|
||||||
return this.mapComment(comment);
|
return this.mapComment(comment);
|
||||||
}
|
}
|
||||||
@@ -214,10 +221,10 @@ export class CommentService {
|
|||||||
async getCommentsForManga(mangaId: string): Promise<Comment[]> {
|
async getCommentsForManga(mangaId: string): Promise<Comment[]> {
|
||||||
const comments = await this.prisma.comment.findMany({
|
const comments = await this.prisma.comment.findMany({
|
||||||
where: { mangaId },
|
where: { mangaId },
|
||||||
include: { user: true },
|
include: { user: true, reports: true },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
return comments.map((c) => this.mapComment(c));
|
return Promise.all(comments.map((c) => this.mapComment(c)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async createCommentForManga(
|
async createCommentForManga(
|
||||||
@@ -233,7 +240,7 @@ export class CommentService {
|
|||||||
userId,
|
userId,
|
||||||
mangaId,
|
mangaId,
|
||||||
},
|
},
|
||||||
include: { user: true },
|
include: { user: true, reports: true },
|
||||||
});
|
});
|
||||||
return this.mapComment(comment);
|
return this.mapComment(comment);
|
||||||
}
|
}
|
||||||
@@ -256,7 +263,7 @@ export class CommentService {
|
|||||||
content: sanitizedContent,
|
content: sanitizedContent,
|
||||||
rawContent: content,
|
rawContent: content,
|
||||||
},
|
},
|
||||||
include: { user: true },
|
include: { user: true, reports: true },
|
||||||
});
|
});
|
||||||
return this.mapComment(comment);
|
return this.mapComment(comment);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,9 @@
|
|||||||
import type { FastifyRequest } from 'fastify';
|
import type { FastifyRequest } from 'fastify';
|
||||||
import { prisma } from '../lib/prisma';
|
import { prisma } from '../lib/prisma';
|
||||||
import { AuditService } from './audit.service';
|
import { AuditService } from './audit.service';
|
||||||
|
import { AchievementService } from './achievement.service';
|
||||||
import type { Like, LikeCountDto, LikedItemDto, LikeResponse } from '@library/shared-types';
|
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 {
|
export class LikeService {
|
||||||
async toggleLike(userId: string, entityType: Like['entityType'], entityId: string, req: FastifyRequest): Promise<LikeResponse> {
|
async toggleLike(userId: string, entityType: Like['entityType'], entityId: string, req: FastifyRequest): Promise<LikeResponse> {
|
||||||
@@ -59,6 +60,14 @@ export class LikeService {
|
|||||||
details: `Liked ${entityType}`
|
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);
|
const count = await this.getLikeCount(entityType, entityId);
|
||||||
return { liked: true, count };
|
return { liked: true, count };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,9 @@
|
|||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { User } from "@library/shared-types";
|
import { User, PrimaryBadge } from "@library/shared-types";
|
||||||
import { prisma } from "../lib/prisma";
|
import { prisma } from "../lib/prisma";
|
||||||
|
import { SuggestionStatus } from "@prisma/client";
|
||||||
|
|
||||||
export class UserService {
|
export class UserService {
|
||||||
private prisma = prisma;
|
private prisma = prisma;
|
||||||
@@ -21,6 +22,18 @@ export class UserService {
|
|||||||
username: user.username,
|
username: user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
avatar: user.avatar || undefined,
|
avatar: user.avatar || undefined,
|
||||||
|
slug: user.slug || undefined,
|
||||||
|
displayName: user.displayName || undefined,
|
||||||
|
bio: user.bio || undefined,
|
||||||
|
profilePublic: user.profilePublic,
|
||||||
|
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,
|
isAdmin: user.isAdmin,
|
||||||
isBanned: user.isBanned,
|
isBanned: user.isBanned,
|
||||||
inDiscord: user.inDiscord,
|
inDiscord: user.inDiscord,
|
||||||
@@ -45,6 +58,18 @@ export class UserService {
|
|||||||
username: user.username,
|
username: user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
avatar: user.avatar || undefined,
|
avatar: user.avatar || undefined,
|
||||||
|
slug: user.slug || undefined,
|
||||||
|
displayName: user.displayName || undefined,
|
||||||
|
bio: user.bio || undefined,
|
||||||
|
profilePublic: user.profilePublic,
|
||||||
|
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,
|
isAdmin: user.isAdmin,
|
||||||
isBanned: user.isBanned,
|
isBanned: user.isBanned,
|
||||||
inDiscord: user.inDiscord,
|
inDiscord: user.inDiscord,
|
||||||
@@ -66,6 +91,18 @@ export class UserService {
|
|||||||
username: user.username,
|
username: user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
avatar: user.avatar || undefined,
|
avatar: user.avatar || undefined,
|
||||||
|
slug: user.slug || undefined,
|
||||||
|
displayName: user.displayName || undefined,
|
||||||
|
bio: user.bio || undefined,
|
||||||
|
profilePublic: user.profilePublic,
|
||||||
|
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,
|
isAdmin: user.isAdmin,
|
||||||
isBanned: user.isBanned,
|
isBanned: user.isBanned,
|
||||||
inDiscord: user.inDiscord,
|
inDiscord: user.inDiscord,
|
||||||
@@ -87,6 +124,18 @@ export class UserService {
|
|||||||
username: user.username,
|
username: user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
avatar: user.avatar || undefined,
|
avatar: user.avatar || undefined,
|
||||||
|
slug: user.slug || undefined,
|
||||||
|
displayName: user.displayName || undefined,
|
||||||
|
bio: user.bio || undefined,
|
||||||
|
profilePublic: user.profilePublic,
|
||||||
|
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,
|
isAdmin: user.isAdmin,
|
||||||
isBanned: user.isBanned,
|
isBanned: user.isBanned,
|
||||||
inDiscord: user.inDiscord,
|
inDiscord: user.inDiscord,
|
||||||
@@ -104,4 +153,179 @@ export class UserService {
|
|||||||
|
|
||||||
return user?.isBanned ?? false;
|
return user?.isBanned ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getUserBySlug(slug: string): Promise<User | null> {
|
||||||
|
const user = await this.prisma.user.findFirst({
|
||||||
|
where: { slug },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
discordId: user.discordId,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
avatar: user.avatar || undefined,
|
||||||
|
slug: user.slug || undefined,
|
||||||
|
displayName: user.displayName || undefined,
|
||||||
|
bio: user.bio || undefined,
|
||||||
|
profilePublic: user.profilePublic,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,4 +4,9 @@
|
|||||||
* @license Naomi's Public License
|
* @license Naomi's Public License
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const getGreeting = (): Cypress.Chainable => cy.get("h1");
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export const getGreeting = (): Cypress.Chainable => {
|
||||||
|
return cy.get("h1");
|
||||||
|
};
|
||||||
|
|||||||
@@ -13,14 +13,14 @@
|
|||||||
*
|
*
|
||||||
* For more comprehensive examples of custom
|
* For more comprehensive examples of custom
|
||||||
* commands please read more here:
|
* commands please read more here:
|
||||||
* https://on.cypress.io/custom-commands
|
* https://on.cypress.io/custom-commands.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-namespace -- Required for Cypress type extensions
|
// eslint-disable-next-line @typescript-eslint/no-namespace -- Required for Cypress type extensions
|
||||||
declare namespace Cypress {
|
declare namespace Cypress {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Subject is required for type definition
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Subject is required for type definition
|
||||||
interface Chainable<Subject> {
|
interface Chainable<Subject> {
|
||||||
login: (email: string, password: string) => void;
|
login: (email: string, password: string)=> void;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,11 +30,17 @@ Cypress.Commands.add("login", (email, password) => {
|
|||||||
console.log("Custom command example: Login", email, password);
|
console.log("Custom command example: Login", email, password);
|
||||||
});
|
});
|
||||||
|
|
||||||
// -- This is a child command --
|
/*
|
||||||
// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
|
* -- This is a child command --
|
||||||
|
* Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
|
||||||
|
*/
|
||||||
|
|
||||||
// -- This is a dual command --
|
/*
|
||||||
// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
|
* -- This is a dual command --
|
||||||
|
* Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
|
||||||
|
*/
|
||||||
|
|
||||||
// -- This will overwrite an existing command --
|
/*
|
||||||
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
|
* -- This will overwrite an existing command --
|
||||||
|
* Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
|
||||||
|
*/
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
* 'supportFile' configuration option.
|
* 'supportFile' configuration option.
|
||||||
*
|
*
|
||||||
* You can read more here:
|
* You can read more here:
|
||||||
* https://on.cypress.io/configuration
|
* https://on.cypress.io/configuration.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Import commands.ts using ES2015 syntax:
|
// Import commands.ts using ES2015 syntax:
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ export const appRoutes: Route[] = [
|
|||||||
path: 'admin/suggestions',
|
path: 'admin/suggestions',
|
||||||
loadComponent: () => import('./components/admin/admin-suggestions.component').then(m => m.AdminSuggestionsComponent)
|
loadComponent: () => import('./components/admin/admin-suggestions.component').then(m => m.AdminSuggestionsComponent)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'admin/reports',
|
||||||
|
loadComponent: () => import('./components/admin-reports/admin-reports.component').then(m => m.AdminReportsComponent)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'my-suggestions',
|
path: 'my-suggestions',
|
||||||
loadComponent: () => import('./components/my-suggestions/my-suggestions.component').then(m => m.MySuggestionsComponent)
|
loadComponent: () => import('./components/my-suggestions/my-suggestions.component').then(m => m.MySuggestionsComponent)
|
||||||
@@ -49,6 +53,18 @@ export const appRoutes: Route[] = [
|
|||||||
path: 'my-likes',
|
path: 'my-likes',
|
||||||
loadComponent: () => import('./components/my-likes/my-likes.component').then(m => m.MyLikesComponent)
|
loadComponent: () => import('./components/my-likes/my-likes.component').then(m => m.MyLikesComponent)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'profile/:identifier',
|
||||||
|
loadComponent: () => import('./components/profile/profile.component').then(m => m.ProfileComponent)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings',
|
||||||
|
loadComponent: () => import('./components/settings/settings.component').then(m => m.SettingsComponent)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'achievements',
|
||||||
|
loadComponent: () => import('./components/achievements/achievements.component').then(m => m.AchievementsComponent)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '**',
|
path: '**',
|
||||||
redirectTo: ''
|
redirectTo: ''
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -14,12 +14,13 @@ import { SanitizeService } from '../../services/sanitize.service';
|
|||||||
import { SuggestionService } from '../../services/suggestion.service';
|
import { SuggestionService } from '../../services/suggestion.service';
|
||||||
import { PaginationComponent } from '../shared/pagination.component';
|
import { PaginationComponent } from '../shared/pagination.component';
|
||||||
import { LikeButtonComponent } from '../shared/like-button.component';
|
import { LikeButtonComponent } from '../shared/like-button.component';
|
||||||
|
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
|
||||||
import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
|
import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-art-gallery',
|
selector: 'app-art-gallery',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
|
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header-section">
|
<div class="header-section">
|
||||||
@@ -468,56 +469,11 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (commentsLoading()[art.id]) {
|
<app-comment-display
|
||||||
<div class="comments-loading">Loading comments...</div>
|
[comments]="getCommentsSignal(art.id)"
|
||||||
} @else {
|
(edit)="handleCommentEdit(art.id, $event)"
|
||||||
@for (comment of comments()[art.id] || []; track comment.id) {
|
(delete)="deleteComment(art.id, $event)"
|
||||||
<div class="comment">
|
/>
|
||||||
<div class="comment-header">
|
|
||||||
@if (comment.user.avatar) {
|
|
||||||
<img [src]="comment.user.avatar" [alt]="comment.user.username" class="comment-avatar">
|
|
||||||
}
|
|
||||||
<span class="comment-author">{{ comment.user.username }}</span>
|
|
||||||
@if (comment.user.inDiscord) {
|
|
||||||
<span class="discord-badge">Discord</span>
|
|
||||||
}
|
|
||||||
@if (comment.user.isVip) {
|
|
||||||
<span class="vip-badge">VIP</span>
|
|
||||||
}
|
|
||||||
@if (comment.user.isMod) {
|
|
||||||
<span class="mod-badge">Mod</span>
|
|
||||||
}
|
|
||||||
@if (comment.user.isStaff) {
|
|
||||||
<span class="staff-badge">Staff</span>
|
|
||||||
}
|
|
||||||
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
|
|
||||||
@if (canEditComment(comment)) {
|
|
||||||
<button (click)="startEditComment(art.id, comment)" class="btn btn-secondary btn-xs">Edit</button>
|
|
||||||
}
|
|
||||||
@if (canDeleteComment(comment)) {
|
|
||||||
<button (click)="deleteComment(art.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@if (editingCommentId() === comment.id) {
|
|
||||||
<div class="comment-edit-form">
|
|
||||||
<textarea
|
|
||||||
[(ngModel)]="editCommentContent"
|
|
||||||
name="editComment"
|
|
||||||
rows="3"
|
|
||||||
></textarea>
|
|
||||||
<div class="comment-edit-actions">
|
|
||||||
<button (click)="saveCommentEdit(art.id, comment.id)" class="btn btn-primary btn-xs">Save</button>
|
|
||||||
<button (click)="cancelCommentEdit()" class="btn btn-secondary btn-xs">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
} @empty {
|
|
||||||
<div class="no-comments">No comments yet. Be the first to comment!</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -1615,4 +1571,21 @@ export class ArtGalleryComponent implements OnInit {
|
|||||||
toggleFilters() {
|
toggleFilters() {
|
||||||
this.showFilters.update(v => !v);
|
this.showFilters.update(v => !v);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleCommentEdit(artId: string, event: { commentId: string; content: string }) {
|
||||||
|
this.commentsService.updateCommentOnArt(artId, event.commentId, event.content).subscribe({
|
||||||
|
next: (updatedComment) => {
|
||||||
|
this.comments.set({
|
||||||
|
...this.comments(),
|
||||||
|
[artId]: (this.comments()[artId] || []).map(c =>
|
||||||
|
c.id === event.commentId ? updatedComment : c
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getCommentsSignal(artId: string) {
|
||||||
|
return signal(this.comments()[artId] || []);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,13 @@ import { SanitizeService } from '../../services/sanitize.service';
|
|||||||
import { SuggestionService } from '../../services/suggestion.service';
|
import { SuggestionService } from '../../services/suggestion.service';
|
||||||
import { PaginationComponent } from '../shared/pagination.component';
|
import { PaginationComponent } from '../shared/pagination.component';
|
||||||
import { LikeButtonComponent } from '../shared/like-button.component';
|
import { LikeButtonComponent } from '../shared/like-button.component';
|
||||||
|
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
|
||||||
import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
|
import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-books-list',
|
selector: 'app-books-list',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
|
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header-section">
|
<div class="header-section">
|
||||||
@@ -655,52 +656,11 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
|||||||
@if (commentsLoading()[book.id]) {
|
@if (commentsLoading()[book.id]) {
|
||||||
<div class="comments-loading">Loading comments...</div>
|
<div class="comments-loading">Loading comments...</div>
|
||||||
} @else {
|
} @else {
|
||||||
@for (comment of comments()[book.id] || []; track comment.id) {
|
<app-comment-display
|
||||||
<div class="comment">
|
[comments]="getCommentsSignal(book.id)"
|
||||||
<div class="comment-header">
|
(edit)="handleCommentEdit(book.id, $event)"
|
||||||
@if (comment.user.avatar) {
|
(delete)="deleteComment(book.id, $event)"
|
||||||
<img [src]="comment.user.avatar" [alt]="comment.user.username" class="comment-avatar">
|
/>
|
||||||
}
|
|
||||||
<span class="comment-author">{{ comment.user.username }}</span>
|
|
||||||
@if (comment.user.inDiscord) {
|
|
||||||
<span class="discord-badge">Discord</span>
|
|
||||||
}
|
|
||||||
@if (comment.user.isVip) {
|
|
||||||
<span class="vip-badge">VIP</span>
|
|
||||||
}
|
|
||||||
@if (comment.user.isMod) {
|
|
||||||
<span class="mod-badge">Mod</span>
|
|
||||||
}
|
|
||||||
@if (comment.user.isStaff) {
|
|
||||||
<span class="staff-badge">Staff</span>
|
|
||||||
}
|
|
||||||
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
|
|
||||||
@if (canEditComment(comment)) {
|
|
||||||
<button (click)="startEditComment(book.id, comment)" class="btn btn-secondary btn-xs">Edit</button>
|
|
||||||
}
|
|
||||||
@if (canDeleteComment(comment)) {
|
|
||||||
<button (click)="deleteComment(book.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@if (editingCommentId() === comment.id) {
|
|
||||||
<div class="comment-edit-form">
|
|
||||||
<textarea
|
|
||||||
[(ngModel)]="editCommentContent"
|
|
||||||
name="editComment"
|
|
||||||
rows="3"
|
|
||||||
></textarea>
|
|
||||||
<div class="comment-edit-actions">
|
|
||||||
<button (click)="saveCommentEdit(book.id, comment.id)" class="btn btn-primary btn-xs">Save</button>
|
|
||||||
<button (click)="cancelCommentEdit()" class="btn btn-secondary btn-xs">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
} @empty {
|
|
||||||
<div class="no-comments">No comments yet. Be the first to comment!</div>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -1982,4 +1942,21 @@ export class BooksListComponent implements OnInit {
|
|||||||
alert('Failed to submit suggestion. Please try again.');
|
alert('Failed to submit suggestion. Please try again.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleCommentEdit(bookId: string, event: { commentId: string; content: string }) {
|
||||||
|
this.commentsService.updateCommentOnBook(bookId, event.commentId, event.content).subscribe({
|
||||||
|
next: (updatedComment) => {
|
||||||
|
this.comments.set({
|
||||||
|
...this.comments(),
|
||||||
|
[bookId]: (this.comments()[bookId] || []).map(c =>
|
||||||
|
c.id === event.commentId ? updatedComment : c
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getCommentsSignal(bookId: string) {
|
||||||
|
return signal(this.comments()[bookId] || []);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,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 { SuggestionService } from '../../services/suggestion.service';
|
||||||
import { PaginationComponent } from '../shared/pagination.component';
|
import { PaginationComponent } from '../shared/pagination.component';
|
||||||
import { LikeButtonComponent } from '../shared/like-button.component';
|
import { LikeButtonComponent } from '../shared/like-button.component';
|
||||||
|
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
|
||||||
import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
|
import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-games-list',
|
selector: 'app-games-list',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
|
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header-section">
|
<div class="header-section">
|
||||||
@@ -608,52 +609,11 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
|||||||
@if (commentsLoading()[game.id]) {
|
@if (commentsLoading()[game.id]) {
|
||||||
<div class="comments-loading">Loading comments...</div>
|
<div class="comments-loading">Loading comments...</div>
|
||||||
} @else {
|
} @else {
|
||||||
@for (comment of comments()[game.id] || []; track comment.id) {
|
<app-comment-display
|
||||||
<div class="comment">
|
[comments]="getCommentsSignal(game.id)"
|
||||||
<div class="comment-header">
|
(edit)="handleCommentEdit(game.id, $event)"
|
||||||
@if (comment.user.avatar) {
|
(delete)="deleteComment(game.id, $event)"
|
||||||
<img [src]="comment.user.avatar" [alt]="comment.user.username" class="comment-avatar">
|
/>
|
||||||
}
|
|
||||||
<span class="comment-author">{{ comment.user.username }}</span>
|
|
||||||
@if (comment.user.inDiscord) {
|
|
||||||
<span class="discord-badge">Discord</span>
|
|
||||||
}
|
|
||||||
@if (comment.user.isVip) {
|
|
||||||
<span class="vip-badge">VIP</span>
|
|
||||||
}
|
|
||||||
@if (comment.user.isMod) {
|
|
||||||
<span class="mod-badge">Mod</span>
|
|
||||||
}
|
|
||||||
@if (comment.user.isStaff) {
|
|
||||||
<span class="staff-badge">Staff</span>
|
|
||||||
}
|
|
||||||
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
|
|
||||||
@if (canEditComment(comment)) {
|
|
||||||
<button (click)="startEditComment(game.id, comment)" class="btn btn-secondary btn-xs">Edit</button>
|
|
||||||
}
|
|
||||||
@if (canDeleteComment(comment)) {
|
|
||||||
<button (click)="deleteComment(game.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@if (editingCommentId() === comment.id) {
|
|
||||||
<div class="comment-edit-form">
|
|
||||||
<textarea
|
|
||||||
[(ngModel)]="editCommentContent"
|
|
||||||
name="editComment"
|
|
||||||
rows="3"
|
|
||||||
></textarea>
|
|
||||||
<div class="comment-edit-actions">
|
|
||||||
<button (click)="saveCommentEdit(game.id, comment.id)" class="btn btn-primary btn-xs">Save</button>
|
|
||||||
<button (click)="cancelCommentEdit()" class="btn btn-secondary btn-xs">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
} @empty {
|
|
||||||
<div class="no-comments">No comments yet. Be the first to comment!</div>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -1739,6 +1699,23 @@ export class GamesListComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleCommentEdit(gameId: string, event: { commentId: string; content: string }) {
|
||||||
|
this.commentsService.updateCommentOnGame(gameId, event.commentId, event.content).subscribe({
|
||||||
|
next: (updatedComment) => {
|
||||||
|
this.comments.set({
|
||||||
|
...this.comments(),
|
||||||
|
[gameId]: (this.comments()[gameId] || []).map(c =>
|
||||||
|
c.id === event.commentId ? updatedComment : c
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getCommentsSignal(gameId: string) {
|
||||||
|
return signal(this.comments()[gameId] || []);
|
||||||
|
}
|
||||||
|
|
||||||
// Suggestion methods
|
// Suggestion methods
|
||||||
toggleSuggestForm() {
|
toggleSuggestForm() {
|
||||||
this.showSuggestForm.update(v => !v);
|
this.showSuggestForm.update(v => !v);
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ import { ApiService } from '../../services/api.service';
|
|||||||
}
|
}
|
||||||
@if (showDropdown()) {
|
@if (showDropdown()) {
|
||||||
<div class="dropdown-menu">
|
<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>
|
||||||
@if (!user.isAdmin) {
|
@if (!user.isAdmin) {
|
||||||
<a routerLink="/my-suggestions" class="dropdown-item" (click)="closeDropdown()">My Suggestions</a>
|
<a routerLink="/my-suggestions" class="dropdown-item" (click)="closeDropdown()">My Suggestions</a>
|
||||||
}
|
}
|
||||||
@@ -58,6 +61,7 @@ import { ApiService } from '../../services/api.service';
|
|||||||
<a routerLink="/admin/users" class="dropdown-item" (click)="closeDropdown()">Users</a>
|
<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/audit" class="dropdown-item" (click)="closeDropdown()">Audit</a>
|
||||||
<a routerLink="/admin/suggestions" class="dropdown-item" (click)="closeDropdown()">Suggestions</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>
|
<button (click)="logout()" class="dropdown-item logout-btn">Logout</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,12 +14,13 @@ import { SanitizeService } from '../../services/sanitize.service';
|
|||||||
import { SuggestionService } from '../../services/suggestion.service';
|
import { SuggestionService } from '../../services/suggestion.service';
|
||||||
import { PaginationComponent } from '../shared/pagination.component';
|
import { PaginationComponent } from '../shared/pagination.component';
|
||||||
import { LikeButtonComponent } from '../shared/like-button.component';
|
import { LikeButtonComponent } from '../shared/like-button.component';
|
||||||
|
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
|
||||||
import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
|
import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-manga-list',
|
selector: 'app-manga-list',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
|
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header-section">
|
<div class="header-section">
|
||||||
@@ -610,56 +611,11 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (commentsLoading()[manga.id]) {
|
<app-comment-display
|
||||||
<div class="comments-loading">Loading comments...</div>
|
[comments]="getCommentsSignal(manga.id)"
|
||||||
} @else {
|
(edit)="handleCommentEdit(manga.id, $event)"
|
||||||
@for (comment of comments()[manga.id] || []; track comment.id) {
|
(delete)="deleteComment(manga.id, $event)"
|
||||||
<div class="comment">
|
/>
|
||||||
<div class="comment-header">
|
|
||||||
@if (comment.user.avatar) {
|
|
||||||
<img [src]="comment.user.avatar" [alt]="comment.user.username" class="comment-avatar">
|
|
||||||
}
|
|
||||||
<span class="comment-author">{{ comment.user.username }}</span>
|
|
||||||
@if (comment.user.inDiscord) {
|
|
||||||
<span class="discord-badge">Discord</span>
|
|
||||||
}
|
|
||||||
@if (comment.user.isVip) {
|
|
||||||
<span class="vip-badge">VIP</span>
|
|
||||||
}
|
|
||||||
@if (comment.user.isMod) {
|
|
||||||
<span class="mod-badge">Mod</span>
|
|
||||||
}
|
|
||||||
@if (comment.user.isStaff) {
|
|
||||||
<span class="staff-badge">Staff</span>
|
|
||||||
}
|
|
||||||
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
|
|
||||||
@if (canEditComment(comment)) {
|
|
||||||
<button (click)="startEditComment(manga.id, comment)" class="btn btn-secondary btn-xs">Edit</button>
|
|
||||||
}
|
|
||||||
@if (canDeleteComment(comment)) {
|
|
||||||
<button (click)="deleteComment(manga.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@if (editingCommentId() === comment.id) {
|
|
||||||
<div class="comment-edit-form">
|
|
||||||
<textarea
|
|
||||||
[(ngModel)]="editCommentContent"
|
|
||||||
name="editComment"
|
|
||||||
rows="3"
|
|
||||||
></textarea>
|
|
||||||
<div class="comment-edit-actions">
|
|
||||||
<button (click)="saveCommentEdit(manga.id, comment.id)" class="btn btn-primary btn-xs">Save</button>
|
|
||||||
<button (click)="cancelCommentEdit()" class="btn btn-secondary btn-xs">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
} @empty {
|
|
||||||
<div class="no-comments">No comments yet. Be the first to comment!</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -1780,4 +1736,21 @@ export class MangaListComponent implements OnInit {
|
|||||||
alert('Failed to submit suggestion. Please try again.');
|
alert('Failed to submit suggestion. Please try again.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleCommentEdit(mangaId: string, event: { commentId: string; content: string }) {
|
||||||
|
this.commentsService.updateCommentOnManga(mangaId, event.commentId, event.content).subscribe({
|
||||||
|
next: (updatedComment) => {
|
||||||
|
this.comments.set({
|
||||||
|
...this.comments(),
|
||||||
|
[mangaId]: (this.comments()[mangaId] || []).map(c =>
|
||||||
|
c.id === event.commentId ? updatedComment : c
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getCommentsSignal(mangaId: string) {
|
||||||
|
return signal(this.comments()[mangaId] || []);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,13 @@ import { SanitizeService } from '../../services/sanitize.service';
|
|||||||
import { SuggestionService } from '../../services/suggestion.service';
|
import { SuggestionService } from '../../services/suggestion.service';
|
||||||
import { PaginationComponent } from '../shared/pagination.component';
|
import { PaginationComponent } from '../shared/pagination.component';
|
||||||
import { LikeButtonComponent } from '../shared/like-button.component';
|
import { LikeButtonComponent } from '../shared/like-button.component';
|
||||||
|
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
|
||||||
import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
|
import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-music-list',
|
selector: 'app-music-list',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
|
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header-section">
|
<div class="header-section">
|
||||||
@@ -686,56 +687,11 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (commentsLoading()[music.id]) {
|
<app-comment-display
|
||||||
<div class="comments-loading">Loading comments...</div>
|
[comments]="getCommentsSignal(music.id)"
|
||||||
} @else {
|
(edit)="handleCommentEdit(music.id, $event)"
|
||||||
@for (comment of comments()[music.id] || []; track comment.id) {
|
(delete)="deleteComment(music.id, $event)"
|
||||||
<div class="comment">
|
/>
|
||||||
<div class="comment-header">
|
|
||||||
@if (comment.user.avatar) {
|
|
||||||
<img [src]="comment.user.avatar" [alt]="comment.user.username" class="comment-avatar">
|
|
||||||
}
|
|
||||||
<span class="comment-author">{{ comment.user.username }}</span>
|
|
||||||
@if (comment.user.inDiscord) {
|
|
||||||
<span class="discord-badge">Discord</span>
|
|
||||||
}
|
|
||||||
@if (comment.user.isVip) {
|
|
||||||
<span class="vip-badge">VIP</span>
|
|
||||||
}
|
|
||||||
@if (comment.user.isMod) {
|
|
||||||
<span class="mod-badge">Mod</span>
|
|
||||||
}
|
|
||||||
@if (comment.user.isStaff) {
|
|
||||||
<span class="staff-badge">Staff</span>
|
|
||||||
}
|
|
||||||
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
|
|
||||||
@if (canEditComment(comment)) {
|
|
||||||
<button (click)="startEditComment(music.id, comment)" class="btn btn-secondary btn-xs">Edit</button>
|
|
||||||
}
|
|
||||||
@if (canDeleteComment(comment)) {
|
|
||||||
<button (click)="deleteComment(music.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@if (editingCommentId() === comment.id) {
|
|
||||||
<div class="comment-edit-form">
|
|
||||||
<textarea
|
|
||||||
[(ngModel)]="editCommentContent"
|
|
||||||
name="editComment"
|
|
||||||
rows="3"
|
|
||||||
></textarea>
|
|
||||||
<div class="comment-edit-actions">
|
|
||||||
<button (click)="saveCommentEdit(music.id, comment.id)" class="btn btn-primary btn-xs">Save</button>
|
|
||||||
<button (click)="cancelCommentEdit()" class="btn btn-secondary btn-xs">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
} @empty {
|
|
||||||
<div class="no-comments">No comments yet. Be the first to comment!</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -2014,4 +1970,21 @@ export class MusicListComponent implements OnInit {
|
|||||||
alert('Failed to submit suggestion. Please try again.');
|
alert('Failed to submit suggestion. Please try again.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleCommentEdit(musicId: string, event: { commentId: string; content: string }) {
|
||||||
|
this.commentsService.updateCommentOnMusic(musicId, event.commentId, event.content).subscribe({
|
||||||
|
next: (updatedComment) => {
|
||||||
|
this.comments.set({
|
||||||
|
...this.comments(),
|
||||||
|
[musicId]: (this.comments()[musicId] || []).map(c =>
|
||||||
|
c.id === event.commentId ? updatedComment : c
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getCommentsSignal(musicId: string) {
|
||||||
|
return signal(this.comments()[musicId] || []);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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 { SuggestionService } from '../../services/suggestion.service';
|
||||||
import { PaginationComponent } from '../shared/pagination.component';
|
import { PaginationComponent } from '../shared/pagination.component';
|
||||||
import { LikeButtonComponent } from '../shared/like-button.component';
|
import { LikeButtonComponent } from '../shared/like-button.component';
|
||||||
|
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
|
||||||
import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
|
import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-shows-list',
|
selector: 'app-shows-list',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
|
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header-section">
|
<div class="header-section">
|
||||||
@@ -604,56 +605,11 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (commentsLoading()[show.id]) {
|
<app-comment-display
|
||||||
<div class="comments-loading">Loading comments...</div>
|
[comments]="getCommentsSignal(show.id)"
|
||||||
} @else {
|
(edit)="handleCommentEdit(show.id, $event)"
|
||||||
@for (comment of comments()[show.id] || []; track comment.id) {
|
(delete)="deleteComment(show.id, $event)"
|
||||||
<div class="comment">
|
/>
|
||||||
<div class="comment-header">
|
|
||||||
@if (comment.user.avatar) {
|
|
||||||
<img [src]="comment.user.avatar" [alt]="comment.user.username" class="comment-avatar">
|
|
||||||
}
|
|
||||||
<span class="comment-author">{{ comment.user.username }}</span>
|
|
||||||
@if (comment.user.inDiscord) {
|
|
||||||
<span class="discord-badge">Discord</span>
|
|
||||||
}
|
|
||||||
@if (comment.user.isVip) {
|
|
||||||
<span class="vip-badge">VIP</span>
|
|
||||||
}
|
|
||||||
@if (comment.user.isMod) {
|
|
||||||
<span class="mod-badge">Mod</span>
|
|
||||||
}
|
|
||||||
@if (comment.user.isStaff) {
|
|
||||||
<span class="staff-badge">Staff</span>
|
|
||||||
}
|
|
||||||
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
|
|
||||||
@if (canEditComment(comment)) {
|
|
||||||
<button (click)="startEditComment(show.id, comment)" class="btn btn-secondary btn-xs">Edit</button>
|
|
||||||
}
|
|
||||||
@if (canDeleteComment(comment)) {
|
|
||||||
<button (click)="deleteComment(show.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@if (editingCommentId() === comment.id) {
|
|
||||||
<div class="comment-edit-form">
|
|
||||||
<textarea
|
|
||||||
[(ngModel)]="editCommentContent"
|
|
||||||
name="editComment"
|
|
||||||
rows="3"
|
|
||||||
></textarea>
|
|
||||||
<div class="comment-edit-actions">
|
|
||||||
<button (click)="saveCommentEdit(show.id, comment.id)" class="btn btn-primary btn-xs">Save</button>
|
|
||||||
<button (click)="cancelCommentEdit()" class="btn btn-secondary btn-xs">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
} @empty {
|
|
||||||
<div class="no-comments">No comments yet. Be the first to comment!</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -1783,4 +1739,21 @@ export class ShowsListComponent implements OnInit {
|
|||||||
alert('Failed to submit suggestion. Please try again.');
|
alert('Failed to submit suggestion. Please try again.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleCommentEdit(showId: string, event: { commentId: string; content: string }) {
|
||||||
|
this.commentsService.updateCommentOnShow(showId, event.commentId, event.content).subscribe({
|
||||||
|
next: (updatedComment) => {
|
||||||
|
this.comments.set({
|
||||||
|
...this.comments(),
|
||||||
|
[showId]: (this.comments()[showId] || []).map(c =>
|
||||||
|
c.id === event.commentId ? updatedComment : c
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getCommentsSignal(showId: string) {
|
||||||
|
return signal(this.comments()[showId] || []);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -123,6 +123,10 @@ export class AuthService {
|
|||||||
return this.user() !== null;
|
return this.user() !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateUser(user: User): void {
|
||||||
|
this.currentUser.set(user);
|
||||||
|
}
|
||||||
|
|
||||||
isAdmin(): boolean {
|
isAdmin(): boolean {
|
||||||
return this.user()?.isAdmin === true;
|
return this.user()?.isAdmin === true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -111,4 +111,13 @@ export class CommentsService {
|
|||||||
updateCommentOnManga(mangaId: string, commentId: string, content: string): Observable<Comment> {
|
updateCommentOnManga(mangaId: string, commentId: string, content: string): Observable<Comment> {
|
||||||
return this.api.put<Comment>(`/manga/${mangaId}/comments/${commentId}`, { content });
|
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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ export { AuthService } from './auth.service';
|
|||||||
export { BooksService } from './books.service';
|
export { BooksService } from './books.service';
|
||||||
export { GamesService } from './games.service';
|
export { GamesService } from './games.service';
|
||||||
export { MusicService } from './music.service';
|
export { MusicService } from './music.service';
|
||||||
|
export { ReportService } from './report.service';
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,53 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { ApiService } from './api.service';
|
import { ApiService } from './api.service';
|
||||||
import { 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({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
@@ -26,4 +72,24 @@ export class UserService {
|
|||||||
unbanUser(userId: string): Observable<User> {
|
unbanUser(userId: string): Observable<User> {
|
||||||
return this.api.post<User>(`/users/${userId}/unban`, {});
|
return this.api.post<User>(`/users/${userId}/unban`, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMe(): Observable<User> {
|
||||||
|
return this.api.get<User>('/users/me');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSettings(settings: UpdateUserSettingsRequest): Observable<User> {
|
||||||
|
return this.api.put<User>('/users/me', settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
getProfile(identifier: string): Observable<UserProfileResponse> {
|
||||||
|
return this.api.get<UserProfileResponse>(`/users/profile/${identifier}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,10 @@
|
|||||||
"@fastify/rate-limit": "10.3.0",
|
"@fastify/rate-limit": "10.3.0",
|
||||||
"@fastify/sensible": "6.0.4",
|
"@fastify/sensible": "6.0.4",
|
||||||
"@fastify/static": "9.0.0",
|
"@fastify/static": "9.0.0",
|
||||||
|
"@fortawesome/angular-fontawesome": "4.0.0",
|
||||||
|
"@fortawesome/fontawesome-svg-core": "7.2.0",
|
||||||
|
"@fortawesome/free-brands-svg-icons": "7.2.0",
|
||||||
|
"@fortawesome/free-solid-svg-icons": "7.2.0",
|
||||||
"@nhcarrigan/logger": "1.1.1",
|
"@nhcarrigan/logger": "1.1.1",
|
||||||
"@prisma/client": "6.19.2",
|
"@prisma/client": "6.19.2",
|
||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
|
|||||||
Generated
+53
@@ -56,6 +56,18 @@ importers:
|
|||||||
'@fastify/static':
|
'@fastify/static':
|
||||||
specifier: 9.0.0
|
specifier: 9.0.0
|
||||||
version: 9.0.0
|
version: 9.0.0
|
||||||
|
'@fortawesome/angular-fontawesome':
|
||||||
|
specifier: 4.0.0
|
||||||
|
version: 4.0.0(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))
|
||||||
|
'@fortawesome/fontawesome-svg-core':
|
||||||
|
specifier: 7.2.0
|
||||||
|
version: 7.2.0
|
||||||
|
'@fortawesome/free-brands-svg-icons':
|
||||||
|
specifier: 7.2.0
|
||||||
|
version: 7.2.0
|
||||||
|
'@fortawesome/free-solid-svg-icons':
|
||||||
|
specifier: 7.2.0
|
||||||
|
version: 7.2.0
|
||||||
'@nhcarrigan/logger':
|
'@nhcarrigan/logger':
|
||||||
specifier: 1.1.1
|
specifier: 1.1.1
|
||||||
version: 1.1.1
|
version: 1.1.1
|
||||||
@@ -1659,6 +1671,27 @@ packages:
|
|||||||
'@fastify/static@9.0.0':
|
'@fastify/static@9.0.0':
|
||||||
resolution: {integrity: sha512-r64H8Woe/vfilg5RTy7lwWlE8ZZcTrc3kebYFMEUBrMqlydhQyoiExQXdYAy2REVpST/G35+stAM8WYp1WGmMA==}
|
resolution: {integrity: sha512-r64H8Woe/vfilg5RTy7lwWlE8ZZcTrc3kebYFMEUBrMqlydhQyoiExQXdYAy2REVpST/G35+stAM8WYp1WGmMA==}
|
||||||
|
|
||||||
|
'@fortawesome/angular-fontawesome@4.0.0':
|
||||||
|
resolution: {integrity: sha512-TCqHqT5ovFY1A4RgMpoBUgS+RX3OVs39+CzHFgzDhbCPAopOa26J748TZJcuZwJAvGAk9tbWeVEmWuLByINAeg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@angular/core': ^21.0.0
|
||||||
|
|
||||||
|
'@fortawesome/fontawesome-common-types@7.2.0':
|
||||||
|
resolution: {integrity: sha512-IpR0bER9FY25p+e7BmFH25MZKEwFHTfRAfhOyJubgiDnoJNsSvJ7nigLraHtp4VOG/cy8D7uiV0dLkHOne5Fhw==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
'@fortawesome/fontawesome-svg-core@7.2.0':
|
||||||
|
resolution: {integrity: sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
'@fortawesome/free-brands-svg-icons@7.2.0':
|
||||||
|
resolution: {integrity: sha512-VNG8xqOip1JuJcC3zsVsKRQ60oXG9+oYNDCosjoU/H9pgYmLTEwWw8pE0jhPz/JWdHeUuK6+NQ3qsM4gIbdbYQ==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
'@fortawesome/free-solid-svg-icons@7.2.0':
|
||||||
|
resolution: {integrity: sha512-YTVITFGN0/24PxzXrwqCgnyd7njDuzp5ZvaCx5nq/jg55kUYd94Nj8UTchBdBofi/L0nwRfjGOg0E41d2u9T1w==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
'@hapi/boom@10.0.1':
|
'@hapi/boom@10.0.1':
|
||||||
resolution: {integrity: sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==}
|
resolution: {integrity: sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==}
|
||||||
|
|
||||||
@@ -11651,6 +11684,26 @@ snapshots:
|
|||||||
fastq: 1.20.1
|
fastq: 1.20.1
|
||||||
glob: 13.0.1
|
glob: 13.0.1
|
||||||
|
|
||||||
|
'@fortawesome/angular-fontawesome@4.0.0(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))':
|
||||||
|
dependencies:
|
||||||
|
'@angular/core': 21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2)
|
||||||
|
'@fortawesome/fontawesome-svg-core': 7.2.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@fortawesome/fontawesome-common-types@7.2.0': {}
|
||||||
|
|
||||||
|
'@fortawesome/fontawesome-svg-core@7.2.0':
|
||||||
|
dependencies:
|
||||||
|
'@fortawesome/fontawesome-common-types': 7.2.0
|
||||||
|
|
||||||
|
'@fortawesome/free-brands-svg-icons@7.2.0':
|
||||||
|
dependencies:
|
||||||
|
'@fortawesome/fontawesome-common-types': 7.2.0
|
||||||
|
|
||||||
|
'@fortawesome/free-solid-svg-icons@7.2.0':
|
||||||
|
dependencies:
|
||||||
|
'@fortawesome/fontawesome-common-types': 7.2.0
|
||||||
|
|
||||||
'@hapi/boom@10.0.1':
|
'@hapi/boom@10.0.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@hapi/hoek': 11.0.7
|
'@hapi/hoek': 11.0.7
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
* @license Naomi's Public License
|
* @license Naomi's Public License
|
||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
export * from "./lib/achievement.constants";
|
||||||
|
export * from "./lib/achievement.types";
|
||||||
export type * from "./lib/art.types";
|
export type * from "./lib/art.types";
|
||||||
export * from "./lib/audit.types";
|
export * from "./lib/audit.types";
|
||||||
export type * from "./lib/auth.types";
|
export * from "./lib/auth.types";
|
||||||
export * from "./lib/book.types";
|
export * from "./lib/book.types";
|
||||||
export type * from "./lib/comment.types";
|
export type * from "./lib/comment.types";
|
||||||
export type * from "./lib/common.types";
|
export type * from "./lib/common.types";
|
||||||
@@ -13,5 +15,6 @@ export * from "./lib/game.types";
|
|||||||
export type * from "./lib/like.types";
|
export type * from "./lib/like.types";
|
||||||
export * from "./lib/manga.types";
|
export * from "./lib/manga.types";
|
||||||
export * from "./lib/music.types";
|
export * from "./lib/music.types";
|
||||||
|
export * from "./lib/report.types";
|
||||||
export * from "./lib/show.types";
|
export * from "./lib/show.types";
|
||||||
export * from "./lib/suggestion.types";
|
export * from "./lib/suggestion.types";
|
||||||
|
|||||||
@@ -0,0 +1,644 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
AchievementCategory,
|
||||||
|
AchievementDefinition,
|
||||||
|
AchievementTier,
|
||||||
|
} from "./achievement.types";
|
||||||
|
|
||||||
|
export const ACHIEVEMENTS: Record<string, AchievementDefinition> = {
|
||||||
|
// ========== SUGGESTION ACHIEVEMENTS (15) ==========
|
||||||
|
suggestion_first_steps: {
|
||||||
|
key: "suggestion_first_steps",
|
||||||
|
title: "First Steps",
|
||||||
|
description: "Submit your first 10 suggestions to the library",
|
||||||
|
category: AchievementCategory.Suggestion,
|
||||||
|
tier: AchievementTier.Bronze,
|
||||||
|
icon: "🌱",
|
||||||
|
points: 50,
|
||||||
|
requirements: { count: 10 },
|
||||||
|
},
|
||||||
|
suggestion_contributor: {
|
||||||
|
key: "suggestion_contributor",
|
||||||
|
title: "Contributor",
|
||||||
|
description: "Submit 50 suggestions to the library",
|
||||||
|
category: AchievementCategory.Suggestion,
|
||||||
|
tier: AchievementTier.Silver,
|
||||||
|
icon: "📝",
|
||||||
|
points: 100,
|
||||||
|
requirements: { count: 50 },
|
||||||
|
},
|
||||||
|
suggestion_dedicated: {
|
||||||
|
key: "suggestion_dedicated",
|
||||||
|
title: "Dedicated",
|
||||||
|
description: "Submit 100 suggestions to the library",
|
||||||
|
category: AchievementCategory.Suggestion,
|
||||||
|
tier: AchievementTier.Gold,
|
||||||
|
icon: "⭐",
|
||||||
|
points: 250,
|
||||||
|
requirements: { count: 100 },
|
||||||
|
},
|
||||||
|
suggestion_master: {
|
||||||
|
key: "suggestion_master",
|
||||||
|
title: "Master Curator",
|
||||||
|
description: "Submit 250 suggestions to the library",
|
||||||
|
category: AchievementCategory.Suggestion,
|
||||||
|
tier: AchievementTier.Platinum,
|
||||||
|
icon: "💎",
|
||||||
|
points: 500,
|
||||||
|
requirements: { count: 250 },
|
||||||
|
},
|
||||||
|
suggestion_legend: {
|
||||||
|
key: "suggestion_legend",
|
||||||
|
title: "Legend",
|
||||||
|
description: "Submit 500 suggestions to the library",
|
||||||
|
category: AchievementCategory.Suggestion,
|
||||||
|
tier: AchievementTier.Diamond,
|
||||||
|
icon: "👑",
|
||||||
|
points: 1000,
|
||||||
|
requirements: { count: 500 },
|
||||||
|
},
|
||||||
|
suggestion_approved: {
|
||||||
|
key: "suggestion_approved",
|
||||||
|
title: "Approved!",
|
||||||
|
description: "Have your first suggestion accepted",
|
||||||
|
category: AchievementCategory.Suggestion,
|
||||||
|
tier: AchievementTier.Bronze,
|
||||||
|
icon: "✅",
|
||||||
|
points: 50,
|
||||||
|
requirements: { count: 1 },
|
||||||
|
},
|
||||||
|
suggestion_quality_5: {
|
||||||
|
key: "suggestion_quality_5",
|
||||||
|
title: "Quality Curator",
|
||||||
|
description: "Have 5 suggestions accepted",
|
||||||
|
category: AchievementCategory.Suggestion,
|
||||||
|
tier: AchievementTier.Silver,
|
||||||
|
icon: "🌟",
|
||||||
|
points: 100,
|
||||||
|
requirements: { count: 5 },
|
||||||
|
},
|
||||||
|
suggestion_quality_10: {
|
||||||
|
key: "suggestion_quality_10",
|
||||||
|
title: "Elite Curator",
|
||||||
|
description: "Have 10 suggestions accepted",
|
||||||
|
category: AchievementCategory.Suggestion,
|
||||||
|
tier: AchievementTier.Gold,
|
||||||
|
icon: "🏆",
|
||||||
|
points: 200,
|
||||||
|
requirements: { count: 10 },
|
||||||
|
},
|
||||||
|
suggestion_quality_25: {
|
||||||
|
key: "suggestion_quality_25",
|
||||||
|
title: "Master Curator",
|
||||||
|
description: "Have 25 suggestions accepted",
|
||||||
|
category: AchievementCategory.Suggestion,
|
||||||
|
tier: AchievementTier.Platinum,
|
||||||
|
icon: "💫",
|
||||||
|
points: 400,
|
||||||
|
requirements: { count: 25 },
|
||||||
|
},
|
||||||
|
suggestion_quality_50: {
|
||||||
|
key: "suggestion_quality_50",
|
||||||
|
title: "Grand Master",
|
||||||
|
description: "Have 50 suggestions accepted",
|
||||||
|
category: AchievementCategory.Suggestion,
|
||||||
|
tier: AchievementTier.Platinum,
|
||||||
|
icon: "🎖️",
|
||||||
|
points: 700,
|
||||||
|
requirements: { count: 50 },
|
||||||
|
},
|
||||||
|
suggestion_quality_100: {
|
||||||
|
key: "suggestion_quality_100",
|
||||||
|
title: "Ultimate Curator",
|
||||||
|
description: "Have 100 suggestions accepted",
|
||||||
|
category: AchievementCategory.Suggestion,
|
||||||
|
tier: AchievementTier.Diamond,
|
||||||
|
icon: "👸",
|
||||||
|
points: 1500,
|
||||||
|
requirements: { count: 100 },
|
||||||
|
},
|
||||||
|
suggestion_acceptance_75: {
|
||||||
|
key: "suggestion_acceptance_75",
|
||||||
|
title: "Quality Over Quantity",
|
||||||
|
description: "Maintain a 75%+ acceptance rate (minimum 20 suggestions)",
|
||||||
|
category: AchievementCategory.Suggestion,
|
||||||
|
tier: AchievementTier.Gold,
|
||||||
|
icon: "🎯",
|
||||||
|
points: 300,
|
||||||
|
requirements: { rate: 0.75, count: 20 },
|
||||||
|
},
|
||||||
|
suggestion_acceptance_100: {
|
||||||
|
key: "suggestion_acceptance_100",
|
||||||
|
title: "Perfect Record",
|
||||||
|
description: "Achieve 100% acceptance rate (minimum 10 suggestions)",
|
||||||
|
category: AchievementCategory.Suggestion,
|
||||||
|
tier: AchievementTier.Platinum,
|
||||||
|
icon: "✨",
|
||||||
|
points: 500,
|
||||||
|
requirements: { rate: 1.0, count: 10 },
|
||||||
|
},
|
||||||
|
suggestion_renaissance: {
|
||||||
|
key: "suggestion_renaissance",
|
||||||
|
title: "Renaissance Person",
|
||||||
|
description: "Submit accepted suggestions across all 6 media types",
|
||||||
|
category: AchievementCategory.Suggestion,
|
||||||
|
tier: AchievementTier.Gold,
|
||||||
|
icon: "🌈",
|
||||||
|
points: 400,
|
||||||
|
requirements: { diversity: true },
|
||||||
|
},
|
||||||
|
suggestion_enthusiast: {
|
||||||
|
key: "suggestion_enthusiast",
|
||||||
|
title: "Suggestion Enthusiast",
|
||||||
|
description: "Submit 5 suggestions in one day",
|
||||||
|
category: AchievementCategory.Suggestion,
|
||||||
|
tier: AchievementTier.Silver,
|
||||||
|
icon: "🔥",
|
||||||
|
points: 150,
|
||||||
|
requirements: { count: 5, dayRange: 1 },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== LIKE ACHIEVEMENTS (12) ==========
|
||||||
|
like_first: {
|
||||||
|
key: "like_first",
|
||||||
|
title: "First Like",
|
||||||
|
description: "Like your first item in the library",
|
||||||
|
category: AchievementCategory.Like,
|
||||||
|
tier: AchievementTier.Bronze,
|
||||||
|
icon: "❤️",
|
||||||
|
points: 25,
|
||||||
|
requirements: { count: 1 },
|
||||||
|
},
|
||||||
|
like_enthusiast: {
|
||||||
|
key: "like_enthusiast",
|
||||||
|
title: "Enthusiast",
|
||||||
|
description: "Like 25 items in the library",
|
||||||
|
category: AchievementCategory.Like,
|
||||||
|
tier: AchievementTier.Bronze,
|
||||||
|
icon: "💕",
|
||||||
|
points: 50,
|
||||||
|
requirements: { count: 25 },
|
||||||
|
},
|
||||||
|
like_fan: {
|
||||||
|
key: "like_fan",
|
||||||
|
title: "Fan",
|
||||||
|
description: "Like 100 items in the library",
|
||||||
|
category: AchievementCategory.Like,
|
||||||
|
tier: AchievementTier.Silver,
|
||||||
|
icon: "💖",
|
||||||
|
points: 100,
|
||||||
|
requirements: { count: 100 },
|
||||||
|
},
|
||||||
|
like_super_fan: {
|
||||||
|
key: "like_super_fan",
|
||||||
|
title: "Super Fan",
|
||||||
|
description: "Like 250 items in the library",
|
||||||
|
category: AchievementCategory.Like,
|
||||||
|
tier: AchievementTier.Gold,
|
||||||
|
icon: "💝",
|
||||||
|
points: 250,
|
||||||
|
requirements: { count: 250 },
|
||||||
|
},
|
||||||
|
like_mega_fan: {
|
||||||
|
key: "like_mega_fan",
|
||||||
|
title: "Mega Fan",
|
||||||
|
description: "Like 500 items in the library",
|
||||||
|
category: AchievementCategory.Like,
|
||||||
|
tier: AchievementTier.Platinum,
|
||||||
|
icon: "💗",
|
||||||
|
points: 500,
|
||||||
|
requirements: { count: 500 },
|
||||||
|
},
|
||||||
|
like_legendary: {
|
||||||
|
key: "like_legendary",
|
||||||
|
title: "Legendary Fan",
|
||||||
|
description: "Like 1000 items in the library",
|
||||||
|
category: AchievementCategory.Like,
|
||||||
|
tier: AchievementTier.Diamond,
|
||||||
|
icon: "💞",
|
||||||
|
points: 1000,
|
||||||
|
requirements: { count: 1000 },
|
||||||
|
},
|
||||||
|
like_book_lover: {
|
||||||
|
key: "like_book_lover",
|
||||||
|
title: "Book Lover",
|
||||||
|
description: "Like 50 books",
|
||||||
|
category: AchievementCategory.Like,
|
||||||
|
tier: AchievementTier.Silver,
|
||||||
|
icon: "📚",
|
||||||
|
points: 100,
|
||||||
|
requirements: { count: 50 },
|
||||||
|
},
|
||||||
|
like_gamer: {
|
||||||
|
key: "like_gamer",
|
||||||
|
title: "Gamer",
|
||||||
|
description: "Like 50 games",
|
||||||
|
category: AchievementCategory.Like,
|
||||||
|
tier: AchievementTier.Silver,
|
||||||
|
icon: "🎮",
|
||||||
|
points: 100,
|
||||||
|
requirements: { count: 50 },
|
||||||
|
},
|
||||||
|
like_cinephile: {
|
||||||
|
key: "like_cinephile",
|
||||||
|
title: "Cinephile",
|
||||||
|
description: "Like 50 shows/films",
|
||||||
|
category: AchievementCategory.Like,
|
||||||
|
tier: AchievementTier.Silver,
|
||||||
|
icon: "🎬",
|
||||||
|
points: 100,
|
||||||
|
requirements: { count: 50 },
|
||||||
|
},
|
||||||
|
like_music: {
|
||||||
|
key: "like_music",
|
||||||
|
title: "Music Enthusiast",
|
||||||
|
description: "Like 50 music albums",
|
||||||
|
category: AchievementCategory.Like,
|
||||||
|
tier: AchievementTier.Silver,
|
||||||
|
icon: "🎵",
|
||||||
|
points: 100,
|
||||||
|
requirements: { count: 50 },
|
||||||
|
},
|
||||||
|
like_diverse: {
|
||||||
|
key: "like_diverse",
|
||||||
|
title: "Diverse Taste",
|
||||||
|
description: "Like items from all 6 media types",
|
||||||
|
category: AchievementCategory.Like,
|
||||||
|
tier: AchievementTier.Gold,
|
||||||
|
icon: "🌍",
|
||||||
|
points: 200,
|
||||||
|
requirements: { diversity: true },
|
||||||
|
},
|
||||||
|
like_binge: {
|
||||||
|
key: "like_binge",
|
||||||
|
title: "Binge Liker",
|
||||||
|
description: "Like 20+ items in one day",
|
||||||
|
category: AchievementCategory.Like,
|
||||||
|
tier: AchievementTier.Silver,
|
||||||
|
icon: "⚡",
|
||||||
|
points: 150,
|
||||||
|
requirements: { count: 20, dayRange: 1 },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== COMMENT ACHIEVEMENTS (12) ==========
|
||||||
|
comment_first: {
|
||||||
|
key: "comment_first",
|
||||||
|
title: "First Thoughts",
|
||||||
|
description: "Leave your first comment in the library",
|
||||||
|
category: AchievementCategory.Comment,
|
||||||
|
tier: AchievementTier.Bronze,
|
||||||
|
icon: "💬",
|
||||||
|
points: 25,
|
||||||
|
requirements: { count: 1 },
|
||||||
|
},
|
||||||
|
comment_reviewer: {
|
||||||
|
key: "comment_reviewer",
|
||||||
|
title: "Reviewer",
|
||||||
|
description: "Leave 10 comments in the library",
|
||||||
|
category: AchievementCategory.Comment,
|
||||||
|
tier: AchievementTier.Bronze,
|
||||||
|
icon: "✍️",
|
||||||
|
points: 50,
|
||||||
|
requirements: { count: 10 },
|
||||||
|
},
|
||||||
|
comment_critic: {
|
||||||
|
key: "comment_critic",
|
||||||
|
title: "Critic",
|
||||||
|
description: "Leave 50 comments in the library",
|
||||||
|
category: AchievementCategory.Comment,
|
||||||
|
tier: AchievementTier.Silver,
|
||||||
|
icon: "🗣️",
|
||||||
|
points: 100,
|
||||||
|
requirements: { count: 50 },
|
||||||
|
},
|
||||||
|
comment_expert: {
|
||||||
|
key: "comment_expert",
|
||||||
|
title: "Expert Critic",
|
||||||
|
description: "Leave 100 comments in the library",
|
||||||
|
category: AchievementCategory.Comment,
|
||||||
|
tier: AchievementTier.Gold,
|
||||||
|
icon: "🎭",
|
||||||
|
points: 250,
|
||||||
|
requirements: { count: 100 },
|
||||||
|
},
|
||||||
|
comment_master: {
|
||||||
|
key: "comment_master",
|
||||||
|
title: "Master Reviewer",
|
||||||
|
description: "Leave 250 comments in the library",
|
||||||
|
category: AchievementCategory.Comment,
|
||||||
|
tier: AchievementTier.Platinum,
|
||||||
|
icon: "📖",
|
||||||
|
points: 500,
|
||||||
|
requirements: { count: 250 },
|
||||||
|
},
|
||||||
|
comment_legend: {
|
||||||
|
key: "comment_legend",
|
||||||
|
title: "Review Legend",
|
||||||
|
description: "Leave 500 comments in the library",
|
||||||
|
category: AchievementCategory.Comment,
|
||||||
|
tier: AchievementTier.Diamond,
|
||||||
|
icon: "🏅",
|
||||||
|
points: 1000,
|
||||||
|
requirements: { count: 500 },
|
||||||
|
},
|
||||||
|
comment_detailed: {
|
||||||
|
key: "comment_detailed",
|
||||||
|
title: "Detailed Critic",
|
||||||
|
description: "Write 10 comments with 500+ characters",
|
||||||
|
category: AchievementCategory.Comment,
|
||||||
|
tier: AchievementTier.Silver,
|
||||||
|
icon: "📝",
|
||||||
|
points: 150,
|
||||||
|
requirements: { count: 10 },
|
||||||
|
},
|
||||||
|
comment_essay: {
|
||||||
|
key: "comment_essay",
|
||||||
|
title: "Essay Writer",
|
||||||
|
description: "Write 5 comments with 1000+ characters",
|
||||||
|
category: AchievementCategory.Comment,
|
||||||
|
tier: AchievementTier.Gold,
|
||||||
|
icon: "📄",
|
||||||
|
points: 300,
|
||||||
|
requirements: { count: 5 },
|
||||||
|
},
|
||||||
|
comment_novel: {
|
||||||
|
key: "comment_novel",
|
||||||
|
title: "Novel Writer",
|
||||||
|
description: "Write 3 comments with 2000+ characters",
|
||||||
|
category: AchievementCategory.Comment,
|
||||||
|
tier: AchievementTier.Platinum,
|
||||||
|
icon: "📚",
|
||||||
|
points: 500,
|
||||||
|
requirements: { count: 3 },
|
||||||
|
},
|
||||||
|
comment_thoughtful: {
|
||||||
|
key: "comment_thoughtful",
|
||||||
|
title: "Thoughtful Reviewer",
|
||||||
|
description: "Comment on 50 different items",
|
||||||
|
category: AchievementCategory.Comment,
|
||||||
|
tier: AchievementTier.Silver,
|
||||||
|
icon: "💭",
|
||||||
|
points: 150,
|
||||||
|
requirements: { uniqueItems: 50 },
|
||||||
|
},
|
||||||
|
comment_diverse: {
|
||||||
|
key: "comment_diverse",
|
||||||
|
title: "Well-Rounded Critic",
|
||||||
|
description: "Comment on all 6 media types",
|
||||||
|
category: AchievementCategory.Comment,
|
||||||
|
tier: AchievementTier.Gold,
|
||||||
|
icon: "🎨",
|
||||||
|
points: 250,
|
||||||
|
requirements: { diversity: true },
|
||||||
|
},
|
||||||
|
comment_first_to_comment: {
|
||||||
|
key: "comment_first_to_comment",
|
||||||
|
title: "Discussion Starter",
|
||||||
|
description: "Be the first to comment on 10 items",
|
||||||
|
category: AchievementCategory.Comment,
|
||||||
|
tier: AchievementTier.Silver,
|
||||||
|
icon: "🗨️",
|
||||||
|
points: 200,
|
||||||
|
requirements: { count: 10 },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== ENGAGEMENT ACHIEVEMENTS (15) ==========
|
||||||
|
engagement_welcome: {
|
||||||
|
key: "engagement_welcome",
|
||||||
|
title: "Welcome!",
|
||||||
|
description: "Complete your profile setup",
|
||||||
|
category: AchievementCategory.Engagement,
|
||||||
|
tier: AchievementTier.Bronze,
|
||||||
|
icon: "👋",
|
||||||
|
points: 25,
|
||||||
|
requirements: {},
|
||||||
|
},
|
||||||
|
engagement_streak_7: {
|
||||||
|
key: "engagement_streak_7",
|
||||||
|
title: "Daily Visitor",
|
||||||
|
description: "Login for 7 days in a row",
|
||||||
|
category: AchievementCategory.Engagement,
|
||||||
|
tier: AchievementTier.Bronze,
|
||||||
|
icon: "🔥",
|
||||||
|
points: 100,
|
||||||
|
requirements: { streak: 7 },
|
||||||
|
},
|
||||||
|
engagement_streak_30: {
|
||||||
|
key: "engagement_streak_30",
|
||||||
|
title: "Dedicated",
|
||||||
|
description: "Login for 30 days in a row",
|
||||||
|
category: AchievementCategory.Engagement,
|
||||||
|
tier: AchievementTier.Silver,
|
||||||
|
icon: "💪",
|
||||||
|
points: 250,
|
||||||
|
requirements: { streak: 30 },
|
||||||
|
},
|
||||||
|
engagement_streak_100: {
|
||||||
|
key: "engagement_streak_100",
|
||||||
|
title: "Committed",
|
||||||
|
description: "Login for 100 days in a row",
|
||||||
|
category: AchievementCategory.Engagement,
|
||||||
|
tier: AchievementTier.Gold,
|
||||||
|
icon: "🏆",
|
||||||
|
points: 500,
|
||||||
|
requirements: { streak: 100 },
|
||||||
|
},
|
||||||
|
engagement_streak_365: {
|
||||||
|
key: "engagement_streak_365",
|
||||||
|
title: "Unstoppable",
|
||||||
|
description: "Login for 365 days in a row",
|
||||||
|
category: AchievementCategory.Engagement,
|
||||||
|
tier: AchievementTier.Diamond,
|
||||||
|
icon: "⚡",
|
||||||
|
points: 2000,
|
||||||
|
requirements: { streak: 365 },
|
||||||
|
},
|
||||||
|
engagement_triple_threat: {
|
||||||
|
key: "engagement_triple_threat",
|
||||||
|
title: "Triple Threat",
|
||||||
|
description: "Submit a suggestion, like an item, and leave a comment in the same day",
|
||||||
|
category: AchievementCategory.Engagement,
|
||||||
|
tier: AchievementTier.Silver,
|
||||||
|
icon: "🎯",
|
||||||
|
points: 200,
|
||||||
|
requirements: { dayRange: 1 },
|
||||||
|
},
|
||||||
|
engagement_power_user: {
|
||||||
|
key: "engagement_power_user",
|
||||||
|
title: "Power User",
|
||||||
|
description: "Achieve Triple Threat 10 times",
|
||||||
|
category: AchievementCategory.Engagement,
|
||||||
|
tier: AchievementTier.Platinum,
|
||||||
|
icon: "💫",
|
||||||
|
points: 750,
|
||||||
|
requirements: { count: 10 },
|
||||||
|
},
|
||||||
|
engagement_early_adopter: {
|
||||||
|
key: "engagement_early_adopter",
|
||||||
|
title: "Early Adopter",
|
||||||
|
description: "Be among the first 10 users to join",
|
||||||
|
category: AchievementCategory.Engagement,
|
||||||
|
tier: AchievementTier.Diamond,
|
||||||
|
icon: "🌟",
|
||||||
|
points: 1000,
|
||||||
|
requirements: {},
|
||||||
|
},
|
||||||
|
engagement_founding_100: {
|
||||||
|
key: "engagement_founding_100",
|
||||||
|
title: "Founding Member",
|
||||||
|
description: "Be among the first 100 users to join",
|
||||||
|
category: AchievementCategory.Engagement,
|
||||||
|
tier: AchievementTier.Gold,
|
||||||
|
icon: "🎖️",
|
||||||
|
points: 500,
|
||||||
|
requirements: {},
|
||||||
|
},
|
||||||
|
engagement_founding_1000: {
|
||||||
|
key: "engagement_founding_1000",
|
||||||
|
title: "Pioneer",
|
||||||
|
description: "Be among the first 1000 users to join",
|
||||||
|
category: AchievementCategory.Engagement,
|
||||||
|
tier: AchievementTier.Silver,
|
||||||
|
icon: "🚀",
|
||||||
|
points: 200,
|
||||||
|
requirements: {},
|
||||||
|
},
|
||||||
|
engagement_veteran_30: {
|
||||||
|
key: "engagement_veteran_30",
|
||||||
|
title: "Veteran",
|
||||||
|
description: "Have an account for 30 days",
|
||||||
|
category: AchievementCategory.Engagement,
|
||||||
|
tier: AchievementTier.Bronze,
|
||||||
|
icon: "⏰",
|
||||||
|
points: 50,
|
||||||
|
requirements: { dayRange: 30 },
|
||||||
|
},
|
||||||
|
engagement_veteran_180: {
|
||||||
|
key: "engagement_veteran_180",
|
||||||
|
title: "Seasoned",
|
||||||
|
description: "Have an account for 6 months",
|
||||||
|
category: AchievementCategory.Engagement,
|
||||||
|
tier: AchievementTier.Silver,
|
||||||
|
icon: "📅",
|
||||||
|
points: 150,
|
||||||
|
requirements: { dayRange: 180 },
|
||||||
|
},
|
||||||
|
engagement_veteran_365: {
|
||||||
|
key: "engagement_veteran_365",
|
||||||
|
title: "Longstanding",
|
||||||
|
description: "Have an account for 1 year",
|
||||||
|
category: AchievementCategory.Engagement,
|
||||||
|
tier: AchievementTier.Gold,
|
||||||
|
icon: "🎂",
|
||||||
|
points: 300,
|
||||||
|
requirements: { dayRange: 365 },
|
||||||
|
},
|
||||||
|
engagement_veteran_730: {
|
||||||
|
key: "engagement_veteran_730",
|
||||||
|
title: "Elder",
|
||||||
|
description: "Have an account for 2 years",
|
||||||
|
category: AchievementCategory.Engagement,
|
||||||
|
tier: AchievementTier.Platinum,
|
||||||
|
icon: "🗓️",
|
||||||
|
points: 600,
|
||||||
|
requirements: { dayRange: 730 },
|
||||||
|
},
|
||||||
|
engagement_veteran_1825: {
|
||||||
|
key: "engagement_veteran_1825",
|
||||||
|
title: "Legend",
|
||||||
|
description: "Have an account for 5 years",
|
||||||
|
category: AchievementCategory.Engagement,
|
||||||
|
tier: AchievementTier.Diamond,
|
||||||
|
icon: "🌠",
|
||||||
|
points: 1500,
|
||||||
|
requirements: { dayRange: 1825 },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== REPORT ACHIEVEMENTS (8) ==========
|
||||||
|
report_watchful: {
|
||||||
|
key: "report_watchful",
|
||||||
|
title: "Watchful Eye",
|
||||||
|
description: "Submit your first valid report (ACTION_TAKEN)",
|
||||||
|
category: AchievementCategory.Report,
|
||||||
|
tier: AchievementTier.Bronze,
|
||||||
|
icon: "👀",
|
||||||
|
points: 50,
|
||||||
|
requirements: { count: 1 },
|
||||||
|
},
|
||||||
|
report_guardian: {
|
||||||
|
key: "report_guardian",
|
||||||
|
title: "Guardian",
|
||||||
|
description: "Have 5 reports result in ACTION_TAKEN",
|
||||||
|
category: AchievementCategory.Report,
|
||||||
|
tier: AchievementTier.Silver,
|
||||||
|
icon: "🛡️",
|
||||||
|
points: 100,
|
||||||
|
requirements: { count: 5 },
|
||||||
|
},
|
||||||
|
report_protector: {
|
||||||
|
key: "report_protector",
|
||||||
|
title: "Protector",
|
||||||
|
description: "Have 10 reports result in ACTION_TAKEN",
|
||||||
|
category: AchievementCategory.Report,
|
||||||
|
tier: AchievementTier.Gold,
|
||||||
|
icon: "⚔️",
|
||||||
|
points: 250,
|
||||||
|
requirements: { count: 10 },
|
||||||
|
},
|
||||||
|
report_vigilant: {
|
||||||
|
key: "report_vigilant",
|
||||||
|
title: "Vigilant Protector",
|
||||||
|
description: "Have 25 reports result in ACTION_TAKEN",
|
||||||
|
category: AchievementCategory.Report,
|
||||||
|
tier: AchievementTier.Platinum,
|
||||||
|
icon: "🔰",
|
||||||
|
points: 500,
|
||||||
|
requirements: { count: 25 },
|
||||||
|
},
|
||||||
|
report_accuracy_80: {
|
||||||
|
key: "report_accuracy_80",
|
||||||
|
title: "Sharp Eye",
|
||||||
|
description: "Maintain 80%+ ACTION_TAKEN rate (minimum 10 reports)",
|
||||||
|
category: AchievementCategory.Report,
|
||||||
|
tier: AchievementTier.Gold,
|
||||||
|
icon: "🎯",
|
||||||
|
points: 300,
|
||||||
|
requirements: { rate: 0.8, count: 10 },
|
||||||
|
},
|
||||||
|
report_accuracy_90: {
|
||||||
|
key: "report_accuracy_90",
|
||||||
|
title: "Eagle Eye",
|
||||||
|
description: "Maintain 90%+ ACTION_TAKEN rate (minimum 10 reports)",
|
||||||
|
category: AchievementCategory.Report,
|
||||||
|
tier: AchievementTier.Platinum,
|
||||||
|
icon: "🦅",
|
||||||
|
points: 500,
|
||||||
|
requirements: { rate: 0.9, count: 10 },
|
||||||
|
},
|
||||||
|
report_consistent: {
|
||||||
|
key: "report_consistent",
|
||||||
|
title: "Consistent Guardian",
|
||||||
|
description: "Submit at least one report weekly for 4 weeks",
|
||||||
|
category: AchievementCategory.Report,
|
||||||
|
tier: AchievementTier.Silver,
|
||||||
|
icon: "📆",
|
||||||
|
points: 200,
|
||||||
|
requirements: { count: 4 },
|
||||||
|
},
|
||||||
|
report_volume: {
|
||||||
|
key: "report_volume",
|
||||||
|
title: "Dedicated Reporter",
|
||||||
|
description: "Submit 50 reports (any status)",
|
||||||
|
category: AchievementCategory.Report,
|
||||||
|
tier: AchievementTier.Silver,
|
||||||
|
icon: "📝",
|
||||||
|
points: 150,
|
||||||
|
requirements: { count: 50 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ACHIEVEMENT_LIST = Object.values(ACHIEVEMENTS);
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
export enum AchievementCategory {
|
||||||
|
Suggestion = "SUGGESTION",
|
||||||
|
Like = "LIKE",
|
||||||
|
Comment = "COMMENT",
|
||||||
|
Engagement = "ENGAGEMENT",
|
||||||
|
Report = "REPORT",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AchievementTier {
|
||||||
|
Bronze = "BRONZE",
|
||||||
|
Silver = "SILVER",
|
||||||
|
Gold = "GOLD",
|
||||||
|
Platinum = "PLATINUM",
|
||||||
|
Diamond = "DIAMOND",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AchievementRequirements {
|
||||||
|
count?: number;
|
||||||
|
rate?: number;
|
||||||
|
streak?: number;
|
||||||
|
diversity?: boolean;
|
||||||
|
uniqueItems?: number;
|
||||||
|
dayRange?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AchievementDefinition {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
category: AchievementCategory;
|
||||||
|
tier: AchievementTier;
|
||||||
|
icon: string;
|
||||||
|
points: number;
|
||||||
|
requirements: AchievementRequirements;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserAchievement {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
achievementKey: string;
|
||||||
|
progress: number;
|
||||||
|
earned: boolean;
|
||||||
|
earnedAt?: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AchievementProgress {
|
||||||
|
definition: AchievementDefinition;
|
||||||
|
progress: number;
|
||||||
|
earned: boolean;
|
||||||
|
earnedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserAchievementSummary {
|
||||||
|
totalPoints: number;
|
||||||
|
totalEarned: number;
|
||||||
|
recentAchievements: AchievementProgress[];
|
||||||
|
progressByCategory: {
|
||||||
|
category: AchievementCategory;
|
||||||
|
earned: number;
|
||||||
|
total: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ enum AuditAction {
|
|||||||
rateLimitExceeded = "RATE_LIMIT_EXCEEDED",
|
rateLimitExceeded = "RATE_LIMIT_EXCEEDED",
|
||||||
csrfValidationFailed = "CSRF_VALIDATION_FAILED",
|
csrfValidationFailed = "CSRF_VALIDATION_FAILED",
|
||||||
unauthorizedAccess = "UNAUTHORIZED_ACCESS",
|
unauthorizedAccess = "UNAUTHORIZED_ACCESS",
|
||||||
|
achievementUnlocked = "ACHIEVEMENT_UNLOCKED",
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AuditCategory {
|
enum AuditCategory {
|
||||||
|
|||||||
@@ -4,18 +4,39 @@
|
|||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/naming-convention -- Prisma enum values use UPPER_CASE */
|
||||||
|
enum PrimaryBadge {
|
||||||
|
STAFF = "STAFF",
|
||||||
|
MOD = "MOD",
|
||||||
|
VIP = "VIP",
|
||||||
|
DISCORD = "DISCORD",
|
||||||
|
}
|
||||||
|
/* eslint-enable @typescript-eslint/naming-convention */
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
username: string;
|
username: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
discordId: string;
|
slug?: string;
|
||||||
isAdmin: boolean;
|
displayName?: string;
|
||||||
isBanned: boolean;
|
bio?: string;
|
||||||
inDiscord: boolean;
|
profilePublic: boolean;
|
||||||
isVip: boolean;
|
primaryBadge?: PrimaryBadge;
|
||||||
isMod: boolean;
|
website?: string;
|
||||||
isStaff: boolean;
|
discordServer?: string;
|
||||||
|
bluesky?: string;
|
||||||
|
github?: string;
|
||||||
|
linkedin?: string;
|
||||||
|
twitch?: string;
|
||||||
|
youtube?: string;
|
||||||
|
discordId: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
isBanned: boolean;
|
||||||
|
inDiscord: boolean;
|
||||||
|
isVip: boolean;
|
||||||
|
isMod: boolean;
|
||||||
|
isStaff: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface JwtPayload {
|
interface JwtPayload {
|
||||||
@@ -36,4 +57,5 @@ interface AuthResponse {
|
|||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { PrimaryBadge };
|
||||||
export type { AuthResponse, JwtPayload, User };
|
export type { AuthResponse, JwtPayload, User };
|
||||||
|
|||||||
@@ -4,30 +4,34 @@
|
|||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { PrimaryBadge } from "./auth.types";
|
||||||
|
|
||||||
interface CommentUser {
|
interface CommentUser {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
inDiscord?: boolean;
|
primaryBadge?: PrimaryBadge;
|
||||||
isVip?: boolean;
|
inDiscord?: boolean;
|
||||||
isMod?: boolean;
|
isVip?: boolean;
|
||||||
isStaff?: boolean;
|
isMod?: boolean;
|
||||||
|
isStaff?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Comment {
|
interface Comment {
|
||||||
id: string;
|
id: string;
|
||||||
content: string;
|
content: string;
|
||||||
rawContent?: string;
|
rawContent?: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
user: CommentUser;
|
user: CommentUser;
|
||||||
gameId?: string;
|
gameId?: string;
|
||||||
bookId?: string;
|
bookId?: string;
|
||||||
musicId?: string;
|
musicId?: string;
|
||||||
artId?: string;
|
artId?: string;
|
||||||
showId?: string;
|
showId?: string;
|
||||||
mangaId?: string;
|
mangaId?: string;
|
||||||
createdAt: Date;
|
hasPendingReports?: boolean;
|
||||||
updatedAt: Date;
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreateCommentDto {
|
interface CreateCommentDto {
|
||||||
|
|||||||
@@ -32,16 +32,16 @@ interface Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface CreateGameDto {
|
interface CreateGameDto {
|
||||||
title: string;
|
title: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
status: GameStatus;
|
status: GameStatus;
|
||||||
dateStarted?: Date;
|
dateStarted?: Date;
|
||||||
dateFinished?: Date;
|
dateFinished?: Date;
|
||||||
rating?: number;
|
rating?: number;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
coverImage?: string;
|
coverImage?: string;
|
||||||
tags?: Array<string>;
|
tags?: Array<string>;
|
||||||
links?: Array<Link>;
|
links?: Array<Link>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdateGameDto extends Partial<CreateGameDto> {
|
interface UpdateGameDto extends Partial<CreateGameDto> {
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
export enum ReportReason {
|
||||||
|
INAPPROPRIATE_CONTENT = "INAPPROPRIATE_CONTENT",
|
||||||
|
HARASSMENT = "HARASSMENT",
|
||||||
|
SPAM = "SPAM",
|
||||||
|
IMPERSONATION = "IMPERSONATION",
|
||||||
|
OFFENSIVE_NAME = "OFFENSIVE_NAME",
|
||||||
|
MALICIOUS_LINKS = "MALICIOUS_LINKS",
|
||||||
|
OTHER = "OTHER",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ReportStatus {
|
||||||
|
PENDING = "PENDING",
|
||||||
|
REVIEWED = "REVIEWED",
|
||||||
|
DISMISSED = "DISMISSED",
|
||||||
|
ACTION_TAKEN = "ACTION_TAKEN",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileReport {
|
||||||
|
id: string;
|
||||||
|
reportedUserId: string;
|
||||||
|
reporterId: string;
|
||||||
|
reason: ReportReason;
|
||||||
|
details: string;
|
||||||
|
status: ReportStatus;
|
||||||
|
reviewedBy?: string;
|
||||||
|
reviewNotes?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileReportWithUsers extends ProfileReport {
|
||||||
|
reportedUser: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName?: string;
|
||||||
|
avatar?: string;
|
||||||
|
};
|
||||||
|
reporter: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName?: string;
|
||||||
|
avatar?: string;
|
||||||
|
};
|
||||||
|
reviewer?: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateReportDto {
|
||||||
|
reportedUserId: string;
|
||||||
|
reason: ReportReason;
|
||||||
|
details: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateReportDto {
|
||||||
|
status: ReportStatus;
|
||||||
|
reviewNotes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentReport {
|
||||||
|
id: string;
|
||||||
|
reportedCommentId: string;
|
||||||
|
reporterId: string;
|
||||||
|
reason: ReportReason;
|
||||||
|
details: string;
|
||||||
|
status: ReportStatus;
|
||||||
|
reviewedBy?: string;
|
||||||
|
reviewNotes?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentReportWithDetails extends CommentReport {
|
||||||
|
reportedComment: {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
rawContent?: string;
|
||||||
|
userId: string;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName?: string;
|
||||||
|
avatar?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
reporter: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName?: string;
|
||||||
|
avatar?: string;
|
||||||
|
};
|
||||||
|
reviewer?: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCommentReportDto {
|
||||||
|
reportedCommentId: string;
|
||||||
|
reason: ReportReason;
|
||||||
|
details: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCommentReportDto {
|
||||||
|
status: ReportStatus;
|
||||||
|
reviewNotes?: string;
|
||||||
|
}
|
||||||
@@ -10,30 +10,32 @@ describe("auth Types", () => {
|
|||||||
describe("user interface", () => {
|
describe("user interface", () => {
|
||||||
it("should accept valid user objects", () => {
|
it("should accept valid user objects", () => {
|
||||||
const userWithAvatar: User = {
|
const userWithAvatar: User = {
|
||||||
avatar: "https://example.com/avatar.png",
|
avatar: "https://example.com/avatar.png",
|
||||||
discordId: "discord123",
|
discordId: "discord123",
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
id: "user123",
|
id: "user123",
|
||||||
inDiscord: true,
|
inDiscord: true,
|
||||||
isAdmin: true,
|
isAdmin: true,
|
||||||
isBanned: false,
|
isBanned: false,
|
||||||
isMod: true,
|
isMod: true,
|
||||||
isStaff: true,
|
isStaff: true,
|
||||||
isVip: false,
|
isVip: false,
|
||||||
username: "testuser",
|
profilePublic: true,
|
||||||
|
username: "testuser",
|
||||||
};
|
};
|
||||||
|
|
||||||
const userWithoutAvatar: User = {
|
const userWithoutAvatar: User = {
|
||||||
discordId: "discord456",
|
discordId: "discord456",
|
||||||
email: "another@example.com",
|
email: "another@example.com",
|
||||||
id: "user456",
|
id: "user456",
|
||||||
inDiscord: false,
|
inDiscord: false,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
isBanned: false,
|
isBanned: false,
|
||||||
isMod: false,
|
isMod: false,
|
||||||
isStaff: false,
|
isStaff: false,
|
||||||
isVip: true,
|
isVip: true,
|
||||||
username: "anotheruser",
|
profilePublic: false,
|
||||||
|
username: "anotheruser",
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(userWithAvatar.avatar).toBe("https://example.com/avatar.png");
|
expect(userWithAvatar.avatar).toBe("https://example.com/avatar.png");
|
||||||
@@ -44,16 +46,17 @@ describe("auth Types", () => {
|
|||||||
|
|
||||||
it("should handle all boolean flags correctly", () => {
|
it("should handle all boolean flags correctly", () => {
|
||||||
const bannedUser: User = {
|
const bannedUser: User = {
|
||||||
discordId: "discord789",
|
discordId: "discord789",
|
||||||
email: "banned@example.com",
|
email: "banned@example.com",
|
||||||
id: "banned123",
|
id: "banned123",
|
||||||
inDiscord: false,
|
inDiscord: false,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
isBanned: true,
|
isBanned: true,
|
||||||
isMod: false,
|
isMod: false,
|
||||||
isStaff: false,
|
isStaff: false,
|
||||||
isVip: false,
|
isVip: false,
|
||||||
username: "banneduser",
|
profilePublic: false,
|
||||||
|
username: "banneduser",
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(bannedUser.isBanned).toBeTruthy();
|
expect(bannedUser.isBanned).toBeTruthy();
|
||||||
@@ -97,17 +100,18 @@ describe("auth Types", () => {
|
|||||||
const authResponse: AuthResponse = {
|
const authResponse: AuthResponse = {
|
||||||
accessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
accessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||||
user: {
|
user: {
|
||||||
avatar: "https://example.com/avatar.png",
|
avatar: "https://example.com/avatar.png",
|
||||||
discordId: "discord123",
|
discordId: "discord123",
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
id: "user123",
|
id: "user123",
|
||||||
inDiscord: true,
|
inDiscord: true,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
isBanned: false,
|
isBanned: false,
|
||||||
isMod: false,
|
isMod: false,
|
||||||
isStaff: false,
|
isStaff: false,
|
||||||
isVip: false,
|
isVip: false,
|
||||||
username: "testuser",
|
profilePublic: true,
|
||||||
|
username: "testuser",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user