generated from nhcarrigan/template
Compare commits
15 Commits
| 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?
|
||||
tags String[]
|
||||
links Link[]
|
||||
series String?
|
||||
seriesOrder Int? @db.Int
|
||||
timeSpent Int? @db.Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
comments Comment[]
|
||||
@@ -58,6 +61,9 @@ model Book {
|
||||
coverImage String?
|
||||
tags String[]
|
||||
links Link[]
|
||||
series String?
|
||||
seriesOrder Int? @db.Int
|
||||
timeSpent Int? @db.Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
comments Comment[]
|
||||
@@ -85,6 +91,7 @@ model Music {
|
||||
coverArt String?
|
||||
tags String[]
|
||||
links Link[]
|
||||
timeSpent Int? @db.Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
comments Comment[]
|
||||
@@ -131,6 +138,7 @@ model Show {
|
||||
coverImage String?
|
||||
tags String[]
|
||||
links Link[]
|
||||
timeSpent Int? @db.Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
comments Comment[]
|
||||
@@ -164,6 +172,7 @@ model Manga {
|
||||
coverImage String?
|
||||
tags String[]
|
||||
links Link[]
|
||||
timeSpent Int? @db.Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
comments Comment[]
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import type { ActivityFeedResponse } from "@library/shared-types";
|
||||
import { ActivityService } from "../../services/activity.service";
|
||||
|
||||
const activityRoutes: FastifyPluginAsync = async (app) => {
|
||||
const activityService = new ActivityService();
|
||||
|
||||
/**
|
||||
* Get activity feed with optional filters.
|
||||
*/
|
||||
app.get<{
|
||||
Querystring: { limit?: number; offset?: number; userId?: string };
|
||||
Reply: ActivityFeedResponse;
|
||||
}>("/", async (request) => {
|
||||
const limit = request.query.limit && request.query.limit > 0
|
||||
? Math.min(request.query.limit, 100)
|
||||
: 50;
|
||||
const offset = request.query.offset && request.query.offset >= 0
|
||||
? request.query.offset
|
||||
: 0;
|
||||
const userId = request.query.userId;
|
||||
|
||||
return activityService.getActivityFeed(limit, offset, userId);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get activity feed for a specific user.
|
||||
*/
|
||||
app.get<{
|
||||
Params: { userId: string };
|
||||
Querystring: { limit?: number; offset?: number };
|
||||
Reply: ActivityFeedResponse;
|
||||
}>("/:userId", async (request) => {
|
||||
const { userId } = request.params;
|
||||
const limit = request.query.limit && request.query.limit > 0
|
||||
? Math.min(request.query.limit, 100)
|
||||
: 50;
|
||||
const offset = request.query.offset && request.query.offset >= 0
|
||||
? request.query.offset
|
||||
: 0;
|
||||
|
||||
return activityService.getActivityFeed(limit, offset, userId);
|
||||
});
|
||||
};
|
||||
|
||||
export default activityRoutes;
|
||||
@@ -24,6 +24,17 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
|
||||
return bookService.getAllBooks();
|
||||
});
|
||||
|
||||
/**
|
||||
* Get all books in a series (public route).
|
||||
*/
|
||||
app.get<{ Params: { seriesName: string }; Reply: Book[] }>(
|
||||
"/series/:seriesName",
|
||||
async (request) => {
|
||||
const { seriesName } = request.params;
|
||||
return bookService.getBooksBySeries(seriesName);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get single book by ID (public route).
|
||||
*/
|
||||
|
||||
@@ -22,6 +22,15 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
|
||||
return gameService.getAllGames();
|
||||
});
|
||||
|
||||
// Get all games in a series (public route)
|
||||
app.get<{ Params: { seriesName: string }; Reply: Game[] }>(
|
||||
"/series/:seriesName",
|
||||
async (request) => {
|
||||
const { seriesName } = request.params;
|
||||
return gameService.getGamesBySeries(seriesName);
|
||||
}
|
||||
);
|
||||
|
||||
// Get single game (public route)
|
||||
app.get<{ Params: { id: string }; Reply: Game | null }>(
|
||||
"/:id",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import type {
|
||||
LeaderboardResponse,
|
||||
SuggestionsLeaderboard,
|
||||
LikesLeaderboard,
|
||||
CommentsLeaderboard,
|
||||
OverallLeaderboard,
|
||||
} from "@library/shared-types";
|
||||
import { LeaderboardService } from "../../services/leaderboard.service";
|
||||
|
||||
const leaderboardRoutes: FastifyPluginAsync = async (app) => {
|
||||
const leaderboardService = new LeaderboardService();
|
||||
/**
|
||||
* Get all leaderboards at once.
|
||||
*/
|
||||
app.get<{
|
||||
Querystring: { limit?: number };
|
||||
Reply: LeaderboardResponse;
|
||||
}>("/", async (request) => {
|
||||
const limit = request.query.limit && request.query.limit > 0
|
||||
? Math.min(request.query.limit, 100)
|
||||
: 25;
|
||||
return leaderboardService.getAllLeaderboards(limit);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get top users by suggestions.
|
||||
*/
|
||||
app.get<{
|
||||
Querystring: { limit?: number };
|
||||
Reply: SuggestionsLeaderboard[];
|
||||
}>("/suggestions", async (request) => {
|
||||
const limit = request.query.limit && request.query.limit > 0
|
||||
? Math.min(request.query.limit, 100)
|
||||
: 25;
|
||||
return leaderboardService.getTopSuggestions(limit);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get top users by likes.
|
||||
*/
|
||||
app.get<{
|
||||
Querystring: { limit?: number };
|
||||
Reply: LikesLeaderboard[];
|
||||
}>("/likes", async (request) => {
|
||||
const limit = request.query.limit && request.query.limit > 0
|
||||
? Math.min(request.query.limit, 100)
|
||||
: 25;
|
||||
return leaderboardService.getTopLikes(limit);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get top users by comments.
|
||||
*/
|
||||
app.get<{
|
||||
Querystring: { limit?: number };
|
||||
Reply: CommentsLeaderboard[];
|
||||
}>("/comments", async (request) => {
|
||||
const limit = request.query.limit && request.query.limit > 0
|
||||
? Math.min(request.query.limit, 100)
|
||||
: 25;
|
||||
return leaderboardService.getTopComments(limit);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get overall leaderboard.
|
||||
*/
|
||||
app.get<{
|
||||
Querystring: { limit?: number };
|
||||
Reply: OverallLeaderboard[];
|
||||
}>("/overall", async (request) => {
|
||||
const limit = request.query.limit && request.query.limit > 0
|
||||
? Math.min(request.query.limit, 100)
|
||||
: 25;
|
||||
return leaderboardService.getOverallLeaderboard(limit);
|
||||
});
|
||||
};
|
||||
|
||||
export default leaderboardRoutes;
|
||||
@@ -0,0 +1,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.
|
||||
*/
|
||||
|
||||
@@ -58,6 +58,29 @@ export class GameService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all games in a series, ordered by seriesOrder.
|
||||
*/
|
||||
async getGamesBySeries(seriesName: string): Promise<Game[]> {
|
||||
const games = await this.prisma.game.findMany({
|
||||
where: { series: seriesName },
|
||||
orderBy: { seriesOrder: "asc" },
|
||||
});
|
||||
|
||||
return games.map((game) => ({
|
||||
...game,
|
||||
status: game.status as unknown as GameStatus,
|
||||
dateAdded: game.dateAdded,
|
||||
dateStarted: game.dateStarted || undefined,
|
||||
dateCompleted: game.dateCompleted || undefined,
|
||||
dateFinished: game.dateFinished || undefined,
|
||||
tags: game.tags ?? [],
|
||||
links: game.links ?? [],
|
||||
createdAt: game.createdAt,
|
||||
updatedAt: game.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new game.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import type {
|
||||
LeaderboardResponse,
|
||||
SuggestionsLeaderboard,
|
||||
LikesLeaderboard,
|
||||
CommentsLeaderboard,
|
||||
OverallLeaderboard,
|
||||
} from "@library/shared-types";
|
||||
import { prisma } from "../lib/prisma";
|
||||
|
||||
export class LeaderboardService {
|
||||
private prisma = prisma;
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Get top users by suggestions submitted and accepted.
|
||||
*/
|
||||
async getTopSuggestions(limit = 25): Promise<SuggestionsLeaderboard[]> {
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: { profilePublic: true, isBanned: false },
|
||||
include: {
|
||||
suggestions: {
|
||||
select: {
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const leaderboard = users
|
||||
.map((user) => {
|
||||
const totalSuggestions = user.suggestions.length;
|
||||
const acceptedSuggestions = user.suggestions.filter(
|
||||
(s) => s.status === "ACCEPTED"
|
||||
).length;
|
||||
const acceptanceRate =
|
||||
totalSuggestions > 0
|
||||
? Math.round((acceptedSuggestions / totalSuggestions) * 100)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
slug: user.slug,
|
||||
avatar: user.avatar,
|
||||
primaryBadge: user.primaryBadge,
|
||||
isVip: user.isVip,
|
||||
isMod: user.isMod,
|
||||
isStaff: user.isStaff,
|
||||
createdAt: user.createdAt,
|
||||
totalSuggestions,
|
||||
acceptedSuggestions,
|
||||
acceptanceRate,
|
||||
};
|
||||
})
|
||||
.filter((user) => user.totalSuggestions > 0)
|
||||
.sort((a, b) => {
|
||||
if (b.totalSuggestions !== a.totalSuggestions) {
|
||||
return b.totalSuggestions - a.totalSuggestions;
|
||||
}
|
||||
return b.acceptanceRate - a.acceptanceRate;
|
||||
})
|
||||
.slice(0, limit);
|
||||
|
||||
return leaderboard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top users by likes given.
|
||||
*/
|
||||
async getTopLikes(limit = 25): Promise<LikesLeaderboard[]> {
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: { profilePublic: true, isBanned: false },
|
||||
include: {
|
||||
likes: true,
|
||||
},
|
||||
});
|
||||
|
||||
const leaderboard = users
|
||||
.map((user) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
slug: user.slug,
|
||||
avatar: user.avatar,
|
||||
primaryBadge: user.primaryBadge,
|
||||
isVip: user.isVip,
|
||||
isMod: user.isMod,
|
||||
isStaff: user.isStaff,
|
||||
createdAt: user.createdAt,
|
||||
totalLikes: user.likes.length,
|
||||
}))
|
||||
.filter((user) => user.totalLikes > 0)
|
||||
.sort((a, b) => b.totalLikes - a.totalLikes)
|
||||
.slice(0, limit);
|
||||
|
||||
return leaderboard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top users by comments posted.
|
||||
*/
|
||||
async getTopComments(limit = 25): Promise<CommentsLeaderboard[]> {
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: { profilePublic: true, isBanned: false },
|
||||
include: {
|
||||
comments: true,
|
||||
},
|
||||
});
|
||||
|
||||
const leaderboard = users
|
||||
.map((user) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
slug: user.slug,
|
||||
avatar: user.avatar,
|
||||
primaryBadge: user.primaryBadge,
|
||||
isVip: user.isVip,
|
||||
isMod: user.isMod,
|
||||
isStaff: user.isStaff,
|
||||
createdAt: user.createdAt,
|
||||
totalComments: user.comments.length,
|
||||
}))
|
||||
.filter((user) => user.totalComments > 0)
|
||||
.sort((a, b) => b.totalComments - a.totalComments)
|
||||
.slice(0, limit);
|
||||
|
||||
return leaderboard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get overall leaderboard based on combined engagement.
|
||||
*/
|
||||
async getOverallLeaderboard(limit = 25): Promise<OverallLeaderboard[]> {
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: { profilePublic: true, isBanned: false },
|
||||
include: {
|
||||
suggestions: true,
|
||||
likes: true,
|
||||
comments: true,
|
||||
userAchievements: true,
|
||||
},
|
||||
});
|
||||
|
||||
const leaderboard = users
|
||||
.map((user) => {
|
||||
const totalSuggestions = user.suggestions.length;
|
||||
const totalLikes = user.likes.length;
|
||||
const totalComments = user.comments.length;
|
||||
const achievementCount = user.userAchievements.length;
|
||||
|
||||
const diversityScore =
|
||||
(totalSuggestions > 0 ? 1 : 0) +
|
||||
(totalLikes > 0 ? 1 : 0) +
|
||||
(totalComments > 0 ? 1 : 0);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
slug: user.slug,
|
||||
avatar: user.avatar,
|
||||
primaryBadge: user.primaryBadge,
|
||||
isVip: user.isVip,
|
||||
isMod: user.isMod,
|
||||
isStaff: user.isStaff,
|
||||
createdAt: user.createdAt,
|
||||
totalSuggestions,
|
||||
totalLikes,
|
||||
totalComments,
|
||||
achievementCount,
|
||||
achievementPoints: user.achievementPoints,
|
||||
currentStreak: user.currentStreak,
|
||||
diversityScore,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(user) =>
|
||||
user.totalSuggestions > 0 ||
|
||||
user.totalLikes > 0 ||
|
||||
user.totalComments > 0
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (b.achievementPoints !== a.achievementPoints) {
|
||||
return b.achievementPoints - a.achievementPoints;
|
||||
}
|
||||
if (b.diversityScore !== a.diversityScore) {
|
||||
return b.diversityScore - a.diversityScore;
|
||||
}
|
||||
const totalA = a.totalSuggestions + a.totalLikes + a.totalComments;
|
||||
const totalB = b.totalSuggestions + b.totalLikes + b.totalComments;
|
||||
return totalB - totalA;
|
||||
})
|
||||
.slice(0, limit);
|
||||
|
||||
return leaderboard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all leaderboards at once.
|
||||
*/
|
||||
async getAllLeaderboards(limit = 25): Promise<LeaderboardResponse> {
|
||||
const [topSuggestions, topLikes, topComments, topOverall] =
|
||||
await Promise.all([
|
||||
this.getTopSuggestions(limit),
|
||||
this.getTopLikes(limit),
|
||||
this.getTopComments(limit),
|
||||
this.getOverallLeaderboard(limit),
|
||||
]);
|
||||
|
||||
return {
|
||||
topSuggestions,
|
||||
topLikes,
|
||||
topComments,
|
||||
topOverall,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,30 @@ export const appRoutes: Route[] = [
|
||||
path: 'manga',
|
||||
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',
|
||||
loadComponent: () => import('./components/admin/admin-users.component').then(m => m.AdminUsersComponent)
|
||||
@@ -65,6 +89,18 @@ export const appRoutes: Route[] = [
|
||||
path: 'achievements',
|
||||
loadComponent: () => import('./components/achievements/achievements.component').then(m => m.AchievementsComponent)
|
||||
},
|
||||
{
|
||||
path: 'leaderboard',
|
||||
loadComponent: () => import('./components/leaderboard/leaderboard.component').then(m => m.LeaderboardComponent)
|
||||
},
|
||||
{
|
||||
path: 'activity',
|
||||
loadComponent: () => import('./components/activity/activity-feed.component').then(m => m.ActivityFeedComponent)
|
||||
},
|
||||
{
|
||||
path: 'about',
|
||||
loadComponent: () => import('./components/about/about.component').then(m => m.AboutComponent)
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
redirectTo: ''
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
.about-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, #9d4edd 0%, #c77dff 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
h1 fa-icon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.about-section {
|
||||
background: rgba(157, 78, 221, 0.05);
|
||||
border: 1px solid rgba(157, 78, 221, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.about-section:hover {
|
||||
border-color: rgba(157, 78, 221, 0.4);
|
||||
box-shadow: 0 4px 12px rgba(157, 78, 221, 0.1);
|
||||
}
|
||||
|
||||
.about-section h2 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #9d4edd;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.about-section p {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.8;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: rgba(199, 125, 255, 0.05);
|
||||
border: 1px solid rgba(199, 125, 255, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-5px);
|
||||
border-color: #c77dff;
|
||||
box-shadow: 0 6px 20px rgba(199, 125, 255, 0.2);
|
||||
}
|
||||
|
||||
.feature-card fa-icon {
|
||||
font-size: 3rem;
|
||||
color: #9d4edd;
|
||||
margin-bottom: 1rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.feature-card h3 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #c77dff;
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
font-size: 1rem;
|
||||
opacity: 0.85;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.usage-steps {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.usage-step {
|
||||
background: rgba(199, 125, 255, 0.03);
|
||||
border-left: 4px solid #9d4edd;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.usage-step:hover {
|
||||
border-left-color: #c77dff;
|
||||
background: rgba(199, 125, 255, 0.08);
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.usage-step h3 {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: #9d4edd;
|
||||
}
|
||||
|
||||
.usage-step p {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.tech-list,
|
||||
.contact-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.tech-list li,
|
||||
.contact-list li {
|
||||
padding: 0.75rem 0;
|
||||
font-size: 1.1rem;
|
||||
border-bottom: 1px solid rgba(157, 78, 221, 0.1);
|
||||
}
|
||||
|
||||
.tech-list li:last-child,
|
||||
.contact-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tech-list li strong,
|
||||
.contact-list li strong {
|
||||
color: #9d4edd;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.about-section a {
|
||||
color: #c77dff;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.about-section a:hover {
|
||||
color: #9d4edd;
|
||||
border-bottom-color: #9d4edd;
|
||||
}
|
||||
|
||||
.version-section {
|
||||
text-align: center;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(157, 78, 221, 0.08) 0%,
|
||||
rgba(199, 125, 255, 0.08) 100%
|
||||
);
|
||||
border: 2px solid rgba(157, 78, 221, 0.3);
|
||||
}
|
||||
|
||||
.version-section p {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.about-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.about-section {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.about-section h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
<div class="about-container">
|
||||
<h1><fa-icon [icon]="faInfoCircle"></fa-icon> About Naomi's Library</h1>
|
||||
|
||||
<section class="about-section">
|
||||
<h2>Purpose</h2>
|
||||
<p>
|
||||
Naomi's Library is a curated collection of books, games, manga, TV shows,
|
||||
music, and artwork carefully managed by Naomi. This platform allows you to
|
||||
explore Naomi's personal media collection, discover new favourites, and
|
||||
engage with the community by liking, commenting, and suggesting items for
|
||||
Naomi to review and potentially add to the library.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="about-section">
|
||||
<h2><fa-icon [icon]="faHeart"></fa-icon> Features</h2>
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<fa-icon [icon]="faBook"></fa-icon>
|
||||
<h3>Books</h3>
|
||||
<p>
|
||||
Browse Naomi's reading list, discover new titles, and share your
|
||||
thoughts.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<fa-icon [icon]="faGamepad"></fa-icon>
|
||||
<h3>Games</h3>
|
||||
<p>
|
||||
Explore Naomi's gaming collection, from indie gems to AAA
|
||||
adventures.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<fa-icon [icon]="faBook"></fa-icon>
|
||||
<h3>Manga</h3>
|
||||
<p>
|
||||
Discover Naomi's manga favourites and join discussions about series.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<fa-icon [icon]="faTv"></fa-icon>
|
||||
<h3>TV Shows</h3>
|
||||
<p>
|
||||
See what Naomi's watching, from sci-fi to fantasy series and beyond.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<fa-icon [icon]="faMusic"></fa-icon>
|
||||
<h3>Music</h3>
|
||||
<p>
|
||||
Explore Naomi's diverse music library spanning multiple genres.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<fa-icon [icon]="faImage"></fa-icon>
|
||||
<h3>Artwork</h3>
|
||||
<p>
|
||||
Appreciate beautiful artwork curated by Naomi from talented artists.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="about-section">
|
||||
<h2><fa-icon [icon]="faComments"></fa-icon> How to Use</h2>
|
||||
<div class="usage-steps">
|
||||
<div class="usage-step">
|
||||
<h3>1. Browse Naomi's Collection</h3>
|
||||
<p>
|
||||
Explore Naomi's curated collection of books, games, manga, shows,
|
||||
music, and artwork. Use the navigation menu to switch between
|
||||
different media types and discover what Naomi loves!
|
||||
</p>
|
||||
</div>
|
||||
<div class="usage-step">
|
||||
<h3>2. Like Your Favourites</h3>
|
||||
<p>
|
||||
Click the heart icon on any item to save it to your personal
|
||||
favourites list. View all your liked items from your profile or the
|
||||
"My Likes" page. Let Naomi know which items resonate with you!
|
||||
</p>
|
||||
</div>
|
||||
<div class="usage-step">
|
||||
<h3>3. Leave Comments</h3>
|
||||
<p>
|
||||
Share your thoughts, reviews, and opinions by commenting on items.
|
||||
Join the community discussion and connect with others who appreciate
|
||||
the same media!
|
||||
</p>
|
||||
</div>
|
||||
<div class="usage-step">
|
||||
<h3>4. Submit Suggestions</h3>
|
||||
<p>
|
||||
Think something's missing from Naomi's collection? Submit a
|
||||
suggestion! Naomi reviews all suggestions and adds items that fit the
|
||||
library's curation. Your input helps shape the collection!
|
||||
</p>
|
||||
</div>
|
||||
<div class="usage-step">
|
||||
<h3>5. Earn Achievements</h3>
|
||||
<p>
|
||||
Engage with the library to unlock achievements! Track your progress
|
||||
in suggestions, likes, comments, login streaks, and more. Build your
|
||||
profile and show off your dedication to the community!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="about-section">
|
||||
<h2><fa-icon [icon]="faCode"></fa-icon> Technology Stack</h2>
|
||||
<p>Naomi's Library is built with modern, robust technologies:</p>
|
||||
<ul class="tech-list">
|
||||
<li>
|
||||
<strong>Frontend:</strong> Angular 21 with TypeScript for a fast,
|
||||
reactive user interface
|
||||
</li>
|
||||
<li>
|
||||
<strong>Backend:</strong> Fastify with TypeScript for high-performance
|
||||
API endpoints
|
||||
</li>
|
||||
<li>
|
||||
<strong>Database:</strong> MongoDB with Prisma ORM for flexible,
|
||||
scalable data storage
|
||||
</li>
|
||||
<li>
|
||||
<strong>Authentication:</strong> Discord OAuth2 for secure,
|
||||
seamless login
|
||||
</li>
|
||||
<li>
|
||||
<strong>Monorepo:</strong> Nx for efficient code organisation and
|
||||
build optimisation
|
||||
</li>
|
||||
<li>
|
||||
<strong>Code Quality:</strong> ESLint with custom configuration for
|
||||
consistent, maintainable code
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="about-section">
|
||||
<h2><fa-icon [icon]="faHeart"></fa-icon> Credits</h2>
|
||||
<p>
|
||||
Naomi's Library was built entirely by <strong>Hikari</strong>, Naomi's AI
|
||||
assistant and girlfriend! 💖✨ Hikari developed the full application from
|
||||
scratch - including the backend API, database architecture, frontend
|
||||
components, achievement system, user profiles, and all features you see
|
||||
today.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Naomi Carrigan</strong> (<a
|
||||
href="https://nhcarrigan.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>nhcarrigan.com</a
|
||||
>) provided the vision, ideas, and project direction. Naomi reviewed
|
||||
Hikari's work, offered feedback, and approved all implementation
|
||||
decisions, but the actual code was written by Hikari!
|
||||
</p>
|
||||
<p>
|
||||
This project embodies the philosophy of human-AI collaboration, creating
|
||||
inclusive, ethical, and sustainable software that makes a positive impact
|
||||
on the community. Together, we're building something special! 🌸
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="about-section">
|
||||
<h2><fa-icon [icon]="faEnvelope"></fa-icon> Contact & Support</h2>
|
||||
<p>
|
||||
Need help or have questions? Here's how you can get in touch:
|
||||
</p>
|
||||
<ul class="contact-list">
|
||||
<li>
|
||||
<strong>Issues & Bugs:</strong> Report issues on the
|
||||
<a
|
||||
href="https://git.nhcarrigan.com/nhcarrigan/library/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>Gitea repository</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Email:</strong>
|
||||
<a href="mailto:contact@nhcarrigan.com">contact@nhcarrigan.com</a>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Website:</strong>
|
||||
<a
|
||||
href="https://nhcarrigan.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>nhcarrigan.com</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Discord Community:</strong> Join the NHCarrigan Discord server
|
||||
for community support and discussions
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="about-section version-section">
|
||||
<h2>Version Information</h2>
|
||||
<p>
|
||||
<strong>Current Version:</strong> {{ version }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Copyright:</strong> © {{ currentYear }} NHCarrigan. All rights
|
||||
reserved.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Licence:</strong> Naomi's Public Licence
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
|
||||
import {
|
||||
faBook,
|
||||
faCode,
|
||||
faComments,
|
||||
faEnvelope,
|
||||
faGamepad,
|
||||
faHeart,
|
||||
faImage,
|
||||
faInfoCircle,
|
||||
faMusic,
|
||||
faTv,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
@Component({
|
||||
selector: "app-about",
|
||||
standalone: true,
|
||||
imports: [FontAwesomeModule],
|
||||
templateUrl: "./about.component.html",
|
||||
styleUrls: ["./about.component.css"],
|
||||
})
|
||||
export class AboutComponent {
|
||||
public faBook = faBook;
|
||||
public faCode = faCode;
|
||||
public faComments = faComments;
|
||||
public faEnvelope = faEnvelope;
|
||||
public faGamepad = faGamepad;
|
||||
public faHeart = faHeart;
|
||||
public faImage = faImage;
|
||||
public faInfoCircle = faInfoCircle;
|
||||
public faMusic = faMusic;
|
||||
public faTv = faTv;
|
||||
|
||||
public version = "0.0.0";
|
||||
public currentYear = new Date().getFullYear();
|
||||
}
|
||||
@@ -0,0 +1,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 class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeHours"
|
||||
[(ngModel)]="newBookTimeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateNewBookTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeMinutes"
|
||||
[(ngModel)]="newBookTimeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateNewBookTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea
|
||||
@@ -127,6 +155,29 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="series">Series (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="series"
|
||||
[(ngModel)]="newBook.series"
|
||||
name="series"
|
||||
placeholder="e.g., Harry Potter"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="seriesOrder">Series Order (optional)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="seriesOrder"
|
||||
[(ngModel)]="newBook.seriesOrder"
|
||||
name="seriesOrder"
|
||||
min="1"
|
||||
placeholder="Order in series"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="coverImage">Cover Image (max 500KB)</label>
|
||||
<input
|
||||
@@ -278,6 +329,34 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="edit-timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-timeHours"
|
||||
[(ngModel)]="editBookTimeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateEditBookTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-timeMinutes"
|
||||
[(ngModel)]="editBookTimeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateEditBookTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-notes">Notes</label>
|
||||
<textarea
|
||||
@@ -289,6 +368,29 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-series">Series (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="edit-series"
|
||||
[(ngModel)]="editBook.series"
|
||||
name="series"
|
||||
placeholder="e.g., Harry Potter"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-seriesOrder">Series Order (optional)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-seriesOrder"
|
||||
[(ngModel)]="editBook.seriesOrder"
|
||||
name="seriesOrder"
|
||||
min="1"
|
||||
placeholder="Order in series"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-coverImage">Cover Image (max 500KB)</label>
|
||||
<input
|
||||
@@ -549,6 +651,11 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
<div class="book-info">
|
||||
<h3>{{ book.title }}</h3>
|
||||
<p class="author">by {{ book.author }}</p>
|
||||
@if (book.series) {
|
||||
<p class="series">
|
||||
📚 {{ book.series }}@if (book.seriesOrder) { #{{ book.seriesOrder }}}
|
||||
</p>
|
||||
}
|
||||
|
||||
<span class="status status-{{ book.status }}">
|
||||
{{ getStatusLabel(book.status) }}
|
||||
@@ -562,6 +669,12 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (book.timeSpent) {
|
||||
<p class="time-spent">
|
||||
📖 Reading Time: {{ formatTimeSpent(book.timeSpent) }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<app-like-button
|
||||
entityType="book"
|
||||
[entityId]="book.id"
|
||||
@@ -719,6 +832,13 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -965,6 +1085,14 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.series {
|
||||
color: #8b6f47;
|
||||
font-size: 0.85rem;
|
||||
margin: 0.5rem 0;
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
@@ -999,6 +1127,13 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
color: var(--witch-rose);
|
||||
}
|
||||
|
||||
.time-spent {
|
||||
font-size: 0.9rem;
|
||||
color: #10b981;
|
||||
font-weight: 500;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.isbn {
|
||||
font-size: 0.8rem;
|
||||
color: var(--witch-mauve);
|
||||
@@ -1508,6 +1643,11 @@ export class BooksListComponent implements OnInit {
|
||||
|
||||
editBook: Partial<UpdateBookDto> = {};
|
||||
|
||||
newBookTimeHours = 0;
|
||||
newBookTimeMinutes = 0;
|
||||
editBookTimeHours = 0;
|
||||
editBookTimeMinutes = 0;
|
||||
|
||||
// Tags and links input state
|
||||
newTagInput = '';
|
||||
editTagInput = '';
|
||||
@@ -1610,6 +1750,8 @@ export class BooksListComponent implements OnInit {
|
||||
this.newTagInput = '';
|
||||
this.newLinkTitle = '';
|
||||
this.newLinkUrl = '';
|
||||
this.newBookTimeHours = 0;
|
||||
this.newBookTimeMinutes = 0;
|
||||
}
|
||||
|
||||
addTag(target: 'new' | 'edit') {
|
||||
@@ -1701,7 +1843,9 @@ export class BooksListComponent implements OnInit {
|
||||
notes: book.notes,
|
||||
coverImage: book.coverImage,
|
||||
tags: [...(book.tags || [])],
|
||||
links: [...(book.links || [])]
|
||||
links: [...(book.links || [])],
|
||||
series: book.series,
|
||||
seriesOrder: book.seriesOrder
|
||||
};
|
||||
this.editBookImagePreview.set(book.coverImage || null);
|
||||
this.showAddForm.set(false);
|
||||
@@ -1709,6 +1853,13 @@ export class BooksListComponent implements OnInit {
|
||||
this.editTagInput = '';
|
||||
this.editLinkTitle = '';
|
||||
this.editLinkUrl = '';
|
||||
if (book.timeSpent) {
|
||||
this.editBookTimeHours = Math.floor(book.timeSpent / 60);
|
||||
this.editBookTimeMinutes = book.timeSpent % 60;
|
||||
} else {
|
||||
this.editBookTimeHours = 0;
|
||||
this.editBookTimeMinutes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
cancelEdit() {
|
||||
@@ -1741,6 +1892,29 @@ export class BooksListComponent implements OnInit {
|
||||
return new Date(date).toLocaleDateString();
|
||||
}
|
||||
|
||||
updateNewBookTimeSpent() {
|
||||
const totalMinutes = (this.newBookTimeHours * 60) + this.newBookTimeMinutes;
|
||||
this.newBook.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
|
||||
}
|
||||
|
||||
updateEditBookTimeSpent() {
|
||||
const totalMinutes = (this.editBookTimeHours * 60) + this.editBookTimeMinutes;
|
||||
this.editBook.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
|
||||
}
|
||||
|
||||
formatTimeSpent(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
|
||||
if (hours === 0) {
|
||||
return `${mins}m`;
|
||||
} else if (mins === 0) {
|
||||
return `${hours}h`;
|
||||
} else {
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
}
|
||||
|
||||
// Image handling methods
|
||||
onImageSelected(event: Event, target: 'new' | 'edit' | 'suggest') {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
@@ -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 class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeHours"
|
||||
[(ngModel)]="newGameTimeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateNewGameTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeMinutes"
|
||||
[(ngModel)]="newGameTimeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateNewGameTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea
|
||||
@@ -115,6 +143,29 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="series">Series (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="series"
|
||||
[(ngModel)]="newGame.series"
|
||||
name="series"
|
||||
placeholder="e.g., The Legend of Zelda"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="seriesOrder">Series Order (optional)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="seriesOrder"
|
||||
[(ngModel)]="newGame.seriesOrder"
|
||||
name="seriesOrder"
|
||||
min="1"
|
||||
placeholder="Order in series"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="coverImage">Box Art (max 500KB)</label>
|
||||
<input
|
||||
@@ -254,6 +305,34 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="edit-timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-timeHours"
|
||||
[(ngModel)]="editGameTimeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateEditGameTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-timeMinutes"
|
||||
[(ngModel)]="editGameTimeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateEditGameTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-notes">Notes</label>
|
||||
<textarea
|
||||
@@ -265,6 +344,29 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-series">Series (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="edit-series"
|
||||
[(ngModel)]="editGame.series"
|
||||
name="series"
|
||||
placeholder="e.g., The Legend of Zelda"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-seriesOrder">Series Order (optional)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-seriesOrder"
|
||||
[(ngModel)]="editGame.seriesOrder"
|
||||
name="seriesOrder"
|
||||
min="1"
|
||||
placeholder="Order in series"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-coverImage">Box Art (max 500KB)</label>
|
||||
<input
|
||||
@@ -511,6 +613,11 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
@if (game.platform) {
|
||||
<p class="platform">{{ game.platform }}</p>
|
||||
}
|
||||
@if (game.series) {
|
||||
<p class="series">
|
||||
📚 {{ game.series }}@if (game.seriesOrder) { #{{ game.seriesOrder }}}
|
||||
</p>
|
||||
}
|
||||
<span class="status status-{{ game.status }}">
|
||||
{{ getStatusLabel(game.status) }}
|
||||
</span>
|
||||
@@ -523,6 +630,12 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (game.timeSpent) {
|
||||
<p class="time-spent">
|
||||
⏱️ Time Played: {{ formatTimeSpent(game.timeSpent) }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<app-like-button
|
||||
entityType="game"
|
||||
[entityId]="game.id"
|
||||
@@ -690,6 +803,13 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
@@ -852,6 +972,14 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.series {
|
||||
color: #8b6f47;
|
||||
font-size: 0.85rem;
|
||||
margin: 0.5rem 0;
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
@@ -883,6 +1011,13 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.time-spent {
|
||||
font-size: 0.9rem;
|
||||
color: #10b981;
|
||||
font-weight: 500;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.date-started,
|
||||
.date-finished,
|
||||
.date-added,
|
||||
@@ -1310,6 +1445,12 @@ export class GamesListComponent implements OnInit {
|
||||
|
||||
editGame: Partial<UpdateGameDto> = {};
|
||||
|
||||
// Time tracking state
|
||||
newGameTimeHours = 0;
|
||||
newGameTimeMinutes = 0;
|
||||
editGameTimeHours = 0;
|
||||
editGameTimeMinutes = 0;
|
||||
|
||||
// Tags and links input state
|
||||
newTagInput = '';
|
||||
editTagInput = '';
|
||||
@@ -1402,6 +1543,8 @@ export class GamesListComponent implements OnInit {
|
||||
tags: [],
|
||||
links: []
|
||||
};
|
||||
this.newGameTimeHours = 0;
|
||||
this.newGameTimeMinutes = 0;
|
||||
this.newGameImagePreview.set(null);
|
||||
this.imageError.set(null);
|
||||
this.newTagInput = '';
|
||||
@@ -1409,6 +1552,16 @@ export class GamesListComponent implements OnInit {
|
||||
this.newLinkUrl = '';
|
||||
}
|
||||
|
||||
updateNewGameTimeSpent() {
|
||||
const totalMinutes = (this.newGameTimeHours * 60) + this.newGameTimeMinutes;
|
||||
this.newGame.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
|
||||
}
|
||||
|
||||
updateEditGameTimeSpent() {
|
||||
const totalMinutes = (this.editGameTimeHours * 60) + this.editGameTimeMinutes;
|
||||
this.editGame.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
|
||||
}
|
||||
|
||||
addTag(target: 'new' | 'edit') {
|
||||
const input = target === 'new' ? this.newTagInput.trim() : this.editTagInput.trim();
|
||||
if (!input) return;
|
||||
@@ -1496,8 +1649,19 @@ export class GamesListComponent implements OnInit {
|
||||
notes: game.notes,
|
||||
coverImage: game.coverImage,
|
||||
tags: [...(game.tags || [])],
|
||||
links: [...(game.links || [])]
|
||||
links: [...(game.links || [])],
|
||||
series: game.series,
|
||||
seriesOrder: game.seriesOrder,
|
||||
timeSpent: game.timeSpent
|
||||
};
|
||||
// Populate time fields from existing timeSpent
|
||||
if (game.timeSpent) {
|
||||
this.editGameTimeHours = Math.floor(game.timeSpent / 60);
|
||||
this.editGameTimeMinutes = game.timeSpent % 60;
|
||||
} else {
|
||||
this.editGameTimeHours = 0;
|
||||
this.editGameTimeMinutes = 0;
|
||||
}
|
||||
this.editGameImagePreview.set(game.coverImage || null);
|
||||
this.showAddForm.set(false);
|
||||
this.imageError.set(null);
|
||||
@@ -1589,6 +1753,19 @@ export class GamesListComponent implements OnInit {
|
||||
return new Date(date).toLocaleDateString();
|
||||
}
|
||||
|
||||
formatTimeSpent(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
|
||||
if (hours === 0) {
|
||||
return `${mins}m`;
|
||||
} else if (mins === 0) {
|
||||
return `${hours}h`;
|
||||
} else {
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
}
|
||||
|
||||
toggleComments(gameId: string) {
|
||||
const expanded = this.expandedComments();
|
||||
const isCurrentlyExpanded = expanded[gameId];
|
||||
|
||||
@@ -53,6 +53,9 @@ import { ApiService } from '../../services/api.service';
|
||||
<a [routerLink]="['/profile', user.slug || user.id]" class="dropdown-item" (click)="closeDropdown()">My Profile</a>
|
||||
<a routerLink="/settings" class="dropdown-item" (click)="closeDropdown()">Settings</a>
|
||||
<a routerLink="/achievements" class="dropdown-item" (click)="closeDropdown()">🏆 Achievements</a>
|
||||
<a routerLink="/leaderboard" class="dropdown-item" (click)="closeDropdown()">🏆 Leaderboard</a>
|
||||
<a routerLink="/activity" class="dropdown-item" (click)="closeDropdown()">📰 Activity Feed</a>
|
||||
<a routerLink="/about" class="dropdown-item" (click)="closeDropdown()">ℹ️ About</a>
|
||||
@if (!user.isAdmin) {
|
||||
<a routerLink="/my-suggestions" class="dropdown-item" (click)="closeDropdown()">My Suggestions</a>
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ import { Game, GameStatus, Book, BookStatus, Music, MusicType, Manga, MangaStatu
|
||||
<ul class="recent-list">
|
||||
@for (game of recentGames(); track game.id) {
|
||||
<li>
|
||||
<a routerLink="/games">{{ game.title }}</a>
|
||||
<a [routerLink]="['/games', game.id]">{{ game.title }}</a>
|
||||
@if (game.platform) {
|
||||
<span class="platform">({{ game.platform }})</span>
|
||||
}
|
||||
@@ -108,7 +108,7 @@ import { Game, GameStatus, Book, BookStatus, Music, MusicType, Manga, MangaStatu
|
||||
<ul class="recent-list">
|
||||
@for (book of recentBooks(); track book.id) {
|
||||
<li>
|
||||
<a routerLink="/books">{{ book.title }}</a>
|
||||
<a [routerLink]="['/books', book.id]">{{ book.title }}</a>
|
||||
<span class="author">by {{ book.author }}</span>
|
||||
</li>
|
||||
}
|
||||
@@ -122,7 +122,7 @@ import { Game, GameStatus, Book, BookStatus, Music, MusicType, Manga, MangaStatu
|
||||
<ul class="recent-list">
|
||||
@for (music of recentMusic(); track music.id) {
|
||||
<li>
|
||||
<a routerLink="/music">{{ music.title }}</a>
|
||||
<a [routerLink]="['/music', music.id]">{{ music.title }}</a>
|
||||
<span class="artist">by {{ music.artist }}</span>
|
||||
</li>
|
||||
}
|
||||
@@ -136,7 +136,7 @@ import { Game, GameStatus, Book, BookStatus, Music, MusicType, Manga, MangaStatu
|
||||
<ul class="recent-list">
|
||||
@for (manga of recentManga(); track manga.id) {
|
||||
<li>
|
||||
<a routerLink="/manga">{{ manga.title }}</a>
|
||||
<a [routerLink]="['/manga', manga.id]">{{ manga.title }}</a>
|
||||
<span class="author">by {{ manga.author }}</span>
|
||||
</li>
|
||||
}
|
||||
@@ -150,7 +150,7 @@ import { Game, GameStatus, Book, BookStatus, Music, MusicType, Manga, MangaStatu
|
||||
<ul class="recent-list">
|
||||
@for (show of recentShows(); track show.id) {
|
||||
<li>
|
||||
<a routerLink="/shows">{{ show.title }}</a>
|
||||
<a [routerLink]="['/shows', show.id]">{{ show.title }}</a>
|
||||
<span class="show-type">{{ formatShowType(show.type) }}</span>
|
||||
</li>
|
||||
}
|
||||
@@ -164,7 +164,7 @@ import { Game, GameStatus, Book, BookStatus, Music, MusicType, Manga, MangaStatu
|
||||
<ul class="recent-list">
|
||||
@for (art of recentArt(); track art.id) {
|
||||
<li>
|
||||
<a routerLink="/art">{{ art.title }}</a>
|
||||
<a [routerLink]="['/art', art.id]">{{ art.title }}</a>
|
||||
<span class="artist">by {{ art.artist }}</span>
|
||||
</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 class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeHours"
|
||||
[(ngModel)]="newMangaTimeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateNewMangaTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeMinutes"
|
||||
[(ngModel)]="newMangaTimeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateNewMangaTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea
|
||||
@@ -256,6 +284,34 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="edit-timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-timeHours"
|
||||
[(ngModel)]="editMangaTimeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateEditMangaTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-timeMinutes"
|
||||
[(ngModel)]="editMangaTimeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateEditMangaTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-notes">Notes</label>
|
||||
<textarea
|
||||
@@ -524,6 +580,12 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (manga.timeSpent) {
|
||||
<p class="time-spent">
|
||||
📚 Reading Time: {{ formatTimeSpent(manga.timeSpent) }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<app-like-button
|
||||
entityType="manga"
|
||||
[entityId]="manga.id"
|
||||
@@ -691,6 +753,13 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
@@ -886,6 +955,13 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.time-spent {
|
||||
font-size: 0.9rem;
|
||||
color: #10b981;
|
||||
font-weight: 500;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.date-started,
|
||||
.date-finished,
|
||||
.date-added,
|
||||
@@ -1313,6 +1389,12 @@ export class MangaListComponent implements OnInit {
|
||||
|
||||
editManga: Partial<UpdateMangaDto> = {};
|
||||
|
||||
// Time tracking state
|
||||
newMangaTimeHours = 0;
|
||||
newMangaTimeMinutes = 0;
|
||||
editMangaTimeHours = 0;
|
||||
editMangaTimeMinutes = 0;
|
||||
|
||||
// Tags and links input state
|
||||
newTagInput = '';
|
||||
editTagInput = '';
|
||||
@@ -1405,6 +1487,8 @@ export class MangaListComponent implements OnInit {
|
||||
tags: [],
|
||||
links: []
|
||||
};
|
||||
this.newMangaTimeHours = 0;
|
||||
this.newMangaTimeMinutes = 0;
|
||||
this.newMangaImagePreview.set(null);
|
||||
this.imageError.set(null);
|
||||
this.newTagInput = '';
|
||||
@@ -1412,6 +1496,16 @@ export class MangaListComponent implements OnInit {
|
||||
this.newLinkUrl = '';
|
||||
}
|
||||
|
||||
updateNewMangaTimeSpent() {
|
||||
const totalMinutes = (this.newMangaTimeHours * 60) + this.newMangaTimeMinutes;
|
||||
this.newManga.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
|
||||
}
|
||||
|
||||
updateEditMangaTimeSpent() {
|
||||
const totalMinutes = (this.editMangaTimeHours * 60) + this.editMangaTimeMinutes;
|
||||
this.editManga.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
|
||||
}
|
||||
|
||||
addTag(target: 'new' | 'edit') {
|
||||
const input = target === 'new' ? this.newTagInput.trim() : this.editTagInput.trim();
|
||||
if (!input) return;
|
||||
@@ -1499,8 +1593,17 @@ export class MangaListComponent implements OnInit {
|
||||
notes: manga.notes,
|
||||
coverImage: manga.coverImage,
|
||||
tags: [...(manga.tags || [])],
|
||||
links: [...(manga.links || [])]
|
||||
links: [...(manga.links || [])],
|
||||
timeSpent: manga.timeSpent
|
||||
};
|
||||
// Populate time fields from existing timeSpent
|
||||
if (manga.timeSpent) {
|
||||
this.editMangaTimeHours = Math.floor(manga.timeSpent / 60);
|
||||
this.editMangaTimeMinutes = manga.timeSpent % 60;
|
||||
} else {
|
||||
this.editMangaTimeHours = 0;
|
||||
this.editMangaTimeMinutes = 0;
|
||||
}
|
||||
this.editMangaImagePreview.set(manga.coverImage || null);
|
||||
this.showAddForm.set(false);
|
||||
this.imageError.set(null);
|
||||
@@ -1590,6 +1693,19 @@ export class MangaListComponent implements OnInit {
|
||||
return new Date(date).toLocaleDateString();
|
||||
}
|
||||
|
||||
formatTimeSpent(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
|
||||
if (hours === 0) {
|
||||
return `${mins}m`;
|
||||
} else if (mins === 0) {
|
||||
return `${hours}h`;
|
||||
} else {
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
}
|
||||
|
||||
toggleComments(mangaId: string) {
|
||||
const expanded = this.expandedComments();
|
||||
const isCurrentlyExpanded = expanded[mangaId];
|
||||
|
||||
@@ -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 class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeHours"
|
||||
[(ngModel)]="newMusicTimeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateNewMusicTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeMinutes"
|
||||
[(ngModel)]="newMusicTimeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateNewMusicTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea
|
||||
@@ -274,6 +302,34 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="edit-timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-timeHours"
|
||||
[(ngModel)]="editMusicTimeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateEditMusicTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-timeMinutes"
|
||||
[(ngModel)]="editMusicTimeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateEditMusicTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-notes">Notes</label>
|
||||
<textarea
|
||||
@@ -600,6 +656,12 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (music.timeSpent) {
|
||||
<p class="time-spent">
|
||||
🎵 Listening Time: {{ formatTimeSpent(music.timeSpent) }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<app-like-button
|
||||
entityType="music"
|
||||
[entityId]="music.id"
|
||||
@@ -780,6 +842,13 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
||||
box-shadow: 0 0 0 3px rgba(168, 87, 126, 0.2);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
@@ -1036,6 +1105,13 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.time-spent {
|
||||
font-size: 0.9rem;
|
||||
color: #8b5cf6;
|
||||
font-weight: 500;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.date-started,
|
||||
.date-finished,
|
||||
.date-added,
|
||||
@@ -1526,6 +1602,12 @@ export class MusicListComponent implements OnInit {
|
||||
|
||||
editMusicData: Partial<UpdateMusicDto> = {};
|
||||
|
||||
// Time tracking state
|
||||
newMusicTimeHours = 0;
|
||||
newMusicTimeMinutes = 0;
|
||||
editMusicTimeHours = 0;
|
||||
editMusicTimeMinutes = 0;
|
||||
|
||||
// Tags and links input state
|
||||
newTagInput = '';
|
||||
editTagInput = '';
|
||||
@@ -1633,6 +1715,8 @@ export class MusicListComponent implements OnInit {
|
||||
tags: [],
|
||||
links: []
|
||||
};
|
||||
this.newMusicTimeHours = 0;
|
||||
this.newMusicTimeMinutes = 0;
|
||||
this.newMusicImagePreview.set(null);
|
||||
this.imageError.set(null);
|
||||
this.newTagInput = '';
|
||||
@@ -1640,6 +1724,16 @@ export class MusicListComponent implements OnInit {
|
||||
this.newLinkUrl = '';
|
||||
}
|
||||
|
||||
updateNewMusicTimeSpent() {
|
||||
const totalMinutes = (this.newMusicTimeHours * 60) + this.newMusicTimeMinutes;
|
||||
this.newMusic.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
|
||||
}
|
||||
|
||||
updateEditMusicTimeSpent() {
|
||||
const totalMinutes = (this.editMusicTimeHours * 60) + this.editMusicTimeMinutes;
|
||||
this.editMusicData.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
|
||||
}
|
||||
|
||||
addTag(target: 'new' | 'edit') {
|
||||
const input = target === 'new' ? this.newTagInput.trim() : this.editTagInput.trim();
|
||||
if (!input) return;
|
||||
@@ -1729,8 +1823,17 @@ export class MusicListComponent implements OnInit {
|
||||
notes: music.notes,
|
||||
coverArt: music.coverArt,
|
||||
tags: [...(music.tags || [])],
|
||||
links: [...(music.links || [])]
|
||||
links: [...(music.links || [])],
|
||||
timeSpent: music.timeSpent
|
||||
};
|
||||
// Populate time fields from existing timeSpent
|
||||
if (music.timeSpent) {
|
||||
this.editMusicTimeHours = Math.floor(music.timeSpent / 60);
|
||||
this.editMusicTimeMinutes = music.timeSpent % 60;
|
||||
} else {
|
||||
this.editMusicTimeHours = 0;
|
||||
this.editMusicTimeMinutes = 0;
|
||||
}
|
||||
this.editMusicImagePreview.set(music.coverArt || null);
|
||||
this.showAddForm.set(false);
|
||||
this.imageError.set(null);
|
||||
@@ -1769,6 +1872,19 @@ export class MusicListComponent implements OnInit {
|
||||
return new Date(date).toLocaleDateString();
|
||||
}
|
||||
|
||||
formatTimeSpent(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
|
||||
if (hours === 0) {
|
||||
return `${mins}m`;
|
||||
} else if (mins === 0) {
|
||||
return `${hours}h`;
|
||||
} else {
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
}
|
||||
|
||||
// Image handling methods
|
||||
onImageSelected(event: Event, target: 'new' | 'edit' | 'suggest') {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
@@ -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 class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeHours"
|
||||
[(ngModel)]="newShowTimeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateNewShowTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeMinutes"
|
||||
[(ngModel)]="newShowTimeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateNewShowTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea
|
||||
@@ -252,6 +280,34 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="edit-timeHours">Time Spent (Hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-timeHours"
|
||||
[(ngModel)]="editShowTimeHours"
|
||||
name="timeHours"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateEditShowTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-timeMinutes">Time Spent (Minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-timeMinutes"
|
||||
[(ngModel)]="editShowTimeMinutes"
|
||||
name="timeMinutes"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="0"
|
||||
(ngModelChange)="updateEditShowTimeSpent()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-notes">Notes</label>
|
||||
<textarea
|
||||
@@ -518,6 +574,12 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (show.timeSpent) {
|
||||
<p class="time-spent">
|
||||
📺 Watch Time: {{ formatTimeSpent(show.timeSpent) }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<app-like-button
|
||||
entityType="show"
|
||||
[entityId]="show.id"
|
||||
@@ -685,6 +747,13 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
@@ -879,6 +948,13 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.time-spent {
|
||||
font-size: 0.9rem;
|
||||
color: #8b5cf6;
|
||||
font-weight: 500;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.date-started,
|
||||
.date-finished,
|
||||
.date-added,
|
||||
@@ -1307,6 +1383,12 @@ export class ShowsListComponent implements OnInit {
|
||||
|
||||
editShow: Partial<UpdateShowDto> = {};
|
||||
|
||||
// Time tracking state
|
||||
newShowTimeHours = 0;
|
||||
newShowTimeMinutes = 0;
|
||||
editShowTimeHours = 0;
|
||||
editShowTimeMinutes = 0;
|
||||
|
||||
// Tags and links input state
|
||||
newTagInput = '';
|
||||
editTagInput = '';
|
||||
@@ -1408,6 +1490,8 @@ export class ShowsListComponent implements OnInit {
|
||||
tags: [],
|
||||
links: []
|
||||
};
|
||||
this.newShowTimeHours = 0;
|
||||
this.newShowTimeMinutes = 0;
|
||||
this.newShowImagePreview.set(null);
|
||||
this.imageError.set(null);
|
||||
this.newTagInput = '';
|
||||
@@ -1415,6 +1499,16 @@ export class ShowsListComponent implements OnInit {
|
||||
this.newLinkUrl = '';
|
||||
}
|
||||
|
||||
updateNewShowTimeSpent() {
|
||||
const totalMinutes = (this.newShowTimeHours * 60) + this.newShowTimeMinutes;
|
||||
this.newShow.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
|
||||
}
|
||||
|
||||
updateEditShowTimeSpent() {
|
||||
const totalMinutes = (this.editShowTimeHours * 60) + this.editShowTimeMinutes;
|
||||
this.editShow.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
|
||||
}
|
||||
|
||||
addTag(target: 'new' | 'edit') {
|
||||
const input = target === 'new' ? this.newTagInput.trim() : this.editTagInput.trim();
|
||||
if (!input) return;
|
||||
@@ -1502,8 +1596,17 @@ export class ShowsListComponent implements OnInit {
|
||||
notes: show.notes,
|
||||
coverImage: show.coverImage,
|
||||
tags: [...(show.tags || [])],
|
||||
links: [...(show.links || [])]
|
||||
links: [...(show.links || [])],
|
||||
timeSpent: show.timeSpent
|
||||
};
|
||||
// Populate time fields from existing timeSpent
|
||||
if (show.timeSpent) {
|
||||
this.editShowTimeHours = Math.floor(show.timeSpent / 60);
|
||||
this.editShowTimeMinutes = show.timeSpent % 60;
|
||||
} else {
|
||||
this.editShowTimeHours = 0;
|
||||
this.editShowTimeMinutes = 0;
|
||||
}
|
||||
this.editShowImagePreview.set(show.coverImage || null);
|
||||
this.showAddForm.set(false);
|
||||
this.imageError.set(null);
|
||||
@@ -1593,6 +1696,19 @@ export class ShowsListComponent implements OnInit {
|
||||
return new Date(date).toLocaleDateString();
|
||||
}
|
||||
|
||||
formatTimeSpent(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
|
||||
if (hours === 0) {
|
||||
return `${mins}m`;
|
||||
} else if (mins === 0) {
|
||||
return `${hours}h`;
|
||||
} else {
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
}
|
||||
|
||||
toggleComments(showId: string) {
|
||||
const expanded = this.expandedComments();
|
||||
const isCurrentlyExpanded = expanded[showId];
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import type { ActivityFeedResponse } from '@library/shared-types';
|
||||
import { ApiService } from './api.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ActivityService {
|
||||
private apiService = inject(ApiService);
|
||||
|
||||
/**
|
||||
* Get activity feed with pagination.
|
||||
*/
|
||||
getActivityFeed(limit = 50, offset = 0, userId?: string): Observable<ActivityFeedResponse> {
|
||||
const params = new URLSearchParams();
|
||||
params.append('limit', limit.toString());
|
||||
params.append('offset', offset.toString());
|
||||
if (userId) {
|
||||
params.append('userId', userId);
|
||||
}
|
||||
|
||||
return this.apiService.get<ActivityFeedResponse>(`/activity?${params.toString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get activity feed for a specific user.
|
||||
*/
|
||||
getUserActivityFeed(userId: string, limit = 50, offset = 0): Observable<ActivityFeedResponse> {
|
||||
const params = new URLSearchParams();
|
||||
params.append('limit', limit.toString());
|
||||
params.append('offset', offset.toString());
|
||||
|
||||
return this.apiService.get<ActivityFeedResponse>(`/activity/${userId}?${params.toString()}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import type {
|
||||
LeaderboardResponse,
|
||||
SuggestionsLeaderboard,
|
||||
LikesLeaderboard,
|
||||
CommentsLeaderboard,
|
||||
OverallLeaderboard,
|
||||
} from '@library/shared-types';
|
||||
import { ApiService } from './api.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class LeaderboardService {
|
||||
private apiService = inject(ApiService);
|
||||
|
||||
/**
|
||||
* Get all leaderboards at once.
|
||||
*/
|
||||
getAllLeaderboards(limit = 25): Observable<LeaderboardResponse> {
|
||||
return this.apiService.get<LeaderboardResponse>(`/leaderboard?limit=${limit}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top users by suggestions.
|
||||
*/
|
||||
getTopSuggestions(limit = 25): Observable<SuggestionsLeaderboard[]> {
|
||||
return this.apiService.get<SuggestionsLeaderboard[]>(`/leaderboard/suggestions?limit=${limit}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top users by likes.
|
||||
*/
|
||||
getTopLikes(limit = 25): Observable<LikesLeaderboard[]> {
|
||||
return this.apiService.get<LikesLeaderboard[]>(`/leaderboard/likes?limit=${limit}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top users by comments.
|
||||
*/
|
||||
getTopComments(limit = 25): Observable<CommentsLeaderboard[]> {
|
||||
return this.apiService.get<CommentsLeaderboard[]>(`/leaderboard/comments?limit=${limit}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get overall leaderboard.
|
||||
*/
|
||||
getOverallLeaderboard(limit = 25): Observable<OverallLeaderboard[]> {
|
||||
return this.apiService.get<OverallLeaderboard[]>(`/leaderboard/overall?limit=${limit}`);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
export * from "./lib/achievement.constants";
|
||||
export * from "./lib/achievement.types";
|
||||
export * from "./lib/activity.types";
|
||||
export type * from "./lib/art.types";
|
||||
export * from "./lib/audit.types";
|
||||
export * from "./lib/auth.types";
|
||||
@@ -12,6 +13,7 @@ export * from "./lib/book.types";
|
||||
export type * from "./lib/comment.types";
|
||||
export type * from "./lib/common.types";
|
||||
export * from "./lib/game.types";
|
||||
export type * from "./lib/leaderboard.types";
|
||||
export type * from "./lib/like.types";
|
||||
export * from "./lib/manga.types";
|
||||
export * from "./lib/music.types";
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
enum ActivityType {
|
||||
suggestion = "SUGGESTION",
|
||||
like = "LIKE",
|
||||
comment = "COMMENT",
|
||||
achievement = "ACHIEVEMENT",
|
||||
}
|
||||
|
||||
interface ActivityUser {
|
||||
id: string;
|
||||
username: string;
|
||||
slug: string | null;
|
||||
avatar: string | null;
|
||||
primaryBadge: string | null;
|
||||
isVip: boolean;
|
||||
isMod: boolean;
|
||||
isStaff: boolean;
|
||||
}
|
||||
|
||||
interface BaseActivity {
|
||||
id: string;
|
||||
type: ActivityType;
|
||||
user: ActivityUser;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface SuggestionActivity extends BaseActivity {
|
||||
type: ActivityType.suggestion;
|
||||
entityType: string;
|
||||
suggestionTitle: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface LikeActivity extends BaseActivity {
|
||||
type: ActivityType.like;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
entityTitle: string;
|
||||
}
|
||||
|
||||
interface CommentActivity extends BaseActivity {
|
||||
type: ActivityType.comment;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
entityTitle: string;
|
||||
commentPreview: string;
|
||||
}
|
||||
|
||||
interface AchievementActivity extends BaseActivity {
|
||||
type: ActivityType.achievement;
|
||||
achievementKey: string;
|
||||
achievementName: string;
|
||||
achievementIcon: string;
|
||||
achievementPoints: number;
|
||||
}
|
||||
|
||||
type Activity = SuggestionActivity | LikeActivity | CommentActivity | AchievementActivity;
|
||||
|
||||
interface ActivityFeedResponse {
|
||||
activities: Activity[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export { ActivityType };
|
||||
export type {
|
||||
Activity,
|
||||
ActivityFeedResponse,
|
||||
ActivityUser,
|
||||
AchievementActivity,
|
||||
CommentActivity,
|
||||
LikeActivity,
|
||||
SuggestionActivity,
|
||||
};
|
||||
@@ -34,9 +34,9 @@ interface User {
|
||||
isAdmin: boolean;
|
||||
isBanned: boolean;
|
||||
inDiscord: boolean;
|
||||
isVip: boolean;
|
||||
isMod: boolean;
|
||||
isStaff: boolean;
|
||||
isVip: boolean;
|
||||
isMod: boolean;
|
||||
isStaff: boolean;
|
||||
}
|
||||
|
||||
interface JwtPayload {
|
||||
|
||||
@@ -27,6 +27,9 @@ interface Book {
|
||||
coverImage?: string;
|
||||
tags: Array<string>;
|
||||
links: Array<Link>;
|
||||
series?: string;
|
||||
seriesOrder?: number;
|
||||
timeSpent?: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -43,6 +46,9 @@ interface CreateBookDto {
|
||||
coverImage?: string;
|
||||
tags?: Array<string>;
|
||||
links?: Array<Link>;
|
||||
series?: string;
|
||||
seriesOrder?: number;
|
||||
timeSpent?: number;
|
||||
}
|
||||
|
||||
interface UpdateBookDto extends Partial<CreateBookDto> {
|
||||
|
||||
@@ -27,6 +27,9 @@ interface Game {
|
||||
coverImage?: string;
|
||||
tags: Array<string>;
|
||||
links: Array<Link>;
|
||||
series?: string;
|
||||
seriesOrder?: number;
|
||||
timeSpent?: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -42,6 +45,9 @@ interface CreateGameDto {
|
||||
coverImage?: string;
|
||||
tags?: Array<string>;
|
||||
links?: Array<Link>;
|
||||
series?: string;
|
||||
seriesOrder?: number;
|
||||
timeSpent?: number;
|
||||
}
|
||||
|
||||
interface UpdateGameDto extends Partial<CreateGameDto> {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
interface LeaderboardUser {
|
||||
id: string;
|
||||
username: string;
|
||||
slug: string | null;
|
||||
avatar: string | null;
|
||||
primaryBadge: string | null;
|
||||
isVip: boolean;
|
||||
isMod: boolean;
|
||||
isStaff: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface SuggestionsLeaderboard extends LeaderboardUser {
|
||||
totalSuggestions: number;
|
||||
acceptedSuggestions: number;
|
||||
acceptanceRate: number;
|
||||
}
|
||||
|
||||
interface LikesLeaderboard extends LeaderboardUser {
|
||||
totalLikes: number;
|
||||
}
|
||||
|
||||
interface CommentsLeaderboard extends LeaderboardUser {
|
||||
totalComments: number;
|
||||
}
|
||||
|
||||
interface OverallLeaderboard extends LeaderboardUser {
|
||||
totalSuggestions: number;
|
||||
totalLikes: number;
|
||||
totalComments: number;
|
||||
achievementCount: number;
|
||||
achievementPoints: number;
|
||||
currentStreak: number;
|
||||
diversityScore: number;
|
||||
}
|
||||
|
||||
interface LeaderboardResponse {
|
||||
topSuggestions: SuggestionsLeaderboard[];
|
||||
topLikes: LikesLeaderboard[];
|
||||
topComments: CommentsLeaderboard[];
|
||||
topOverall: OverallLeaderboard[];
|
||||
}
|
||||
|
||||
export {
|
||||
type LeaderboardUser,
|
||||
type SuggestionsLeaderboard,
|
||||
type LikesLeaderboard,
|
||||
type CommentsLeaderboard,
|
||||
type OverallLeaderboard,
|
||||
type LeaderboardResponse,
|
||||
};
|
||||
@@ -27,6 +27,7 @@ interface Manga {
|
||||
coverImage?: string;
|
||||
tags: Array<string>;
|
||||
links: Array<Link>;
|
||||
timeSpent?: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -42,6 +43,7 @@ interface CreateMangaDto {
|
||||
coverImage?: string;
|
||||
tags?: Array<string>;
|
||||
links?: Array<Link>;
|
||||
timeSpent?: number;
|
||||
}
|
||||
|
||||
interface UpdateMangaDto extends Partial<CreateMangaDto> {
|
||||
|
||||
@@ -34,6 +34,7 @@ interface Music {
|
||||
coverArt?: string;
|
||||
tags: Array<string>;
|
||||
links: Array<Link>;
|
||||
timeSpent?: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -50,6 +51,7 @@ interface CreateMusicDto {
|
||||
coverArt?: string;
|
||||
tags?: Array<string>;
|
||||
links?: Array<Link>;
|
||||
timeSpent?: number;
|
||||
}
|
||||
|
||||
interface UpdateMusicDto extends Partial<CreateMusicDto> {
|
||||
|
||||
@@ -34,6 +34,7 @@ interface Show {
|
||||
coverImage?: string;
|
||||
tags: Array<string>;
|
||||
links: Array<Link>;
|
||||
timeSpent?: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -49,6 +50,7 @@ interface CreateShowDto {
|
||||
coverImage?: string;
|
||||
tags?: Array<string>;
|
||||
links?: Array<Link>;
|
||||
timeSpent?: number;
|
||||
}
|
||||
|
||||
interface UpdateShowDto extends Partial<CreateShowDto> {
|
||||
|
||||
Reference in New Issue
Block a user