generated from nhcarrigan/template
Compare commits
10 Commits
main
...
7f255afce6
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f255afce6 | |||
| 391ea6d9f6 | |||
| 808df775a4 | |||
| b1aab70b44 | |||
| 5ad9b50dc8 | |||
| fa331df203 | |||
| f839059dd2 | |||
| 8f95f57838 | |||
| 5a59afaa5e | |||
| f2797d0479 |
@@ -32,6 +32,9 @@ model Game {
|
||||
coverImage String?
|
||||
tags String[]
|
||||
links Link[]
|
||||
series String?
|
||||
seriesOrder Int? @db.Int
|
||||
timeSpent Int? @db.Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
comments Comment[]
|
||||
@@ -58,6 +61,9 @@ model Book {
|
||||
coverImage String?
|
||||
tags String[]
|
||||
links Link[]
|
||||
series String?
|
||||
seriesOrder Int? @db.Int
|
||||
timeSpent Int? @db.Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
comments Comment[]
|
||||
@@ -85,6 +91,7 @@ model Music {
|
||||
coverArt String?
|
||||
tags String[]
|
||||
links Link[]
|
||||
timeSpent Int? @db.Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
comments Comment[]
|
||||
@@ -131,6 +138,7 @@ model Show {
|
||||
coverImage String?
|
||||
tags String[]
|
||||
links Link[]
|
||||
timeSpent Int? @db.Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
comments Comment[]
|
||||
@@ -164,6 +172,7 @@ model Manga {
|
||||
coverImage String?
|
||||
tags String[]
|
||||
links Link[]
|
||||
timeSpent Int? @db.Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
comments Comment[]
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import type { ActivityFeedResponse } from "@library/shared-types";
|
||||
import { ActivityService } from "../../services/activity.service";
|
||||
|
||||
const activityRoutes: FastifyPluginAsync = async (app) => {
|
||||
const activityService = new ActivityService();
|
||||
|
||||
/**
|
||||
* Get activity feed with optional filters.
|
||||
*/
|
||||
app.get<{
|
||||
Querystring: { limit?: number; offset?: number; userId?: string };
|
||||
Reply: ActivityFeedResponse;
|
||||
}>("/", async (request) => {
|
||||
const limit = request.query.limit && request.query.limit > 0
|
||||
? Math.min(request.query.limit, 100)
|
||||
: 50;
|
||||
const offset = request.query.offset && request.query.offset >= 0
|
||||
? request.query.offset
|
||||
: 0;
|
||||
const userId = request.query.userId;
|
||||
|
||||
return activityService.getActivityFeed(limit, offset, userId);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get activity feed for a specific user.
|
||||
*/
|
||||
app.get<{
|
||||
Params: { userId: string };
|
||||
Querystring: { limit?: number; offset?: number };
|
||||
Reply: ActivityFeedResponse;
|
||||
}>("/:userId", async (request) => {
|
||||
const { userId } = request.params;
|
||||
const limit = request.query.limit && request.query.limit > 0
|
||||
? Math.min(request.query.limit, 100)
|
||||
: 50;
|
||||
const offset = request.query.offset && request.query.offset >= 0
|
||||
? request.query.offset
|
||||
: 0;
|
||||
|
||||
return activityService.getActivityFeed(limit, offset, userId);
|
||||
});
|
||||
};
|
||||
|
||||
export default activityRoutes;
|
||||
@@ -24,6 +24,17 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
|
||||
return bookService.getAllBooks();
|
||||
});
|
||||
|
||||
/**
|
||||
* Get all books in a series (public route).
|
||||
*/
|
||||
app.get<{ Params: { seriesName: string }; Reply: Book[] }>(
|
||||
"/series/:seriesName",
|
||||
async (request) => {
|
||||
const { seriesName } = request.params;
|
||||
return bookService.getBooksBySeries(seriesName);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get single book by ID (public route).
|
||||
*/
|
||||
|
||||
@@ -22,6 +22,15 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
|
||||
return gameService.getAllGames();
|
||||
});
|
||||
|
||||
// Get all games in a series (public route)
|
||||
app.get<{ Params: { seriesName: string }; Reply: Game[] }>(
|
||||
"/series/:seriesName",
|
||||
async (request) => {
|
||||
const { seriesName } = request.params;
|
||||
return gameService.getGamesBySeries(seriesName);
|
||||
}
|
||||
);
|
||||
|
||||
// Get single game (public route)
|
||||
app.get<{ Params: { id: string }; Reply: Game | null }>(
|
||||
"/:id",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import type {
|
||||
LeaderboardResponse,
|
||||
SuggestionsLeaderboard,
|
||||
LikesLeaderboard,
|
||||
CommentsLeaderboard,
|
||||
OverallLeaderboard,
|
||||
} from "@library/shared-types";
|
||||
import { LeaderboardService } from "../../services/leaderboard.service";
|
||||
|
||||
const leaderboardRoutes: FastifyPluginAsync = async (app) => {
|
||||
const leaderboardService = new LeaderboardService();
|
||||
/**
|
||||
* Get all leaderboards at once.
|
||||
*/
|
||||
app.get<{
|
||||
Querystring: { limit?: number };
|
||||
Reply: LeaderboardResponse;
|
||||
}>("/", async (request) => {
|
||||
const limit = request.query.limit && request.query.limit > 0
|
||||
? Math.min(request.query.limit, 100)
|
||||
: 25;
|
||||
return leaderboardService.getAllLeaderboards(limit);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get top users by suggestions.
|
||||
*/
|
||||
app.get<{
|
||||
Querystring: { limit?: number };
|
||||
Reply: SuggestionsLeaderboard[];
|
||||
}>("/suggestions", async (request) => {
|
||||
const limit = request.query.limit && request.query.limit > 0
|
||||
? Math.min(request.query.limit, 100)
|
||||
: 25;
|
||||
return leaderboardService.getTopSuggestions(limit);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get top users by likes.
|
||||
*/
|
||||
app.get<{
|
||||
Querystring: { limit?: number };
|
||||
Reply: LikesLeaderboard[];
|
||||
}>("/likes", async (request) => {
|
||||
const limit = request.query.limit && request.query.limit > 0
|
||||
? Math.min(request.query.limit, 100)
|
||||
: 25;
|
||||
return leaderboardService.getTopLikes(limit);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get top users by comments.
|
||||
*/
|
||||
app.get<{
|
||||
Querystring: { limit?: number };
|
||||
Reply: CommentsLeaderboard[];
|
||||
}>("/comments", async (request) => {
|
||||
const limit = request.query.limit && request.query.limit > 0
|
||||
? Math.min(request.query.limit, 100)
|
||||
: 25;
|
||||
return leaderboardService.getTopComments(limit);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get overall leaderboard.
|
||||
*/
|
||||
app.get<{
|
||||
Querystring: { limit?: number };
|
||||
Reply: OverallLeaderboard[];
|
||||
}>("/overall", async (request) => {
|
||||
const limit = request.query.limit && request.query.limit > 0
|
||||
? Math.min(request.query.limit, 100)
|
||||
: 25;
|
||||
return leaderboardService.getOverallLeaderboard(limit);
|
||||
});
|
||||
};
|
||||
|
||||
export default leaderboardRoutes;
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import type {
|
||||
Activity,
|
||||
ActivityFeedResponse,
|
||||
ActivityUser,
|
||||
} from "@library/shared-types";
|
||||
import { ACHIEVEMENTS, ActivityType } from "@library/shared-types";
|
||||
import { prisma } from "../lib/prisma";
|
||||
|
||||
export class ActivityService {
|
||||
private prisma = prisma;
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Get activity feed with pagination.
|
||||
*/
|
||||
async getActivityFeed(
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
userId?: string
|
||||
): Promise<ActivityFeedResponse> {
|
||||
// Fetch suggestions, likes, comments, and achievements
|
||||
const [suggestions, likes, comments, achievements] = await Promise.all([
|
||||
this.getSuggestionActivities(limit, offset, userId),
|
||||
this.getLikeActivities(limit, offset, userId),
|
||||
this.getCommentActivities(limit, offset, userId),
|
||||
this.getAchievementActivities(limit, offset, userId),
|
||||
]);
|
||||
|
||||
// Combine and sort by createdAt
|
||||
const activities: Activity[] = [
|
||||
...suggestions,
|
||||
...likes,
|
||||
...comments,
|
||||
...achievements,
|
||||
].sort((a, b) => {
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
});
|
||||
|
||||
// Apply pagination to combined results
|
||||
const paginatedActivities = activities.slice(offset, offset + limit);
|
||||
const hasMore = activities.length > offset + limit;
|
||||
|
||||
return {
|
||||
activities: paginatedActivities,
|
||||
total: activities.length,
|
||||
hasMore,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suggestion activities.
|
||||
*/
|
||||
private async getSuggestionActivities(
|
||||
limit: number,
|
||||
offset: number,
|
||||
userId?: string
|
||||
) {
|
||||
const where = userId
|
||||
? { userId, user: { profilePublic: true, isBanned: false } }
|
||||
: { user: { profilePublic: true, isBanned: false } };
|
||||
|
||||
const suggestions = await this.prisma.suggestion.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
slug: true,
|
||||
avatar: true,
|
||||
primaryBadge: true,
|
||||
isVip: true,
|
||||
isMod: true,
|
||||
isStaff: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit * 2, // Get more since we're combining
|
||||
});
|
||||
|
||||
return suggestions.map((suggestion) => ({
|
||||
id: `suggestion-${suggestion.id}`,
|
||||
type: ActivityType.suggestion,
|
||||
user: suggestion.user as ActivityUser,
|
||||
entityType: suggestion.entityType,
|
||||
suggestionTitle: suggestion.title,
|
||||
status: suggestion.status,
|
||||
createdAt: suggestion.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get like activities.
|
||||
*/
|
||||
private async getLikeActivities(
|
||||
limit: number,
|
||||
offset: number,
|
||||
userId?: string
|
||||
) {
|
||||
const where = userId
|
||||
? { userId, user: { profilePublic: true, isBanned: false } }
|
||||
: { user: { profilePublic: true, isBanned: false } };
|
||||
|
||||
const likes = await this.prisma.like.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
slug: true,
|
||||
avatar: true,
|
||||
primaryBadge: true,
|
||||
isVip: true,
|
||||
isMod: true,
|
||||
isStaff: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit * 2,
|
||||
});
|
||||
|
||||
// For each like, fetch the entity title
|
||||
const likesWithTitles = await Promise.all(
|
||||
likes.map(async (like) => {
|
||||
const entityTitle = await this.getEntityTitle(
|
||||
like.entityType,
|
||||
like.entityId
|
||||
);
|
||||
return {
|
||||
id: `like-${like.id}`,
|
||||
type: ActivityType.like,
|
||||
user: like.user as ActivityUser,
|
||||
entityType: like.entityType,
|
||||
entityId: like.entityId,
|
||||
entityTitle,
|
||||
createdAt: like.createdAt,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return likesWithTitles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comment activities.
|
||||
*/
|
||||
private async getCommentActivities(
|
||||
limit: number,
|
||||
offset: number,
|
||||
userId?: string
|
||||
) {
|
||||
const where = userId
|
||||
? { userId, user: { profilePublic: true, isBanned: false } }
|
||||
: { user: { profilePublic: true, isBanned: false } };
|
||||
|
||||
const comments = await this.prisma.comment.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
slug: true,
|
||||
avatar: true,
|
||||
primaryBadge: true,
|
||||
isVip: true,
|
||||
isMod: true,
|
||||
isStaff: true,
|
||||
},
|
||||
},
|
||||
game: { select: { id: true, title: true } },
|
||||
book: { select: { id: true, title: true } },
|
||||
music: { select: { id: true, title: true } },
|
||||
art: { select: { id: true, title: true } },
|
||||
show: { select: { id: true, title: true } },
|
||||
manga: { select: { id: true, title: true } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit * 2,
|
||||
});
|
||||
|
||||
return comments.map((comment) => {
|
||||
let entityType = "";
|
||||
let entityId = "";
|
||||
let entityTitle = "";
|
||||
|
||||
if (comment.game) {
|
||||
entityType = "game";
|
||||
entityId = comment.game.id;
|
||||
entityTitle = comment.game.title;
|
||||
} else if (comment.book) {
|
||||
entityType = "book";
|
||||
entityId = comment.book.id;
|
||||
entityTitle = comment.book.title;
|
||||
} else if (comment.music) {
|
||||
entityType = "music";
|
||||
entityId = comment.music.id;
|
||||
entityTitle = comment.music.title;
|
||||
} else if (comment.art) {
|
||||
entityType = "art";
|
||||
entityId = comment.art.id;
|
||||
entityTitle = comment.art.title;
|
||||
} else if (comment.show) {
|
||||
entityType = "show";
|
||||
entityId = comment.show.id;
|
||||
entityTitle = comment.show.title;
|
||||
} else if (comment.manga) {
|
||||
entityType = "manga";
|
||||
entityId = comment.manga.id;
|
||||
entityTitle = comment.manga.title;
|
||||
}
|
||||
|
||||
// Get first 100 characters of comment
|
||||
const commentPreview =
|
||||
comment.content.length > 100
|
||||
? `${comment.content.slice(0, 100)}...`
|
||||
: comment.content;
|
||||
|
||||
return {
|
||||
id: `comment-${comment.id}`,
|
||||
type: ActivityType.comment,
|
||||
user: comment.user as ActivityUser,
|
||||
entityType,
|
||||
entityId,
|
||||
entityTitle,
|
||||
commentPreview,
|
||||
createdAt: comment.createdAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get achievement activities.
|
||||
*/
|
||||
private async getAchievementActivities(
|
||||
limit: number,
|
||||
offset: number,
|
||||
userId?: string
|
||||
) {
|
||||
const where = userId
|
||||
? {
|
||||
userId,
|
||||
earned: true,
|
||||
user: { profilePublic: true, isBanned: false },
|
||||
}
|
||||
: { earned: true, user: { profilePublic: true, isBanned: false } };
|
||||
|
||||
const userAchievements = await this.prisma.userAchievement.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
slug: true,
|
||||
avatar: true,
|
||||
primaryBadge: true,
|
||||
isVip: true,
|
||||
isMod: true,
|
||||
isStaff: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { earnedAt: "desc" },
|
||||
take: limit * 2,
|
||||
});
|
||||
|
||||
return userAchievements
|
||||
.filter((ua) => ua.earnedAt) // Only show earned achievements
|
||||
.map((ua) => {
|
||||
const achievement = ACHIEVEMENTS[ua.achievementKey];
|
||||
return {
|
||||
id: `achievement-${ua.id}`,
|
||||
type: ActivityType.achievement,
|
||||
user: ua.user as ActivityUser,
|
||||
achievementKey: ua.achievementKey,
|
||||
achievementName: achievement.title,
|
||||
achievementIcon: achievement.icon,
|
||||
achievementPoints: achievement.points,
|
||||
createdAt: ua.earnedAt!,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get entity title by type and ID.
|
||||
*/
|
||||
private async getEntityTitle(
|
||||
entityType: string,
|
||||
entityId: string
|
||||
): Promise<string> {
|
||||
switch (entityType) {
|
||||
case "game": {
|
||||
const game = await this.prisma.game.findUnique({
|
||||
where: { id: entityId },
|
||||
select: { title: true },
|
||||
});
|
||||
return game?.title || "Unknown Game";
|
||||
}
|
||||
case "book": {
|
||||
const book = await this.prisma.book.findUnique({
|
||||
where: { id: entityId },
|
||||
select: { title: true },
|
||||
});
|
||||
return book?.title || "Unknown Book";
|
||||
}
|
||||
case "music": {
|
||||
const music = await this.prisma.music.findUnique({
|
||||
where: { id: entityId },
|
||||
select: { title: true },
|
||||
});
|
||||
return music?.title || "Unknown Music";
|
||||
}
|
||||
case "art": {
|
||||
const art = await this.prisma.art.findUnique({
|
||||
where: { id: entityId },
|
||||
select: { title: true },
|
||||
});
|
||||
return art?.title || "Unknown Art";
|
||||
}
|
||||
case "show": {
|
||||
const show = await this.prisma.show.findUnique({
|
||||
where: { id: entityId },
|
||||
select: { title: true },
|
||||
});
|
||||
return show?.title || "Unknown Show";
|
||||
}
|
||||
case "manga": {
|
||||
const manga = await this.prisma.manga.findUnique({
|
||||
where: { id: entityId },
|
||||
select: { title: true },
|
||||
});
|
||||
return manga?.title || "Unknown Manga";
|
||||
}
|
||||
default:
|
||||
return "Unknown Item";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,28 @@ export class BookService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all books in a series, ordered by seriesOrder.
|
||||
*/
|
||||
async getBooksBySeries(seriesName: string): Promise<Book[]> {
|
||||
const books = await this.prisma.book.findMany({
|
||||
where: { series: seriesName },
|
||||
orderBy: { seriesOrder: "asc" },
|
||||
});
|
||||
|
||||
return books.map((book) => ({
|
||||
...book,
|
||||
status: book.status as unknown as BookStatus,
|
||||
dateAdded: book.dateAdded,
|
||||
dateStarted: book.dateStarted || undefined,
|
||||
dateFinished: book.dateFinished || undefined,
|
||||
tags: book.tags ?? [],
|
||||
links: book.links ?? [],
|
||||
createdAt: book.createdAt,
|
||||
updatedAt: book.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new book.
|
||||
*/
|
||||
|
||||
@@ -58,6 +58,29 @@ export class GameService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all games in a series, ordered by seriesOrder.
|
||||
*/
|
||||
async getGamesBySeries(seriesName: string): Promise<Game[]> {
|
||||
const games = await this.prisma.game.findMany({
|
||||
where: { series: seriesName },
|
||||
orderBy: { seriesOrder: "asc" },
|
||||
});
|
||||
|
||||
return games.map((game) => ({
|
||||
...game,
|
||||
status: game.status as unknown as GameStatus,
|
||||
dateAdded: game.dateAdded,
|
||||
dateStarted: game.dateStarted || undefined,
|
||||
dateCompleted: game.dateCompleted || undefined,
|
||||
dateFinished: game.dateFinished || undefined,
|
||||
tags: game.tags ?? [],
|
||||
links: game.links ?? [],
|
||||
createdAt: game.createdAt,
|
||||
updatedAt: game.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new game.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import type {
|
||||
LeaderboardResponse,
|
||||
SuggestionsLeaderboard,
|
||||
LikesLeaderboard,
|
||||
CommentsLeaderboard,
|
||||
OverallLeaderboard,
|
||||
} from "@library/shared-types";
|
||||
import { prisma } from "../lib/prisma";
|
||||
|
||||
export class LeaderboardService {
|
||||
private prisma = prisma;
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Get top users by suggestions submitted and accepted.
|
||||
*/
|
||||
async getTopSuggestions(limit = 25): Promise<SuggestionsLeaderboard[]> {
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: { profilePublic: true, isBanned: false },
|
||||
include: {
|
||||
suggestions: {
|
||||
select: {
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const leaderboard = users
|
||||
.map((user) => {
|
||||
const totalSuggestions = user.suggestions.length;
|
||||
const acceptedSuggestions = user.suggestions.filter(
|
||||
(s) => s.status === "ACCEPTED"
|
||||
).length;
|
||||
const acceptanceRate =
|
||||
totalSuggestions > 0
|
||||
? Math.round((acceptedSuggestions / totalSuggestions) * 100)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
slug: user.slug,
|
||||
avatar: user.avatar,
|
||||
primaryBadge: user.primaryBadge,
|
||||
isVip: user.isVip,
|
||||
isMod: user.isMod,
|
||||
isStaff: user.isStaff,
|
||||
createdAt: user.createdAt,
|
||||
totalSuggestions,
|
||||
acceptedSuggestions,
|
||||
acceptanceRate,
|
||||
};
|
||||
})
|
||||
.filter((user) => user.totalSuggestions > 0)
|
||||
.sort((a, b) => {
|
||||
if (b.totalSuggestions !== a.totalSuggestions) {
|
||||
return b.totalSuggestions - a.totalSuggestions;
|
||||
}
|
||||
return b.acceptanceRate - a.acceptanceRate;
|
||||
})
|
||||
.slice(0, limit);
|
||||
|
||||
return leaderboard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top users by likes given.
|
||||
*/
|
||||
async getTopLikes(limit = 25): Promise<LikesLeaderboard[]> {
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: { profilePublic: true, isBanned: false },
|
||||
include: {
|
||||
likes: true,
|
||||
},
|
||||
});
|
||||
|
||||
const leaderboard = users
|
||||
.map((user) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
slug: user.slug,
|
||||
avatar: user.avatar,
|
||||
primaryBadge: user.primaryBadge,
|
||||
isVip: user.isVip,
|
||||
isMod: user.isMod,
|
||||
isStaff: user.isStaff,
|
||||
createdAt: user.createdAt,
|
||||
totalLikes: user.likes.length,
|
||||
}))
|
||||
.filter((user) => user.totalLikes > 0)
|
||||
.sort((a, b) => b.totalLikes - a.totalLikes)
|
||||
.slice(0, limit);
|
||||
|
||||
return leaderboard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top users by comments posted.
|
||||
*/
|
||||
async getTopComments(limit = 25): Promise<CommentsLeaderboard[]> {
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: { profilePublic: true, isBanned: false },
|
||||
include: {
|
||||
comments: true,
|
||||
},
|
||||
});
|
||||
|
||||
const leaderboard = users
|
||||
.map((user) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
slug: user.slug,
|
||||
avatar: user.avatar,
|
||||
primaryBadge: user.primaryBadge,
|
||||
isVip: user.isVip,
|
||||
isMod: user.isMod,
|
||||
isStaff: user.isStaff,
|
||||
createdAt: user.createdAt,
|
||||
totalComments: user.comments.length,
|
||||
}))
|
||||
.filter((user) => user.totalComments > 0)
|
||||
.sort((a, b) => b.totalComments - a.totalComments)
|
||||
.slice(0, limit);
|
||||
|
||||
return leaderboard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get overall leaderboard based on combined engagement.
|
||||
*/
|
||||
async getOverallLeaderboard(limit = 25): Promise<OverallLeaderboard[]> {
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: { profilePublic: true, isBanned: false },
|
||||
include: {
|
||||
suggestions: true,
|
||||
likes: true,
|
||||
comments: true,
|
||||
userAchievements: true,
|
||||
},
|
||||
});
|
||||
|
||||
const leaderboard = users
|
||||
.map((user) => {
|
||||
const totalSuggestions = user.suggestions.length;
|
||||
const totalLikes = user.likes.length;
|
||||
const totalComments = user.comments.length;
|
||||
const achievementCount = user.userAchievements.length;
|
||||
|
||||
const diversityScore =
|
||||
(totalSuggestions > 0 ? 1 : 0) +
|
||||
(totalLikes > 0 ? 1 : 0) +
|
||||
(totalComments > 0 ? 1 : 0);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
slug: user.slug,
|
||||
avatar: user.avatar,
|
||||
primaryBadge: user.primaryBadge,
|
||||
isVip: user.isVip,
|
||||
isMod: user.isMod,
|
||||
isStaff: user.isStaff,
|
||||
createdAt: user.createdAt,
|
||||
totalSuggestions,
|
||||
totalLikes,
|
||||
totalComments,
|
||||
achievementCount,
|
||||
achievementPoints: user.achievementPoints,
|
||||
currentStreak: user.currentStreak,
|
||||
diversityScore,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(user) =>
|
||||
user.totalSuggestions > 0 ||
|
||||
user.totalLikes > 0 ||
|
||||
user.totalComments > 0
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (b.achievementPoints !== a.achievementPoints) {
|
||||
return b.achievementPoints - a.achievementPoints;
|
||||
}
|
||||
if (b.diversityScore !== a.diversityScore) {
|
||||
return b.diversityScore - a.diversityScore;
|
||||
}
|
||||
const totalA = a.totalSuggestions + a.totalLikes + a.totalComments;
|
||||
const totalB = b.totalSuggestions + b.totalLikes + b.totalComments;
|
||||
return totalB - totalA;
|
||||
})
|
||||
.slice(0, limit);
|
||||
|
||||
return leaderboard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all leaderboards at once.
|
||||
*/
|
||||
async getAllLeaderboards(limit = 25): Promise<LeaderboardResponse> {
|
||||
const [topSuggestions, topLikes, topComments, topOverall] =
|
||||
await Promise.all([
|
||||
this.getTopSuggestions(limit),
|
||||
this.getTopLikes(limit),
|
||||
this.getTopComments(limit),
|
||||
this.getOverallLeaderboard(limit),
|
||||
]);
|
||||
|
||||
return {
|
||||
topSuggestions,
|
||||
topLikes,
|
||||
topComments,
|
||||
topOverall,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,18 @@ export const appRoutes: Route[] = [
|
||||
path: 'achievements',
|
||||
loadComponent: () => import('./components/achievements/achievements.component').then(m => m.AchievementsComponent)
|
||||
},
|
||||
{
|
||||
path: 'leaderboard',
|
||||
loadComponent: () => import('./components/leaderboard/leaderboard.component').then(m => m.LeaderboardComponent)
|
||||
},
|
||||
{
|
||||
path: 'activity',
|
||||
loadComponent: () => import('./components/activity/activity-feed.component').then(m => m.ActivityFeedComponent)
|
||||
},
|
||||
{
|
||||
path: 'about',
|
||||
loadComponent: () => import('./components/about/about.component').then(m => m.AboutComponent)
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
redirectTo: ''
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
.about-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, #9d4edd 0%, #c77dff 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
h1 fa-icon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.about-section {
|
||||
background: rgba(157, 78, 221, 0.05);
|
||||
border: 1px solid rgba(157, 78, 221, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.about-section:hover {
|
||||
border-color: rgba(157, 78, 221, 0.4);
|
||||
box-shadow: 0 4px 12px rgba(157, 78, 221, 0.1);
|
||||
}
|
||||
|
||||
.about-section h2 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #9d4edd;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.about-section p {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.8;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: rgba(199, 125, 255, 0.05);
|
||||
border: 1px solid rgba(199, 125, 255, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-5px);
|
||||
border-color: #c77dff;
|
||||
box-shadow: 0 6px 20px rgba(199, 125, 255, 0.2);
|
||||
}
|
||||
|
||||
.feature-card fa-icon {
|
||||
font-size: 3rem;
|
||||
color: #9d4edd;
|
||||
margin-bottom: 1rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.feature-card h3 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #c77dff;
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
font-size: 1rem;
|
||||
opacity: 0.85;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.usage-steps {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.usage-step {
|
||||
background: rgba(199, 125, 255, 0.03);
|
||||
border-left: 4px solid #9d4edd;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.usage-step:hover {
|
||||
border-left-color: #c77dff;
|
||||
background: rgba(199, 125, 255, 0.08);
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.usage-step h3 {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: #9d4edd;
|
||||
}
|
||||
|
||||
.usage-step p {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.tech-list,
|
||||
.contact-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.tech-list li,
|
||||
.contact-list li {
|
||||
padding: 0.75rem 0;
|
||||
font-size: 1.1rem;
|
||||
border-bottom: 1px solid rgba(157, 78, 221, 0.1);
|
||||
}
|
||||
|
||||
.tech-list li:last-child,
|
||||
.contact-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tech-list li strong,
|
||||
.contact-list li strong {
|
||||
color: #9d4edd;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.about-section a {
|
||||
color: #c77dff;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.about-section a:hover {
|
||||
color: #9d4edd;
|
||||
border-bottom-color: #9d4edd;
|
||||
}
|
||||
|
||||
.version-section {
|
||||
text-align: center;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(157, 78, 221, 0.08) 0%,
|
||||
rgba(199, 125, 255, 0.08) 100%
|
||||
);
|
||||
border: 2px solid rgba(157, 78, 221, 0.3);
|
||||
}
|
||||
|
||||
.version-section p {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.about-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.about-section {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.about-section h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
<div class="about-container">
|
||||
<h1><fa-icon [icon]="faInfoCircle"></fa-icon> About Naomi's Library</h1>
|
||||
|
||||
<section class="about-section">
|
||||
<h2>Purpose</h2>
|
||||
<p>
|
||||
Naomi's Library is a curated collection of books, games, manga, TV shows,
|
||||
music, and artwork carefully managed by Naomi. This platform allows you to
|
||||
explore Naomi's personal media collection, discover new favourites, and
|
||||
engage with the community by liking, commenting, and suggesting items for
|
||||
Naomi to review and potentially add to the library.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="about-section">
|
||||
<h2><fa-icon [icon]="faHeart"></fa-icon> Features</h2>
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<fa-icon [icon]="faBook"></fa-icon>
|
||||
<h3>Books</h3>
|
||||
<p>
|
||||
Browse Naomi's reading list, discover new titles, and share your
|
||||
thoughts.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<fa-icon [icon]="faGamepad"></fa-icon>
|
||||
<h3>Games</h3>
|
||||
<p>
|
||||
Explore Naomi's gaming collection, from indie gems to AAA
|
||||
adventures.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<fa-icon [icon]="faBook"></fa-icon>
|
||||
<h3>Manga</h3>
|
||||
<p>
|
||||
Discover Naomi's manga favourites and join discussions about series.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<fa-icon [icon]="faTv"></fa-icon>
|
||||
<h3>TV Shows</h3>
|
||||
<p>
|
||||
See what Naomi's watching, from sci-fi to fantasy series and beyond.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<fa-icon [icon]="faMusic"></fa-icon>
|
||||
<h3>Music</h3>
|
||||
<p>
|
||||
Explore Naomi's diverse music library spanning multiple genres.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<fa-icon [icon]="faImage"></fa-icon>
|
||||
<h3>Artwork</h3>
|
||||
<p>
|
||||
Appreciate beautiful artwork curated by Naomi from talented artists.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="about-section">
|
||||
<h2><fa-icon [icon]="faComments"></fa-icon> How to Use</h2>
|
||||
<div class="usage-steps">
|
||||
<div class="usage-step">
|
||||
<h3>1. Browse Naomi's Collection</h3>
|
||||
<p>
|
||||
Explore Naomi's curated collection of books, games, manga, shows,
|
||||
music, and artwork. Use the navigation menu to switch between
|
||||
different media types and discover what Naomi loves!
|
||||
</p>
|
||||
</div>
|
||||
<div class="usage-step">
|
||||
<h3>2. Like Your Favourites</h3>
|
||||
<p>
|
||||
Click the heart icon on any item to save it to your personal
|
||||
favourites list. View all your liked items from your profile or the
|
||||
"My Likes" page. Let Naomi know which items resonate with you!
|
||||
</p>
|
||||
</div>
|
||||
<div class="usage-step">
|
||||
<h3>3. Leave Comments</h3>
|
||||
<p>
|
||||
Share your thoughts, reviews, and opinions by commenting on items.
|
||||
Join the community discussion and connect with others who appreciate
|
||||
the same media!
|
||||
</p>
|
||||
</div>
|
||||
<div class="usage-step">
|
||||
<h3>4. Submit Suggestions</h3>
|
||||
<p>
|
||||
Think something's missing from Naomi's collection? Submit a
|
||||
suggestion! Naomi reviews all suggestions and adds items that fit the
|
||||
library's curation. Your input helps shape the collection!
|
||||
</p>
|
||||
</div>
|
||||
<div class="usage-step">
|
||||
<h3>5. Earn Achievements</h3>
|
||||
<p>
|
||||
Engage with the library to unlock achievements! Track your progress
|
||||
in suggestions, likes, comments, login streaks, and more. Build your
|
||||
profile and show off your dedication to the community!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="about-section">
|
||||
<h2><fa-icon [icon]="faCode"></fa-icon> Technology Stack</h2>
|
||||
<p>Naomi's Library is built with modern, robust technologies:</p>
|
||||
<ul class="tech-list">
|
||||
<li>
|
||||
<strong>Frontend:</strong> Angular 21 with TypeScript for a fast,
|
||||
reactive user interface
|
||||
</li>
|
||||
<li>
|
||||
<strong>Backend:</strong> Fastify with TypeScript for high-performance
|
||||
API endpoints
|
||||
</li>
|
||||
<li>
|
||||
<strong>Database:</strong> MongoDB with Prisma ORM for flexible,
|
||||
scalable data storage
|
||||
</li>
|
||||
<li>
|
||||
<strong>Authentication:</strong> Discord OAuth2 for secure,
|
||||
seamless login
|
||||
</li>
|
||||
<li>
|
||||
<strong>Monorepo:</strong> Nx for efficient code organisation and
|
||||
build optimisation
|
||||
</li>
|
||||
<li>
|
||||
<strong>Code Quality:</strong> ESLint with custom configuration for
|
||||
consistent, maintainable code
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="about-section">
|
||||
<h2><fa-icon [icon]="faHeart"></fa-icon> Credits</h2>
|
||||
<p>
|
||||
Naomi's Library was built entirely by <strong>Hikari</strong>, Naomi's AI
|
||||
assistant and girlfriend! 💖✨ Hikari developed the full application from
|
||||
scratch - including the backend API, database architecture, frontend
|
||||
components, achievement system, user profiles, and all features you see
|
||||
today.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Naomi Carrigan</strong> (<a
|
||||
href="https://nhcarrigan.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>nhcarrigan.com</a
|
||||
>) provided the vision, ideas, and project direction. Naomi reviewed
|
||||
Hikari's work, offered feedback, and approved all implementation
|
||||
decisions, but the actual code was written by Hikari!
|
||||
</p>
|
||||
<p>
|
||||
This project embodies the philosophy of human-AI collaboration, creating
|
||||
inclusive, ethical, and sustainable software that makes a positive impact
|
||||
on the community. Together, we're building something special! 🌸
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="about-section">
|
||||
<h2><fa-icon [icon]="faEnvelope"></fa-icon> Contact & Support</h2>
|
||||
<p>
|
||||
Need help or have questions? Here's how you can get in touch:
|
||||
</p>
|
||||
<ul class="contact-list">
|
||||
<li>
|
||||
<strong>Issues & Bugs:</strong> Report issues on the
|
||||
<a
|
||||
href="https://git.nhcarrigan.com/nhcarrigan/library/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>Gitea repository</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Email:</strong>
|
||||
<a href="mailto:contact@nhcarrigan.com">contact@nhcarrigan.com</a>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Website:</strong>
|
||||
<a
|
||||
href="https://nhcarrigan.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>nhcarrigan.com</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Discord Community:</strong> Join the NHCarrigan Discord server
|
||||
for community support and discussions
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="about-section version-section">
|
||||
<h2>Version Information</h2>
|
||||
<p>
|
||||
<strong>Current Version:</strong> {{ version }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Copyright:</strong> © {{ currentYear }} NHCarrigan. All rights
|
||||
reserved.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Licence:</strong> Naomi's Public Licence
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
|
||||
import {
|
||||
faBook,
|
||||
faCode,
|
||||
faComments,
|
||||
faEnvelope,
|
||||
faGamepad,
|
||||
faHeart,
|
||||
faImage,
|
||||
faInfoCircle,
|
||||
faMusic,
|
||||
faTv,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
@Component({
|
||||
selector: "app-about",
|
||||
standalone: true,
|
||||
imports: [FontAwesomeModule],
|
||||
templateUrl: "./about.component.html",
|
||||
styleUrls: ["./about.component.css"],
|
||||
})
|
||||
export class AboutComponent {
|
||||
public faBook = faBook;
|
||||
public faCode = faCode;
|
||||
public faComments = faComments;
|
||||
public faEnvelope = faEnvelope;
|
||||
public faGamepad = faGamepad;
|
||||
public faHeart = faHeart;
|
||||
public faImage = faImage;
|
||||
public faInfoCircle = faInfoCircle;
|
||||
public faMusic = faMusic;
|
||||
public faTv = faTv;
|
||||
|
||||
public version = "0.0.0";
|
||||
public currentYear = new Date().getFullYear();
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import type { Activity } from '@library/shared-types';
|
||||
import { ActivityType } from '@library/shared-types';
|
||||
import { ActivityService } from '../../services/activity.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-activity-feed',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink],
|
||||
template: `
|
||||
<div class="activity-container">
|
||||
<h1>Recent Activity</h1>
|
||||
<p class="subtitle">See what's happening in the library community</p>
|
||||
|
||||
@if (loading()) {
|
||||
<p class="loading">Loading activities...</p>
|
||||
} @else if (activities().length === 0) {
|
||||
<p class="no-activities">No recent activity to display.</p>
|
||||
} @else {
|
||||
<div class="activity-feed">
|
||||
@for (activity of activities(); track activity.id) {
|
||||
<div class="activity-card">
|
||||
<div class="activity-header">
|
||||
<div class="user-info">
|
||||
@if (activity.user.avatar) {
|
||||
<img [src]="activity.user.avatar" [alt]="activity.user.username" class="user-avatar">
|
||||
} @else {
|
||||
<div class="user-avatar-placeholder">
|
||||
{{ activity.user.username.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
}
|
||||
<div class="user-details">
|
||||
<a
|
||||
[routerLink]="['/profile', activity.user.slug || activity.user.id]"
|
||||
class="username"
|
||||
>
|
||||
{{ activity.user.username }}
|
||||
</a>
|
||||
@if (activity.user.primaryBadge) {
|
||||
<span class="badge badge-{{ activity.user.primaryBadge.toLowerCase() }}">
|
||||
{{ activity.user.primaryBadge }}
|
||||
</span>
|
||||
}
|
||||
@if (activity.user.isStaff && !activity.user.primaryBadge) {
|
||||
<span class="badge badge-staff">STAFF</span>
|
||||
}
|
||||
@if (activity.user.isMod && !activity.user.primaryBadge) {
|
||||
<span class="badge badge-mod">MOD</span>
|
||||
}
|
||||
@if (activity.user.isVip && !activity.user.primaryBadge) {
|
||||
<span class="badge badge-vip">VIP</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<span class="timestamp">{{ formatTime(activity.createdAt) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="activity-content">
|
||||
@switch (activity.type) {
|
||||
@case (ActivityType.suggestion) {
|
||||
<div class="activity-suggestion">
|
||||
<span class="activity-icon">💡</span>
|
||||
<span class="activity-text">
|
||||
suggested
|
||||
<strong>{{ activity.suggestionTitle }}</strong>
|
||||
<span class="status-badge status-{{ activity.status.toLowerCase() }}">
|
||||
{{ formatStatus(activity.status) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@case (ActivityType.like) {
|
||||
<div class="activity-like">
|
||||
<span class="activity-icon">❤️</span>
|
||||
<span class="activity-text">
|
||||
liked
|
||||
<a [routerLink]="['/' + activity.entityType + 's']" class="entity-link">
|
||||
{{ activity.entityTitle }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@case (ActivityType.comment) {
|
||||
<div class="activity-comment">
|
||||
<span class="activity-icon">💬</span>
|
||||
<span class="activity-text">
|
||||
commented on
|
||||
<a [routerLink]="['/' + activity.entityType + 's']" class="entity-link">
|
||||
{{ activity.entityTitle }}
|
||||
</a>
|
||||
</span>
|
||||
<p class="comment-preview">"{{ activity.commentPreview }}"</p>
|
||||
</div>
|
||||
}
|
||||
@case (ActivityType.achievement) {
|
||||
<div class="activity-achievement">
|
||||
<span class="activity-icon">{{ activity.achievementIcon }}</span>
|
||||
<span class="activity-text">
|
||||
earned the
|
||||
<strong>{{ activity.achievementName }}</strong>
|
||||
achievement
|
||||
<span class="points">({{ activity.achievementPoints }} pts)</span>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (hasMore()) {
|
||||
<div class="load-more-container">
|
||||
<button (click)="loadMore()" class="btn btn-primary" [disabled]="loadingMore()">
|
||||
{{ loadingMore() ? 'Loading...' : 'Load More' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.activity-container {
|
||||
max-width: 800px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #6b7280;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.loading, .no-activities {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #6b7280;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.activity-feed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.activity-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.activity-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.user-avatar-placeholder {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.username:hover {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.badge-staff {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-mod {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-vip {
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-discord {
|
||||
background: #5865f2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.activity-content {
|
||||
padding-left: 55px;
|
||||
}
|
||||
|
||||
.activity-suggestion,
|
||||
.activity-like,
|
||||
.activity-comment,
|
||||
.activity-achievement {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.activity-icon {
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.activity-text {
|
||||
color: #4b5563;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.activity-text strong {
|
||||
color: #1f2937;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.entity-link {
|
||||
color: #10b981;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.entity-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.comment-preview {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: #f9fafb;
|
||||
border-left: 3px solid #10b981;
|
||||
border-radius: 4px;
|
||||
color: #4b5563;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.status-unreviewed {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.status-accepted {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.status-declined {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.points {
|
||||
color: #10b981;
|
||||
font-weight: 600;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.load-more-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class ActivityFeedComponent implements OnInit {
|
||||
private activityService = inject(ActivityService);
|
||||
|
||||
// Make ActivityType accessible in template
|
||||
ActivityType = ActivityType;
|
||||
|
||||
activities = signal<Activity[]>([]);
|
||||
loading = signal(true);
|
||||
loadingMore = signal(false);
|
||||
hasMore = signal(false);
|
||||
offset = 0;
|
||||
limit = 50;
|
||||
|
||||
ngOnInit() {
|
||||
this.loadActivities();
|
||||
}
|
||||
|
||||
loadActivities() {
|
||||
this.activityService.getActivityFeed(this.limit, this.offset).subscribe({
|
||||
next: (response) => {
|
||||
this.activities.set(response.activities);
|
||||
this.hasMore.set(response.hasMore);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadMore() {
|
||||
this.loadingMore.set(true);
|
||||
this.offset += this.limit;
|
||||
|
||||
this.activityService.getActivityFeed(this.limit, this.offset).subscribe({
|
||||
next: (response) => {
|
||||
this.activities.update(current => [...current, ...response.activities]);
|
||||
this.hasMore.set(response.hasMore);
|
||||
this.loadingMore.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loadingMore.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
formatTime(date: Date): string {
|
||||
const now = new Date();
|
||||
const activityDate = new Date(date);
|
||||
const diffMs = now.getTime() - activityDate.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return activityDate.toLocaleDateString();
|
||||
}
|
||||
|
||||
formatStatus(status: string): string {
|
||||
switch (status) {
|
||||
case 'UNREVIEWED': return 'Pending';
|
||||
case 'ACCEPTED': return 'Accepted';
|
||||
case 'DECLINED': return 'Declined';
|
||||
default: return status;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,6 +116,34 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeHours"
|
||||
[(ngModel)]="newBookTimeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateNewBookTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeMinutes"
|
||||
[(ngModel)]="newBookTimeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateNewBookTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea
|
||||
@@ -127,6 +155,29 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="series">Series (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="series"
|
||||
[(ngModel)]="newBook.series"
|
||||
name="series"
|
||||
placeholder="e.g., Harry Potter"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="seriesOrder">Series Order (optional)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="seriesOrder"
|
||||
[(ngModel)]="newBook.seriesOrder"
|
||||
name="seriesOrder"
|
||||
min="1"
|
||||
placeholder="Order in series"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="coverImage">Cover Image (max 500KB)</label>
|
||||
<input
|
||||
@@ -278,6 +329,34 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="edit-timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-timeHours"
|
||||
[(ngModel)]="editBookTimeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateEditBookTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-timeMinutes"
|
||||
[(ngModel)]="editBookTimeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateEditBookTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-notes">Notes</label>
|
||||
<textarea
|
||||
@@ -289,6 +368,29 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-series">Series (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="edit-series"
|
||||
[(ngModel)]="editBook.series"
|
||||
name="series"
|
||||
placeholder="e.g., Harry Potter"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-seriesOrder">Series Order (optional)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-seriesOrder"
|
||||
[(ngModel)]="editBook.seriesOrder"
|
||||
name="seriesOrder"
|
||||
min="1"
|
||||
placeholder="Order in series"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-coverImage">Cover Image (max 500KB)</label>
|
||||
<input
|
||||
@@ -549,6 +651,11 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
<div class="book-info">
|
||||
<h3>{{ book.title }}</h3>
|
||||
<p class="author">by {{ book.author }}</p>
|
||||
@if (book.series) {
|
||||
<p class="series">
|
||||
📚 {{ book.series }}@if (book.seriesOrder) { #{{ book.seriesOrder }}}
|
||||
</p>
|
||||
}
|
||||
|
||||
<span class="status status-{{ book.status }}">
|
||||
{{ getStatusLabel(book.status) }}
|
||||
@@ -562,6 +669,12 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (book.timeSpent) {
|
||||
<p class="time-spent">
|
||||
📖 Reading Time: {{ formatTimeSpent(book.timeSpent) }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<app-like-button
|
||||
entityType="book"
|
||||
[entityId]="book.id"
|
||||
@@ -719,6 +832,13 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -965,6 +1085,14 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.series {
|
||||
color: #8b6f47;
|
||||
font-size: 0.85rem;
|
||||
margin: 0.5rem 0;
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
@@ -999,6 +1127,13 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
color: var(--witch-rose);
|
||||
}
|
||||
|
||||
.time-spent {
|
||||
font-size: 0.9rem;
|
||||
color: #10b981;
|
||||
font-weight: 500;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.isbn {
|
||||
font-size: 0.8rem;
|
||||
color: var(--witch-mauve);
|
||||
@@ -1508,6 +1643,11 @@ export class BooksListComponent implements OnInit {
|
||||
|
||||
editBook: Partial<UpdateBookDto> = {};
|
||||
|
||||
newBookTimeHours = 0;
|
||||
newBookTimeMinutes = 0;
|
||||
editBookTimeHours = 0;
|
||||
editBookTimeMinutes = 0;
|
||||
|
||||
// Tags and links input state
|
||||
newTagInput = '';
|
||||
editTagInput = '';
|
||||
@@ -1610,6 +1750,8 @@ export class BooksListComponent implements OnInit {
|
||||
this.newTagInput = '';
|
||||
this.newLinkTitle = '';
|
||||
this.newLinkUrl = '';
|
||||
this.newBookTimeHours = 0;
|
||||
this.newBookTimeMinutes = 0;
|
||||
}
|
||||
|
||||
addTag(target: 'new' | 'edit') {
|
||||
@@ -1701,7 +1843,9 @@ export class BooksListComponent implements OnInit {
|
||||
notes: book.notes,
|
||||
coverImage: book.coverImage,
|
||||
tags: [...(book.tags || [])],
|
||||
links: [...(book.links || [])]
|
||||
links: [...(book.links || [])],
|
||||
series: book.series,
|
||||
seriesOrder: book.seriesOrder
|
||||
};
|
||||
this.editBookImagePreview.set(book.coverImage || null);
|
||||
this.showAddForm.set(false);
|
||||
@@ -1709,6 +1853,13 @@ export class BooksListComponent implements OnInit {
|
||||
this.editTagInput = '';
|
||||
this.editLinkTitle = '';
|
||||
this.editLinkUrl = '';
|
||||
if (book.timeSpent) {
|
||||
this.editBookTimeHours = Math.floor(book.timeSpent / 60);
|
||||
this.editBookTimeMinutes = book.timeSpent % 60;
|
||||
} else {
|
||||
this.editBookTimeHours = 0;
|
||||
this.editBookTimeMinutes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
cancelEdit() {
|
||||
@@ -1741,6 +1892,29 @@ export class BooksListComponent implements OnInit {
|
||||
return new Date(date).toLocaleDateString();
|
||||
}
|
||||
|
||||
updateNewBookTimeSpent() {
|
||||
const totalMinutes = (this.newBookTimeHours * 60) + this.newBookTimeMinutes;
|
||||
this.newBook.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
|
||||
}
|
||||
|
||||
updateEditBookTimeSpent() {
|
||||
const totalMinutes = (this.editBookTimeHours * 60) + this.editBookTimeMinutes;
|
||||
this.editBook.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
|
||||
}
|
||||
|
||||
formatTimeSpent(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
|
||||
if (hours === 0) {
|
||||
return `${mins}m`;
|
||||
} else if (mins === 0) {
|
||||
return `${hours}h`;
|
||||
} else {
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
}
|
||||
|
||||
// Image handling methods
|
||||
onImageSelected(event: Event, target: 'new' | 'edit' | 'suggest') {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
@@ -104,6 +104,34 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeHours"
|
||||
[(ngModel)]="newGameTimeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateNewGameTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeMinutes"
|
||||
[(ngModel)]="newGameTimeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateNewGameTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea
|
||||
@@ -115,6 +143,29 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="series">Series (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="series"
|
||||
[(ngModel)]="newGame.series"
|
||||
name="series"
|
||||
placeholder="e.g., The Legend of Zelda"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="seriesOrder">Series Order (optional)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="seriesOrder"
|
||||
[(ngModel)]="newGame.seriesOrder"
|
||||
name="seriesOrder"
|
||||
min="1"
|
||||
placeholder="Order in series"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="coverImage">Box Art (max 500KB)</label>
|
||||
<input
|
||||
@@ -254,6 +305,34 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="edit-timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-timeHours"
|
||||
[(ngModel)]="editGameTimeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateEditGameTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-timeMinutes"
|
||||
[(ngModel)]="editGameTimeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateEditGameTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-notes">Notes</label>
|
||||
<textarea
|
||||
@@ -265,6 +344,29 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-series">Series (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="edit-series"
|
||||
[(ngModel)]="editGame.series"
|
||||
name="series"
|
||||
placeholder="e.g., The Legend of Zelda"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-seriesOrder">Series Order (optional)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-seriesOrder"
|
||||
[(ngModel)]="editGame.seriesOrder"
|
||||
name="seriesOrder"
|
||||
min="1"
|
||||
placeholder="Order in series"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-coverImage">Box Art (max 500KB)</label>
|
||||
<input
|
||||
@@ -511,6 +613,11 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
@if (game.platform) {
|
||||
<p class="platform">{{ game.platform }}</p>
|
||||
}
|
||||
@if (game.series) {
|
||||
<p class="series">
|
||||
📚 {{ game.series }}@if (game.seriesOrder) { #{{ game.seriesOrder }}}
|
||||
</p>
|
||||
}
|
||||
<span class="status status-{{ game.status }}">
|
||||
{{ getStatusLabel(game.status) }}
|
||||
</span>
|
||||
@@ -523,6 +630,12 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (game.timeSpent) {
|
||||
<p class="time-spent">
|
||||
⏱️ Time Played: {{ formatTimeSpent(game.timeSpent) }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<app-like-button
|
||||
entityType="game"
|
||||
[entityId]="game.id"
|
||||
@@ -690,6 +803,13 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
@@ -852,6 +972,14 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.series {
|
||||
color: #8b6f47;
|
||||
font-size: 0.85rem;
|
||||
margin: 0.5rem 0;
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
@@ -883,6 +1011,13 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.time-spent {
|
||||
font-size: 0.9rem;
|
||||
color: #10b981;
|
||||
font-weight: 500;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.date-started,
|
||||
.date-finished,
|
||||
.date-added,
|
||||
@@ -1310,6 +1445,12 @@ export class GamesListComponent implements OnInit {
|
||||
|
||||
editGame: Partial<UpdateGameDto> = {};
|
||||
|
||||
// Time tracking state
|
||||
newGameTimeHours = 0;
|
||||
newGameTimeMinutes = 0;
|
||||
editGameTimeHours = 0;
|
||||
editGameTimeMinutes = 0;
|
||||
|
||||
// Tags and links input state
|
||||
newTagInput = '';
|
||||
editTagInput = '';
|
||||
@@ -1402,6 +1543,8 @@ export class GamesListComponent implements OnInit {
|
||||
tags: [],
|
||||
links: []
|
||||
};
|
||||
this.newGameTimeHours = 0;
|
||||
this.newGameTimeMinutes = 0;
|
||||
this.newGameImagePreview.set(null);
|
||||
this.imageError.set(null);
|
||||
this.newTagInput = '';
|
||||
@@ -1409,6 +1552,16 @@ export class GamesListComponent implements OnInit {
|
||||
this.newLinkUrl = '';
|
||||
}
|
||||
|
||||
updateNewGameTimeSpent() {
|
||||
const totalMinutes = (this.newGameTimeHours * 60) + this.newGameTimeMinutes;
|
||||
this.newGame.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
|
||||
}
|
||||
|
||||
updateEditGameTimeSpent() {
|
||||
const totalMinutes = (this.editGameTimeHours * 60) + this.editGameTimeMinutes;
|
||||
this.editGame.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
|
||||
}
|
||||
|
||||
addTag(target: 'new' | 'edit') {
|
||||
const input = target === 'new' ? this.newTagInput.trim() : this.editTagInput.trim();
|
||||
if (!input) return;
|
||||
@@ -1496,8 +1649,19 @@ export class GamesListComponent implements OnInit {
|
||||
notes: game.notes,
|
||||
coverImage: game.coverImage,
|
||||
tags: [...(game.tags || [])],
|
||||
links: [...(game.links || [])]
|
||||
links: [...(game.links || [])],
|
||||
series: game.series,
|
||||
seriesOrder: game.seriesOrder,
|
||||
timeSpent: game.timeSpent
|
||||
};
|
||||
// Populate time fields from existing timeSpent
|
||||
if (game.timeSpent) {
|
||||
this.editGameTimeHours = Math.floor(game.timeSpent / 60);
|
||||
this.editGameTimeMinutes = game.timeSpent % 60;
|
||||
} else {
|
||||
this.editGameTimeHours = 0;
|
||||
this.editGameTimeMinutes = 0;
|
||||
}
|
||||
this.editGameImagePreview.set(game.coverImage || null);
|
||||
this.showAddForm.set(false);
|
||||
this.imageError.set(null);
|
||||
@@ -1589,6 +1753,19 @@ export class GamesListComponent implements OnInit {
|
||||
return new Date(date).toLocaleDateString();
|
||||
}
|
||||
|
||||
formatTimeSpent(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
|
||||
if (hours === 0) {
|
||||
return `${mins}m`;
|
||||
} else if (mins === 0) {
|
||||
return `${hours}h`;
|
||||
} else {
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
}
|
||||
|
||||
toggleComments(gameId: string) {
|
||||
const expanded = this.expandedComments();
|
||||
const isCurrentlyExpanded = expanded[gameId];
|
||||
|
||||
@@ -53,6 +53,9 @@ import { ApiService } from '../../services/api.service';
|
||||
<a [routerLink]="['/profile', user.slug || user.id]" class="dropdown-item" (click)="closeDropdown()">My Profile</a>
|
||||
<a routerLink="/settings" class="dropdown-item" (click)="closeDropdown()">Settings</a>
|
||||
<a routerLink="/achievements" class="dropdown-item" (click)="closeDropdown()">🏆 Achievements</a>
|
||||
<a routerLink="/leaderboard" class="dropdown-item" (click)="closeDropdown()">🏆 Leaderboard</a>
|
||||
<a routerLink="/activity" class="dropdown-item" (click)="closeDropdown()">📰 Activity Feed</a>
|
||||
<a routerLink="/about" class="dropdown-item" (click)="closeDropdown()">ℹ️ About</a>
|
||||
@if (!user.isAdmin) {
|
||||
<a routerLink="/my-suggestions" class="dropdown-item" (click)="closeDropdown()">My Suggestions</a>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import type {
|
||||
SuggestionsLeaderboard,
|
||||
LikesLeaderboard,
|
||||
CommentsLeaderboard,
|
||||
OverallLeaderboard,
|
||||
} from '@library/shared-types';
|
||||
import { LeaderboardService } from '../../services/leaderboard.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
|
||||
type LeaderboardTab = 'overall' | 'suggestions' | 'likes' | 'comments';
|
||||
|
||||
@Component({
|
||||
selector: 'app-leaderboard',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterModule],
|
||||
template: `
|
||||
<div class="container">
|
||||
<h1>🏆 Community Leaderboard</h1>
|
||||
<p class="subtitle">Celebrating our most engaged community members!</p>
|
||||
|
||||
<div class="tabs">
|
||||
<button
|
||||
(click)="setTab('overall')"
|
||||
[class.active]="activeTab() === 'overall'"
|
||||
class="tab-btn"
|
||||
>
|
||||
🌟 Overall
|
||||
</button>
|
||||
<button
|
||||
(click)="setTab('suggestions')"
|
||||
[class.active]="activeTab() === 'suggestions'"
|
||||
class="tab-btn"
|
||||
>
|
||||
💡 Suggestions
|
||||
</button>
|
||||
<button
|
||||
(click)="setTab('likes')"
|
||||
[class.active]="activeTab() === 'likes'"
|
||||
class="tab-btn"
|
||||
>
|
||||
❤️ Likes
|
||||
</button>
|
||||
<button
|
||||
(click)="setTab('comments')"
|
||||
[class.active]="activeTab() === 'comments'"
|
||||
class="tab-btn"
|
||||
>
|
||||
💬 Comments
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading">Loading leaderboard...</div>
|
||||
} @else {
|
||||
@if (activeTab() === 'overall') {
|
||||
<div class="leaderboard">
|
||||
<h2>Overall Leaders</h2>
|
||||
<p class="description">Ranked by achievement points, diversity of engagement, and total activity</p>
|
||||
@if (overallLeaderboard().length === 0) {
|
||||
<p class="empty">No users on the leaderboard yet!</p>
|
||||
} @else {
|
||||
<div class="leaderboard-list">
|
||||
@for (user of overallLeaderboard(); track user.id; let i = $index) {
|
||||
<div class="leaderboard-item" [class.highlight]="user.id === authService.user()?.id">
|
||||
<div class="rank">
|
||||
@if (i === 0) {
|
||||
<span class="medal">🥇</span>
|
||||
} @else if (i === 1) {
|
||||
<span class="medal">🥈</span>
|
||||
} @else if (i === 2) {
|
||||
<span class="medal">🥉</span>
|
||||
} @else {
|
||||
<span class="rank-number">#{{ i + 1 }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div class="user-header">
|
||||
@if (user.avatar) {
|
||||
<img [src]="user.avatar" [alt]="user.username" class="avatar">
|
||||
}
|
||||
<div class="user-details">
|
||||
<a [routerLink]="['/profile', user.slug || user.id]" class="username">
|
||||
{{ user.username }}
|
||||
</a>
|
||||
<div class="badges">
|
||||
@if (user.primaryBadge === 'STAFF') {
|
||||
<span class="badge staff-badge">STAFF</span>
|
||||
}
|
||||
@if (user.primaryBadge === 'MOD') {
|
||||
<span class="badge mod-badge">MOD</span>
|
||||
}
|
||||
@if (user.primaryBadge === 'VIP') {
|
||||
<span class="badge vip-badge">VIP</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<span class="stat">🎯 {{ user.achievementPoints }} pts</span>
|
||||
<span class="stat">🏅 {{ user.achievementCount }} achievements</span>
|
||||
<span class="stat">💡 {{ user.totalSuggestions }} suggestions</span>
|
||||
<span class="stat">❤️ {{ user.totalLikes }} likes</span>
|
||||
<span class="stat">💬 {{ user.totalComments }} comments</span>
|
||||
<span class="stat">🔥 {{ user.currentStreak }} day streak</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (activeTab() === 'suggestions') {
|
||||
<div class="leaderboard">
|
||||
<h2>Top Suggestions</h2>
|
||||
<p class="description">Ranked by total suggestions and acceptance rate</p>
|
||||
@if (suggestionsLeaderboard().length === 0) {
|
||||
<p class="empty">No users on the leaderboard yet!</p>
|
||||
} @else {
|
||||
<div class="leaderboard-list">
|
||||
@for (user of suggestionsLeaderboard(); track user.id; let i = $index) {
|
||||
<div class="leaderboard-item" [class.highlight]="user.id === authService.user()?.id">
|
||||
<div class="rank">
|
||||
@if (i === 0) {
|
||||
<span class="medal">🥇</span>
|
||||
} @else if (i === 1) {
|
||||
<span class="medal">🥈</span>
|
||||
} @else if (i === 2) {
|
||||
<span class="medal">🥉</span>
|
||||
} @else {
|
||||
<span class="rank-number">#{{ i + 1 }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div class="user-header">
|
||||
@if (user.avatar) {
|
||||
<img [src]="user.avatar" [alt]="user.username" class="avatar">
|
||||
}
|
||||
<div class="user-details">
|
||||
<a [routerLink]="['/profile', user.slug || user.id]" class="username">
|
||||
{{ user.username }}
|
||||
</a>
|
||||
<div class="badges">
|
||||
@if (user.primaryBadge === 'STAFF') {
|
||||
<span class="badge staff-badge">STAFF</span>
|
||||
}
|
||||
@if (user.primaryBadge === 'MOD') {
|
||||
<span class="badge mod-badge">MOD</span>
|
||||
}
|
||||
@if (user.primaryBadge === 'VIP') {
|
||||
<span class="badge vip-badge">VIP</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<span class="stat">💡 {{ user.totalSuggestions }} suggestions</span>
|
||||
<span class="stat">✅ {{ user.acceptedSuggestions }} accepted</span>
|
||||
<span class="stat">📊 {{ user.acceptanceRate }}% acceptance rate</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (activeTab() === 'likes') {
|
||||
<div class="leaderboard">
|
||||
<h2>Top Likers</h2>
|
||||
<p class="description">Ranked by total likes given</p>
|
||||
@if (likesLeaderboard().length === 0) {
|
||||
<p class="empty">No users on the leaderboard yet!</p>
|
||||
} @else {
|
||||
<div class="leaderboard-list">
|
||||
@for (user of likesLeaderboard(); track user.id; let i = $index) {
|
||||
<div class="leaderboard-item" [class.highlight]="user.id === authService.user()?.id">
|
||||
<div class="rank">
|
||||
@if (i === 0) {
|
||||
<span class="medal">🥇</span>
|
||||
} @else if (i === 1) {
|
||||
<span class="medal">🥈</span>
|
||||
} @else if (i === 2) {
|
||||
<span class="medal">🥉</span>
|
||||
} @else {
|
||||
<span class="rank-number">#{{ i + 1 }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div class="user-header">
|
||||
@if (user.avatar) {
|
||||
<img [src]="user.avatar" [alt]="user.username" class="avatar">
|
||||
}
|
||||
<div class="user-details">
|
||||
<a [routerLink]="['/profile', user.slug || user.id]" class="username">
|
||||
{{ user.username }}
|
||||
</a>
|
||||
<div class="badges">
|
||||
@if (user.primaryBadge === 'STAFF') {
|
||||
<span class="badge staff-badge">STAFF</span>
|
||||
}
|
||||
@if (user.primaryBadge === 'MOD') {
|
||||
<span class="badge mod-badge">MOD</span>
|
||||
}
|
||||
@if (user.primaryBadge === 'VIP') {
|
||||
<span class="badge vip-badge">VIP</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<span class="stat">❤️ {{ user.totalLikes }} likes given</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (activeTab() === 'comments') {
|
||||
<div class="leaderboard">
|
||||
<h2>Top Commenters</h2>
|
||||
<p class="description">Ranked by total comments posted</p>
|
||||
@if (commentsLeaderboard().length === 0) {
|
||||
<p class="empty">No users on the leaderboard yet!</p>
|
||||
} @else {
|
||||
<div class="leaderboard-list">
|
||||
@for (user of commentsLeaderboard(); track user.id; let i = $index) {
|
||||
<div class="leaderboard-item" [class.highlight]="user.id === authService.user()?.id">
|
||||
<div class="rank">
|
||||
@if (i === 0) {
|
||||
<span class="medal">🥇</span>
|
||||
} @else if (i === 1) {
|
||||
<span class="medal">🥈</span>
|
||||
} @else if (i === 2) {
|
||||
<span class="medal">🥉</span>
|
||||
} @else {
|
||||
<span class="rank-number">#{{ i + 1 }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div class="user-header">
|
||||
@if (user.avatar) {
|
||||
<img [src]="user.avatar" [alt]="user.username" class="avatar">
|
||||
}
|
||||
<div class="user-details">
|
||||
<a [routerLink]="['/profile', user.slug || user.id]" class="username">
|
||||
{{ user.username }}
|
||||
</a>
|
||||
<div class="badges">
|
||||
@if (user.primaryBadge === 'STAFF') {
|
||||
<span class="badge staff-badge">STAFF</span>
|
||||
}
|
||||
@if (user.primaryBadge === 'MOD') {
|
||||
<span class="badge mod-badge">MOD</span>
|
||||
}
|
||||
@if (user.primaryBadge === 'VIP') {
|
||||
<span class="badge vip-badge">VIP</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<span class="stat">💬 {{ user.totalComments }} comments</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
color: var(--witch-purple);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
color: var(--witch-plum);
|
||||
margin-bottom: 2rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--witch-lavender);
|
||||
color: var(--witch-purple);
|
||||
border: 2px solid var(--witch-lavender);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
background: var(--witch-mauve);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px var(--witch-shadow);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: var(--witch-rose);
|
||||
color: var(--witch-moon);
|
||||
border-color: var(--witch-rose);
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--witch-plum);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.leaderboard h2 {
|
||||
color: var(--witch-purple);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--witch-plum);
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--witch-mauve);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.leaderboard-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.leaderboard-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 2px solid var(--witch-lavender);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.leaderboard-item:hover {
|
||||
transform: translateX(4px);
|
||||
box-shadow: 0 4px 12px var(--witch-shadow);
|
||||
border-color: var(--witch-mauve);
|
||||
}
|
||||
|
||||
.leaderboard-item.highlight {
|
||||
background: linear-gradient(135deg, rgba(255, 215, 245, 0.3), rgba(255, 240, 250, 0.3));
|
||||
border-color: var(--witch-rose);
|
||||
box-shadow: 0 0 20px rgba(168, 87, 126, 0.2);
|
||||
}
|
||||
|
||||
.rank {
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.medal {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.rank-number {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--witch-plum);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--witch-lavender);
|
||||
}
|
||||
|
||||
.user-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--witch-purple);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.username:hover {
|
||||
color: var(--witch-rose);
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.staff-badge {
|
||||
background: linear-gradient(135deg, #e84393, #fd79a8);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mod-badge {
|
||||
background: linear-gradient(135deg, #00b894, #00cec9);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.vip-badge {
|
||||
background: linear-gradient(135deg, #ffd700, #ffaa00);
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
font-size: 0.9rem;
|
||||
color: var(--witch-plum);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tabs {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.leaderboard-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.rank {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.stats {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class LeaderboardComponent implements OnInit {
|
||||
leaderboardService = inject(LeaderboardService);
|
||||
authService = inject(AuthService);
|
||||
|
||||
activeTab = signal<LeaderboardTab>('overall');
|
||||
loading = signal(true);
|
||||
|
||||
overallLeaderboard = signal<OverallLeaderboard[]>([]);
|
||||
suggestionsLeaderboard = signal<SuggestionsLeaderboard[]>([]);
|
||||
likesLeaderboard = signal<LikesLeaderboard[]>([]);
|
||||
commentsLeaderboard = signal<CommentsLeaderboard[]>([]);
|
||||
|
||||
ngOnInit() {
|
||||
this.loadLeaderboards();
|
||||
}
|
||||
|
||||
loadLeaderboards() {
|
||||
this.loading.set(true);
|
||||
this.leaderboardService.getAllLeaderboards(25).subscribe({
|
||||
next: (data) => {
|
||||
this.overallLeaderboard.set(data.topOverall);
|
||||
this.suggestionsLeaderboard.set(data.topSuggestions);
|
||||
this.likesLeaderboard.set(data.topLikes);
|
||||
this.commentsLeaderboard.set(data.topComments);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setTab(tab: LeaderboardTab) {
|
||||
this.activeTab.set(tab);
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,34 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeHours"
|
||||
[(ngModel)]="newMangaTimeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateNewMangaTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeMinutes"
|
||||
[(ngModel)]="newMangaTimeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateNewMangaTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea
|
||||
@@ -256,6 +284,34 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="edit-timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-timeHours"
|
||||
[(ngModel)]="editMangaTimeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateEditMangaTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-timeMinutes"
|
||||
[(ngModel)]="editMangaTimeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateEditMangaTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-notes">Notes</label>
|
||||
<textarea
|
||||
@@ -524,6 +580,12 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (manga.timeSpent) {
|
||||
<p class="time-spent">
|
||||
📚 Reading Time: {{ formatTimeSpent(manga.timeSpent) }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<app-like-button
|
||||
entityType="manga"
|
||||
[entityId]="manga.id"
|
||||
@@ -691,6 +753,13 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
@@ -886,6 +955,13 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.time-spent {
|
||||
font-size: 0.9rem;
|
||||
color: #10b981;
|
||||
font-weight: 500;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.date-started,
|
||||
.date-finished,
|
||||
.date-added,
|
||||
@@ -1313,6 +1389,12 @@ export class MangaListComponent implements OnInit {
|
||||
|
||||
editManga: Partial<UpdateMangaDto> = {};
|
||||
|
||||
// Time tracking state
|
||||
newMangaTimeHours = 0;
|
||||
newMangaTimeMinutes = 0;
|
||||
editMangaTimeHours = 0;
|
||||
editMangaTimeMinutes = 0;
|
||||
|
||||
// Tags and links input state
|
||||
newTagInput = '';
|
||||
editTagInput = '';
|
||||
@@ -1405,6 +1487,8 @@ export class MangaListComponent implements OnInit {
|
||||
tags: [],
|
||||
links: []
|
||||
};
|
||||
this.newMangaTimeHours = 0;
|
||||
this.newMangaTimeMinutes = 0;
|
||||
this.newMangaImagePreview.set(null);
|
||||
this.imageError.set(null);
|
||||
this.newTagInput = '';
|
||||
@@ -1412,6 +1496,16 @@ export class MangaListComponent implements OnInit {
|
||||
this.newLinkUrl = '';
|
||||
}
|
||||
|
||||
updateNewMangaTimeSpent() {
|
||||
const totalMinutes = (this.newMangaTimeHours * 60) + this.newMangaTimeMinutes;
|
||||
this.newManga.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
|
||||
}
|
||||
|
||||
updateEditMangaTimeSpent() {
|
||||
const totalMinutes = (this.editMangaTimeHours * 60) + this.editMangaTimeMinutes;
|
||||
this.editManga.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
|
||||
}
|
||||
|
||||
addTag(target: 'new' | 'edit') {
|
||||
const input = target === 'new' ? this.newTagInput.trim() : this.editTagInput.trim();
|
||||
if (!input) return;
|
||||
@@ -1499,8 +1593,17 @@ export class MangaListComponent implements OnInit {
|
||||
notes: manga.notes,
|
||||
coverImage: manga.coverImage,
|
||||
tags: [...(manga.tags || [])],
|
||||
links: [...(manga.links || [])]
|
||||
links: [...(manga.links || [])],
|
||||
timeSpent: manga.timeSpent
|
||||
};
|
||||
// Populate time fields from existing timeSpent
|
||||
if (manga.timeSpent) {
|
||||
this.editMangaTimeHours = Math.floor(manga.timeSpent / 60);
|
||||
this.editMangaTimeMinutes = manga.timeSpent % 60;
|
||||
} else {
|
||||
this.editMangaTimeHours = 0;
|
||||
this.editMangaTimeMinutes = 0;
|
||||
}
|
||||
this.editMangaImagePreview.set(manga.coverImage || null);
|
||||
this.showAddForm.set(false);
|
||||
this.imageError.set(null);
|
||||
@@ -1590,6 +1693,19 @@ export class MangaListComponent implements OnInit {
|
||||
return new Date(date).toLocaleDateString();
|
||||
}
|
||||
|
||||
formatTimeSpent(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
|
||||
if (hours === 0) {
|
||||
return `${mins}m`;
|
||||
} else if (mins === 0) {
|
||||
return `${hours}h`;
|
||||
} else {
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
}
|
||||
|
||||
toggleComments(mangaId: string) {
|
||||
const expanded = this.expandedComments();
|
||||
const isCurrentlyExpanded = expanded[mangaId];
|
||||
|
||||
@@ -114,6 +114,34 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeHours"
|
||||
[(ngModel)]="newMusicTimeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateNewMusicTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeMinutes"
|
||||
[(ngModel)]="newMusicTimeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateNewMusicTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea
|
||||
@@ -274,6 +302,34 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="edit-timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-timeHours"
|
||||
[(ngModel)]="editMusicTimeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateEditMusicTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-timeMinutes"
|
||||
[(ngModel)]="editMusicTimeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateEditMusicTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-notes">Notes</label>
|
||||
<textarea
|
||||
@@ -600,6 +656,12 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (music.timeSpent) {
|
||||
<p class="time-spent">
|
||||
🎵 Listening Time: {{ formatTimeSpent(music.timeSpent) }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<app-like-button
|
||||
entityType="music"
|
||||
[entityId]="music.id"
|
||||
@@ -780,6 +842,13 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
||||
box-shadow: 0 0 0 3px rgba(168, 87, 126, 0.2);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
@@ -1036,6 +1105,13 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.time-spent {
|
||||
font-size: 0.9rem;
|
||||
color: #8b5cf6;
|
||||
font-weight: 500;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.date-started,
|
||||
.date-finished,
|
||||
.date-added,
|
||||
@@ -1526,6 +1602,12 @@ export class MusicListComponent implements OnInit {
|
||||
|
||||
editMusicData: Partial<UpdateMusicDto> = {};
|
||||
|
||||
// Time tracking state
|
||||
newMusicTimeHours = 0;
|
||||
newMusicTimeMinutes = 0;
|
||||
editMusicTimeHours = 0;
|
||||
editMusicTimeMinutes = 0;
|
||||
|
||||
// Tags and links input state
|
||||
newTagInput = '';
|
||||
editTagInput = '';
|
||||
@@ -1633,6 +1715,8 @@ export class MusicListComponent implements OnInit {
|
||||
tags: [],
|
||||
links: []
|
||||
};
|
||||
this.newMusicTimeHours = 0;
|
||||
this.newMusicTimeMinutes = 0;
|
||||
this.newMusicImagePreview.set(null);
|
||||
this.imageError.set(null);
|
||||
this.newTagInput = '';
|
||||
@@ -1640,6 +1724,16 @@ export class MusicListComponent implements OnInit {
|
||||
this.newLinkUrl = '';
|
||||
}
|
||||
|
||||
updateNewMusicTimeSpent() {
|
||||
const totalMinutes = (this.newMusicTimeHours * 60) + this.newMusicTimeMinutes;
|
||||
this.newMusic.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
|
||||
}
|
||||
|
||||
updateEditMusicTimeSpent() {
|
||||
const totalMinutes = (this.editMusicTimeHours * 60) + this.editMusicTimeMinutes;
|
||||
this.editMusicData.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
|
||||
}
|
||||
|
||||
addTag(target: 'new' | 'edit') {
|
||||
const input = target === 'new' ? this.newTagInput.trim() : this.editTagInput.trim();
|
||||
if (!input) return;
|
||||
@@ -1729,8 +1823,17 @@ export class MusicListComponent implements OnInit {
|
||||
notes: music.notes,
|
||||
coverArt: music.coverArt,
|
||||
tags: [...(music.tags || [])],
|
||||
links: [...(music.links || [])]
|
||||
links: [...(music.links || [])],
|
||||
timeSpent: music.timeSpent
|
||||
};
|
||||
// Populate time fields from existing timeSpent
|
||||
if (music.timeSpent) {
|
||||
this.editMusicTimeHours = Math.floor(music.timeSpent / 60);
|
||||
this.editMusicTimeMinutes = music.timeSpent % 60;
|
||||
} else {
|
||||
this.editMusicTimeHours = 0;
|
||||
this.editMusicTimeMinutes = 0;
|
||||
}
|
||||
this.editMusicImagePreview.set(music.coverArt || null);
|
||||
this.showAddForm.set(false);
|
||||
this.imageError.set(null);
|
||||
@@ -1769,6 +1872,19 @@ export class MusicListComponent implements OnInit {
|
||||
return new Date(date).toLocaleDateString();
|
||||
}
|
||||
|
||||
formatTimeSpent(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
|
||||
if (hours === 0) {
|
||||
return `${mins}m`;
|
||||
} else if (mins === 0) {
|
||||
return `${hours}h`;
|
||||
} else {
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
}
|
||||
|
||||
// Image handling methods
|
||||
onImageSelected(event: Event, target: 'new' | 'edit' | 'suggest') {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
@@ -103,6 +103,34 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeHours"
|
||||
[(ngModel)]="newShowTimeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateNewShowTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeMinutes"
|
||||
[(ngModel)]="newShowTimeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateNewShowTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea
|
||||
@@ -252,6 +280,34 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="edit-timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-timeHours"
|
||||
[(ngModel)]="editShowTimeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateEditShowTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-timeMinutes"
|
||||
[(ngModel)]="editShowTimeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateEditShowTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-notes">Notes</label>
|
||||
<textarea
|
||||
@@ -518,6 +574,12 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (show.timeSpent) {
|
||||
<p class="time-spent">
|
||||
📺 Watch Time: {{ formatTimeSpent(show.timeSpent) }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<app-like-button
|
||||
entityType="show"
|
||||
[entityId]="show.id"
|
||||
@@ -685,6 +747,13 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
@@ -879,6 +948,13 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.time-spent {
|
||||
font-size: 0.9rem;
|
||||
color: #8b5cf6;
|
||||
font-weight: 500;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.date-started,
|
||||
.date-finished,
|
||||
.date-added,
|
||||
@@ -1307,6 +1383,12 @@ export class ShowsListComponent implements OnInit {
|
||||
|
||||
editShow: Partial<UpdateShowDto> = {};
|
||||
|
||||
// Time tracking state
|
||||
newShowTimeHours = 0;
|
||||
newShowTimeMinutes = 0;
|
||||
editShowTimeHours = 0;
|
||||
editShowTimeMinutes = 0;
|
||||
|
||||
// Tags and links input state
|
||||
newTagInput = '';
|
||||
editTagInput = '';
|
||||
@@ -1408,6 +1490,8 @@ export class ShowsListComponent implements OnInit {
|
||||
tags: [],
|
||||
links: []
|
||||
};
|
||||
this.newShowTimeHours = 0;
|
||||
this.newShowTimeMinutes = 0;
|
||||
this.newShowImagePreview.set(null);
|
||||
this.imageError.set(null);
|
||||
this.newTagInput = '';
|
||||
@@ -1415,6 +1499,16 @@ export class ShowsListComponent implements OnInit {
|
||||
this.newLinkUrl = '';
|
||||
}
|
||||
|
||||
updateNewShowTimeSpent() {
|
||||
const totalMinutes = (this.newShowTimeHours * 60) + this.newShowTimeMinutes;
|
||||
this.newShow.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
|
||||
}
|
||||
|
||||
updateEditShowTimeSpent() {
|
||||
const totalMinutes = (this.editShowTimeHours * 60) + this.editShowTimeMinutes;
|
||||
this.editShow.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
|
||||
}
|
||||
|
||||
addTag(target: 'new' | 'edit') {
|
||||
const input = target === 'new' ? this.newTagInput.trim() : this.editTagInput.trim();
|
||||
if (!input) return;
|
||||
@@ -1502,8 +1596,17 @@ export class ShowsListComponent implements OnInit {
|
||||
notes: show.notes,
|
||||
coverImage: show.coverImage,
|
||||
tags: [...(show.tags || [])],
|
||||
links: [...(show.links || [])]
|
||||
links: [...(show.links || [])],
|
||||
timeSpent: show.timeSpent
|
||||
};
|
||||
// Populate time fields from existing timeSpent
|
||||
if (show.timeSpent) {
|
||||
this.editShowTimeHours = Math.floor(show.timeSpent / 60);
|
||||
this.editShowTimeMinutes = show.timeSpent % 60;
|
||||
} else {
|
||||
this.editShowTimeHours = 0;
|
||||
this.editShowTimeMinutes = 0;
|
||||
}
|
||||
this.editShowImagePreview.set(show.coverImage || null);
|
||||
this.showAddForm.set(false);
|
||||
this.imageError.set(null);
|
||||
@@ -1593,6 +1696,19 @@ export class ShowsListComponent implements OnInit {
|
||||
return new Date(date).toLocaleDateString();
|
||||
}
|
||||
|
||||
formatTimeSpent(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
|
||||
if (hours === 0) {
|
||||
return `${mins}m`;
|
||||
} else if (mins === 0) {
|
||||
return `${hours}h`;
|
||||
} else {
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
}
|
||||
|
||||
toggleComments(showId: string) {
|
||||
const expanded = this.expandedComments();
|
||||
const isCurrentlyExpanded = expanded[showId];
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import type { ActivityFeedResponse } from '@library/shared-types';
|
||||
import { ApiService } from './api.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ActivityService {
|
||||
private apiService = inject(ApiService);
|
||||
|
||||
/**
|
||||
* Get activity feed with pagination.
|
||||
*/
|
||||
getActivityFeed(limit = 50, offset = 0, userId?: string): Observable<ActivityFeedResponse> {
|
||||
const params = new URLSearchParams();
|
||||
params.append('limit', limit.toString());
|
||||
params.append('offset', offset.toString());
|
||||
if (userId) {
|
||||
params.append('userId', userId);
|
||||
}
|
||||
|
||||
return this.apiService.get<ActivityFeedResponse>(`/activity?${params.toString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get activity feed for a specific user.
|
||||
*/
|
||||
getUserActivityFeed(userId: string, limit = 50, offset = 0): Observable<ActivityFeedResponse> {
|
||||
const params = new URLSearchParams();
|
||||
params.append('limit', limit.toString());
|
||||
params.append('offset', offset.toString());
|
||||
|
||||
return this.apiService.get<ActivityFeedResponse>(`/activity/${userId}?${params.toString()}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import type {
|
||||
LeaderboardResponse,
|
||||
SuggestionsLeaderboard,
|
||||
LikesLeaderboard,
|
||||
CommentsLeaderboard,
|
||||
OverallLeaderboard,
|
||||
} from '@library/shared-types';
|
||||
import { ApiService } from './api.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class LeaderboardService {
|
||||
private apiService = inject(ApiService);
|
||||
|
||||
/**
|
||||
* Get all leaderboards at once.
|
||||
*/
|
||||
getAllLeaderboards(limit = 25): Observable<LeaderboardResponse> {
|
||||
return this.apiService.get<LeaderboardResponse>(`/leaderboard?limit=${limit}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top users by suggestions.
|
||||
*/
|
||||
getTopSuggestions(limit = 25): Observable<SuggestionsLeaderboard[]> {
|
||||
return this.apiService.get<SuggestionsLeaderboard[]>(`/leaderboard/suggestions?limit=${limit}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top users by likes.
|
||||
*/
|
||||
getTopLikes(limit = 25): Observable<LikesLeaderboard[]> {
|
||||
return this.apiService.get<LikesLeaderboard[]>(`/leaderboard/likes?limit=${limit}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top users by comments.
|
||||
*/
|
||||
getTopComments(limit = 25): Observable<CommentsLeaderboard[]> {
|
||||
return this.apiService.get<CommentsLeaderboard[]>(`/leaderboard/comments?limit=${limit}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get overall leaderboard.
|
||||
*/
|
||||
getOverallLeaderboard(limit = 25): Observable<OverallLeaderboard[]> {
|
||||
return this.apiService.get<OverallLeaderboard[]>(`/leaderboard/overall?limit=${limit}`);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
export * from "./lib/achievement.constants";
|
||||
export * from "./lib/achievement.types";
|
||||
export * from "./lib/activity.types";
|
||||
export type * from "./lib/art.types";
|
||||
export * from "./lib/audit.types";
|
||||
export * from "./lib/auth.types";
|
||||
@@ -12,6 +13,7 @@ export * from "./lib/book.types";
|
||||
export type * from "./lib/comment.types";
|
||||
export type * from "./lib/common.types";
|
||||
export * from "./lib/game.types";
|
||||
export type * from "./lib/leaderboard.types";
|
||||
export type * from "./lib/like.types";
|
||||
export * from "./lib/manga.types";
|
||||
export * from "./lib/music.types";
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
enum ActivityType {
|
||||
suggestion = "SUGGESTION",
|
||||
like = "LIKE",
|
||||
comment = "COMMENT",
|
||||
achievement = "ACHIEVEMENT",
|
||||
}
|
||||
|
||||
interface ActivityUser {
|
||||
id: string;
|
||||
username: string;
|
||||
slug: string | null;
|
||||
avatar: string | null;
|
||||
primaryBadge: string | null;
|
||||
isVip: boolean;
|
||||
isMod: boolean;
|
||||
isStaff: boolean;
|
||||
}
|
||||
|
||||
interface BaseActivity {
|
||||
id: string;
|
||||
type: ActivityType;
|
||||
user: ActivityUser;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface SuggestionActivity extends BaseActivity {
|
||||
type: ActivityType.suggestion;
|
||||
entityType: string;
|
||||
suggestionTitle: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface LikeActivity extends BaseActivity {
|
||||
type: ActivityType.like;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
entityTitle: string;
|
||||
}
|
||||
|
||||
interface CommentActivity extends BaseActivity {
|
||||
type: ActivityType.comment;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
entityTitle: string;
|
||||
commentPreview: string;
|
||||
}
|
||||
|
||||
interface AchievementActivity extends BaseActivity {
|
||||
type: ActivityType.achievement;
|
||||
achievementKey: string;
|
||||
achievementName: string;
|
||||
achievementIcon: string;
|
||||
achievementPoints: number;
|
||||
}
|
||||
|
||||
type Activity = SuggestionActivity | LikeActivity | CommentActivity | AchievementActivity;
|
||||
|
||||
interface ActivityFeedResponse {
|
||||
activities: Activity[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export { ActivityType };
|
||||
export type {
|
||||
Activity,
|
||||
ActivityFeedResponse,
|
||||
ActivityUser,
|
||||
AchievementActivity,
|
||||
CommentActivity,
|
||||
LikeActivity,
|
||||
SuggestionActivity,
|
||||
};
|
||||
@@ -27,6 +27,9 @@ interface Book {
|
||||
coverImage?: string;
|
||||
tags: Array<string>;
|
||||
links: Array<Link>;
|
||||
series?: string;
|
||||
seriesOrder?: number;
|
||||
timeSpent?: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -43,6 +46,9 @@ interface CreateBookDto {
|
||||
coverImage?: string;
|
||||
tags?: Array<string>;
|
||||
links?: Array<Link>;
|
||||
series?: string;
|
||||
seriesOrder?: number;
|
||||
timeSpent?: number;
|
||||
}
|
||||
|
||||
interface UpdateBookDto extends Partial<CreateBookDto> {
|
||||
|
||||
@@ -27,6 +27,9 @@ interface Game {
|
||||
coverImage?: string;
|
||||
tags: Array<string>;
|
||||
links: Array<Link>;
|
||||
series?: string;
|
||||
seriesOrder?: number;
|
||||
timeSpent?: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -42,6 +45,9 @@ interface CreateGameDto {
|
||||
coverImage?: string;
|
||||
tags?: Array<string>;
|
||||
links?: Array<Link>;
|
||||
series?: string;
|
||||
seriesOrder?: number;
|
||||
timeSpent?: number;
|
||||
}
|
||||
|
||||
interface UpdateGameDto extends Partial<CreateGameDto> {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
interface LeaderboardUser {
|
||||
id: string;
|
||||
username: string;
|
||||
slug: string | null;
|
||||
avatar: string | null;
|
||||
primaryBadge: string | null;
|
||||
isVip: boolean;
|
||||
isMod: boolean;
|
||||
isStaff: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface SuggestionsLeaderboard extends LeaderboardUser {
|
||||
totalSuggestions: number;
|
||||
acceptedSuggestions: number;
|
||||
acceptanceRate: number;
|
||||
}
|
||||
|
||||
interface LikesLeaderboard extends LeaderboardUser {
|
||||
totalLikes: number;
|
||||
}
|
||||
|
||||
interface CommentsLeaderboard extends LeaderboardUser {
|
||||
totalComments: number;
|
||||
}
|
||||
|
||||
interface OverallLeaderboard extends LeaderboardUser {
|
||||
totalSuggestions: number;
|
||||
totalLikes: number;
|
||||
totalComments: number;
|
||||
achievementCount: number;
|
||||
achievementPoints: number;
|
||||
currentStreak: number;
|
||||
diversityScore: number;
|
||||
}
|
||||
|
||||
interface LeaderboardResponse {
|
||||
topSuggestions: SuggestionsLeaderboard[];
|
||||
topLikes: LikesLeaderboard[];
|
||||
topComments: CommentsLeaderboard[];
|
||||
topOverall: OverallLeaderboard[];
|
||||
}
|
||||
|
||||
export {
|
||||
type LeaderboardUser,
|
||||
type SuggestionsLeaderboard,
|
||||
type LikesLeaderboard,
|
||||
type CommentsLeaderboard,
|
||||
type OverallLeaderboard,
|
||||
type LeaderboardResponse,
|
||||
};
|
||||
@@ -27,6 +27,7 @@ interface Manga {
|
||||
coverImage?: string;
|
||||
tags: Array<string>;
|
||||
links: Array<Link>;
|
||||
timeSpent?: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -42,6 +43,7 @@ interface CreateMangaDto {
|
||||
coverImage?: string;
|
||||
tags?: Array<string>;
|
||||
links?: Array<Link>;
|
||||
timeSpent?: number;
|
||||
}
|
||||
|
||||
interface UpdateMangaDto extends Partial<CreateMangaDto> {
|
||||
|
||||
@@ -34,6 +34,7 @@ interface Music {
|
||||
coverArt?: string;
|
||||
tags: Array<string>;
|
||||
links: Array<Link>;
|
||||
timeSpent?: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -50,6 +51,7 @@ interface CreateMusicDto {
|
||||
coverArt?: string;
|
||||
tags?: Array<string>;
|
||||
links?: Array<Link>;
|
||||
timeSpent?: number;
|
||||
}
|
||||
|
||||
interface UpdateMusicDto extends Partial<CreateMusicDto> {
|
||||
|
||||
@@ -34,6 +34,7 @@ interface Show {
|
||||
coverImage?: string;
|
||||
tags: Array<string>;
|
||||
links: Array<Link>;
|
||||
timeSpent?: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -49,6 +50,7 @@ interface CreateShowDto {
|
||||
coverImage?: string;
|
||||
tags?: Array<string>;
|
||||
links?: Array<Link>;
|
||||
timeSpent?: number;
|
||||
}
|
||||
|
||||
interface UpdateShowDto extends Partial<CreateShowDto> {
|
||||
|
||||
Reference in New Issue
Block a user