generated from nhcarrigan/template
Compare commits
15 Commits
v1.1.0
...
7606a18e38
| Author | SHA1 | Date | |
|---|---|---|---|
| 7606a18e38 | |||
| b8d3365158 | |||
| 6ef787a3b8 | |||
| 512e7eec13 | |||
| f0052cad76 | |||
| 7f255afce6 | |||
| 391ea6d9f6 | |||
| 808df775a4 | |||
| b1aab70b44 | |||
| 5ad9b50dc8 | |||
| fa331df203 | |||
| f839059dd2 | |||
| 8f95f57838 | |||
| 5a59afaa5e | |||
| f2797d0479 |
@@ -32,6 +32,9 @@ model Game {
|
|||||||
coverImage String?
|
coverImage String?
|
||||||
tags String[]
|
tags String[]
|
||||||
links Link[]
|
links Link[]
|
||||||
|
series String?
|
||||||
|
seriesOrder Int? @db.Int
|
||||||
|
timeSpent Int? @db.Int
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
@@ -58,6 +61,9 @@ model Book {
|
|||||||
coverImage String?
|
coverImage String?
|
||||||
tags String[]
|
tags String[]
|
||||||
links Link[]
|
links Link[]
|
||||||
|
series String?
|
||||||
|
seriesOrder Int? @db.Int
|
||||||
|
timeSpent Int? @db.Int
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
@@ -85,6 +91,7 @@ model Music {
|
|||||||
coverArt String?
|
coverArt String?
|
||||||
tags String[]
|
tags String[]
|
||||||
links Link[]
|
links Link[]
|
||||||
|
timeSpent Int? @db.Int
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
@@ -131,6 +138,7 @@ model Show {
|
|||||||
coverImage String?
|
coverImage String?
|
||||||
tags String[]
|
tags String[]
|
||||||
links Link[]
|
links Link[]
|
||||||
|
timeSpent Int? @db.Int
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
@@ -164,6 +172,7 @@ model Manga {
|
|||||||
coverImage String?
|
coverImage String?
|
||||||
tags String[]
|
tags String[]
|
||||||
links Link[]
|
links Link[]
|
||||||
|
timeSpent Int? @db.Int
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
comments Comment[]
|
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();
|
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).
|
* Get single book by ID (public route).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
return gameService.getAllGames();
|
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)
|
// Get single game (public route)
|
||||||
app.get<{ Params: { id: string }; Reply: Game | null }>(
|
app.get<{ Params: { id: string }; Reply: Game | null }>(
|
||||||
"/:id",
|
"/: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,353 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Activity,
|
||||||
|
ActivityFeedResponse,
|
||||||
|
ActivityUser,
|
||||||
|
SuggestionActivity,
|
||||||
|
LikeActivity,
|
||||||
|
CommentActivity,
|
||||||
|
AchievementActivity,
|
||||||
|
} 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
|
||||||
|
): Promise<SuggestionActivity[]> {
|
||||||
|
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
|
||||||
|
): Promise<LikeActivity[]> {
|
||||||
|
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): Promise<LikeActivity> => {
|
||||||
|
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
|
||||||
|
): Promise<CommentActivity[]> {
|
||||||
|
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
|
||||||
|
): Promise<AchievementActivity[]> {
|
||||||
|
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.
|
* 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.
|
* 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,30 @@ export const appRoutes: Route[] = [
|
|||||||
path: 'manga',
|
path: 'manga',
|
||||||
loadComponent: () => import('./components/manga/manga-list.component').then(m => m.MangaListComponent)
|
loadComponent: () => import('./components/manga/manga-list.component').then(m => m.MangaListComponent)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'games/:id',
|
||||||
|
loadComponent: () => import('./components/games/game-detail.component').then(m => m.GameDetailComponent)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'books/:id',
|
||||||
|
loadComponent: () => import('./components/books/book-detail.component').then(m => m.BookDetailComponent)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'music/:id',
|
||||||
|
loadComponent: () => import('./components/music/music-detail.component').then(m => m.MusicDetailComponent)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'art/:id',
|
||||||
|
loadComponent: () => import('./components/art/art-detail.component').then(m => m.ArtDetailComponent)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'shows/:id',
|
||||||
|
loadComponent: () => import('./components/shows/show-detail.component').then(m => m.ShowDetailComponent)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'manga/:id',
|
||||||
|
loadComponent: () => import('./components/manga/manga-detail.component').then(m => m.MangaDetailComponent)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'admin/users',
|
path: 'admin/users',
|
||||||
loadComponent: () => import('./components/admin/admin-users.component').then(m => m.AdminUsersComponent)
|
loadComponent: () => import('./components/admin/admin-users.component').then(m => m.AdminUsersComponent)
|
||||||
@@ -65,6 +89,18 @@ export const appRoutes: Route[] = [
|
|||||||
path: 'achievements',
|
path: 'achievements',
|
||||||
loadComponent: () => import('./components/achievements/achievements.component').then(m => m.AchievementsComponent)
|
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: '**',
|
path: '**',
|
||||||
redirectTo: ''
|
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,440 @@
|
|||||||
|
/**
|
||||||
|
* @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';
|
||||||
|
import { SanitizeService } from '../../services/sanitize.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', activity.entityId]" class="entity-link">
|
||||||
|
{{ activity.entityTitle }}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@case (ActivityType.comment) {
|
||||||
|
<div class="activity-comment">
|
||||||
|
<div class="activity-comment-header">
|
||||||
|
<span class="activity-icon">💬</span>
|
||||||
|
<span class="activity-text">
|
||||||
|
commented on
|
||||||
|
<a [routerLink]="['/' + activity.entityType + 's', activity.entityId]" class="entity-link">
|
||||||
|
{{ activity.entityTitle }}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="comment-preview" [innerHTML]="sanitizeService.sanitizeHtml(activity.commentPreview)"></div>
|
||||||
|
</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-header,
|
||||||
|
.activity-achievement {
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-comment {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
margin-left: 55px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-left: 3px solid #10b981;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
public sanitizeService = inject(SanitizeService);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,542 @@
|
|||||||
|
/**
|
||||||
|
* @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, ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ArtService } from '../../services/art.service';
|
||||||
|
import { CommentsService } from '../../services/comments.service';
|
||||||
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
import { SanitizeService } from '../../services/sanitize.service';
|
||||||
|
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
|
||||||
|
import { LikeButtonComponent } from '../shared/like-button.component';
|
||||||
|
import { Art, Comment } from '@library/shared-types';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-art-detail',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent],
|
||||||
|
template: `
|
||||||
|
<div class="container">
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a routerLink="/art" class="breadcrumb-link">← Back to Art</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="loading">Loading artwork details...</div>
|
||||||
|
} @else if (error()) {
|
||||||
|
<div class="error-state">
|
||||||
|
<h2>Artwork Not Found</h2>
|
||||||
|
<p>{{ error() }}</p>
|
||||||
|
<a routerLink="/art" class="btn btn-primary">Return to Art Gallery</a>
|
||||||
|
</div>
|
||||||
|
} @else if (art()) {
|
||||||
|
<div class="art-detail-card">
|
||||||
|
@if (art()!.imageUrl) {
|
||||||
|
<div class="art-image-section">
|
||||||
|
<img [src]="art()!.imageUrl" [alt]="art()!.description || art()!.title" class="art-image-large">
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="art-content">
|
||||||
|
<div class="art-header">
|
||||||
|
<h1>{{ art()!.title }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="artist">by {{ art()!.artist }}</p>
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Added:</span>
|
||||||
|
<span class="info-value">{{ formatDate(art()!.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Last Updated:</span>
|
||||||
|
<span class="info-value">{{ formatDate(art()!.updatedAt) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="like-section">
|
||||||
|
<app-like-button
|
||||||
|
entityType="art"
|
||||||
|
[entityId]="art()!.id"
|
||||||
|
></app-like-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (art()!.description) {
|
||||||
|
<div class="notes-section">
|
||||||
|
<h3>Description</h3>
|
||||||
|
<p class="notes">{{ art()!.description }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (art()!.tags && art()!.tags.length > 0) {
|
||||||
|
<div class="tags-section">
|
||||||
|
<h3>Tags</h3>
|
||||||
|
<div class="tags-display">
|
||||||
|
@for (tag of art()!.tags; track tag) {
|
||||||
|
<span class="tag-chip">{{ tag }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (art()!.links && art()!.links.length > 0) {
|
||||||
|
<div class="links-section">
|
||||||
|
<h3>External Links</h3>
|
||||||
|
<div class="links-display">
|
||||||
|
@for (link of art()!.links; track link.url) {
|
||||||
|
<a [href]="link.url" target="_blank" rel="noopener noreferrer" class="external-link">
|
||||||
|
{{ link.title }} ↗
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="comments-section">
|
||||||
|
<h3>Comments</h3>
|
||||||
|
@if (authService.isAuthenticated()) {
|
||||||
|
@if (authService.user()?.isBanned) {
|
||||||
|
<div class="banned-notice">
|
||||||
|
You have been banned from commenting.
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<form (ngSubmit)="addComment()" class="comment-form">
|
||||||
|
<textarea
|
||||||
|
[(ngModel)]="newCommentContent"
|
||||||
|
name="comment"
|
||||||
|
placeholder="Add a comment (Markdown supported)..."
|
||||||
|
rows="3"
|
||||||
|
></textarea>
|
||||||
|
<button type="submit" class="btn btn-primary">Post Comment</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<div class="auth-prompt">
|
||||||
|
<p>Please sign in to comment.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (commentsLoading()) {
|
||||||
|
<div class="comments-loading">Loading comments...</div>
|
||||||
|
} @else {
|
||||||
|
<app-comment-display
|
||||||
|
[comments]="comments"
|
||||||
|
(edit)="handleCommentEdit($event)"
|
||||||
|
(delete)="deleteComment($event)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.container {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-link {
|
||||||
|
color: #ec4899;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-link:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: #666;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state h2 {
|
||||||
|
color: #991b1b;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state p {
|
||||||
|
color: #dc2626;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.art-detail-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.art-image-section {
|
||||||
|
width: 100%;
|
||||||
|
background: #f3f4f6;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.art-image-large {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 600px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.art-content {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.art-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.art-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 2rem;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist {
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4b5563;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating span {
|
||||||
|
color: #e5e7eb;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating span.filled {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-text {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.like-section {
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-section,
|
||||||
|
.tags-section,
|
||||||
|
.links-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-section h3,
|
||||||
|
.tags-section h3,
|
||||||
|
.links-section h3,
|
||||||
|
.comments-section h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #4b5563;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-display {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip {
|
||||||
|
background: #ec4899;
|
||||||
|
color: white;
|
||||||
|
padding: 0.375rem 0.875rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links-display {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-link {
|
||||||
|
color: #ec4899;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 2px solid #ec4899;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-link:hover {
|
||||||
|
background: #ec4899;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding-top: 2rem;
|
||||||
|
border-top: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
resize: vertical;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #ec4899;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banned-notice {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
color: #991b1b;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-prompt {
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-prompt p {
|
||||||
|
margin: 0;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: all 0.3s;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #ec4899;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.container {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.art-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.art-header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class ArtDetailComponent implements OnInit {
|
||||||
|
private readonly artService = inject(ArtService);
|
||||||
|
private readonly commentsService = inject(CommentsService);
|
||||||
|
readonly authService = inject(AuthService);
|
||||||
|
private readonly sanitizeService = inject(SanitizeService);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
art = signal<Art | null>(null);
|
||||||
|
comments = signal<Comment[]>([]);
|
||||||
|
loading = signal(true);
|
||||||
|
commentsLoading = signal(false);
|
||||||
|
error = signal<string | null>(null);
|
||||||
|
newCommentContent = '';
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
const artId = this.route.snapshot.paramMap.get('id');
|
||||||
|
if (!artId) {
|
||||||
|
this.error.set('No artwork ID provided');
|
||||||
|
this.loading.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadArt(artId);
|
||||||
|
this.loadComments(artId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadArt(artId: string) {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.artService.getArtById(artId).subscribe({
|
||||||
|
next: (art) => {
|
||||||
|
if (!art) {
|
||||||
|
this.error.set('Artwork not found');
|
||||||
|
} else {
|
||||||
|
this.art.set(art);
|
||||||
|
}
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.error.set('Failed to load artwork. It may not exist or there was an error.');
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadComments(artId: string) {
|
||||||
|
this.commentsLoading.set(true);
|
||||||
|
this.commentsService.getCommentsForArt(artId).subscribe({
|
||||||
|
next: (comments) => {
|
||||||
|
this.comments.set(comments);
|
||||||
|
this.commentsLoading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.commentsLoading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addComment() {
|
||||||
|
const art = this.art();
|
||||||
|
if (!art || !this.newCommentContent.trim()) return;
|
||||||
|
|
||||||
|
this.commentsService.addCommentToArt(art.id, { content: this.newCommentContent }).subscribe({
|
||||||
|
next: (comment) => {
|
||||||
|
this.comments.set([comment, ...this.comments()]);
|
||||||
|
this.newCommentContent = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCommentEdit(event: { commentId: string; content: string }) {
|
||||||
|
const art = this.art();
|
||||||
|
if (!art) return;
|
||||||
|
|
||||||
|
this.commentsService.updateCommentOnArt(art.id, event.commentId, event.content).subscribe({
|
||||||
|
next: (updatedComment) => {
|
||||||
|
this.comments.set(
|
||||||
|
this.comments().map(c => c.id === event.commentId ? updatedComment : c)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteComment(commentId: string) {
|
||||||
|
const art = this.art();
|
||||||
|
if (!art || !confirm('Are you sure you want to delete this comment?')) return;
|
||||||
|
|
||||||
|
this.commentsService.deleteCommentFromArt(art.id, commentId).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.comments.set(this.comments().filter(c => c.id !== commentId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(date: Date | string): string {
|
||||||
|
return new Date(date).toLocaleDateString('en-GB', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,654 @@
|
|||||||
|
/**
|
||||||
|
* @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, ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { BooksService } from '../../services/books.service';
|
||||||
|
import { CommentsService } from '../../services/comments.service';
|
||||||
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
import { SanitizeService } from '../../services/sanitize.service';
|
||||||
|
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
|
||||||
|
import { LikeButtonComponent } from '../shared/like-button.component';
|
||||||
|
import { Book, Comment, BookStatus } from '@library/shared-types';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-book-detail',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent],
|
||||||
|
template: `
|
||||||
|
<div class="container">
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a routerLink="/books" class="breadcrumb-link">← Back to Books</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="loading">Loading book details...</div>
|
||||||
|
} @else if (error()) {
|
||||||
|
<div class="error-state">
|
||||||
|
<h2>Book Not Found</h2>
|
||||||
|
<p>{{ error() }}</p>
|
||||||
|
<a routerLink="/books" class="btn btn-primary">Return to Books</a>
|
||||||
|
</div>
|
||||||
|
} @else if (book()) {
|
||||||
|
<div class="book-detail-card">
|
||||||
|
@if (book()!.coverImage) {
|
||||||
|
<div class="book-cover-section">
|
||||||
|
<img [src]="book()!.coverImage" [alt]="book()!.title" class="book-cover-large">
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="book-content">
|
||||||
|
<div class="book-header">
|
||||||
|
<h1>{{ book()!.title }}</h1>
|
||||||
|
<span class="status status-{{ book()!.status }}">
|
||||||
|
{{ getStatusLabel(book()!.status) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="author">by {{ book()!.author }}</p>
|
||||||
|
|
||||||
|
@if (book()!.series) {
|
||||||
|
<p class="series">
|
||||||
|
📚 {{ book()!.series }}@if (book()!.seriesOrder) { #{{ book()!.seriesOrder }}}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (book()!.isbn) {
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">ISBN:</span>
|
||||||
|
<span class="info-value">{{ book()!.isbn }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (book()!.rating) {
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Rating:</span>
|
||||||
|
<div class="rating">
|
||||||
|
@for (star of [1,2,3,4,5,6,7,8,9,10]; track star) {
|
||||||
|
<span [class.filled]="star <= book()!.rating!">★</span>
|
||||||
|
}
|
||||||
|
<span class="rating-text">({{ book()!.rating }}/10)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (book()!.timeSpent) {
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Reading Time:</span>
|
||||||
|
<span class="info-value time-spent">{{ formatTimeSpent(book()!.timeSpent!) }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (book()!.dateStarted) {
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Started:</span>
|
||||||
|
<span class="info-value">{{ formatDate(book()!.dateStarted!) }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (book()!.dateFinished) {
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Finished:</span>
|
||||||
|
<span class="info-value">{{ formatDate(book()!.dateFinished!) }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Added:</span>
|
||||||
|
<span class="info-value">{{ formatDate(book()!.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Last Updated:</span>
|
||||||
|
<span class="info-value">{{ formatDate(book()!.updatedAt) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="like-section">
|
||||||
|
<app-like-button
|
||||||
|
entityType="book"
|
||||||
|
[entityId]="book()!.id"
|
||||||
|
></app-like-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (book()!.notes) {
|
||||||
|
<div class="notes-section">
|
||||||
|
<h3>Notes</h3>
|
||||||
|
<p class="notes">{{ book()!.notes }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (book()!.tags && book()!.tags.length > 0) {
|
||||||
|
<div class="tags-section">
|
||||||
|
<h3>Tags</h3>
|
||||||
|
<div class="tags-display">
|
||||||
|
@for (tag of book()!.tags; track tag) {
|
||||||
|
<span class="tag-chip">{{ tag }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (book()!.links && book()!.links.length > 0) {
|
||||||
|
<div class="links-section">
|
||||||
|
<h3>External Links</h3>
|
||||||
|
<div class="links-display">
|
||||||
|
@for (link of book()!.links; track link.url) {
|
||||||
|
<a [href]="link.url" target="_blank" rel="noopener noreferrer" class="external-link">
|
||||||
|
{{ link.title }} ↗
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="comments-section">
|
||||||
|
<h3>Comments</h3>
|
||||||
|
@if (authService.isAuthenticated()) {
|
||||||
|
@if (authService.user()?.isBanned) {
|
||||||
|
<div class="banned-notice">
|
||||||
|
You have been banned from commenting.
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<form (ngSubmit)="addComment()" class="comment-form">
|
||||||
|
<textarea
|
||||||
|
[(ngModel)]="newCommentContent"
|
||||||
|
name="comment"
|
||||||
|
placeholder="Add a comment (Markdown supported)..."
|
||||||
|
rows="3"
|
||||||
|
></textarea>
|
||||||
|
<button type="submit" class="btn btn-primary">Post Comment</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<div class="auth-prompt">
|
||||||
|
<p>Please sign in to comment.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (commentsLoading()) {
|
||||||
|
<div class="comments-loading">Loading comments...</div>
|
||||||
|
} @else {
|
||||||
|
<app-comment-display
|
||||||
|
[comments]="comments"
|
||||||
|
(edit)="handleCommentEdit($event)"
|
||||||
|
(delete)="deleteComment($event)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.container {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-link {
|
||||||
|
color: #8b6f47;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-link:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: #666;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state h2 {
|
||||||
|
color: #991b1b;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state p {
|
||||||
|
color: #dc2626;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-detail-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-cover-section {
|
||||||
|
width: 100%;
|
||||||
|
background: #f3f4f6;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-cover-large {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 400px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-content {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 2rem;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author {
|
||||||
|
color: #6b7280;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-reading {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-finished {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-toRead {
|
||||||
|
background: #e0e7ff;
|
||||||
|
color: #3730a3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-retired {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series {
|
||||||
|
color: #8b6f47;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0.5rem 0 1.5rem 0;
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4b5563;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-spent {
|
||||||
|
color: #10b981;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating span {
|
||||||
|
color: #e5e7eb;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating span.filled {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-text {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.like-section {
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-section,
|
||||||
|
.tags-section,
|
||||||
|
.links-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-section h3,
|
||||||
|
.tags-section h3,
|
||||||
|
.links-section h3,
|
||||||
|
.comments-section h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #4b5563;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-display {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip {
|
||||||
|
background: #8b6f47;
|
||||||
|
color: white;
|
||||||
|
padding: 0.375rem 0.875rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links-display {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-link {
|
||||||
|
color: #8b6f47;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 2px solid #8b6f47;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-link:hover {
|
||||||
|
background: #8b6f47;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding-top: 2rem;
|
||||||
|
border-top: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
resize: vertical;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #8b6f47;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banned-notice {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
color: #991b1b;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-prompt {
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-prompt p {
|
||||||
|
margin: 0;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: all 0.3s;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #8b6f47;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.container {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class BookDetailComponent implements OnInit {
|
||||||
|
private readonly booksService = inject(BooksService);
|
||||||
|
private readonly commentsService = inject(CommentsService);
|
||||||
|
readonly authService = inject(AuthService);
|
||||||
|
private readonly sanitizeService = inject(SanitizeService);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
book = signal<Book | null>(null);
|
||||||
|
comments = signal<Comment[]>([]);
|
||||||
|
loading = signal(true);
|
||||||
|
commentsLoading = signal(false);
|
||||||
|
error = signal<string | null>(null);
|
||||||
|
newCommentContent = '';
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
const bookId = this.route.snapshot.paramMap.get('id');
|
||||||
|
if (!bookId) {
|
||||||
|
this.error.set('No book ID provided');
|
||||||
|
this.loading.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadBook(bookId);
|
||||||
|
this.loadComments(bookId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadBook(bookId: string) {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.booksService.getBookById(bookId).subscribe({
|
||||||
|
next: (book) => {
|
||||||
|
if (!book) {
|
||||||
|
this.error.set('Book not found');
|
||||||
|
} else {
|
||||||
|
this.book.set(book);
|
||||||
|
}
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.error.set('Failed to load book. It may not exist or there was an error.');
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadComments(bookId: string) {
|
||||||
|
this.commentsLoading.set(true);
|
||||||
|
this.commentsService.getCommentsForBook(bookId).subscribe({
|
||||||
|
next: (comments) => {
|
||||||
|
this.comments.set(comments);
|
||||||
|
this.commentsLoading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.commentsLoading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addComment() {
|
||||||
|
const book = this.book();
|
||||||
|
if (!book || !this.newCommentContent.trim()) return;
|
||||||
|
|
||||||
|
this.commentsService.addCommentToBook(book.id, { content: this.newCommentContent }).subscribe({
|
||||||
|
next: (comment) => {
|
||||||
|
this.comments.set([comment, ...this.comments()]);
|
||||||
|
this.newCommentContent = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCommentEdit(event: { commentId: string; content: string }) {
|
||||||
|
const book = this.book();
|
||||||
|
if (!book) return;
|
||||||
|
|
||||||
|
this.commentsService.updateCommentOnBook(book.id, event.commentId, event.content).subscribe({
|
||||||
|
next: (updatedComment) => {
|
||||||
|
this.comments.set(
|
||||||
|
this.comments().map(c => c.id === event.commentId ? updatedComment : c)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteComment(commentId: string) {
|
||||||
|
const book = this.book();
|
||||||
|
if (!book || !confirm('Are you sure you want to delete this comment?')) return;
|
||||||
|
|
||||||
|
this.commentsService.deleteCommentFromBook(book.id, commentId).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.comments.set(this.comments().filter(c => c.id !== commentId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusLabel(status: BookStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case BookStatus.reading: return 'Currently Reading';
|
||||||
|
case BookStatus.finished: return 'Finished';
|
||||||
|
case BookStatus.toRead: return 'To Read';
|
||||||
|
case BookStatus.retired: return 'Retired';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(date: Date | string): string {
|
||||||
|
return new Date(date).toLocaleDateString('en-GB', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
formatTimeSpent(minutes: number): string {
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
|
||||||
|
if (hours === 0) {
|
||||||
|
return `${mins} minutes`;
|
||||||
|
} else if (mins === 0) {
|
||||||
|
return `${hours} hour${hours === 1 ? '' : 's'}`;
|
||||||
|
} else {
|
||||||
|
return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,6 +116,34 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
|||||||
>
|
>
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<label for="notes">Notes</label>
|
<label for="notes">Notes</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -127,6 +155,29 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
|||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<label for="coverImage">Cover Image (max 500KB)</label>
|
<label for="coverImage">Cover Image (max 500KB)</label>
|
||||||
<input
|
<input
|
||||||
@@ -278,6 +329,34 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
|||||||
>
|
>
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<label for="edit-notes">Notes</label>
|
<label for="edit-notes">Notes</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -289,6 +368,29 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
|||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<label for="edit-coverImage">Cover Image (max 500KB)</label>
|
<label for="edit-coverImage">Cover Image (max 500KB)</label>
|
||||||
<input
|
<input
|
||||||
@@ -549,6 +651,11 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
|||||||
<div class="book-info">
|
<div class="book-info">
|
||||||
<h3>{{ book.title }}</h3>
|
<h3>{{ book.title }}</h3>
|
||||||
<p class="author">by {{ book.author }}</p>
|
<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 }}">
|
<span class="status status-{{ book.status }}">
|
||||||
{{ getStatusLabel(book.status) }}
|
{{ getStatusLabel(book.status) }}
|
||||||
@@ -562,6 +669,12 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (book.timeSpent) {
|
||||||
|
<p class="time-spent">
|
||||||
|
📖 Reading Time: {{ formatTimeSpent(book.timeSpent) }}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
<app-like-button
|
<app-like-button
|
||||||
entityType="book"
|
entityType="book"
|
||||||
[entityId]="book.id"
|
[entityId]="book.id"
|
||||||
@@ -719,6 +832,13 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.form-group {
|
.form-group {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
@@ -965,6 +1085,14 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
|||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.series {
|
||||||
|
color: #8b6f47;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
.status {
|
.status {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
@@ -999,6 +1127,13 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
|||||||
color: var(--witch-rose);
|
color: var(--witch-rose);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.time-spent {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #10b981;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
.isbn {
|
.isbn {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--witch-mauve);
|
color: var(--witch-mauve);
|
||||||
@@ -1508,6 +1643,11 @@ export class BooksListComponent implements OnInit {
|
|||||||
|
|
||||||
editBook: Partial<UpdateBookDto> = {};
|
editBook: Partial<UpdateBookDto> = {};
|
||||||
|
|
||||||
|
newBookTimeHours = 0;
|
||||||
|
newBookTimeMinutes = 0;
|
||||||
|
editBookTimeHours = 0;
|
||||||
|
editBookTimeMinutes = 0;
|
||||||
|
|
||||||
// Tags and links input state
|
// Tags and links input state
|
||||||
newTagInput = '';
|
newTagInput = '';
|
||||||
editTagInput = '';
|
editTagInput = '';
|
||||||
@@ -1610,6 +1750,8 @@ export class BooksListComponent implements OnInit {
|
|||||||
this.newTagInput = '';
|
this.newTagInput = '';
|
||||||
this.newLinkTitle = '';
|
this.newLinkTitle = '';
|
||||||
this.newLinkUrl = '';
|
this.newLinkUrl = '';
|
||||||
|
this.newBookTimeHours = 0;
|
||||||
|
this.newBookTimeMinutes = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
addTag(target: 'new' | 'edit') {
|
addTag(target: 'new' | 'edit') {
|
||||||
@@ -1701,7 +1843,9 @@ export class BooksListComponent implements OnInit {
|
|||||||
notes: book.notes,
|
notes: book.notes,
|
||||||
coverImage: book.coverImage,
|
coverImage: book.coverImage,
|
||||||
tags: [...(book.tags || [])],
|
tags: [...(book.tags || [])],
|
||||||
links: [...(book.links || [])]
|
links: [...(book.links || [])],
|
||||||
|
series: book.series,
|
||||||
|
seriesOrder: book.seriesOrder
|
||||||
};
|
};
|
||||||
this.editBookImagePreview.set(book.coverImage || null);
|
this.editBookImagePreview.set(book.coverImage || null);
|
||||||
this.showAddForm.set(false);
|
this.showAddForm.set(false);
|
||||||
@@ -1709,6 +1853,13 @@ export class BooksListComponent implements OnInit {
|
|||||||
this.editTagInput = '';
|
this.editTagInput = '';
|
||||||
this.editLinkTitle = '';
|
this.editLinkTitle = '';
|
||||||
this.editLinkUrl = '';
|
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() {
|
cancelEdit() {
|
||||||
@@ -1741,6 +1892,29 @@ export class BooksListComponent implements OnInit {
|
|||||||
return new Date(date).toLocaleDateString();
|
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
|
// Image handling methods
|
||||||
onImageSelected(event: Event, target: 'new' | 'edit' | 'suggest') {
|
onImageSelected(event: Event, target: 'new' | 'edit' | 'suggest') {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
|
|||||||
@@ -0,0 +1,645 @@
|
|||||||
|
/**
|
||||||
|
* @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, ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { GamesService } from '../../services/games.service';
|
||||||
|
import { CommentsService } from '../../services/comments.service';
|
||||||
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
import { SanitizeService } from '../../services/sanitize.service';
|
||||||
|
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
|
||||||
|
import { LikeButtonComponent } from '../shared/like-button.component';
|
||||||
|
import { Game, Comment, GameStatus } from '@library/shared-types';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-game-detail',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent],
|
||||||
|
template: `
|
||||||
|
<div class="container">
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a routerLink="/games" class="breadcrumb-link">← Back to Games</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="loading">Loading game details...</div>
|
||||||
|
} @else if (error()) {
|
||||||
|
<div class="error-state">
|
||||||
|
<h2>Game Not Found</h2>
|
||||||
|
<p>{{ error() }}</p>
|
||||||
|
<a routerLink="/games" class="btn btn-primary">Return to Games</a>
|
||||||
|
</div>
|
||||||
|
} @else if (game()) {
|
||||||
|
<div class="game-detail-card">
|
||||||
|
@if (game()!.coverImage) {
|
||||||
|
<div class="game-cover-section">
|
||||||
|
<img [src]="game()!.coverImage" [alt]="game()!.title" class="game-cover-large">
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="game-content">
|
||||||
|
<div class="game-header">
|
||||||
|
<h1>{{ game()!.title }}</h1>
|
||||||
|
<span class="status status-{{ game()!.status }}">
|
||||||
|
{{ getStatusLabel(game()!.status) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (game()!.series) {
|
||||||
|
<p class="series">
|
||||||
|
📚 {{ game()!.series }}@if (game()!.seriesOrder) { #{{ game()!.seriesOrder }}}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (game()!.platform) {
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Platform:</span>
|
||||||
|
<span class="info-value">{{ game()!.platform }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (game()!.rating) {
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Rating:</span>
|
||||||
|
<div class="rating">
|
||||||
|
@for (star of [1,2,3,4,5,6,7,8,9,10]; track star) {
|
||||||
|
<span [class.filled]="star <= game()!.rating!">★</span>
|
||||||
|
}
|
||||||
|
<span class="rating-text">({{ game()!.rating }}/10)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (game()!.timeSpent) {
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Time Played:</span>
|
||||||
|
<span class="info-value time-spent">{{ formatTimeSpent(game()!.timeSpent!) }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (game()!.dateStarted) {
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Started:</span>
|
||||||
|
<span class="info-value">{{ formatDate(game()!.dateStarted!) }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (game()!.dateFinished) {
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Finished:</span>
|
||||||
|
<span class="info-value">{{ formatDate(game()!.dateFinished!) }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Added:</span>
|
||||||
|
<span class="info-value">{{ formatDate(game()!.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Last Updated:</span>
|
||||||
|
<span class="info-value">{{ formatDate(game()!.updatedAt) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="like-section">
|
||||||
|
<app-like-button
|
||||||
|
entityType="game"
|
||||||
|
[entityId]="game()!.id"
|
||||||
|
></app-like-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (game()!.notes) {
|
||||||
|
<div class="notes-section">
|
||||||
|
<h3>Notes</h3>
|
||||||
|
<p class="notes">{{ game()!.notes }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (game()!.tags && game()!.tags.length > 0) {
|
||||||
|
<div class="tags-section">
|
||||||
|
<h3>Tags</h3>
|
||||||
|
<div class="tags-display">
|
||||||
|
@for (tag of game()!.tags; track tag) {
|
||||||
|
<span class="tag-chip">{{ tag }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (game()!.links && game()!.links.length > 0) {
|
||||||
|
<div class="links-section">
|
||||||
|
<h3>External Links</h3>
|
||||||
|
<div class="links-display">
|
||||||
|
@for (link of game()!.links; track link.url) {
|
||||||
|
<a [href]="link.url" target="_blank" rel="noopener noreferrer" class="external-link">
|
||||||
|
{{ link.title }} ↗
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="comments-section">
|
||||||
|
<h3>Comments</h3>
|
||||||
|
@if (authService.isAuthenticated()) {
|
||||||
|
@if (authService.user()?.isBanned) {
|
||||||
|
<div class="banned-notice">
|
||||||
|
You have been banned from commenting.
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<form (ngSubmit)="addComment()" class="comment-form">
|
||||||
|
<textarea
|
||||||
|
[(ngModel)]="newCommentContent"
|
||||||
|
name="comment"
|
||||||
|
placeholder="Add a comment (Markdown supported)..."
|
||||||
|
rows="3"
|
||||||
|
></textarea>
|
||||||
|
<button type="submit" class="btn btn-primary">Post Comment</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<div class="auth-prompt">
|
||||||
|
<p>Please sign in to comment.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (commentsLoading()) {
|
||||||
|
<div class="comments-loading">Loading comments...</div>
|
||||||
|
} @else {
|
||||||
|
<app-comment-display
|
||||||
|
[comments]="comments"
|
||||||
|
(edit)="handleCommentEdit($event)"
|
||||||
|
(delete)="deleteComment($event)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.container {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-link {
|
||||||
|
color: #ff6b6b;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-link:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: #666;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state h2 {
|
||||||
|
color: #991b1b;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state p {
|
||||||
|
color: #dc2626;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-detail-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-cover-section {
|
||||||
|
width: 100%;
|
||||||
|
background: #f3f4f6;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-cover-large {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 400px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-content {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 2rem;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-playing {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-completed {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-backlog {
|
||||||
|
background: #e0e7ff;
|
||||||
|
color: #3730a3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-retired {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series {
|
||||||
|
color: #8b6f47;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0.5rem 0 1.5rem 0;
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4b5563;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-spent {
|
||||||
|
color: #10b981;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating span {
|
||||||
|
color: #e5e7eb;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating span.filled {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-text {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.like-section {
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-section,
|
||||||
|
.tags-section,
|
||||||
|
.links-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-section h3,
|
||||||
|
.tags-section h3,
|
||||||
|
.links-section h3,
|
||||||
|
.comments-section h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #4b5563;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-display {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip {
|
||||||
|
background: #ff6b6b;
|
||||||
|
color: white;
|
||||||
|
padding: 0.375rem 0.875rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links-display {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-link {
|
||||||
|
color: #ff6b6b;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 2px solid #ff6b6b;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-link:hover {
|
||||||
|
background: #ff6b6b;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding-top: 2rem;
|
||||||
|
border-top: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
resize: vertical;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banned-notice {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
color: #991b1b;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-prompt {
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-prompt p {
|
||||||
|
margin: 0;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: all 0.3s;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #ff6b6b;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.container {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class GameDetailComponent implements OnInit {
|
||||||
|
private readonly gamesService = inject(GamesService);
|
||||||
|
private readonly commentsService = inject(CommentsService);
|
||||||
|
readonly authService = inject(AuthService);
|
||||||
|
private readonly sanitizeService = inject(SanitizeService);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
game = signal<Game | null>(null);
|
||||||
|
comments = signal<Comment[]>([]);
|
||||||
|
loading = signal(true);
|
||||||
|
commentsLoading = signal(false);
|
||||||
|
error = signal<string | null>(null);
|
||||||
|
newCommentContent = '';
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
const gameId = this.route.snapshot.paramMap.get('id');
|
||||||
|
if (!gameId) {
|
||||||
|
this.error.set('No game ID provided');
|
||||||
|
this.loading.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadGame(gameId);
|
||||||
|
this.loadComments(gameId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadGame(gameId: string) {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.gamesService.getGameById(gameId).subscribe({
|
||||||
|
next: (game) => {
|
||||||
|
if (!game) {
|
||||||
|
this.error.set('Game not found');
|
||||||
|
} else {
|
||||||
|
this.game.set(game);
|
||||||
|
}
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.error.set('Failed to load game. It may not exist or there was an error.');
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadComments(gameId: string) {
|
||||||
|
this.commentsLoading.set(true);
|
||||||
|
this.commentsService.getCommentsForGame(gameId).subscribe({
|
||||||
|
next: (comments) => {
|
||||||
|
this.comments.set(comments);
|
||||||
|
this.commentsLoading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.commentsLoading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addComment() {
|
||||||
|
const game = this.game();
|
||||||
|
if (!game || !this.newCommentContent.trim()) return;
|
||||||
|
|
||||||
|
this.commentsService.addCommentToGame(game.id, { content: this.newCommentContent }).subscribe({
|
||||||
|
next: (comment) => {
|
||||||
|
this.comments.set([comment, ...this.comments()]);
|
||||||
|
this.newCommentContent = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCommentEdit(event: { commentId: string; content: string }) {
|
||||||
|
const game = this.game();
|
||||||
|
if (!game) return;
|
||||||
|
|
||||||
|
this.commentsService.updateCommentOnGame(game.id, event.commentId, event.content).subscribe({
|
||||||
|
next: (updatedComment) => {
|
||||||
|
this.comments.set(
|
||||||
|
this.comments().map(c => c.id === event.commentId ? updatedComment : c)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteComment(commentId: string) {
|
||||||
|
const game = this.game();
|
||||||
|
if (!game || !confirm('Are you sure you want to delete this comment?')) return;
|
||||||
|
|
||||||
|
this.commentsService.deleteCommentFromGame(game.id, commentId).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.comments.set(this.comments().filter(c => c.id !== commentId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusLabel(status: GameStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case GameStatus.playing: return 'Currently Playing';
|
||||||
|
case GameStatus.completed: return 'Completed';
|
||||||
|
case GameStatus.backlog: return 'In Backlog';
|
||||||
|
case GameStatus.retired: return 'Retired';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(date: Date | string): string {
|
||||||
|
return new Date(date).toLocaleDateString('en-GB', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
formatTimeSpent(minutes: number): string {
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
|
||||||
|
if (hours === 0) {
|
||||||
|
return `${mins} minutes`;
|
||||||
|
} else if (mins === 0) {
|
||||||
|
return `${hours} hour${hours === 1 ? '' : 's'}`;
|
||||||
|
} else {
|
||||||
|
return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,6 +104,34 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
|||||||
>
|
>
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<label for="notes">Notes</label>
|
<label for="notes">Notes</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -115,6 +143,29 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
|||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<label for="coverImage">Box Art (max 500KB)</label>
|
<label for="coverImage">Box Art (max 500KB)</label>
|
||||||
<input
|
<input
|
||||||
@@ -254,6 +305,34 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
|||||||
>
|
>
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<label for="edit-notes">Notes</label>
|
<label for="edit-notes">Notes</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -265,6 +344,29 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
|||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<label for="edit-coverImage">Box Art (max 500KB)</label>
|
<label for="edit-coverImage">Box Art (max 500KB)</label>
|
||||||
<input
|
<input
|
||||||
@@ -511,6 +613,11 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
|||||||
@if (game.platform) {
|
@if (game.platform) {
|
||||||
<p class="platform">{{ game.platform }}</p>
|
<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 }}">
|
<span class="status status-{{ game.status }}">
|
||||||
{{ getStatusLabel(game.status) }}
|
{{ getStatusLabel(game.status) }}
|
||||||
</span>
|
</span>
|
||||||
@@ -523,6 +630,12 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (game.timeSpent) {
|
||||||
|
<p class="time-spent">
|
||||||
|
⏱️ Time Played: {{ formatTimeSpent(game.timeSpent) }}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
<app-like-button
|
<app-like-button
|
||||||
entityType="game"
|
entityType="game"
|
||||||
[entityId]="game.id"
|
[entityId]="game.id"
|
||||||
@@ -690,6 +803,13 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.form-actions {
|
.form-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -852,6 +972,14 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
|||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.series {
|
||||||
|
color: #8b6f47;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
.status {
|
.status {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
@@ -883,6 +1011,13 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
|||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.time-spent {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #10b981;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
.date-started,
|
.date-started,
|
||||||
.date-finished,
|
.date-finished,
|
||||||
.date-added,
|
.date-added,
|
||||||
@@ -1310,6 +1445,12 @@ export class GamesListComponent implements OnInit {
|
|||||||
|
|
||||||
editGame: Partial<UpdateGameDto> = {};
|
editGame: Partial<UpdateGameDto> = {};
|
||||||
|
|
||||||
|
// Time tracking state
|
||||||
|
newGameTimeHours = 0;
|
||||||
|
newGameTimeMinutes = 0;
|
||||||
|
editGameTimeHours = 0;
|
||||||
|
editGameTimeMinutes = 0;
|
||||||
|
|
||||||
// Tags and links input state
|
// Tags and links input state
|
||||||
newTagInput = '';
|
newTagInput = '';
|
||||||
editTagInput = '';
|
editTagInput = '';
|
||||||
@@ -1402,6 +1543,8 @@ export class GamesListComponent implements OnInit {
|
|||||||
tags: [],
|
tags: [],
|
||||||
links: []
|
links: []
|
||||||
};
|
};
|
||||||
|
this.newGameTimeHours = 0;
|
||||||
|
this.newGameTimeMinutes = 0;
|
||||||
this.newGameImagePreview.set(null);
|
this.newGameImagePreview.set(null);
|
||||||
this.imageError.set(null);
|
this.imageError.set(null);
|
||||||
this.newTagInput = '';
|
this.newTagInput = '';
|
||||||
@@ -1409,6 +1552,16 @@ export class GamesListComponent implements OnInit {
|
|||||||
this.newLinkUrl = '';
|
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') {
|
addTag(target: 'new' | 'edit') {
|
||||||
const input = target === 'new' ? this.newTagInput.trim() : this.editTagInput.trim();
|
const input = target === 'new' ? this.newTagInput.trim() : this.editTagInput.trim();
|
||||||
if (!input) return;
|
if (!input) return;
|
||||||
@@ -1496,8 +1649,19 @@ export class GamesListComponent implements OnInit {
|
|||||||
notes: game.notes,
|
notes: game.notes,
|
||||||
coverImage: game.coverImage,
|
coverImage: game.coverImage,
|
||||||
tags: [...(game.tags || [])],
|
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.editGameImagePreview.set(game.coverImage || null);
|
||||||
this.showAddForm.set(false);
|
this.showAddForm.set(false);
|
||||||
this.imageError.set(null);
|
this.imageError.set(null);
|
||||||
@@ -1589,6 +1753,19 @@ export class GamesListComponent implements OnInit {
|
|||||||
return new Date(date).toLocaleDateString();
|
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) {
|
toggleComments(gameId: string) {
|
||||||
const expanded = this.expandedComments();
|
const expanded = this.expandedComments();
|
||||||
const isCurrentlyExpanded = expanded[gameId];
|
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]="['/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="/settings" class="dropdown-item" (click)="closeDropdown()">Settings</a>
|
||||||
<a routerLink="/achievements" class="dropdown-item" (click)="closeDropdown()">🏆 Achievements</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) {
|
@if (!user.isAdmin) {
|
||||||
<a routerLink="/my-suggestions" class="dropdown-item" (click)="closeDropdown()">My Suggestions</a>
|
<a routerLink="/my-suggestions" class="dropdown-item" (click)="closeDropdown()">My Suggestions</a>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ import { Game, GameStatus, Book, BookStatus, Music, MusicType, Manga, MangaStatu
|
|||||||
<ul class="recent-list">
|
<ul class="recent-list">
|
||||||
@for (game of recentGames(); track game.id) {
|
@for (game of recentGames(); track game.id) {
|
||||||
<li>
|
<li>
|
||||||
<a routerLink="/games">{{ game.title }}</a>
|
<a [routerLink]="['/games', game.id]">{{ game.title }}</a>
|
||||||
@if (game.platform) {
|
@if (game.platform) {
|
||||||
<span class="platform">({{ game.platform }})</span>
|
<span class="platform">({{ game.platform }})</span>
|
||||||
}
|
}
|
||||||
@@ -108,7 +108,7 @@ import { Game, GameStatus, Book, BookStatus, Music, MusicType, Manga, MangaStatu
|
|||||||
<ul class="recent-list">
|
<ul class="recent-list">
|
||||||
@for (book of recentBooks(); track book.id) {
|
@for (book of recentBooks(); track book.id) {
|
||||||
<li>
|
<li>
|
||||||
<a routerLink="/books">{{ book.title }}</a>
|
<a [routerLink]="['/books', book.id]">{{ book.title }}</a>
|
||||||
<span class="author">by {{ book.author }}</span>
|
<span class="author">by {{ book.author }}</span>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
@@ -122,7 +122,7 @@ import { Game, GameStatus, Book, BookStatus, Music, MusicType, Manga, MangaStatu
|
|||||||
<ul class="recent-list">
|
<ul class="recent-list">
|
||||||
@for (music of recentMusic(); track music.id) {
|
@for (music of recentMusic(); track music.id) {
|
||||||
<li>
|
<li>
|
||||||
<a routerLink="/music">{{ music.title }}</a>
|
<a [routerLink]="['/music', music.id]">{{ music.title }}</a>
|
||||||
<span class="artist">by {{ music.artist }}</span>
|
<span class="artist">by {{ music.artist }}</span>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
@@ -136,7 +136,7 @@ import { Game, GameStatus, Book, BookStatus, Music, MusicType, Manga, MangaStatu
|
|||||||
<ul class="recent-list">
|
<ul class="recent-list">
|
||||||
@for (manga of recentManga(); track manga.id) {
|
@for (manga of recentManga(); track manga.id) {
|
||||||
<li>
|
<li>
|
||||||
<a routerLink="/manga">{{ manga.title }}</a>
|
<a [routerLink]="['/manga', manga.id]">{{ manga.title }}</a>
|
||||||
<span class="author">by {{ manga.author }}</span>
|
<span class="author">by {{ manga.author }}</span>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
@@ -150,7 +150,7 @@ import { Game, GameStatus, Book, BookStatus, Music, MusicType, Manga, MangaStatu
|
|||||||
<ul class="recent-list">
|
<ul class="recent-list">
|
||||||
@for (show of recentShows(); track show.id) {
|
@for (show of recentShows(); track show.id) {
|
||||||
<li>
|
<li>
|
||||||
<a routerLink="/shows">{{ show.title }}</a>
|
<a [routerLink]="['/shows', show.id]">{{ show.title }}</a>
|
||||||
<span class="show-type">{{ formatShowType(show.type) }}</span>
|
<span class="show-type">{{ formatShowType(show.type) }}</span>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
@@ -164,7 +164,7 @@ import { Game, GameStatus, Book, BookStatus, Music, MusicType, Manga, MangaStatu
|
|||||||
<ul class="recent-list">
|
<ul class="recent-list">
|
||||||
@for (art of recentArt(); track art.id) {
|
@for (art of recentArt(); track art.id) {
|
||||||
<li>
|
<li>
|
||||||
<a routerLink="/art">{{ art.title }}</a>
|
<a [routerLink]="['/art', art.id]">{{ art.title }}</a>
|
||||||
<span class="artist">by {{ art.artist }}</span>
|
<span class="artist">by {{ art.artist }}</span>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,633 @@
|
|||||||
|
/**
|
||||||
|
* @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, ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { MangaService } from '../../services/manga.service';
|
||||||
|
import { CommentsService } from '../../services/comments.service';
|
||||||
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
import { SanitizeService } from '../../services/sanitize.service';
|
||||||
|
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
|
||||||
|
import { LikeButtonComponent } from '../shared/like-button.component';
|
||||||
|
import { Manga, Comment, MangaStatus } from '@library/shared-types';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-manga-detail',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent],
|
||||||
|
template: `
|
||||||
|
<div class="container">
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a routerLink="/manga" class="breadcrumb-link">← Back to Manga</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="loading">Loading manga details...</div>
|
||||||
|
} @else if (error()) {
|
||||||
|
<div class="error-state">
|
||||||
|
<h2>Manga Not Found</h2>
|
||||||
|
<p>{{ error() }}</p>
|
||||||
|
<a routerLink="/manga" class="btn btn-primary">Return to Manga</a>
|
||||||
|
</div>
|
||||||
|
} @else if (manga()) {
|
||||||
|
<div class="manga-detail-card">
|
||||||
|
@if (manga()!.coverImage) {
|
||||||
|
<div class="manga-cover-section">
|
||||||
|
<img [src]="manga()!.coverImage" [alt]="manga()!.title" class="manga-cover-large">
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="manga-content">
|
||||||
|
<div class="manga-header">
|
||||||
|
<h1>{{ manga()!.title }}</h1>
|
||||||
|
<span class="status status-{{ manga()!.status }}">
|
||||||
|
{{ getStatusLabel(manga()!.status) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="author">by {{ manga()!.author }}</p>
|
||||||
|
|
||||||
|
@if (manga()!.rating) {
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Rating:</span>
|
||||||
|
<div class="rating">
|
||||||
|
@for (star of [1,2,3,4,5,6,7,8,9,10]; track star) {
|
||||||
|
<span [class.filled]="star <= manga()!.rating!">★</span>
|
||||||
|
}
|
||||||
|
<span class="rating-text">({{ manga()!.rating }}/10)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (manga()!.timeSpent) {
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Reading Time:</span>
|
||||||
|
<span class="info-value time-spent">{{ formatTimeSpent(manga()!.timeSpent!) }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (manga()!.dateStarted) {
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Started:</span>
|
||||||
|
<span class="info-value">{{ formatDate(manga()!.dateStarted!) }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (manga()!.dateFinished) {
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Finished:</span>
|
||||||
|
<span class="info-value">{{ formatDate(manga()!.dateFinished!) }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Added:</span>
|
||||||
|
<span class="info-value">{{ formatDate(manga()!.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Last Updated:</span>
|
||||||
|
<span class="info-value">{{ formatDate(manga()!.updatedAt) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="like-section">
|
||||||
|
<app-like-button
|
||||||
|
entityType="manga"
|
||||||
|
[entityId]="manga()!.id"
|
||||||
|
></app-like-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (manga()!.notes) {
|
||||||
|
<div class="notes-section">
|
||||||
|
<h3>Notes</h3>
|
||||||
|
<p class="notes">{{ manga()!.notes }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (manga()!.tags && manga()!.tags.length > 0) {
|
||||||
|
<div class="tags-section">
|
||||||
|
<h3>Tags</h3>
|
||||||
|
<div class="tags-display">
|
||||||
|
@for (tag of manga()!.tags; track tag) {
|
||||||
|
<span class="tag-chip">{{ tag }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (manga()!.links && manga()!.links.length > 0) {
|
||||||
|
<div class="links-section">
|
||||||
|
<h3>External Links</h3>
|
||||||
|
<div class="links-display">
|
||||||
|
@for (link of manga()!.links; track link.url) {
|
||||||
|
<a [href]="link.url" target="_blank" rel="noopener noreferrer" class="external-link">
|
||||||
|
{{ link.title }} ↗
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="comments-section">
|
||||||
|
<h3>Comments</h3>
|
||||||
|
@if (authService.isAuthenticated()) {
|
||||||
|
@if (authService.user()?.isBanned) {
|
||||||
|
<div class="banned-notice">
|
||||||
|
You have been banned from commenting.
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<form (ngSubmit)="addComment()" class="comment-form">
|
||||||
|
<textarea
|
||||||
|
[(ngModel)]="newCommentContent"
|
||||||
|
name="comment"
|
||||||
|
placeholder="Add a comment (Markdown supported)..."
|
||||||
|
rows="3"
|
||||||
|
></textarea>
|
||||||
|
<button type="submit" class="btn btn-primary">Post Comment</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<div class="auth-prompt">
|
||||||
|
<p>Please sign in to comment.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (commentsLoading()) {
|
||||||
|
<div class="comments-loading">Loading comments...</div>
|
||||||
|
} @else {
|
||||||
|
<app-comment-display
|
||||||
|
[comments]="comments"
|
||||||
|
(edit)="handleCommentEdit($event)"
|
||||||
|
(delete)="deleteComment($event)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.container {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-link {
|
||||||
|
color: #f59e0b;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-link:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: #666;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state h2 {
|
||||||
|
color: #991b1b;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state p {
|
||||||
|
color: #dc2626;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manga-detail-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.manga-cover-section {
|
||||||
|
width: 100%;
|
||||||
|
background: #f3f4f6;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manga-cover-large {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 400px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manga-content {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manga-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manga-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 2rem;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author {
|
||||||
|
color: #6b7280;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-reading {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-completed {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-wantToRead {
|
||||||
|
background: #e0e7ff;
|
||||||
|
color: #3730a3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-retired {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4b5563;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-spent {
|
||||||
|
color: #f59e0b;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating span {
|
||||||
|
color: #e5e7eb;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating span.filled {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-text {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.like-section {
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-section,
|
||||||
|
.tags-section,
|
||||||
|
.links-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-section h3,
|
||||||
|
.tags-section h3,
|
||||||
|
.links-section h3,
|
||||||
|
.comments-section h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #4b5563;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-display {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip {
|
||||||
|
background: #f59e0b;
|
||||||
|
color: white;
|
||||||
|
padding: 0.375rem 0.875rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links-display {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-link {
|
||||||
|
color: #f59e0b;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 2px solid #f59e0b;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-link:hover {
|
||||||
|
background: #f59e0b;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding-top: 2rem;
|
||||||
|
border-top: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
resize: vertical;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banned-notice {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
color: #991b1b;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-prompt {
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-prompt p {
|
||||||
|
margin: 0;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: all 0.3s;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #f59e0b;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.container {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manga-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manga-header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class MangaDetailComponent implements OnInit {
|
||||||
|
private readonly mangaService = inject(MangaService);
|
||||||
|
private readonly commentsService = inject(CommentsService);
|
||||||
|
readonly authService = inject(AuthService);
|
||||||
|
private readonly sanitizeService = inject(SanitizeService);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
manga = signal<Manga | null>(null);
|
||||||
|
comments = signal<Comment[]>([]);
|
||||||
|
loading = signal(true);
|
||||||
|
commentsLoading = signal(false);
|
||||||
|
error = signal<string | null>(null);
|
||||||
|
newCommentContent = '';
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
const mangaId = this.route.snapshot.paramMap.get('id');
|
||||||
|
if (!mangaId) {
|
||||||
|
this.error.set('No manga ID provided');
|
||||||
|
this.loading.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadManga(mangaId);
|
||||||
|
this.loadComments(mangaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadManga(mangaId: string) {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.mangaService.getMangaById(mangaId).subscribe({
|
||||||
|
next: (manga) => {
|
||||||
|
if (!manga) {
|
||||||
|
this.error.set('Manga not found');
|
||||||
|
} else {
|
||||||
|
this.manga.set(manga);
|
||||||
|
}
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.error.set('Failed to load manga. It may not exist or there was an error.');
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadComments(mangaId: string) {
|
||||||
|
this.commentsLoading.set(true);
|
||||||
|
this.commentsService.getCommentsForManga(mangaId).subscribe({
|
||||||
|
next: (comments) => {
|
||||||
|
this.comments.set(comments);
|
||||||
|
this.commentsLoading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.commentsLoading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addComment() {
|
||||||
|
const manga = this.manga();
|
||||||
|
if (!manga || !this.newCommentContent.trim()) return;
|
||||||
|
|
||||||
|
this.commentsService.addCommentToManga(manga.id, { content: this.newCommentContent }).subscribe({
|
||||||
|
next: (comment) => {
|
||||||
|
this.comments.set([comment, ...this.comments()]);
|
||||||
|
this.newCommentContent = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCommentEdit(event: { commentId: string; content: string }) {
|
||||||
|
const manga = this.manga();
|
||||||
|
if (!manga) return;
|
||||||
|
|
||||||
|
this.commentsService.updateCommentOnManga(manga.id, event.commentId, event.content).subscribe({
|
||||||
|
next: (updatedComment) => {
|
||||||
|
this.comments.set(
|
||||||
|
this.comments().map(c => c.id === event.commentId ? updatedComment : c)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteComment(commentId: string) {
|
||||||
|
const manga = this.manga();
|
||||||
|
if (!manga || !confirm('Are you sure you want to delete this comment?')) return;
|
||||||
|
|
||||||
|
this.commentsService.deleteCommentFromManga(manga.id, commentId).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.comments.set(this.comments().filter(c => c.id !== commentId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusLabel(status: MangaStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case MangaStatus.reading: return 'Currently Reading';
|
||||||
|
case MangaStatus.completed: return 'Completed';
|
||||||
|
case MangaStatus.wantToRead: return 'Want to Read';
|
||||||
|
case MangaStatus.retired: return 'Retired';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(date: Date | string): string {
|
||||||
|
return new Date(date).toLocaleDateString('en-GB', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
formatTimeSpent(minutes: number): string {
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
|
||||||
|
if (hours === 0) {
|
||||||
|
return `${mins} minutes`;
|
||||||
|
} else if (mins === 0) {
|
||||||
|
return `${hours} hour${hours === 1 ? '' : 's'}`;
|
||||||
|
} else {
|
||||||
|
return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,6 +105,34 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
|||||||
>
|
>
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<label for="notes">Notes</label>
|
<label for="notes">Notes</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -256,6 +284,34 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
|||||||
>
|
>
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<label for="edit-notes">Notes</label>
|
<label for="edit-notes">Notes</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -524,6 +580,12 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (manga.timeSpent) {
|
||||||
|
<p class="time-spent">
|
||||||
|
📚 Reading Time: {{ formatTimeSpent(manga.timeSpent) }}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
<app-like-button
|
<app-like-button
|
||||||
entityType="manga"
|
entityType="manga"
|
||||||
[entityId]="manga.id"
|
[entityId]="manga.id"
|
||||||
@@ -691,6 +753,13 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.form-actions {
|
.form-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -886,6 +955,13 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
|||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.time-spent {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #10b981;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
.date-started,
|
.date-started,
|
||||||
.date-finished,
|
.date-finished,
|
||||||
.date-added,
|
.date-added,
|
||||||
@@ -1313,6 +1389,12 @@ export class MangaListComponent implements OnInit {
|
|||||||
|
|
||||||
editManga: Partial<UpdateMangaDto> = {};
|
editManga: Partial<UpdateMangaDto> = {};
|
||||||
|
|
||||||
|
// Time tracking state
|
||||||
|
newMangaTimeHours = 0;
|
||||||
|
newMangaTimeMinutes = 0;
|
||||||
|
editMangaTimeHours = 0;
|
||||||
|
editMangaTimeMinutes = 0;
|
||||||
|
|
||||||
// Tags and links input state
|
// Tags and links input state
|
||||||
newTagInput = '';
|
newTagInput = '';
|
||||||
editTagInput = '';
|
editTagInput = '';
|
||||||
@@ -1405,6 +1487,8 @@ export class MangaListComponent implements OnInit {
|
|||||||
tags: [],
|
tags: [],
|
||||||
links: []
|
links: []
|
||||||
};
|
};
|
||||||
|
this.newMangaTimeHours = 0;
|
||||||
|
this.newMangaTimeMinutes = 0;
|
||||||
this.newMangaImagePreview.set(null);
|
this.newMangaImagePreview.set(null);
|
||||||
this.imageError.set(null);
|
this.imageError.set(null);
|
||||||
this.newTagInput = '';
|
this.newTagInput = '';
|
||||||
@@ -1412,6 +1496,16 @@ export class MangaListComponent implements OnInit {
|
|||||||
this.newLinkUrl = '';
|
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') {
|
addTag(target: 'new' | 'edit') {
|
||||||
const input = target === 'new' ? this.newTagInput.trim() : this.editTagInput.trim();
|
const input = target === 'new' ? this.newTagInput.trim() : this.editTagInput.trim();
|
||||||
if (!input) return;
|
if (!input) return;
|
||||||
@@ -1499,8 +1593,17 @@ export class MangaListComponent implements OnInit {
|
|||||||
notes: manga.notes,
|
notes: manga.notes,
|
||||||
coverImage: manga.coverImage,
|
coverImage: manga.coverImage,
|
||||||
tags: [...(manga.tags || [])],
|
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.editMangaImagePreview.set(manga.coverImage || null);
|
||||||
this.showAddForm.set(false);
|
this.showAddForm.set(false);
|
||||||
this.imageError.set(null);
|
this.imageError.set(null);
|
||||||
@@ -1590,6 +1693,19 @@ export class MangaListComponent implements OnInit {
|
|||||||
return new Date(date).toLocaleDateString();
|
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) {
|
toggleComments(mangaId: string) {
|
||||||
const expanded = this.expandedComments();
|
const expanded = this.expandedComments();
|
||||||
const isCurrentlyExpanded = expanded[mangaId];
|
const isCurrentlyExpanded = expanded[mangaId];
|
||||||
|
|||||||
@@ -0,0 +1,667 @@
|
|||||||
|
/**
|
||||||
|
* @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, ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { MusicService } from '../../services/music.service';
|
||||||
|
import { CommentsService } from '../../services/comments.service';
|
||||||
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
import { SanitizeService } from '../../services/sanitize.service';
|
||||||
|
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
|
||||||
|
import { LikeButtonComponent } from '../shared/like-button.component';
|
||||||
|
import { Music, Comment, MusicStatus, MusicType } from '@library/shared-types';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-music-detail',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent],
|
||||||
|
template: `
|
||||||
|
<div class="container">
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a routerLink="/music" class="breadcrumb-link">← Back to Music</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="loading">Loading music details...</div>
|
||||||
|
} @else if (error()) {
|
||||||
|
<div class="error-state">
|
||||||
|
<h2>Music Not Found</h2>
|
||||||
|
<p>{{ error() }}</p>
|
||||||
|
<a routerLink="/music" class="btn btn-primary">Return to Music</a>
|
||||||
|
</div>
|
||||||
|
} @else if (music()) {
|
||||||
|
<div class="music-detail-card">
|
||||||
|
@if (music()!.coverArt) {
|
||||||
|
<div class="music-cover-section">
|
||||||
|
<img [src]="music()!.coverArt" [alt]="music()!.title" class="music-cover-large">
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="music-content">
|
||||||
|
<div class="music-header">
|
||||||
|
<h1>{{ music()!.title }}</h1>
|
||||||
|
<span class="type-badge type-{{ music()!.type }}">
|
||||||
|
{{ getTypeLabel(music()!.type) }}
|
||||||
|
</span>
|
||||||
|
<span class="status status-{{ music()!.status }}">
|
||||||
|
{{ getStatusLabel(music()!.status) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="artist">by {{ music()!.artist }}</p>
|
||||||
|
|
||||||
|
@if (music()!.rating) {
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Rating:</span>
|
||||||
|
<div class="rating">
|
||||||
|
@for (star of [1,2,3,4,5,6,7,8,9,10]; track star) {
|
||||||
|
<span [class.filled]="star <= music()!.rating!">★</span>
|
||||||
|
}
|
||||||
|
<span class="rating-text">({{ music()!.rating }}/10)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (music()!.timeSpent) {
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Listening Time:</span>
|
||||||
|
<span class="info-value time-spent">{{ formatTimeSpent(music()!.timeSpent!) }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (music()!.dateStarted) {
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Started:</span>
|
||||||
|
<span class="info-value">{{ formatDate(music()!.dateStarted!) }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (music()!.dateFinished) {
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Finished:</span>
|
||||||
|
<span class="info-value">{{ formatDate(music()!.dateFinished!) }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Added:</span>
|
||||||
|
<span class="info-value">{{ formatDate(music()!.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Last Updated:</span>
|
||||||
|
<span class="info-value">{{ formatDate(music()!.updatedAt) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="like-section">
|
||||||
|
<app-like-button
|
||||||
|
entityType="music"
|
||||||
|
[entityId]="music()!.id"
|
||||||
|
></app-like-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (music()!.notes) {
|
||||||
|
<div class="notes-section">
|
||||||
|
<h3>Notes</h3>
|
||||||
|
<p class="notes">{{ music()!.notes }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (music()!.tags && music()!.tags.length > 0) {
|
||||||
|
<div class="tags-section">
|
||||||
|
<h3>Tags</h3>
|
||||||
|
<div class="tags-display">
|
||||||
|
@for (tag of music()!.tags; track tag) {
|
||||||
|
<span class="tag-chip">{{ tag }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (music()!.links && music()!.links.length > 0) {
|
||||||
|
<div class="links-section">
|
||||||
|
<h3>External Links</h3>
|
||||||
|
<div class="links-display">
|
||||||
|
@for (link of music()!.links; track link.url) {
|
||||||
|
<a [href]="link.url" target="_blank" rel="noopener noreferrer" class="external-link">
|
||||||
|
{{ link.title }} ↗
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="comments-section">
|
||||||
|
<h3>Comments</h3>
|
||||||
|
@if (authService.isAuthenticated()) {
|
||||||
|
@if (authService.user()?.isBanned) {
|
||||||
|
<div class="banned-notice">
|
||||||
|
You have been banned from commenting.
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<form (ngSubmit)="addComment()" class="comment-form">
|
||||||
|
<textarea
|
||||||
|
[(ngModel)]="newCommentContent"
|
||||||
|
name="comment"
|
||||||
|
placeholder="Add a comment (Markdown supported)..."
|
||||||
|
rows="3"
|
||||||
|
></textarea>
|
||||||
|
<button type="submit" class="btn btn-primary">Post Comment</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<div class="auth-prompt">
|
||||||
|
<p>Please sign in to comment.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (commentsLoading()) {
|
||||||
|
<div class="comments-loading">Loading comments...</div>
|
||||||
|
} @else {
|
||||||
|
<app-comment-display
|
||||||
|
[comments]="comments"
|
||||||
|
(edit)="handleCommentEdit($event)"
|
||||||
|
(delete)="deleteComment($event)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.container {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-link {
|
||||||
|
color: #74b9ff;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-link:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: #666;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state h2 {
|
||||||
|
color: #991b1b;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state p {
|
||||||
|
color: #dc2626;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-detail-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-cover-section {
|
||||||
|
width: 100%;
|
||||||
|
background: #f3f4f6;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-cover-large {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 400px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-content {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 2rem;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist {
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-album {
|
||||||
|
background: #a78bfa;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-single {
|
||||||
|
background: #fb923c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-ep {
|
||||||
|
background: #60a5fa;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-listening {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-completed {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-wantToListen {
|
||||||
|
background: #e0e7ff;
|
||||||
|
color: #3730a3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-retired {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4b5563;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-spent {
|
||||||
|
color: #8b5cf6;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating span {
|
||||||
|
color: #e5e7eb;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating span.filled {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-text {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.like-section {
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-section,
|
||||||
|
.tags-section,
|
||||||
|
.links-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-section h3,
|
||||||
|
.tags-section h3,
|
||||||
|
.links-section h3,
|
||||||
|
.comments-section h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #4b5563;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-display {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip {
|
||||||
|
background: #74b9ff;
|
||||||
|
color: white;
|
||||||
|
padding: 0.375rem 0.875rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links-display {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-link {
|
||||||
|
color: #74b9ff;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 2px solid #74b9ff;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-link:hover {
|
||||||
|
background: #74b9ff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding-top: 2rem;
|
||||||
|
border-top: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
resize: vertical;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #74b9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banned-notice {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
color: #991b1b;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-prompt {
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-prompt p {
|
||||||
|
margin: 0;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: all 0.3s;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #74b9ff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.container {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class MusicDetailComponent implements OnInit {
|
||||||
|
private readonly musicService = inject(MusicService);
|
||||||
|
private readonly commentsService = inject(CommentsService);
|
||||||
|
readonly authService = inject(AuthService);
|
||||||
|
private readonly sanitizeService = inject(SanitizeService);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
music = signal<Music | null>(null);
|
||||||
|
comments = signal<Comment[]>([]);
|
||||||
|
loading = signal(true);
|
||||||
|
commentsLoading = signal(false);
|
||||||
|
error = signal<string | null>(null);
|
||||||
|
newCommentContent = '';
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
const musicId = this.route.snapshot.paramMap.get('id');
|
||||||
|
if (!musicId) {
|
||||||
|
this.error.set('No music ID provided');
|
||||||
|
this.loading.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadMusic(musicId);
|
||||||
|
this.loadComments(musicId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadMusic(musicId: string) {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.musicService.getMusicById(musicId).subscribe({
|
||||||
|
next: (music) => {
|
||||||
|
if (!music) {
|
||||||
|
this.error.set('Music not found');
|
||||||
|
} else {
|
||||||
|
this.music.set(music);
|
||||||
|
}
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.error.set('Failed to load music. It may not exist or there was an error.');
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadComments(musicId: string) {
|
||||||
|
this.commentsLoading.set(true);
|
||||||
|
this.commentsService.getCommentsForMusic(musicId).subscribe({
|
||||||
|
next: (comments) => {
|
||||||
|
this.comments.set(comments);
|
||||||
|
this.commentsLoading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.commentsLoading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addComment() {
|
||||||
|
const music = this.music();
|
||||||
|
if (!music || !this.newCommentContent.trim()) return;
|
||||||
|
|
||||||
|
this.commentsService.addCommentToMusic(music.id, { content: this.newCommentContent }).subscribe({
|
||||||
|
next: (comment) => {
|
||||||
|
this.comments.set([comment, ...this.comments()]);
|
||||||
|
this.newCommentContent = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCommentEdit(event: { commentId: string; content: string }) {
|
||||||
|
const music = this.music();
|
||||||
|
if (!music) return;
|
||||||
|
|
||||||
|
this.commentsService.updateCommentOnMusic(music.id, event.commentId, event.content).subscribe({
|
||||||
|
next: (updatedComment) => {
|
||||||
|
this.comments.set(
|
||||||
|
this.comments().map(c => c.id === event.commentId ? updatedComment : c)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteComment(commentId: string) {
|
||||||
|
const music = this.music();
|
||||||
|
if (!music || !confirm('Are you sure you want to delete this comment?')) return;
|
||||||
|
|
||||||
|
this.commentsService.deleteCommentFromMusic(music.id, commentId).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.comments.set(this.comments().filter(c => c.id !== commentId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getTypeLabel(type: MusicType): string {
|
||||||
|
switch (type) {
|
||||||
|
case MusicType.album: return 'Album';
|
||||||
|
case MusicType.single: return 'Single';
|
||||||
|
case MusicType.ep: return 'EP';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusLabel(status: MusicStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case MusicStatus.listening: return 'Currently Listening';
|
||||||
|
case MusicStatus.completed: return 'Completed';
|
||||||
|
case MusicStatus.wantToListen: return 'Want to Listen';
|
||||||
|
case MusicStatus.retired: return 'Retired';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(date: Date | string): string {
|
||||||
|
return new Date(date).toLocaleDateString('en-GB', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
formatTimeSpent(minutes: number): string {
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
|
||||||
|
if (hours === 0) {
|
||||||
|
return `${mins} minutes`;
|
||||||
|
} else if (mins === 0) {
|
||||||
|
return `${hours} hour${hours === 1 ? '' : 's'}`;
|
||||||
|
} else {
|
||||||
|
return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -114,6 +114,34 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
|||||||
>
|
>
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<label for="notes">Notes</label>
|
<label for="notes">Notes</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -274,6 +302,34 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
|||||||
>
|
>
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<label for="edit-notes">Notes</label>
|
<label for="edit-notes">Notes</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -600,6 +656,12 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (music.timeSpent) {
|
||||||
|
<p class="time-spent">
|
||||||
|
🎵 Listening Time: {{ formatTimeSpent(music.timeSpent) }}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
<app-like-button
|
<app-like-button
|
||||||
entityType="music"
|
entityType="music"
|
||||||
[entityId]="music.id"
|
[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);
|
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 {
|
.form-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -1036,6 +1105,13 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
|||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.time-spent {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #8b5cf6;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
.date-started,
|
.date-started,
|
||||||
.date-finished,
|
.date-finished,
|
||||||
.date-added,
|
.date-added,
|
||||||
@@ -1526,6 +1602,12 @@ export class MusicListComponent implements OnInit {
|
|||||||
|
|
||||||
editMusicData: Partial<UpdateMusicDto> = {};
|
editMusicData: Partial<UpdateMusicDto> = {};
|
||||||
|
|
||||||
|
// Time tracking state
|
||||||
|
newMusicTimeHours = 0;
|
||||||
|
newMusicTimeMinutes = 0;
|
||||||
|
editMusicTimeHours = 0;
|
||||||
|
editMusicTimeMinutes = 0;
|
||||||
|
|
||||||
// Tags and links input state
|
// Tags and links input state
|
||||||
newTagInput = '';
|
newTagInput = '';
|
||||||
editTagInput = '';
|
editTagInput = '';
|
||||||
@@ -1633,6 +1715,8 @@ export class MusicListComponent implements OnInit {
|
|||||||
tags: [],
|
tags: [],
|
||||||
links: []
|
links: []
|
||||||
};
|
};
|
||||||
|
this.newMusicTimeHours = 0;
|
||||||
|
this.newMusicTimeMinutes = 0;
|
||||||
this.newMusicImagePreview.set(null);
|
this.newMusicImagePreview.set(null);
|
||||||
this.imageError.set(null);
|
this.imageError.set(null);
|
||||||
this.newTagInput = '';
|
this.newTagInput = '';
|
||||||
@@ -1640,6 +1724,16 @@ export class MusicListComponent implements OnInit {
|
|||||||
this.newLinkUrl = '';
|
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') {
|
addTag(target: 'new' | 'edit') {
|
||||||
const input = target === 'new' ? this.newTagInput.trim() : this.editTagInput.trim();
|
const input = target === 'new' ? this.newTagInput.trim() : this.editTagInput.trim();
|
||||||
if (!input) return;
|
if (!input) return;
|
||||||
@@ -1729,8 +1823,17 @@ export class MusicListComponent implements OnInit {
|
|||||||
notes: music.notes,
|
notes: music.notes,
|
||||||
coverArt: music.coverArt,
|
coverArt: music.coverArt,
|
||||||
tags: [...(music.tags || [])],
|
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.editMusicImagePreview.set(music.coverArt || null);
|
||||||
this.showAddForm.set(false);
|
this.showAddForm.set(false);
|
||||||
this.imageError.set(null);
|
this.imageError.set(null);
|
||||||
@@ -1769,6 +1872,19 @@ export class MusicListComponent implements OnInit {
|
|||||||
return new Date(date).toLocaleDateString();
|
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
|
// Image handling methods
|
||||||
onImageSelected(event: Event, target: 'new' | 'edit' | 'suggest') {
|
onImageSelected(event: Event, target: 'new' | 'edit' | 'suggest') {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
|
|||||||
@@ -0,0 +1,664 @@
|
|||||||
|
/**
|
||||||
|
* @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, ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ShowsService } from '../../services/shows.service';
|
||||||
|
import { CommentsService } from '../../services/comments.service';
|
||||||
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
import { SanitizeService } from '../../services/sanitize.service';
|
||||||
|
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
|
||||||
|
import { LikeButtonComponent } from '../shared/like-button.component';
|
||||||
|
import { Show, Comment, ShowStatus, ShowType } from '@library/shared-types';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-show-detail',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent],
|
||||||
|
template: `
|
||||||
|
<div class="container">
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a routerLink="/shows" class="breadcrumb-link">← Back to Shows</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="loading">Loading show details...</div>
|
||||||
|
} @else if (error()) {
|
||||||
|
<div class="error-state">
|
||||||
|
<h2>Show Not Found</h2>
|
||||||
|
<p>{{ error() }}</p>
|
||||||
|
<a routerLink="/shows" class="btn btn-primary">Return to Shows</a>
|
||||||
|
</div>
|
||||||
|
} @else if (show()) {
|
||||||
|
<div class="show-detail-card">
|
||||||
|
@if (show()!.coverImage) {
|
||||||
|
<div class="show-poster-section">
|
||||||
|
<img [src]="show()!.coverImage" [alt]="show()!.title" class="show-poster-large">
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="show-content">
|
||||||
|
<div class="show-header">
|
||||||
|
<h1>{{ show()!.title }}</h1>
|
||||||
|
<span class="type-badge type-{{ show()!.type }}">
|
||||||
|
{{ getTypeLabel(show()!.type) }}
|
||||||
|
</span>
|
||||||
|
<span class="status status-{{ show()!.status }}">
|
||||||
|
{{ getStatusLabel(show()!.status) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (show()!.rating) {
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Rating:</span>
|
||||||
|
<div class="rating">
|
||||||
|
@for (star of [1,2,3,4,5,6,7,8,9,10]; track star) {
|
||||||
|
<span [class.filled]="star <= show()!.rating!">★</span>
|
||||||
|
}
|
||||||
|
<span class="rating-text">({{ show()!.rating }}/10)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (show()!.timeSpent) {
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Watch Time:</span>
|
||||||
|
<span class="info-value time-spent">{{ formatTimeSpent(show()!.timeSpent!) }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (show()!.dateStarted) {
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Started:</span>
|
||||||
|
<span class="info-value">{{ formatDate(show()!.dateStarted!) }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (show()!.dateFinished) {
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Finished:</span>
|
||||||
|
<span class="info-value">{{ formatDate(show()!.dateFinished!) }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Added:</span>
|
||||||
|
<span class="info-value">{{ formatDate(show()!.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Last Updated:</span>
|
||||||
|
<span class="info-value">{{ formatDate(show()!.updatedAt) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="like-section">
|
||||||
|
<app-like-button
|
||||||
|
entityType="show"
|
||||||
|
[entityId]="show()!.id"
|
||||||
|
></app-like-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (show()!.notes) {
|
||||||
|
<div class="notes-section">
|
||||||
|
<h3>Notes</h3>
|
||||||
|
<p class="notes">{{ show()!.notes }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (show()!.tags && show()!.tags.length > 0) {
|
||||||
|
<div class="tags-section">
|
||||||
|
<h3>Tags</h3>
|
||||||
|
<div class="tags-display">
|
||||||
|
@for (tag of show()!.tags; track tag) {
|
||||||
|
<span class="tag-chip">{{ tag }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (show()!.links && show()!.links.length > 0) {
|
||||||
|
<div class="links-section">
|
||||||
|
<h3>External Links</h3>
|
||||||
|
<div class="links-display">
|
||||||
|
@for (link of show()!.links; track link.url) {
|
||||||
|
<a [href]="link.url" target="_blank" rel="noopener noreferrer" class="external-link">
|
||||||
|
{{ link.title }} ↗
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="comments-section">
|
||||||
|
<h3>Comments</h3>
|
||||||
|
@if (authService.isAuthenticated()) {
|
||||||
|
@if (authService.user()?.isBanned) {
|
||||||
|
<div class="banned-notice">
|
||||||
|
You have been banned from commenting.
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<form (ngSubmit)="addComment()" class="comment-form">
|
||||||
|
<textarea
|
||||||
|
[(ngModel)]="newCommentContent"
|
||||||
|
name="comment"
|
||||||
|
placeholder="Add a comment (Markdown supported)..."
|
||||||
|
rows="3"
|
||||||
|
></textarea>
|
||||||
|
<button type="submit" class="btn btn-primary">Post Comment</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<div class="auth-prompt">
|
||||||
|
<p>Please sign in to comment.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (commentsLoading()) {
|
||||||
|
<div class="comments-loading">Loading comments...</div>
|
||||||
|
} @else {
|
||||||
|
<app-comment-display
|
||||||
|
[comments]="comments"
|
||||||
|
(edit)="handleCommentEdit($event)"
|
||||||
|
(delete)="deleteComment($event)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.container {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-link {
|
||||||
|
color: #10b981;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-link:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: #666;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state h2 {
|
||||||
|
color: #991b1b;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state p {
|
||||||
|
color: #dc2626;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-detail-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-poster-section {
|
||||||
|
width: 100%;
|
||||||
|
background: #f3f4f6;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-poster-large {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 400px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-content {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 2rem;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tvSeries {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-anime {
|
||||||
|
background: #ec4899;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-film {
|
||||||
|
background: #8b5cf6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-documentary {
|
||||||
|
background: #14b8a6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-watching {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-completed {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-wantToWatch {
|
||||||
|
background: #e0e7ff;
|
||||||
|
color: #3730a3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-retired {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4b5563;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-spent {
|
||||||
|
color: #10b981;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating span {
|
||||||
|
color: #e5e7eb;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating span.filled {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-text {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.like-section {
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-section,
|
||||||
|
.tags-section,
|
||||||
|
.links-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-section h3,
|
||||||
|
.tags-section h3,
|
||||||
|
.links-section h3,
|
||||||
|
.comments-section h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #4b5563;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-display {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip {
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
padding: 0.375rem 0.875rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links-display {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-link {
|
||||||
|
color: #10b981;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 2px solid #10b981;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-link:hover {
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding-top: 2rem;
|
||||||
|
border-top: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
resize: vertical;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banned-notice {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
color: #991b1b;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-prompt {
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-prompt p {
|
||||||
|
margin: 0;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: all 0.3s;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.container {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class ShowDetailComponent implements OnInit {
|
||||||
|
private readonly showsService = inject(ShowsService);
|
||||||
|
private readonly commentsService = inject(CommentsService);
|
||||||
|
readonly authService = inject(AuthService);
|
||||||
|
private readonly sanitizeService = inject(SanitizeService);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
show = signal<Show | null>(null);
|
||||||
|
comments = signal<Comment[]>([]);
|
||||||
|
loading = signal(true);
|
||||||
|
commentsLoading = signal(false);
|
||||||
|
error = signal<string | null>(null);
|
||||||
|
newCommentContent = '';
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
const showId = this.route.snapshot.paramMap.get('id');
|
||||||
|
if (!showId) {
|
||||||
|
this.error.set('No show ID provided');
|
||||||
|
this.loading.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadShow(showId);
|
||||||
|
this.loadComments(showId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadShow(showId: string) {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.showsService.getShowById(showId).subscribe({
|
||||||
|
next: (show) => {
|
||||||
|
if (!show) {
|
||||||
|
this.error.set('Show not found');
|
||||||
|
} else {
|
||||||
|
this.show.set(show);
|
||||||
|
}
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.error.set('Failed to load show. It may not exist or there was an error.');
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadComments(showId: string) {
|
||||||
|
this.commentsLoading.set(true);
|
||||||
|
this.commentsService.getCommentsForShow(showId).subscribe({
|
||||||
|
next: (comments) => {
|
||||||
|
this.comments.set(comments);
|
||||||
|
this.commentsLoading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.commentsLoading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addComment() {
|
||||||
|
const show = this.show();
|
||||||
|
if (!show || !this.newCommentContent.trim()) return;
|
||||||
|
|
||||||
|
this.commentsService.addCommentToShow(show.id, { content: this.newCommentContent }).subscribe({
|
||||||
|
next: (comment) => {
|
||||||
|
this.comments.set([comment, ...this.comments()]);
|
||||||
|
this.newCommentContent = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCommentEdit(event: { commentId: string; content: string }) {
|
||||||
|
const show = this.show();
|
||||||
|
if (!show) return;
|
||||||
|
|
||||||
|
this.commentsService.updateCommentOnShow(show.id, event.commentId, event.content).subscribe({
|
||||||
|
next: (updatedComment) => {
|
||||||
|
this.comments.set(
|
||||||
|
this.comments().map(c => c.id === event.commentId ? updatedComment : c)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteComment(commentId: string) {
|
||||||
|
const show = this.show();
|
||||||
|
if (!show || !confirm('Are you sure you want to delete this comment?')) return;
|
||||||
|
|
||||||
|
this.commentsService.deleteCommentFromShow(show.id, commentId).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.comments.set(this.comments().filter(c => c.id !== commentId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getTypeLabel(type: ShowType): string {
|
||||||
|
switch (type) {
|
||||||
|
case ShowType.tvSeries: return 'TV Series';
|
||||||
|
case ShowType.anime: return 'Anime';
|
||||||
|
case ShowType.film: return 'Film';
|
||||||
|
case ShowType.documentary: return 'Documentary';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusLabel(status: ShowStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case ShowStatus.watching: return 'Currently Watching';
|
||||||
|
case ShowStatus.completed: return 'Completed';
|
||||||
|
case ShowStatus.wantToWatch: return 'Want to Watch';
|
||||||
|
case ShowStatus.retired: return 'Retired';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(date: Date | string): string {
|
||||||
|
return new Date(date).toLocaleDateString('en-GB', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
formatTimeSpent(minutes: number): string {
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
|
||||||
|
if (hours === 0) {
|
||||||
|
return `${mins} minutes`;
|
||||||
|
} else if (mins === 0) {
|
||||||
|
return `${hours} hour${hours === 1 ? '' : 's'}`;
|
||||||
|
} else {
|
||||||
|
return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,6 +103,34 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
|||||||
>
|
>
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<label for="notes">Notes</label>
|
<label for="notes">Notes</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -252,6 +280,34 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
|||||||
>
|
>
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<label for="edit-notes">Notes</label>
|
<label for="edit-notes">Notes</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -518,6 +574,12 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (show.timeSpent) {
|
||||||
|
<p class="time-spent">
|
||||||
|
📺 Watch Time: {{ formatTimeSpent(show.timeSpent) }}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
<app-like-button
|
<app-like-button
|
||||||
entityType="show"
|
entityType="show"
|
||||||
[entityId]="show.id"
|
[entityId]="show.id"
|
||||||
@@ -685,6 +747,13 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.form-actions {
|
.form-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -879,6 +948,13 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
|||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.time-spent {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #8b5cf6;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
.date-started,
|
.date-started,
|
||||||
.date-finished,
|
.date-finished,
|
||||||
.date-added,
|
.date-added,
|
||||||
@@ -1307,6 +1383,12 @@ export class ShowsListComponent implements OnInit {
|
|||||||
|
|
||||||
editShow: Partial<UpdateShowDto> = {};
|
editShow: Partial<UpdateShowDto> = {};
|
||||||
|
|
||||||
|
// Time tracking state
|
||||||
|
newShowTimeHours = 0;
|
||||||
|
newShowTimeMinutes = 0;
|
||||||
|
editShowTimeHours = 0;
|
||||||
|
editShowTimeMinutes = 0;
|
||||||
|
|
||||||
// Tags and links input state
|
// Tags and links input state
|
||||||
newTagInput = '';
|
newTagInput = '';
|
||||||
editTagInput = '';
|
editTagInput = '';
|
||||||
@@ -1408,6 +1490,8 @@ export class ShowsListComponent implements OnInit {
|
|||||||
tags: [],
|
tags: [],
|
||||||
links: []
|
links: []
|
||||||
};
|
};
|
||||||
|
this.newShowTimeHours = 0;
|
||||||
|
this.newShowTimeMinutes = 0;
|
||||||
this.newShowImagePreview.set(null);
|
this.newShowImagePreview.set(null);
|
||||||
this.imageError.set(null);
|
this.imageError.set(null);
|
||||||
this.newTagInput = '';
|
this.newTagInput = '';
|
||||||
@@ -1415,6 +1499,16 @@ export class ShowsListComponent implements OnInit {
|
|||||||
this.newLinkUrl = '';
|
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') {
|
addTag(target: 'new' | 'edit') {
|
||||||
const input = target === 'new' ? this.newTagInput.trim() : this.editTagInput.trim();
|
const input = target === 'new' ? this.newTagInput.trim() : this.editTagInput.trim();
|
||||||
if (!input) return;
|
if (!input) return;
|
||||||
@@ -1502,8 +1596,17 @@ export class ShowsListComponent implements OnInit {
|
|||||||
notes: show.notes,
|
notes: show.notes,
|
||||||
coverImage: show.coverImage,
|
coverImage: show.coverImage,
|
||||||
tags: [...(show.tags || [])],
|
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.editShowImagePreview.set(show.coverImage || null);
|
||||||
this.showAddForm.set(false);
|
this.showAddForm.set(false);
|
||||||
this.imageError.set(null);
|
this.imageError.set(null);
|
||||||
@@ -1593,6 +1696,19 @@ export class ShowsListComponent implements OnInit {
|
|||||||
return new Date(date).toLocaleDateString();
|
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) {
|
toggleComments(showId: string) {
|
||||||
const expanded = this.expandedComments();
|
const expanded = this.expandedComments();
|
||||||
const isCurrentlyExpanded = expanded[showId];
|
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.constants";
|
||||||
export * from "./lib/achievement.types";
|
export * from "./lib/achievement.types";
|
||||||
|
export * from "./lib/activity.types";
|
||||||
export type * from "./lib/art.types";
|
export type * from "./lib/art.types";
|
||||||
export * from "./lib/audit.types";
|
export * from "./lib/audit.types";
|
||||||
export * from "./lib/auth.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/comment.types";
|
||||||
export type * from "./lib/common.types";
|
export type * from "./lib/common.types";
|
||||||
export * from "./lib/game.types";
|
export * from "./lib/game.types";
|
||||||
|
export type * from "./lib/leaderboard.types";
|
||||||
export type * from "./lib/like.types";
|
export type * from "./lib/like.types";
|
||||||
export * from "./lib/manga.types";
|
export * from "./lib/manga.types";
|
||||||
export * from "./lib/music.types";
|
export * from "./lib/music.types";
|
||||||
|
|||||||
@@ -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;
|
coverImage?: string;
|
||||||
tags: Array<string>;
|
tags: Array<string>;
|
||||||
links: Array<Link>;
|
links: Array<Link>;
|
||||||
|
series?: string;
|
||||||
|
seriesOrder?: number;
|
||||||
|
timeSpent?: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@@ -43,6 +46,9 @@ interface CreateBookDto {
|
|||||||
coverImage?: string;
|
coverImage?: string;
|
||||||
tags?: Array<string>;
|
tags?: Array<string>;
|
||||||
links?: Array<Link>;
|
links?: Array<Link>;
|
||||||
|
series?: string;
|
||||||
|
seriesOrder?: number;
|
||||||
|
timeSpent?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdateBookDto extends Partial<CreateBookDto> {
|
interface UpdateBookDto extends Partial<CreateBookDto> {
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ interface Game {
|
|||||||
coverImage?: string;
|
coverImage?: string;
|
||||||
tags: Array<string>;
|
tags: Array<string>;
|
||||||
links: Array<Link>;
|
links: Array<Link>;
|
||||||
|
series?: string;
|
||||||
|
seriesOrder?: number;
|
||||||
|
timeSpent?: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@@ -42,6 +45,9 @@ interface CreateGameDto {
|
|||||||
coverImage?: string;
|
coverImage?: string;
|
||||||
tags?: Array<string>;
|
tags?: Array<string>;
|
||||||
links?: Array<Link>;
|
links?: Array<Link>;
|
||||||
|
series?: string;
|
||||||
|
seriesOrder?: number;
|
||||||
|
timeSpent?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdateGameDto extends Partial<CreateGameDto> {
|
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;
|
coverImage?: string;
|
||||||
tags: Array<string>;
|
tags: Array<string>;
|
||||||
links: Array<Link>;
|
links: Array<Link>;
|
||||||
|
timeSpent?: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@@ -42,6 +43,7 @@ interface CreateMangaDto {
|
|||||||
coverImage?: string;
|
coverImage?: string;
|
||||||
tags?: Array<string>;
|
tags?: Array<string>;
|
||||||
links?: Array<Link>;
|
links?: Array<Link>;
|
||||||
|
timeSpent?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdateMangaDto extends Partial<CreateMangaDto> {
|
interface UpdateMangaDto extends Partial<CreateMangaDto> {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ interface Music {
|
|||||||
coverArt?: string;
|
coverArt?: string;
|
||||||
tags: Array<string>;
|
tags: Array<string>;
|
||||||
links: Array<Link>;
|
links: Array<Link>;
|
||||||
|
timeSpent?: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@@ -50,6 +51,7 @@ interface CreateMusicDto {
|
|||||||
coverArt?: string;
|
coverArt?: string;
|
||||||
tags?: Array<string>;
|
tags?: Array<string>;
|
||||||
links?: Array<Link>;
|
links?: Array<Link>;
|
||||||
|
timeSpent?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdateMusicDto extends Partial<CreateMusicDto> {
|
interface UpdateMusicDto extends Partial<CreateMusicDto> {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ interface Show {
|
|||||||
coverImage?: string;
|
coverImage?: string;
|
||||||
tags: Array<string>;
|
tags: Array<string>;
|
||||||
links: Array<Link>;
|
links: Array<Link>;
|
||||||
|
timeSpent?: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@@ -49,6 +50,7 @@ interface CreateShowDto {
|
|||||||
coverImage?: string;
|
coverImage?: string;
|
||||||
tags?: Array<string>;
|
tags?: Array<string>;
|
||||||
links?: Array<Link>;
|
links?: Array<Link>;
|
||||||
|
timeSpent?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdateShowDto extends Partial<CreateShowDto> {
|
interface UpdateShowDto extends Partial<CreateShowDto> {
|
||||||
|
|||||||
Reference in New Issue
Block a user