generated from nhcarrigan/template
feat: add manga and shows collections
This commit is contained in:
@@ -96,6 +96,55 @@ model Art {
|
|||||||
comments Comment[]
|
comments Comment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Show {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
title String
|
||||||
|
type ShowType
|
||||||
|
status ShowStatus
|
||||||
|
dateAdded DateTime @default(now())
|
||||||
|
dateCompleted DateTime?
|
||||||
|
rating Int? @db.Int @default(0)
|
||||||
|
notes String?
|
||||||
|
coverImage String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
comments Comment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ShowType {
|
||||||
|
TV_SERIES
|
||||||
|
ANIME
|
||||||
|
FILM
|
||||||
|
DOCUMENTARY
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ShowStatus {
|
||||||
|
WATCHING
|
||||||
|
COMPLETED
|
||||||
|
WANT_TO_WATCH
|
||||||
|
}
|
||||||
|
|
||||||
|
model Manga {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
title String
|
||||||
|
author String
|
||||||
|
status MangaStatus
|
||||||
|
dateAdded DateTime @default(now())
|
||||||
|
dateCompleted DateTime?
|
||||||
|
rating Int? @db.Int @default(0)
|
||||||
|
notes String?
|
||||||
|
coverImage String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
comments Comment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MangaStatus {
|
||||||
|
READING
|
||||||
|
COMPLETED
|
||||||
|
WANT_TO_READ
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
discordId String @unique
|
discordId String @unique
|
||||||
@@ -121,6 +170,10 @@ model Comment {
|
|||||||
music Music? @relation(fields: [musicId], references: [id])
|
music Music? @relation(fields: [musicId], references: [id])
|
||||||
artId String? @db.ObjectId
|
artId String? @db.ObjectId
|
||||||
art Art? @relation(fields: [artId], references: [id])
|
art Art? @relation(fields: [artId], references: [id])
|
||||||
|
showId String? @db.ObjectId
|
||||||
|
show Show? @relation(fields: [showId], references: [id])
|
||||||
|
mangaId String? @db.ObjectId
|
||||||
|
manga Manga? @relation(fields: [mangaId], references: [id])
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FastifyPluginAsync } from "fastify";
|
||||||
|
import { Manga, CreateMangaDto, UpdateMangaDto, Comment, CreateCommentDto } from "@library/shared-types";
|
||||||
|
import { MangaService } from "../../services/manga.service";
|
||||||
|
import { CommentService } from "../../services/comment.service";
|
||||||
|
import { adminGuard } from "../../middleware/admin-guard";
|
||||||
|
|
||||||
|
const mangaRoutes: FastifyPluginAsync = async (app) => {
|
||||||
|
const mangaService = new MangaService();
|
||||||
|
const commentService = new CommentService();
|
||||||
|
|
||||||
|
app.get<{ Reply: Manga[] }>("/", async () => {
|
||||||
|
return mangaService.getAllManga();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string }; Reply: Manga | null }>(
|
||||||
|
"/:id",
|
||||||
|
async (request) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
return mangaService.getMangaById(id);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Body: CreateMangaDto; Reply: Manga }>(
|
||||||
|
"/",
|
||||||
|
{
|
||||||
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
return mangaService.createManga(request.body);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.put<{
|
||||||
|
Params: { id: string };
|
||||||
|
Body: UpdateMangaDto;
|
||||||
|
Reply: Manga | null;
|
||||||
|
}>(
|
||||||
|
"/:id",
|
||||||
|
{
|
||||||
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
return mangaService.updateManga(id, request.body);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string }; Reply: { success: boolean } }>(
|
||||||
|
"/:id",
|
||||||
|
{
|
||||||
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
await mangaService.deleteManga(id);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string }; Reply: Comment[] }>(
|
||||||
|
"/:id/comments",
|
||||||
|
async (request) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
return commentService.getCommentsForManga(id);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
|
||||||
|
"/:id/comments",
|
||||||
|
{
|
||||||
|
preValidation: [app.authenticate],
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
const userId = request.user.id;
|
||||||
|
return commentService.createCommentForManga(id, userId, request.body);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } }>(
|
||||||
|
"/:id/comments/:commentId",
|
||||||
|
{
|
||||||
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
const { commentId } = request.params;
|
||||||
|
await commentService.deleteComment(commentId);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default mangaRoutes;
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FastifyPluginAsync } from "fastify";
|
||||||
|
import { Show, CreateShowDto, UpdateShowDto, Comment, CreateCommentDto } from "@library/shared-types";
|
||||||
|
import { ShowService } from "../../services/show.service";
|
||||||
|
import { CommentService } from "../../services/comment.service";
|
||||||
|
import { adminGuard } from "../../middleware/admin-guard";
|
||||||
|
|
||||||
|
const showsRoutes: FastifyPluginAsync = async (app) => {
|
||||||
|
const showService = new ShowService();
|
||||||
|
const commentService = new CommentService();
|
||||||
|
|
||||||
|
app.get<{ Reply: Show[] }>("/", async () => {
|
||||||
|
return showService.getAllShows();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string }; Reply: Show | null }>(
|
||||||
|
"/:id",
|
||||||
|
async (request) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
return showService.getShowById(id);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Body: CreateShowDto; Reply: Show }>(
|
||||||
|
"/",
|
||||||
|
{
|
||||||
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
return showService.createShow(request.body);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.put<{
|
||||||
|
Params: { id: string };
|
||||||
|
Body: UpdateShowDto;
|
||||||
|
Reply: Show | null;
|
||||||
|
}>(
|
||||||
|
"/:id",
|
||||||
|
{
|
||||||
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
return showService.updateShow(id, request.body);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string }; Reply: { success: boolean } }>(
|
||||||
|
"/:id",
|
||||||
|
{
|
||||||
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
await showService.deleteShow(id);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string }; Reply: Comment[] }>(
|
||||||
|
"/:id/comments",
|
||||||
|
async (request) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
return commentService.getCommentsForShow(id);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
|
||||||
|
"/:id/comments",
|
||||||
|
{
|
||||||
|
preValidation: [app.authenticate],
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
const userId = request.user.id;
|
||||||
|
return commentService.createCommentForShow(id, userId, request.body);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } }>(
|
||||||
|
"/:id/comments/:commentId",
|
||||||
|
{
|
||||||
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
const { commentId } = request.params;
|
||||||
|
await commentService.deleteComment(commentId);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default showsRoutes;
|
||||||
@@ -64,6 +64,8 @@ export class CommentService {
|
|||||||
bookId: comment.bookId || undefined,
|
bookId: comment.bookId || undefined,
|
||||||
musicId: comment.musicId || undefined,
|
musicId: comment.musicId || undefined,
|
||||||
artId: comment.artId || undefined,
|
artId: comment.artId || undefined,
|
||||||
|
showId: comment.showId || undefined,
|
||||||
|
mangaId: comment.mangaId || undefined,
|
||||||
createdAt: comment.createdAt,
|
createdAt: comment.createdAt,
|
||||||
updatedAt: comment.updatedAt,
|
updatedAt: comment.updatedAt,
|
||||||
};
|
};
|
||||||
@@ -173,6 +175,58 @@ export class CommentService {
|
|||||||
return this.mapComment(comment);
|
return this.mapComment(comment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getCommentsForShow(showId: string): Promise<Comment[]> {
|
||||||
|
const comments = await this.prisma.comment.findMany({
|
||||||
|
where: { showId },
|
||||||
|
include: { user: true },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
return comments.map((c) => this.mapComment(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCommentForShow(
|
||||||
|
showId: string,
|
||||||
|
userId: string,
|
||||||
|
data: CreateCommentDto
|
||||||
|
): Promise<Comment> {
|
||||||
|
const sanitizedContent = this.sanitizeMarkdown(data.content);
|
||||||
|
const comment = await this.prisma.comment.create({
|
||||||
|
data: {
|
||||||
|
content: sanitizedContent,
|
||||||
|
userId,
|
||||||
|
showId,
|
||||||
|
},
|
||||||
|
include: { user: true },
|
||||||
|
});
|
||||||
|
return this.mapComment(comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCommentsForManga(mangaId: string): Promise<Comment[]> {
|
||||||
|
const comments = await this.prisma.comment.findMany({
|
||||||
|
where: { mangaId },
|
||||||
|
include: { user: true },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
return comments.map((c) => this.mapComment(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCommentForManga(
|
||||||
|
mangaId: string,
|
||||||
|
userId: string,
|
||||||
|
data: CreateCommentDto
|
||||||
|
): Promise<Comment> {
|
||||||
|
const sanitizedContent = this.sanitizeMarkdown(data.content);
|
||||||
|
const comment = await this.prisma.comment.create({
|
||||||
|
data: {
|
||||||
|
content: sanitizedContent,
|
||||||
|
userId,
|
||||||
|
mangaId,
|
||||||
|
},
|
||||||
|
include: { user: true },
|
||||||
|
});
|
||||||
|
return this.mapComment(comment);
|
||||||
|
}
|
||||||
|
|
||||||
async deleteComment(commentId: string): Promise<void> {
|
async deleteComment(commentId: string): Promise<void> {
|
||||||
await this.prisma.comment.delete({
|
await this.prisma.comment.delete({
|
||||||
where: { id: commentId },
|
where: { id: commentId },
|
||||||
|
|||||||
@@ -7,4 +7,6 @@
|
|||||||
export { AuthService } from "./auth.service";
|
export { AuthService } from "./auth.service";
|
||||||
export { GameService } from "./game.service";
|
export { GameService } from "./game.service";
|
||||||
export { BookService } from "./book.service";
|
export { BookService } from "./book.service";
|
||||||
export { MusicService } from "./music.service";
|
export { MusicService } from "./music.service";
|
||||||
|
export { ShowService } from "./show.service";
|
||||||
|
export { MangaService } from "./manga.service";
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto } from "@library/shared-types";
|
||||||
|
import { prisma } from "../lib/prisma";
|
||||||
|
|
||||||
|
export class MangaService {
|
||||||
|
private prisma = prisma;
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
async getAllManga(): Promise<Manga[]> {
|
||||||
|
const manga = await this.prisma.manga.findMany({
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return manga.map((m) => ({
|
||||||
|
...m,
|
||||||
|
status: m.status as unknown as MangaStatus,
|
||||||
|
dateAdded: m.dateAdded,
|
||||||
|
dateCompleted: m.dateCompleted || undefined,
|
||||||
|
createdAt: m.createdAt,
|
||||||
|
updatedAt: m.updatedAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMangaById(id: string): Promise<Manga | null> {
|
||||||
|
const manga = await this.prisma.manga.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!manga) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...manga,
|
||||||
|
status: manga.status as unknown as MangaStatus,
|
||||||
|
dateAdded: manga.dateAdded,
|
||||||
|
dateCompleted: manga.dateCompleted || undefined,
|
||||||
|
createdAt: manga.createdAt,
|
||||||
|
updatedAt: manga.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createManga(data: CreateMangaDto): Promise<Manga> {
|
||||||
|
const manga = await this.prisma.manga.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
status: data.status.toUpperCase() as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...manga,
|
||||||
|
status: manga.status as unknown as MangaStatus,
|
||||||
|
dateAdded: manga.dateAdded,
|
||||||
|
dateCompleted: manga.dateCompleted || undefined,
|
||||||
|
createdAt: manga.createdAt,
|
||||||
|
updatedAt: manga.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateManga(id: string, data: UpdateMangaDto): Promise<Manga> {
|
||||||
|
const updateData = { ...data };
|
||||||
|
if (updateData.status) {
|
||||||
|
updateData.status = updateData.status.toUpperCase() as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const manga = await this.prisma.manga.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...manga,
|
||||||
|
status: manga.status as unknown as MangaStatus,
|
||||||
|
dateAdded: manga.dateAdded,
|
||||||
|
dateCompleted: manga.dateCompleted || undefined,
|
||||||
|
createdAt: manga.createdAt,
|
||||||
|
updatedAt: manga.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteManga(id: string): Promise<void> {
|
||||||
|
await this.prisma.manga.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto } from "@library/shared-types";
|
||||||
|
import { prisma } from "../lib/prisma";
|
||||||
|
|
||||||
|
export class ShowService {
|
||||||
|
private prisma = prisma;
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
async getAllShows(): Promise<Show[]> {
|
||||||
|
const shows = await this.prisma.show.findMany({
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return shows.map((show) => ({
|
||||||
|
...show,
|
||||||
|
type: show.type as unknown as ShowType,
|
||||||
|
status: show.status as unknown as ShowStatus,
|
||||||
|
dateAdded: show.dateAdded,
|
||||||
|
dateCompleted: show.dateCompleted || undefined,
|
||||||
|
createdAt: show.createdAt,
|
||||||
|
updatedAt: show.updatedAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getShowById(id: string): Promise<Show | null> {
|
||||||
|
const show = await this.prisma.show.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!show) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...show,
|
||||||
|
type: show.type as unknown as ShowType,
|
||||||
|
status: show.status as unknown as ShowStatus,
|
||||||
|
dateAdded: show.dateAdded,
|
||||||
|
dateCompleted: show.dateCompleted || undefined,
|
||||||
|
createdAt: show.createdAt,
|
||||||
|
updatedAt: show.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createShow(data: CreateShowDto): Promise<Show> {
|
||||||
|
const show = await this.prisma.show.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
type: data.type.toUpperCase() as any,
|
||||||
|
status: data.status.toUpperCase() as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...show,
|
||||||
|
type: show.type as unknown as ShowType,
|
||||||
|
status: show.status as unknown as ShowStatus,
|
||||||
|
dateAdded: show.dateAdded,
|
||||||
|
dateCompleted: show.dateCompleted || undefined,
|
||||||
|
createdAt: show.createdAt,
|
||||||
|
updatedAt: show.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateShow(id: string, data: UpdateShowDto): Promise<Show> {
|
||||||
|
const updateData = { ...data };
|
||||||
|
if (updateData.type) {
|
||||||
|
updateData.type = updateData.type.toUpperCase() as any;
|
||||||
|
}
|
||||||
|
if (updateData.status) {
|
||||||
|
updateData.status = updateData.status.toUpperCase() as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const show = await this.prisma.show.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...show,
|
||||||
|
type: show.type as unknown as ShowType,
|
||||||
|
status: show.status as unknown as ShowStatus,
|
||||||
|
dateAdded: show.dateAdded,
|
||||||
|
dateCompleted: show.dateCompleted || undefined,
|
||||||
|
createdAt: show.createdAt,
|
||||||
|
updatedAt: show.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteShow(id: string): Promise<void> {
|
||||||
|
await this.prisma.show.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,14 @@ export const appRoutes: Route[] = [
|
|||||||
path: 'art',
|
path: 'art',
|
||||||
loadComponent: () => import('./components/art/art-gallery.component').then(m => m.ArtGalleryComponent)
|
loadComponent: () => import('./components/art/art-gallery.component').then(m => m.ArtGalleryComponent)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'shows',
|
||||||
|
loadComponent: () => import('./components/shows/shows-list.component').then(m => m.ShowsListComponent)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'manga',
|
||||||
|
loadComponent: () => import('./components/manga/manga-list.component').then(m => m.MangaListComponent)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '**',
|
path: '**',
|
||||||
redirectTo: ''
|
redirectTo: ''
|
||||||
|
|||||||
@@ -75,14 +75,14 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment } from '@librar
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="rating">Rating (0-5)</label>
|
<label for="rating">Rating (1-10)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
id="rating"
|
id="rating"
|
||||||
[(ngModel)]="newBook.rating"
|
[(ngModel)]="newBook.rating"
|
||||||
name="rating"
|
name="rating"
|
||||||
min="0"
|
min="1"
|
||||||
max="5"
|
max="10"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -172,14 +172,14 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment } from '@librar
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="edit-rating">Rating (0-5)</label>
|
<label for="edit-rating">Rating (1-10)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
id="edit-rating"
|
id="edit-rating"
|
||||||
[(ngModel)]="editBook.rating"
|
[(ngModel)]="editBook.rating"
|
||||||
name="rating"
|
name="rating"
|
||||||
min="0"
|
min="1"
|
||||||
max="5"
|
max="10"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -278,8 +278,8 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment } from '@librar
|
|||||||
|
|
||||||
@if (book.rating) {
|
@if (book.rating) {
|
||||||
<div class="rating">
|
<div class="rating">
|
||||||
@for (star of [1,2,3,4,5]; track star) {
|
@for (star of [1,2,3,4,5,6,7,8,9,10]; track star) {
|
||||||
<span [class.filled]="star <= book.rating">★</span>
|
<span [class.filled]="star <= book.rating!">★</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,13 +63,13 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment } from '@librar
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="rating">Rating (0-10)</label>
|
<label for="rating">Rating (1-10)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
id="rating"
|
id="rating"
|
||||||
[(ngModel)]="newGame.rating"
|
[(ngModel)]="newGame.rating"
|
||||||
name="rating"
|
name="rating"
|
||||||
min="0"
|
min="1"
|
||||||
max="10"
|
max="10"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,13 +148,13 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment } from '@librar
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="edit-rating">Rating (0-10)</label>
|
<label for="edit-rating">Rating (1-10)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
id="edit-rating"
|
id="edit-rating"
|
||||||
[(ngModel)]="editGame.rating"
|
[(ngModel)]="editGame.rating"
|
||||||
name="rating"
|
name="rating"
|
||||||
min="0"
|
min="1"
|
||||||
max="10"
|
max="10"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -253,7 +253,9 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment } from '@librar
|
|||||||
|
|
||||||
@if (game.rating) {
|
@if (game.rating) {
|
||||||
<div class="rating">
|
<div class="rating">
|
||||||
⭐ {{ game.rating }}/10
|
@for (star of [1,2,3,4,5,6,7,8,9,10]; track star) {
|
||||||
|
<span [class.filled]="star <= game.rating!">★</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,7 +469,15 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment } from '@librar
|
|||||||
|
|
||||||
.rating {
|
.rating {
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
font-weight: 500;
|
}
|
||||||
|
|
||||||
|
.rating span {
|
||||||
|
color: #e5e7eb;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating span.filled {
|
||||||
|
color: #f59e0b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notes {
|
.notes {
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import { AuthService } from '../../services/auth.service';
|
|||||||
<li><a routerLink="/games" routerLinkActive="active">Games</a></li>
|
<li><a routerLink="/games" routerLinkActive="active">Games</a></li>
|
||||||
<li><a routerLink="/books" routerLinkActive="active">Books</a></li>
|
<li><a routerLink="/books" routerLinkActive="active">Books</a></li>
|
||||||
<li><a routerLink="/music" routerLinkActive="active">Music</a></li>
|
<li><a routerLink="/music" routerLinkActive="active">Music</a></li>
|
||||||
|
<li><a routerLink="/shows" routerLinkActive="active">Shows</a></li>
|
||||||
|
<li><a routerLink="/manga" routerLinkActive="active">Manga</a></li>
|
||||||
<li><a routerLink="/art" routerLinkActive="active">Art</a></li>
|
<li><a routerLink="/art" routerLinkActive="active">Art</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -72,9 +74,10 @@ import { AuthService } from '../../services/auth.service';
|
|||||||
.nav-links {
|
.nav-links {
|
||||||
display: flex;
|
display: flex;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
gap: 2rem;
|
gap: 1rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-links a {
|
.nav-links a {
|
||||||
|
|||||||
@@ -0,0 +1,893 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject, signal, computed } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { MangaService } from '../../services/manga.service';
|
||||||
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
import { CommentsService } from '../../services/comments.service';
|
||||||
|
import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment } from '@library/shared-types';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-manga-list',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
template: `
|
||||||
|
<div class="container">
|
||||||
|
<div class="header-section">
|
||||||
|
<h2>My Manga Collection</h2>
|
||||||
|
@if (authService.isAdmin()) {
|
||||||
|
<button (click)="toggleAddForm()" class="btn btn-primary">
|
||||||
|
{{ showAddForm() ? 'Cancel' : 'Add Manga' }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (showAddForm() && authService.isAdmin()) {
|
||||||
|
<form (ngSubmit)="addManga()" class="add-form">
|
||||||
|
<h3>Add New Manga</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="title">Title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="title"
|
||||||
|
[(ngModel)]="newManga.title"
|
||||||
|
name="title"
|
||||||
|
required
|
||||||
|
placeholder="Enter manga title"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="author">Author/Artist</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="author"
|
||||||
|
[(ngModel)]="newManga.author"
|
||||||
|
name="author"
|
||||||
|
required
|
||||||
|
placeholder="Enter author or artist name"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="status">Status</label>
|
||||||
|
<select id="status" [(ngModel)]="newManga.status" name="status" required>
|
||||||
|
<option [value]="MangaStatus.reading">Currently Reading</option>
|
||||||
|
<option [value]="MangaStatus.completed">Completed</option>
|
||||||
|
<option [value]="MangaStatus.wantToRead">Want to Read</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="rating">Rating (1-10)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="rating"
|
||||||
|
[(ngModel)]="newManga.rating"
|
||||||
|
name="rating"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="notes">Notes</label>
|
||||||
|
<textarea
|
||||||
|
id="notes"
|
||||||
|
[(ngModel)]="newManga.notes"
|
||||||
|
name="notes"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Any thoughts about the manga..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="coverImage">Cover Art (max 500KB)</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="coverImage"
|
||||||
|
name="coverImage"
|
||||||
|
accept="image/*"
|
||||||
|
(change)="onImageSelected($event, 'new')"
|
||||||
|
>
|
||||||
|
@if (newMangaImagePreview()) {
|
||||||
|
<div class="image-preview">
|
||||||
|
<img [src]="newMangaImagePreview()" alt="Cover preview">
|
||||||
|
<button type="button" (click)="clearImage('new')" class="btn btn-danger btn-sm">Remove</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (imageError()) {
|
||||||
|
<span class="error-text">{{ imageError() }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Add Manga</button>
|
||||||
|
<button type="button" (click)="toggleAddForm()" class="btn btn-secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (editingManga() && authService.isAdmin()) {
|
||||||
|
<form (ngSubmit)="saveEdit()" class="add-form">
|
||||||
|
<h3>Edit Manga</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-title">Title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="edit-title"
|
||||||
|
[(ngModel)]="editManga.title"
|
||||||
|
name="title"
|
||||||
|
required
|
||||||
|
placeholder="Enter manga title"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-author">Author/Artist</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="edit-author"
|
||||||
|
[(ngModel)]="editManga.author"
|
||||||
|
name="author"
|
||||||
|
required
|
||||||
|
placeholder="Enter author or artist name"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-status">Status</label>
|
||||||
|
<select id="edit-status" [(ngModel)]="editManga.status" name="status" required>
|
||||||
|
<option [value]="MangaStatus.reading">Currently Reading</option>
|
||||||
|
<option [value]="MangaStatus.completed">Completed</option>
|
||||||
|
<option [value]="MangaStatus.wantToRead">Want to Read</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-rating">Rating (1-10)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="edit-rating"
|
||||||
|
[(ngModel)]="editManga.rating"
|
||||||
|
name="rating"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-notes">Notes</label>
|
||||||
|
<textarea
|
||||||
|
id="edit-notes"
|
||||||
|
[(ngModel)]="editManga.notes"
|
||||||
|
name="notes"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Any thoughts about the manga..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-coverImage">Cover Art (max 500KB)</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="edit-coverImage"
|
||||||
|
name="coverImage"
|
||||||
|
accept="image/*"
|
||||||
|
(change)="onImageSelected($event, 'edit')"
|
||||||
|
>
|
||||||
|
@if (editMangaImagePreview()) {
|
||||||
|
<div class="image-preview">
|
||||||
|
<img [src]="editMangaImagePreview()" alt="Cover preview">
|
||||||
|
<button type="button" (click)="clearImage('edit')" class="btn btn-danger btn-sm">Remove</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (imageError()) {
|
||||||
|
<span class="error-text">{{ imageError() }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||||
|
<button type="button" (click)="cancelEdit()" class="btn btn-secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<button
|
||||||
|
(click)="setFilter('all')"
|
||||||
|
[class.active]="statusFilter() === 'all'"
|
||||||
|
class="filter-btn"
|
||||||
|
>
|
||||||
|
All ({{ mangaList().length }})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="setFilter(MangaStatus.reading)"
|
||||||
|
[class.active]="statusFilter() === MangaStatus.reading"
|
||||||
|
class="filter-btn"
|
||||||
|
>
|
||||||
|
Reading ({{ readingCount() }})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="setFilter(MangaStatus.completed)"
|
||||||
|
[class.active]="statusFilter() === MangaStatus.completed"
|
||||||
|
class="filter-btn"
|
||||||
|
>
|
||||||
|
Completed ({{ completedCount() }})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="setFilter(MangaStatus.wantToRead)"
|
||||||
|
[class.active]="statusFilter() === MangaStatus.wantToRead"
|
||||||
|
class="filter-btn"
|
||||||
|
>
|
||||||
|
Want to Read ({{ wantToReadCount() }})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="loading">Loading manga...</div>
|
||||||
|
} @else if (filteredManga().length === 0) {
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>No manga found in this category.</p>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="manga-grid">
|
||||||
|
@for (manga of filteredManga(); track manga.id) {
|
||||||
|
<div class="manga-card" [class.completed]="manga.status === MangaStatus.completed">
|
||||||
|
@if (manga.coverImage) {
|
||||||
|
<img [src]="manga.coverImage" [alt]="manga.title" class="manga-cover">
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="manga-info">
|
||||||
|
<h3>{{ manga.title }}</h3>
|
||||||
|
<p class="author">by {{ manga.author }}</p>
|
||||||
|
<span class="status status-{{ manga.status }}">
|
||||||
|
{{ getStatusLabel(manga.status) }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
@if (manga.rating) {
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (manga.notes) {
|
||||||
|
<p class="notes">{{ manga.notes }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (authService.isAdmin()) {
|
||||||
|
<div class="actions">
|
||||||
|
<button (click)="startEdit(manga)" class="btn btn-secondary btn-sm">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button (click)="deleteManga(manga)" class="btn btn-danger btn-sm">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="comments-section">
|
||||||
|
<button (click)="toggleComments(manga.id)" class="btn btn-secondary btn-sm comments-toggle">
|
||||||
|
{{ expandedComments()[manga.id] ? 'Hide' : 'Show' }} Comments{{ comments()[manga.id] ? ' (' + getCommentCount(manga.id) + ')' : '' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (expandedComments()[manga.id]) {
|
||||||
|
<div class="comments-container">
|
||||||
|
@if (authService.isAuthenticated()) {
|
||||||
|
<form (ngSubmit)="addComment(manga.id)" class="comment-form">
|
||||||
|
<textarea
|
||||||
|
[(ngModel)]="newCommentContent[manga.id]"
|
||||||
|
name="comment"
|
||||||
|
placeholder="Add a comment (Markdown supported)..."
|
||||||
|
rows="2"
|
||||||
|
></textarea>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (commentsLoading()[manga.id]) {
|
||||||
|
<div class="comments-loading">Loading comments...</div>
|
||||||
|
} @else {
|
||||||
|
@for (comment of comments()[manga.id] || []; track comment.id) {
|
||||||
|
<div class="comment">
|
||||||
|
<div class="comment-header">
|
||||||
|
@if (comment.user.avatar) {
|
||||||
|
<img [src]="comment.user.avatar" [alt]="comment.user.username" class="comment-avatar">
|
||||||
|
}
|
||||||
|
<span class="comment-author">{{ comment.user.username }}</span>
|
||||||
|
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
|
||||||
|
@if (authService.isAdmin()) {
|
||||||
|
<button (click)="deleteComment(manga.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="comment-content" [innerHTML]="comment.content"></div>
|
||||||
|
</div>
|
||||||
|
} @empty {
|
||||||
|
<div class="no-comments">No comments yet. Be the first to comment!</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-section {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-form {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #e5e7eb;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn:hover {
|
||||||
|
background: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn.active {
|
||||||
|
background: #ec4899;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manga-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manga-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform 0.3s, box-shadow 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manga-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.manga-card.completed {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manga-cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manga-info {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manga-info h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-READING { background: #fef3c7; color: #92400e; }
|
||||||
|
.status-COMPLETED { background: #d1fae5; color: #065f46; }
|
||||||
|
.status-WANT_TO_READ { background: #fce7f3; color: #9d174d; }
|
||||||
|
|
||||||
|
.rating {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating span {
|
||||||
|
color: #e5e7eb;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating span.filled {
|
||||||
|
color: #ec4899;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #4b5563;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary { background: #ec4899; color: white; }
|
||||||
|
.btn-secondary { background: #6b7280; color: white; }
|
||||||
|
.btn-danger { background: #ef4444; color: white; }
|
||||||
|
.btn-sm { padding: 0.25rem 0.75rem; font-size: 0.85rem; }
|
||||||
|
.btn-xs { padding: 0.15rem 0.5rem; font-size: 0.75rem; }
|
||||||
|
|
||||||
|
.comments-section {
|
||||||
|
margin-top: 1rem;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-toggle {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-container {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
resize: vertical;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-avatar {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-author {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-date {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-loading,
|
||||||
|
.no-comments {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview img {
|
||||||
|
max-width: 100px;
|
||||||
|
max-height: 150px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="file"] {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 2px dashed #e5e7eb;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f9fafb;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="file"]:hover {
|
||||||
|
border-color: #ec4899;
|
||||||
|
}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class MangaListComponent implements OnInit {
|
||||||
|
mangaService = inject(MangaService);
|
||||||
|
authService = inject(AuthService);
|
||||||
|
commentsService = inject(CommentsService);
|
||||||
|
|
||||||
|
mangaList = signal<Manga[]>([]);
|
||||||
|
loading = signal(true);
|
||||||
|
showAddForm = signal(false);
|
||||||
|
editingManga = signal<Manga | null>(null);
|
||||||
|
statusFilter = signal<'all' | MangaStatus>('all');
|
||||||
|
|
||||||
|
comments = signal<Record<string, Comment[]>>({});
|
||||||
|
commentsLoading = signal<Record<string, boolean>>({});
|
||||||
|
expandedComments = signal<Record<string, boolean>>({});
|
||||||
|
newCommentContent: Record<string, string> = {};
|
||||||
|
|
||||||
|
newMangaImagePreview = signal<string | null>(null);
|
||||||
|
editMangaImagePreview = signal<string | null>(null);
|
||||||
|
imageError = signal<string | null>(null);
|
||||||
|
private readonly MAX_IMAGE_SIZE = 500 * 1024;
|
||||||
|
|
||||||
|
MangaStatus = MangaStatus;
|
||||||
|
|
||||||
|
readingCount = computed(() => this.mangaList().filter(m => m.status === MangaStatus.reading).length);
|
||||||
|
completedCount = computed(() => this.mangaList().filter(m => m.status === MangaStatus.completed).length);
|
||||||
|
wantToReadCount = computed(() => this.mangaList().filter(m => m.status === MangaStatus.wantToRead).length);
|
||||||
|
|
||||||
|
filteredManga = computed(() => {
|
||||||
|
const filter = this.statusFilter();
|
||||||
|
if (filter === 'all') {
|
||||||
|
return this.mangaList();
|
||||||
|
}
|
||||||
|
return this.mangaList().filter(m => m.status === filter);
|
||||||
|
});
|
||||||
|
|
||||||
|
newManga: Partial<CreateMangaDto> = {
|
||||||
|
title: '',
|
||||||
|
author: '',
|
||||||
|
status: MangaStatus.wantToRead,
|
||||||
|
rating: undefined,
|
||||||
|
notes: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
editManga: Partial<UpdateMangaDto> = {};
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.loadManga();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadManga() {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.mangaService.getAllManga().subscribe({
|
||||||
|
next: (manga) => {
|
||||||
|
this.mangaList.set(manga);
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilter(filter: 'all' | MangaStatus) {
|
||||||
|
this.statusFilter.set(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusLabel(status: MangaStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case MangaStatus.reading: return 'Currently Reading';
|
||||||
|
case MangaStatus.completed: return 'Completed';
|
||||||
|
case MangaStatus.wantToRead: return 'Want to Read';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleAddForm() {
|
||||||
|
this.showAddForm.update(v => !v);
|
||||||
|
if (!this.showAddForm()) {
|
||||||
|
this.resetForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetForm() {
|
||||||
|
this.newManga = {
|
||||||
|
title: '',
|
||||||
|
author: '',
|
||||||
|
status: MangaStatus.wantToRead,
|
||||||
|
rating: undefined,
|
||||||
|
notes: '',
|
||||||
|
coverImage: undefined
|
||||||
|
};
|
||||||
|
this.newMangaImagePreview.set(null);
|
||||||
|
this.imageError.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
addManga() {
|
||||||
|
if (!this.newManga.title || !this.newManga.author || !this.newManga.status) return;
|
||||||
|
|
||||||
|
const mangaToAdd: CreateMangaDto = {
|
||||||
|
title: this.newManga.title,
|
||||||
|
author: this.newManga.author,
|
||||||
|
status: this.newManga.status,
|
||||||
|
rating: this.newManga.rating,
|
||||||
|
notes: this.newManga.notes,
|
||||||
|
coverImage: this.newManga.coverImage
|
||||||
|
};
|
||||||
|
|
||||||
|
this.mangaService.createManga(mangaToAdd).subscribe(() => {
|
||||||
|
this.loadManga();
|
||||||
|
this.toggleAddForm();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteManga(manga: Manga) {
|
||||||
|
if (confirm(`Are you sure you want to delete "${manga.title}"?`)) {
|
||||||
|
this.mangaService.deleteManga(manga.id).subscribe(() => {
|
||||||
|
this.loadManga();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startEdit(manga: Manga) {
|
||||||
|
this.editingManga.set(manga);
|
||||||
|
this.editManga = {
|
||||||
|
title: manga.title,
|
||||||
|
author: manga.author,
|
||||||
|
status: manga.status,
|
||||||
|
rating: manga.rating,
|
||||||
|
notes: manga.notes,
|
||||||
|
coverImage: manga.coverImage
|
||||||
|
};
|
||||||
|
this.editMangaImagePreview.set(manga.coverImage || null);
|
||||||
|
this.showAddForm.set(false);
|
||||||
|
this.imageError.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelEdit() {
|
||||||
|
this.editingManga.set(null);
|
||||||
|
this.editManga = {};
|
||||||
|
this.editMangaImagePreview.set(null);
|
||||||
|
this.imageError.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveEdit() {
|
||||||
|
const manga = this.editingManga();
|
||||||
|
if (!manga || !this.editManga.title || !this.editManga.author || !this.editManga.status) return;
|
||||||
|
|
||||||
|
this.mangaService.updateManga(manga.id, this.editManga).subscribe(() => {
|
||||||
|
this.loadManga();
|
||||||
|
this.cancelEdit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onImageSelected(event: Event, target: 'new' | 'edit') {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
this.imageError.set(null);
|
||||||
|
|
||||||
|
if (file.size > this.MAX_IMAGE_SIZE) {
|
||||||
|
this.imageError.set(`Image too large. Maximum size is ${this.MAX_IMAGE_SIZE / 1024}KB.`);
|
||||||
|
input.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
this.imageError.set('Please select an image file.');
|
||||||
|
input.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const base64 = reader.result as string;
|
||||||
|
if (target === 'new') {
|
||||||
|
this.newMangaImagePreview.set(base64);
|
||||||
|
this.newManga.coverImage = base64;
|
||||||
|
} else {
|
||||||
|
this.editMangaImagePreview.set(base64);
|
||||||
|
this.editManga.coverImage = base64;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearImage(target: 'new' | 'edit') {
|
||||||
|
if (target === 'new') {
|
||||||
|
this.newMangaImagePreview.set(null);
|
||||||
|
this.newManga.coverImage = undefined;
|
||||||
|
} else {
|
||||||
|
this.editMangaImagePreview.set(null);
|
||||||
|
this.editManga.coverImage = undefined;
|
||||||
|
}
|
||||||
|
this.imageError.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(date: Date | string): string {
|
||||||
|
return new Date(date).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleComments(mangaId: string) {
|
||||||
|
const expanded = this.expandedComments();
|
||||||
|
const isCurrentlyExpanded = expanded[mangaId];
|
||||||
|
|
||||||
|
this.expandedComments.set({
|
||||||
|
...expanded,
|
||||||
|
[mangaId]: !isCurrentlyExpanded
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isCurrentlyExpanded && !this.comments()[mangaId]) {
|
||||||
|
this.loadComments(mangaId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadComments(mangaId: string) {
|
||||||
|
this.commentsLoading.set({
|
||||||
|
...this.commentsLoading(),
|
||||||
|
[mangaId]: true
|
||||||
|
});
|
||||||
|
|
||||||
|
this.commentsService.getCommentsForManga(mangaId).subscribe({
|
||||||
|
next: (comments) => {
|
||||||
|
this.comments.set({
|
||||||
|
...this.comments(),
|
||||||
|
[mangaId]: comments
|
||||||
|
});
|
||||||
|
this.commentsLoading.set({
|
||||||
|
...this.commentsLoading(),
|
||||||
|
[mangaId]: false
|
||||||
|
});
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.commentsLoading.set({
|
||||||
|
...this.commentsLoading(),
|
||||||
|
[mangaId]: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getCommentCount(mangaId: string): number {
|
||||||
|
return this.comments()[mangaId]?.length || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
addComment(mangaId: string) {
|
||||||
|
const content = this.newCommentContent[mangaId];
|
||||||
|
if (!content?.trim()) return;
|
||||||
|
|
||||||
|
this.commentsService.addCommentToManga(mangaId, { content }).subscribe({
|
||||||
|
next: (comment) => {
|
||||||
|
this.comments.set({
|
||||||
|
...this.comments(),
|
||||||
|
[mangaId]: [comment, ...(this.comments()[mangaId] || [])]
|
||||||
|
});
|
||||||
|
this.newCommentContent[mangaId] = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteComment(mangaId: string, commentId: string) {
|
||||||
|
if (!confirm('Are you sure you want to delete this comment?')) return;
|
||||||
|
|
||||||
|
this.commentsService.deleteCommentFromManga(mangaId, commentId).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.comments.set({
|
||||||
|
...this.comments(),
|
||||||
|
[mangaId]: (this.comments()[mangaId] || []).filter(c => c.id !== commentId)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,14 +73,14 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="rating">Rating (0-5)</label>
|
<label for="rating">Rating (1-10)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
id="rating"
|
id="rating"
|
||||||
[(ngModel)]="newMusic.rating"
|
[(ngModel)]="newMusic.rating"
|
||||||
name="rating"
|
name="rating"
|
||||||
min="0"
|
min="1"
|
||||||
max="5"
|
max="10"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -168,14 +168,14 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="edit-rating">Rating (0-5)</label>
|
<label for="edit-rating">Rating (1-10)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
id="edit-rating"
|
id="edit-rating"
|
||||||
[(ngModel)]="editMusicData.rating"
|
[(ngModel)]="editMusicData.rating"
|
||||||
name="rating"
|
name="rating"
|
||||||
min="0"
|
min="1"
|
||||||
max="5"
|
max="10"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -320,8 +320,8 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment
|
|||||||
|
|
||||||
@if (music.rating) {
|
@if (music.rating) {
|
||||||
<div class="rating">
|
<div class="rating">
|
||||||
@for (star of [1,2,3,4,5]; track star) {
|
@for (star of [1,2,3,4,5,6,7,8,9,10]; track star) {
|
||||||
<span [class.filled]="star <= music.rating">★</span>
|
<span [class.filled]="star <= music.rating!">★</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,898 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject, signal, computed } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ShowsService } from '../../services/shows.service';
|
||||||
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
import { CommentsService } from '../../services/comments.service';
|
||||||
|
import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment } from '@library/shared-types';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-shows-list',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
template: `
|
||||||
|
<div class="container">
|
||||||
|
<div class="header-section">
|
||||||
|
<h2>My Shows & Films</h2>
|
||||||
|
@if (authService.isAdmin()) {
|
||||||
|
<button (click)="toggleAddForm()" class="btn btn-primary">
|
||||||
|
{{ showAddForm() ? 'Cancel' : 'Add Show' }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (showAddForm() && authService.isAdmin()) {
|
||||||
|
<form (ngSubmit)="addShow()" class="add-form">
|
||||||
|
<h3>Add New Show</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="title">Title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="title"
|
||||||
|
[(ngModel)]="newShow.title"
|
||||||
|
name="title"
|
||||||
|
required
|
||||||
|
placeholder="Enter show title"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="type">Type</label>
|
||||||
|
<select id="type" [(ngModel)]="newShow.type" name="type" required>
|
||||||
|
<option [value]="ShowType.tvSeries">TV Series</option>
|
||||||
|
<option [value]="ShowType.anime">Anime</option>
|
||||||
|
<option [value]="ShowType.film">Film</option>
|
||||||
|
<option [value]="ShowType.documentary">Documentary</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="status">Status</label>
|
||||||
|
<select id="status" [(ngModel)]="newShow.status" name="status" required>
|
||||||
|
<option [value]="ShowStatus.watching">Currently Watching</option>
|
||||||
|
<option [value]="ShowStatus.completed">Completed</option>
|
||||||
|
<option [value]="ShowStatus.wantToWatch">Want to Watch</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="rating">Rating (1-10)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="rating"
|
||||||
|
[(ngModel)]="newShow.rating"
|
||||||
|
name="rating"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="notes">Notes</label>
|
||||||
|
<textarea
|
||||||
|
id="notes"
|
||||||
|
[(ngModel)]="newShow.notes"
|
||||||
|
name="notes"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Any thoughts about the show..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="coverImage">Poster/Cover Art (max 500KB)</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="coverImage"
|
||||||
|
name="coverImage"
|
||||||
|
accept="image/*"
|
||||||
|
(change)="onImageSelected($event, 'new')"
|
||||||
|
>
|
||||||
|
@if (newShowImagePreview()) {
|
||||||
|
<div class="image-preview">
|
||||||
|
<img [src]="newShowImagePreview()" alt="Cover preview">
|
||||||
|
<button type="button" (click)="clearImage('new')" class="btn btn-danger btn-sm">Remove</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (imageError()) {
|
||||||
|
<span class="error-text">{{ imageError() }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Add Show</button>
|
||||||
|
<button type="button" (click)="toggleAddForm()" class="btn btn-secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (editingShow() && authService.isAdmin()) {
|
||||||
|
<form (ngSubmit)="saveEdit()" class="add-form">
|
||||||
|
<h3>Edit Show</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-title">Title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="edit-title"
|
||||||
|
[(ngModel)]="editShow.title"
|
||||||
|
name="title"
|
||||||
|
required
|
||||||
|
placeholder="Enter show title"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-type">Type</label>
|
||||||
|
<select id="edit-type" [(ngModel)]="editShow.type" name="type" required>
|
||||||
|
<option [value]="ShowType.tvSeries">TV Series</option>
|
||||||
|
<option [value]="ShowType.anime">Anime</option>
|
||||||
|
<option [value]="ShowType.film">Film</option>
|
||||||
|
<option [value]="ShowType.documentary">Documentary</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-status">Status</label>
|
||||||
|
<select id="edit-status" [(ngModel)]="editShow.status" name="status" required>
|
||||||
|
<option [value]="ShowStatus.watching">Currently Watching</option>
|
||||||
|
<option [value]="ShowStatus.completed">Completed</option>
|
||||||
|
<option [value]="ShowStatus.wantToWatch">Want to Watch</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-rating">Rating (1-10)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="edit-rating"
|
||||||
|
[(ngModel)]="editShow.rating"
|
||||||
|
name="rating"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-notes">Notes</label>
|
||||||
|
<textarea
|
||||||
|
id="edit-notes"
|
||||||
|
[(ngModel)]="editShow.notes"
|
||||||
|
name="notes"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Any thoughts about the show..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-coverImage">Poster/Cover Art (max 500KB)</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="edit-coverImage"
|
||||||
|
name="coverImage"
|
||||||
|
accept="image/*"
|
||||||
|
(change)="onImageSelected($event, 'edit')"
|
||||||
|
>
|
||||||
|
@if (editShowImagePreview()) {
|
||||||
|
<div class="image-preview">
|
||||||
|
<img [src]="editShowImagePreview()" alt="Cover preview">
|
||||||
|
<button type="button" (click)="clearImage('edit')" class="btn btn-danger btn-sm">Remove</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (imageError()) {
|
||||||
|
<span class="error-text">{{ imageError() }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||||
|
<button type="button" (click)="cancelEdit()" class="btn btn-secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<button
|
||||||
|
(click)="setFilter('all')"
|
||||||
|
[class.active]="statusFilter() === 'all'"
|
||||||
|
class="filter-btn"
|
||||||
|
>
|
||||||
|
All ({{ shows().length }})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="setFilter(ShowStatus.watching)"
|
||||||
|
[class.active]="statusFilter() === ShowStatus.watching"
|
||||||
|
class="filter-btn"
|
||||||
|
>
|
||||||
|
Watching ({{ watchingCount() }})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="setFilter(ShowStatus.completed)"
|
||||||
|
[class.active]="statusFilter() === ShowStatus.completed"
|
||||||
|
class="filter-btn"
|
||||||
|
>
|
||||||
|
Completed ({{ completedCount() }})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="setFilter(ShowStatus.wantToWatch)"
|
||||||
|
[class.active]="statusFilter() === ShowStatus.wantToWatch"
|
||||||
|
class="filter-btn"
|
||||||
|
>
|
||||||
|
Want to Watch ({{ wantToWatchCount() }})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="loading">Loading shows...</div>
|
||||||
|
} @else if (filteredShows().length === 0) {
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>No shows found in this category.</p>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="shows-grid">
|
||||||
|
@for (show of filteredShows(); track show.id) {
|
||||||
|
<div class="show-card" [class.completed]="show.status === ShowStatus.completed">
|
||||||
|
@if (show.coverImage) {
|
||||||
|
<img [src]="show.coverImage" [alt]="show.title" class="show-cover">
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="show-info">
|
||||||
|
<h3>{{ show.title }}</h3>
|
||||||
|
<p class="type">{{ getTypeLabel(show.type) }}</p>
|
||||||
|
<span class="status status-{{ show.status }}">
|
||||||
|
{{ getStatusLabel(show.status) }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
@if (show.rating) {
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (show.notes) {
|
||||||
|
<p class="notes">{{ show.notes }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (authService.isAdmin()) {
|
||||||
|
<div class="actions">
|
||||||
|
<button (click)="startEdit(show)" class="btn btn-secondary btn-sm">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button (click)="deleteShow(show)" class="btn btn-danger btn-sm">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="comments-section">
|
||||||
|
<button (click)="toggleComments(show.id)" class="btn btn-secondary btn-sm comments-toggle">
|
||||||
|
{{ expandedComments()[show.id] ? 'Hide' : 'Show' }} Comments{{ comments()[show.id] ? ' (' + getCommentCount(show.id) + ')' : '' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (expandedComments()[show.id]) {
|
||||||
|
<div class="comments-container">
|
||||||
|
@if (authService.isAuthenticated()) {
|
||||||
|
<form (ngSubmit)="addComment(show.id)" class="comment-form">
|
||||||
|
<textarea
|
||||||
|
[(ngModel)]="newCommentContent[show.id]"
|
||||||
|
name="comment"
|
||||||
|
placeholder="Add a comment (Markdown supported)..."
|
||||||
|
rows="2"
|
||||||
|
></textarea>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (commentsLoading()[show.id]) {
|
||||||
|
<div class="comments-loading">Loading comments...</div>
|
||||||
|
} @else {
|
||||||
|
@for (comment of comments()[show.id] || []; track comment.id) {
|
||||||
|
<div class="comment">
|
||||||
|
<div class="comment-header">
|
||||||
|
@if (comment.user.avatar) {
|
||||||
|
<img [src]="comment.user.avatar" [alt]="comment.user.username" class="comment-avatar">
|
||||||
|
}
|
||||||
|
<span class="comment-author">{{ comment.user.username }}</span>
|
||||||
|
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
|
||||||
|
@if (authService.isAdmin()) {
|
||||||
|
<button (click)="deleteComment(show.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="comment-content" [innerHTML]="comment.content"></div>
|
||||||
|
</div>
|
||||||
|
} @empty {
|
||||||
|
<div class="no-comments">No comments yet. Be the first to comment!</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-section {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-form {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #e5e7eb;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn:hover {
|
||||||
|
background: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn.active {
|
||||||
|
background: #8b5cf6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shows-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform 0.3s, box-shadow 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-card.completed {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-info {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-info h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-WATCHING { background: #fef3c7; color: #92400e; }
|
||||||
|
.status-COMPLETED { background: #d1fae5; color: #065f46; }
|
||||||
|
.status-WANT_TO_WATCH { background: #e0e7ff; color: #3730a3; }
|
||||||
|
|
||||||
|
.rating {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating span {
|
||||||
|
color: #e5e7eb;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating span.filled {
|
||||||
|
color: #8b5cf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #4b5563;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary { background: #8b5cf6; color: white; }
|
||||||
|
.btn-secondary { background: #6b7280; color: white; }
|
||||||
|
.btn-danger { background: #ef4444; color: white; }
|
||||||
|
.btn-sm { padding: 0.25rem 0.75rem; font-size: 0.85rem; }
|
||||||
|
.btn-xs { padding: 0.15rem 0.5rem; font-size: 0.75rem; }
|
||||||
|
|
||||||
|
.comments-section {
|
||||||
|
margin-top: 1rem;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-toggle {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-container {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
resize: vertical;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-avatar {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-author {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-date {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-loading,
|
||||||
|
.no-comments {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview img {
|
||||||
|
max-width: 100px;
|
||||||
|
max-height: 150px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="file"] {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 2px dashed #e5e7eb;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f9fafb;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="file"]:hover {
|
||||||
|
border-color: #8b5cf6;
|
||||||
|
}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class ShowsListComponent implements OnInit {
|
||||||
|
showsService = inject(ShowsService);
|
||||||
|
authService = inject(AuthService);
|
||||||
|
commentsService = inject(CommentsService);
|
||||||
|
|
||||||
|
shows = signal<Show[]>([]);
|
||||||
|
loading = signal(true);
|
||||||
|
showAddForm = signal(false);
|
||||||
|
editingShow = signal<Show | null>(null);
|
||||||
|
statusFilter = signal<'all' | ShowStatus>('all');
|
||||||
|
|
||||||
|
comments = signal<Record<string, Comment[]>>({});
|
||||||
|
commentsLoading = signal<Record<string, boolean>>({});
|
||||||
|
expandedComments = signal<Record<string, boolean>>({});
|
||||||
|
newCommentContent: Record<string, string> = {};
|
||||||
|
|
||||||
|
newShowImagePreview = signal<string | null>(null);
|
||||||
|
editShowImagePreview = signal<string | null>(null);
|
||||||
|
imageError = signal<string | null>(null);
|
||||||
|
private readonly MAX_IMAGE_SIZE = 500 * 1024;
|
||||||
|
|
||||||
|
ShowStatus = ShowStatus;
|
||||||
|
ShowType = ShowType;
|
||||||
|
|
||||||
|
watchingCount = computed(() => this.shows().filter(show => show.status === ShowStatus.watching).length);
|
||||||
|
completedCount = computed(() => this.shows().filter(show => show.status === ShowStatus.completed).length);
|
||||||
|
wantToWatchCount = computed(() => this.shows().filter(show => show.status === ShowStatus.wantToWatch).length);
|
||||||
|
|
||||||
|
filteredShows = computed(() => {
|
||||||
|
const filter = this.statusFilter();
|
||||||
|
if (filter === 'all') {
|
||||||
|
return this.shows();
|
||||||
|
}
|
||||||
|
return this.shows().filter(show => show.status === filter);
|
||||||
|
});
|
||||||
|
|
||||||
|
newShow: Partial<CreateShowDto> = {
|
||||||
|
title: '',
|
||||||
|
type: ShowType.tvSeries,
|
||||||
|
status: ShowStatus.wantToWatch,
|
||||||
|
rating: undefined,
|
||||||
|
notes: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
editShow: Partial<UpdateShowDto> = {};
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.loadShows();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadShows() {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.showsService.getAllShows().subscribe({
|
||||||
|
next: (shows) => {
|
||||||
|
this.shows.set(shows);
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilter(filter: 'all' | ShowStatus) {
|
||||||
|
this.statusFilter.set(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusLabel(status: ShowStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case ShowStatus.watching: return 'Currently Watching';
|
||||||
|
case ShowStatus.completed: return 'Completed';
|
||||||
|
case ShowStatus.wantToWatch: return 'Want to Watch';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleAddForm() {
|
||||||
|
this.showAddForm.update(v => !v);
|
||||||
|
if (!this.showAddForm()) {
|
||||||
|
this.resetForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetForm() {
|
||||||
|
this.newShow = {
|
||||||
|
title: '',
|
||||||
|
type: ShowType.tvSeries,
|
||||||
|
status: ShowStatus.wantToWatch,
|
||||||
|
rating: undefined,
|
||||||
|
notes: '',
|
||||||
|
coverImage: undefined
|
||||||
|
};
|
||||||
|
this.newShowImagePreview.set(null);
|
||||||
|
this.imageError.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
addShow() {
|
||||||
|
if (!this.newShow.title || !this.newShow.type || !this.newShow.status) return;
|
||||||
|
|
||||||
|
const showToAdd: CreateShowDto = {
|
||||||
|
title: this.newShow.title,
|
||||||
|
type: this.newShow.type,
|
||||||
|
status: this.newShow.status,
|
||||||
|
rating: this.newShow.rating,
|
||||||
|
notes: this.newShow.notes,
|
||||||
|
coverImage: this.newShow.coverImage
|
||||||
|
};
|
||||||
|
|
||||||
|
this.showsService.createShow(showToAdd).subscribe(() => {
|
||||||
|
this.loadShows();
|
||||||
|
this.toggleAddForm();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteShow(show: Show) {
|
||||||
|
if (confirm(`Are you sure you want to delete "${show.title}"?`)) {
|
||||||
|
this.showsService.deleteShow(show.id).subscribe(() => {
|
||||||
|
this.loadShows();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startEdit(show: Show) {
|
||||||
|
this.editingShow.set(show);
|
||||||
|
this.editShow = {
|
||||||
|
title: show.title,
|
||||||
|
type: show.type,
|
||||||
|
status: show.status,
|
||||||
|
rating: show.rating,
|
||||||
|
notes: show.notes,
|
||||||
|
coverImage: show.coverImage
|
||||||
|
};
|
||||||
|
this.editShowImagePreview.set(show.coverImage || null);
|
||||||
|
this.showAddForm.set(false);
|
||||||
|
this.imageError.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelEdit() {
|
||||||
|
this.editingShow.set(null);
|
||||||
|
this.editShow = {};
|
||||||
|
this.editShowImagePreview.set(null);
|
||||||
|
this.imageError.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveEdit() {
|
||||||
|
const show = this.editingShow();
|
||||||
|
if (!show || !this.editShow.title || !this.editShow.type || !this.editShow.status) return;
|
||||||
|
|
||||||
|
this.showsService.updateShow(show.id, this.editShow).subscribe(() => {
|
||||||
|
this.loadShows();
|
||||||
|
this.cancelEdit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onImageSelected(event: Event, target: 'new' | 'edit') {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
this.imageError.set(null);
|
||||||
|
|
||||||
|
if (file.size > this.MAX_IMAGE_SIZE) {
|
||||||
|
this.imageError.set(`Image too large. Maximum size is ${this.MAX_IMAGE_SIZE / 1024}KB.`);
|
||||||
|
input.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
this.imageError.set('Please select an image file.');
|
||||||
|
input.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const base64 = reader.result as string;
|
||||||
|
if (target === 'new') {
|
||||||
|
this.newShowImagePreview.set(base64);
|
||||||
|
this.newShow.coverImage = base64;
|
||||||
|
} else {
|
||||||
|
this.editShowImagePreview.set(base64);
|
||||||
|
this.editShow.coverImage = base64;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearImage(target: 'new' | 'edit') {
|
||||||
|
if (target === 'new') {
|
||||||
|
this.newShowImagePreview.set(null);
|
||||||
|
this.newShow.coverImage = undefined;
|
||||||
|
} else {
|
||||||
|
this.editShowImagePreview.set(null);
|
||||||
|
this.editShow.coverImage = undefined;
|
||||||
|
}
|
||||||
|
this.imageError.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(date: Date | string): string {
|
||||||
|
return new Date(date).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleComments(showId: string) {
|
||||||
|
const expanded = this.expandedComments();
|
||||||
|
const isCurrentlyExpanded = expanded[showId];
|
||||||
|
|
||||||
|
this.expandedComments.set({
|
||||||
|
...expanded,
|
||||||
|
[showId]: !isCurrentlyExpanded
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isCurrentlyExpanded && !this.comments()[showId]) {
|
||||||
|
this.loadComments(showId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadComments(showId: string) {
|
||||||
|
this.commentsLoading.set({
|
||||||
|
...this.commentsLoading(),
|
||||||
|
[showId]: true
|
||||||
|
});
|
||||||
|
|
||||||
|
this.commentsService.getCommentsForShow(showId).subscribe({
|
||||||
|
next: (comments) => {
|
||||||
|
this.comments.set({
|
||||||
|
...this.comments(),
|
||||||
|
[showId]: comments
|
||||||
|
});
|
||||||
|
this.commentsLoading.set({
|
||||||
|
...this.commentsLoading(),
|
||||||
|
[showId]: false
|
||||||
|
});
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.commentsLoading.set({
|
||||||
|
...this.commentsLoading(),
|
||||||
|
[showId]: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getCommentCount(showId: string): number {
|
||||||
|
return this.comments()[showId]?.length || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
addComment(showId: string) {
|
||||||
|
const content = this.newCommentContent[showId];
|
||||||
|
if (!content?.trim()) return;
|
||||||
|
|
||||||
|
this.commentsService.addCommentToShow(showId, { content }).subscribe({
|
||||||
|
next: (comment) => {
|
||||||
|
this.comments.set({
|
||||||
|
...this.comments(),
|
||||||
|
[showId]: [comment, ...(this.comments()[showId] || [])]
|
||||||
|
});
|
||||||
|
this.newCommentContent[showId] = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteComment(showId: string, commentId: string) {
|
||||||
|
if (!confirm('Are you sure you want to delete this comment?')) return;
|
||||||
|
|
||||||
|
this.commentsService.deleteCommentFromShow(showId, commentId).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.comments.set({
|
||||||
|
...this.comments(),
|
||||||
|
[showId]: (this.comments()[showId] || []).filter(c => c.id !== commentId)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,4 +62,28 @@ export class CommentsService {
|
|||||||
deleteCommentFromArt(artId: string, commentId: string): Observable<{ success: boolean }> {
|
deleteCommentFromArt(artId: string, commentId: string): Observable<{ success: boolean }> {
|
||||||
return this.api.delete<{ success: boolean }>(`/art/${artId}/comments/${commentId}`);
|
return this.api.delete<{ success: boolean }>(`/art/${artId}/comments/${commentId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getCommentsForShow(showId: string): Observable<Comment[]> {
|
||||||
|
return this.api.get<Comment[]>(`/shows/${showId}/comments`);
|
||||||
|
}
|
||||||
|
|
||||||
|
addCommentToShow(showId: string, comment: CreateCommentDto): Observable<Comment> {
|
||||||
|
return this.api.post<Comment>(`/shows/${showId}/comments`, comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCommentFromShow(showId: string, commentId: string): Observable<{ success: boolean }> {
|
||||||
|
return this.api.delete<{ success: boolean }>(`/shows/${showId}/comments/${commentId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCommentsForManga(mangaId: string): Observable<Comment[]> {
|
||||||
|
return this.api.get<Comment[]>(`/manga/${mangaId}/comments`);
|
||||||
|
}
|
||||||
|
|
||||||
|
addCommentToManga(mangaId: string, comment: CreateCommentDto): Observable<Comment> {
|
||||||
|
return this.api.post<Comment>(`/manga/${mangaId}/comments`, comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCommentFromManga(mangaId: string, commentId: string): Observable<{ success: boolean }> {
|
||||||
|
return this.api.delete<{ success: boolean }>(`/manga/${mangaId}/comments/${commentId}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { ApiService } from './api.service';
|
||||||
|
import { Manga, CreateMangaDto, UpdateMangaDto } from '@library/shared-types';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class MangaService {
|
||||||
|
constructor(private api: ApiService) {}
|
||||||
|
|
||||||
|
getAllManga(): Observable<Manga[]> {
|
||||||
|
return this.api.get<Manga[]>('/manga');
|
||||||
|
}
|
||||||
|
|
||||||
|
getMangaById(id: string): Observable<Manga | null> {
|
||||||
|
return this.api.get<Manga | null>(`/manga/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
createManga(manga: CreateMangaDto): Observable<Manga> {
|
||||||
|
return this.api.post<Manga>('/manga', manga);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateManga(id: string, manga: UpdateMangaDto): Observable<Manga> {
|
||||||
|
return this.api.put<Manga>(`/manga/${id}`, manga);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteManga(id: string): Observable<{ success: boolean }> {
|
||||||
|
return this.api.delete<{ success: boolean }>(`/manga/${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { ApiService } from './api.service';
|
||||||
|
import { Show, CreateShowDto, UpdateShowDto } from '@library/shared-types';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ShowsService {
|
||||||
|
constructor(private api: ApiService) {}
|
||||||
|
|
||||||
|
getAllShows(): Observable<Show[]> {
|
||||||
|
return this.api.get<Show[]>('/shows');
|
||||||
|
}
|
||||||
|
|
||||||
|
getShowById(id: string): Observable<Show | null> {
|
||||||
|
return this.api.get<Show | null>(`/shows/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
createShow(show: CreateShowDto): Observable<Show> {
|
||||||
|
return this.api.post<Show>('/shows', show);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateShow(id: string, show: UpdateShowDto): Observable<Show> {
|
||||||
|
return this.api.put<Show>(`/shows/${id}`, show);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteShow(id: string): Observable<{ success: boolean }> {
|
||||||
|
return this.api.delete<{ success: boolean }>(`/shows/${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,5 +7,7 @@ export * from "./lib/game.types";
|
|||||||
export * from "./lib/book.types";
|
export * from "./lib/book.types";
|
||||||
export * from "./lib/music.types";
|
export * from "./lib/music.types";
|
||||||
export * from "./lib/art.types";
|
export * from "./lib/art.types";
|
||||||
|
export * from "./lib/show.types";
|
||||||
|
export * from "./lib/manga.types";
|
||||||
export type * from "./lib/auth.types";
|
export type * from "./lib/auth.types";
|
||||||
export * from "./lib/comment.types";
|
export * from "./lib/comment.types";
|
||||||
@@ -19,6 +19,8 @@ export interface Comment {
|
|||||||
bookId?: string;
|
bookId?: string;
|
||||||
musicId?: string;
|
musicId?: string;
|
||||||
artId?: string;
|
artId?: string;
|
||||||
|
showId?: string;
|
||||||
|
mangaId?: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
export enum MangaStatus {
|
||||||
|
reading = "READING",
|
||||||
|
completed = "COMPLETED",
|
||||||
|
wantToRead = "WANT_TO_READ",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Manga {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
status: MangaStatus;
|
||||||
|
dateAdded: Date;
|
||||||
|
dateCompleted?: Date;
|
||||||
|
rating?: number;
|
||||||
|
notes?: string;
|
||||||
|
coverImage?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateMangaDto {
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
status: MangaStatus;
|
||||||
|
rating?: number;
|
||||||
|
notes?: string;
|
||||||
|
coverImage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateMangaDto extends Partial<CreateMangaDto> {
|
||||||
|
dateCompleted?: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
export enum ShowType {
|
||||||
|
tvSeries = "TV_SERIES",
|
||||||
|
anime = "ANIME",
|
||||||
|
film = "FILM",
|
||||||
|
documentary = "DOCUMENTARY",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ShowStatus {
|
||||||
|
watching = "WATCHING",
|
||||||
|
completed = "COMPLETED",
|
||||||
|
wantToWatch = "WANT_TO_WATCH",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Show {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
type: ShowType;
|
||||||
|
status: ShowStatus;
|
||||||
|
dateAdded: Date;
|
||||||
|
dateCompleted?: Date;
|
||||||
|
rating?: number;
|
||||||
|
notes?: string;
|
||||||
|
coverImage?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateShowDto {
|
||||||
|
title: string;
|
||||||
|
type: ShowType;
|
||||||
|
status: ShowStatus;
|
||||||
|
rating?: number;
|
||||||
|
notes?: string;
|
||||||
|
coverImage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateShowDto extends Partial<CreateShowDto> {
|
||||||
|
dateCompleted?: Date;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user