diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 95e59f9..03c622c 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -96,6 +96,55 @@ model Art { 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 { id String @id @default(auto()) @map("_id") @db.ObjectId discordId String @unique @@ -121,6 +170,10 @@ model Comment { music Music? @relation(fields: [musicId], references: [id]) artId String? @db.ObjectId 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()) updatedAt DateTime @updatedAt } diff --git a/api/src/app/routes/manga/index.ts b/api/src/app/routes/manga/index.ts new file mode 100644 index 0000000..0e0f2d3 --- /dev/null +++ b/api/src/app/routes/manga/index.ts @@ -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; diff --git a/api/src/app/routes/shows/index.ts b/api/src/app/routes/shows/index.ts new file mode 100644 index 0000000..4eb2850 --- /dev/null +++ b/api/src/app/routes/shows/index.ts @@ -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; diff --git a/api/src/app/services/comment.service.ts b/api/src/app/services/comment.service.ts index 4d777e0..1f32f92 100644 --- a/api/src/app/services/comment.service.ts +++ b/api/src/app/services/comment.service.ts @@ -64,6 +64,8 @@ export class CommentService { bookId: comment.bookId || undefined, musicId: comment.musicId || undefined, artId: comment.artId || undefined, + showId: comment.showId || undefined, + mangaId: comment.mangaId || undefined, createdAt: comment.createdAt, updatedAt: comment.updatedAt, }; @@ -173,6 +175,58 @@ export class CommentService { return this.mapComment(comment); } + async getCommentsForShow(showId: string): Promise { + 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 { + 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 { + 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 { + 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 { await this.prisma.comment.delete({ where: { id: commentId }, diff --git a/api/src/app/services/index.ts b/api/src/app/services/index.ts index de84b79..035938f 100644 --- a/api/src/app/services/index.ts +++ b/api/src/app/services/index.ts @@ -7,4 +7,6 @@ export { AuthService } from "./auth.service"; export { GameService } from "./game.service"; export { BookService } from "./book.service"; -export { MusicService } from "./music.service"; \ No newline at end of file +export { MusicService } from "./music.service"; +export { ShowService } from "./show.service"; +export { MangaService } from "./manga.service"; \ No newline at end of file diff --git a/api/src/app/services/manga.service.ts b/api/src/app/services/manga.service.ts new file mode 100644 index 0000000..95a1d2e --- /dev/null +++ b/api/src/app/services/manga.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + await this.prisma.manga.delete({ + where: { id }, + }); + } +} diff --git a/api/src/app/services/show.service.ts b/api/src/app/services/show.service.ts new file mode 100644 index 0000000..09fdbeb --- /dev/null +++ b/api/src/app/services/show.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + await this.prisma.show.delete({ + where: { id }, + }); + } +} diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index 140a48e..7a99108 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -21,6 +21,14 @@ export const appRoutes: Route[] = [ path: 'art', 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: '**', redirectTo: '' diff --git a/apps/frontend/src/app/components/books/books-list.component.ts b/apps/frontend/src/app/components/books/books-list.component.ts index c04f683..02347c2 100644 --- a/apps/frontend/src/app/components/books/books-list.component.ts +++ b/apps/frontend/src/app/components/books/books-list.component.ts @@ -75,14 +75,14 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment } from '@librar
- +
@@ -172,14 +172,14 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment } from '@librar
- +
@@ -278,8 +278,8 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment } from '@librar @if (book.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) { + }
} diff --git a/apps/frontend/src/app/components/games/games-list.component.ts b/apps/frontend/src/app/components/games/games-list.component.ts index c5aea2c..89b267a 100644 --- a/apps/frontend/src/app/components/games/games-list.component.ts +++ b/apps/frontend/src/app/components/games/games-list.component.ts @@ -63,13 +63,13 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment } from '@librar
- +
@@ -148,13 +148,13 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment } from '@librar
- +
@@ -253,7 +253,9 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment } from '@librar @if (game.rating) {
- ⭐ {{ game.rating }}/10 + @for (star of [1,2,3,4,5,6,7,8,9,10]; track star) { + + }
} @@ -467,7 +469,15 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment } from '@librar .rating { margin: 0.5rem 0; - font-weight: 500; + } + + .rating span { + color: #e5e7eb; + font-size: 1rem; + } + + .rating span.filled { + color: #f59e0b; } .notes { diff --git a/apps/frontend/src/app/components/header/header.component.ts b/apps/frontend/src/app/components/header/header.component.ts index 48a3f00..f0795a0 100644 --- a/apps/frontend/src/app/components/header/header.component.ts +++ b/apps/frontend/src/app/components/header/header.component.ts @@ -24,6 +24,8 @@ import { AuthService } from '../../services/auth.service';
  • Games
  • Books
  • Music
  • +
  • Shows
  • +
  • Manga
  • Art
  • @@ -72,9 +74,10 @@ import { AuthService } from '../../services/auth.service'; .nav-links { display: flex; list-style: none; - gap: 2rem; + gap: 1rem; margin: 0; padding: 0; + flex-wrap: wrap; } .nav-links a { diff --git a/apps/frontend/src/app/components/manga/manga-list.component.ts b/apps/frontend/src/app/components/manga/manga-list.component.ts new file mode 100644 index 0000000..46a5ade --- /dev/null +++ b/apps/frontend/src/app/components/manga/manga-list.component.ts @@ -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: ` +
    +
    +

    My Manga Collection

    + @if (authService.isAdmin()) { + + } +
    + + @if (showAddForm() && authService.isAdmin()) { +
    +

    Add New Manga

    +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + + @if (newMangaImagePreview()) { +
    + Cover preview + +
    + } + @if (imageError()) { + {{ imageError() }} + } +
    + +
    + + +
    +
    + } + + @if (editingManga() && authService.isAdmin()) { +
    +

    Edit Manga

    +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + + @if (editMangaImagePreview()) { +
    + Cover preview + +
    + } + @if (imageError()) { + {{ imageError() }} + } +
    + +
    + + +
    +
    + } + +
    + + + + +
    + + @if (loading()) { +
    Loading manga...
    + } @else if (filteredManga().length === 0) { +
    +

    No manga found in this category.

    +
    + } @else { +
    + @for (manga of filteredManga(); track manga.id) { +
    + @if (manga.coverImage) { + + } + +
    +

    {{ manga.title }}

    +

    by {{ manga.author }}

    + + {{ getStatusLabel(manga.status) }} + + + @if (manga.rating) { +
    + @for (star of [1,2,3,4,5,6,7,8,9,10]; track star) { + + } +
    + } + + @if (manga.notes) { +

    {{ manga.notes }}

    + } + + @if (authService.isAdmin()) { +
    + + +
    + } + +
    + + + @if (expandedComments()[manga.id]) { +
    + @if (authService.isAuthenticated()) { +
    + + +
    + } + + @if (commentsLoading()[manga.id]) { +
    Loading comments...
    + } @else { + @for (comment of comments()[manga.id] || []; track comment.id) { +
    +
    + @if (comment.user.avatar) { + + } + {{ comment.user.username }} + {{ formatDate(comment.createdAt) }} + @if (authService.isAdmin()) { + + } +
    +
    +
    + } @empty { +
    No comments yet. Be the first to comment!
    + } + } +
    + } +
    +
    +
    + } +
    + } +
    + `, + 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([]); + loading = signal(true); + showAddForm = signal(false); + editingManga = signal(null); + statusFilter = signal<'all' | MangaStatus>('all'); + + comments = signal>({}); + commentsLoading = signal>({}); + expandedComments = signal>({}); + newCommentContent: Record = {}; + + newMangaImagePreview = signal(null); + editMangaImagePreview = signal(null); + imageError = signal(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 = { + title: '', + author: '', + status: MangaStatus.wantToRead, + rating: undefined, + notes: '' + }; + + editManga: Partial = {}; + + 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) + }); + } + }); + } +} diff --git a/apps/frontend/src/app/components/music/music-list.component.ts b/apps/frontend/src/app/components/music/music-list.component.ts index 4a93a14..16dd27f 100644 --- a/apps/frontend/src/app/components/music/music-list.component.ts +++ b/apps/frontend/src/app/components/music/music-list.component.ts @@ -73,14 +73,14 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment
    - +
    @@ -168,14 +168,14 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment
    - +
    @@ -320,8 +320,8 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment @if (music.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) { + }
    } diff --git a/apps/frontend/src/app/components/shows/shows-list.component.ts b/apps/frontend/src/app/components/shows/shows-list.component.ts new file mode 100644 index 0000000..b17e2f1 --- /dev/null +++ b/apps/frontend/src/app/components/shows/shows-list.component.ts @@ -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: ` +
    +
    +

    My Shows & Films

    + @if (authService.isAdmin()) { + + } +
    + + @if (showAddForm() && authService.isAdmin()) { +
    +

    Add New Show

    +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + + @if (newShowImagePreview()) { +
    + Cover preview + +
    + } + @if (imageError()) { + {{ imageError() }} + } +
    + +
    + + +
    +
    + } + + @if (editingShow() && authService.isAdmin()) { +
    +

    Edit Show

    +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + + @if (editShowImagePreview()) { +
    + Cover preview + +
    + } + @if (imageError()) { + {{ imageError() }} + } +
    + +
    + + +
    +
    + } + +
    + + + + +
    + + @if (loading()) { +
    Loading shows...
    + } @else if (filteredShows().length === 0) { +
    +

    No shows found in this category.

    +
    + } @else { +
    + @for (show of filteredShows(); track show.id) { +
    + @if (show.coverImage) { + + } + +
    +

    {{ show.title }}

    +

    {{ getTypeLabel(show.type) }}

    + + {{ getStatusLabel(show.status) }} + + + @if (show.rating) { +
    + @for (star of [1,2,3,4,5,6,7,8,9,10]; track star) { + + } +
    + } + + @if (show.notes) { +

    {{ show.notes }}

    + } + + @if (authService.isAdmin()) { +
    + + +
    + } + +
    + + + @if (expandedComments()[show.id]) { +
    + @if (authService.isAuthenticated()) { +
    + + +
    + } + + @if (commentsLoading()[show.id]) { +
    Loading comments...
    + } @else { + @for (comment of comments()[show.id] || []; track comment.id) { +
    +
    + @if (comment.user.avatar) { + + } + {{ comment.user.username }} + {{ formatDate(comment.createdAt) }} + @if (authService.isAdmin()) { + + } +
    +
    +
    + } @empty { +
    No comments yet. Be the first to comment!
    + } + } +
    + } +
    +
    +
    + } +
    + } +
    + `, + 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([]); + loading = signal(true); + showAddForm = signal(false); + editingShow = signal(null); + statusFilter = signal<'all' | ShowStatus>('all'); + + comments = signal>({}); + commentsLoading = signal>({}); + expandedComments = signal>({}); + newCommentContent: Record = {}; + + newShowImagePreview = signal(null); + editShowImagePreview = signal(null); + imageError = signal(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 = { + title: '', + type: ShowType.tvSeries, + status: ShowStatus.wantToWatch, + rating: undefined, + notes: '' + }; + + editShow: Partial = {}; + + 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) + }); + } + }); + } +} diff --git a/apps/frontend/src/app/services/comments.service.ts b/apps/frontend/src/app/services/comments.service.ts index 0bb755b..1cb3ee6 100644 --- a/apps/frontend/src/app/services/comments.service.ts +++ b/apps/frontend/src/app/services/comments.service.ts @@ -62,4 +62,28 @@ export class CommentsService { deleteCommentFromArt(artId: string, commentId: string): Observable<{ success: boolean }> { return this.api.delete<{ success: boolean }>(`/art/${artId}/comments/${commentId}`); } + + getCommentsForShow(showId: string): Observable { + return this.api.get(`/shows/${showId}/comments`); + } + + addCommentToShow(showId: string, comment: CreateCommentDto): Observable { + return this.api.post(`/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 { + return this.api.get(`/manga/${mangaId}/comments`); + } + + addCommentToManga(mangaId: string, comment: CreateCommentDto): Observable { + return this.api.post(`/manga/${mangaId}/comments`, comment); + } + + deleteCommentFromManga(mangaId: string, commentId: string): Observable<{ success: boolean }> { + return this.api.delete<{ success: boolean }>(`/manga/${mangaId}/comments/${commentId}`); + } } diff --git a/apps/frontend/src/app/services/manga.service.ts b/apps/frontend/src/app/services/manga.service.ts new file mode 100644 index 0000000..d89d464 --- /dev/null +++ b/apps/frontend/src/app/services/manga.service.ts @@ -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 { + return this.api.get('/manga'); + } + + getMangaById(id: string): Observable { + return this.api.get(`/manga/${id}`); + } + + createManga(manga: CreateMangaDto): Observable { + return this.api.post('/manga', manga); + } + + updateManga(id: string, manga: UpdateMangaDto): Observable { + return this.api.put(`/manga/${id}`, manga); + } + + deleteManga(id: string): Observable<{ success: boolean }> { + return this.api.delete<{ success: boolean }>(`/manga/${id}`); + } +} diff --git a/apps/frontend/src/app/services/shows.service.ts b/apps/frontend/src/app/services/shows.service.ts new file mode 100644 index 0000000..594eeb1 --- /dev/null +++ b/apps/frontend/src/app/services/shows.service.ts @@ -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 { + return this.api.get('/shows'); + } + + getShowById(id: string): Observable { + return this.api.get(`/shows/${id}`); + } + + createShow(show: CreateShowDto): Observable { + return this.api.post('/shows', show); + } + + updateShow(id: string, show: UpdateShowDto): Observable { + return this.api.put(`/shows/${id}`, show); + } + + deleteShow(id: string): Observable<{ success: boolean }> { + return this.api.delete<{ success: boolean }>(`/shows/${id}`); + } +} diff --git a/shared-types/src/index.ts b/shared-types/src/index.ts index 7e980f2..4947a43 100644 --- a/shared-types/src/index.ts +++ b/shared-types/src/index.ts @@ -7,5 +7,7 @@ export * from "./lib/game.types"; export * from "./lib/book.types"; export * from "./lib/music.types"; export * from "./lib/art.types"; +export * from "./lib/show.types"; +export * from "./lib/manga.types"; export type * from "./lib/auth.types"; export * from "./lib/comment.types"; \ No newline at end of file diff --git a/shared-types/src/lib/comment.types.ts b/shared-types/src/lib/comment.types.ts index b2440af..191342e 100644 --- a/shared-types/src/lib/comment.types.ts +++ b/shared-types/src/lib/comment.types.ts @@ -19,6 +19,8 @@ export interface Comment { bookId?: string; musicId?: string; artId?: string; + showId?: string; + mangaId?: string; createdAt: Date; updatedAt: Date; } diff --git a/shared-types/src/lib/manga.types.ts b/shared-types/src/lib/manga.types.ts new file mode 100644 index 0000000..6604d01 --- /dev/null +++ b/shared-types/src/lib/manga.types.ts @@ -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 { + dateCompleted?: Date; +} diff --git a/shared-types/src/lib/show.types.ts b/shared-types/src/lib/show.types.ts new file mode 100644 index 0000000..879b368 --- /dev/null +++ b/shared-types/src/lib/show.types.ts @@ -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 { + dateCompleted?: Date; +}