From f2797d0479fe5b2139b5d07292690e8fc28d8447 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Thu, 19 Feb 2026 22:40:08 -0800 Subject: [PATCH 01/27] feat: add About page with library information MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements comprehensive About page (#51) with the following sections: - Purpose: Explains that this is Naomi's curated collection - Features: Overview of all 6 media types (Books, Games, Manga, Shows, Music, Art) - How to Use: 5-step guide for browsing, liking, commenting, suggesting, and earning achievements - Technology Stack: Details Angular, Fastify, MongoDB, Prisma, and other technologies - Credits: Acknowledges Hikari as the developer and Naomi as the visionary - Contact & Support: Links to Gitea, email, website, and Discord community - Version Information: Current version and licence details Technical implementation: - New AboutComponent with responsive design - Added /about route to app routes - Added "â„šī¸ About" link to user dropdown menu - Beautiful purple gradient styling matching the witchy aesthetic - FontAwesome icons for visual appeal - Fully responsive for all device sizes Co-Authored-By: Claude Sonnet 4.5 --- apps/frontend/src/app/app.routes.ts | 4 + .../app/components/about/about.component.css | 194 ++++++++++++++++ .../app/components/about/about.component.html | 216 ++++++++++++++++++ .../app/components/about/about.component.ts | 44 ++++ .../app/components/header/header.component.ts | 1 + 5 files changed, 459 insertions(+) create mode 100644 apps/frontend/src/app/components/about/about.component.css create mode 100644 apps/frontend/src/app/components/about/about.component.html create mode 100644 apps/frontend/src/app/components/about/about.component.ts diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index 139b7ad..4e07a5a 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -65,6 +65,10 @@ export const appRoutes: Route[] = [ path: 'achievements', loadComponent: () => import('./components/achievements/achievements.component').then(m => m.AchievementsComponent) }, + { + path: 'about', + loadComponent: () => import('./components/about/about.component').then(m => m.AboutComponent) + }, { path: '**', redirectTo: '' diff --git a/apps/frontend/src/app/components/about/about.component.css b/apps/frontend/src/app/components/about/about.component.css new file mode 100644 index 0000000..1a2448b --- /dev/null +++ b/apps/frontend/src/app/components/about/about.component.css @@ -0,0 +1,194 @@ +.about-container { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; +} + +h1 { + font-size: 2.5rem; + margin-bottom: 2rem; + text-align: center; + background: linear-gradient(135deg, #9d4edd 0%, #c77dff 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +h1 fa-icon { + margin-right: 0.5rem; +} + +.about-section { + background: rgba(157, 78, 221, 0.05); + border: 1px solid rgba(157, 78, 221, 0.2); + border-radius: 12px; + padding: 2rem; + margin-bottom: 2rem; + transition: all 0.3s ease; +} + +.about-section:hover { + border-color: rgba(157, 78, 221, 0.4); + box-shadow: 0 4px 12px rgba(157, 78, 221, 0.1); +} + +.about-section h2 { + font-size: 2rem; + margin-bottom: 1rem; + color: #9d4edd; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.about-section p { + font-size: 1.1rem; + line-height: 1.8; + margin-bottom: 1rem; + opacity: 0.9; +} + +.features-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1.5rem; + margin-top: 1.5rem; +} + +.feature-card { + background: rgba(199, 125, 255, 0.05); + border: 1px solid rgba(199, 125, 255, 0.3); + border-radius: 8px; + padding: 1.5rem; + text-align: center; + transition: all 0.3s ease; +} + +.feature-card:hover { + transform: translateY(-5px); + border-color: #c77dff; + box-shadow: 0 6px 20px rgba(199, 125, 255, 0.2); +} + +.feature-card fa-icon { + font-size: 3rem; + color: #9d4edd; + margin-bottom: 1rem; + display: block; +} + +.feature-card h3 { + font-size: 1.4rem; + margin-bottom: 0.5rem; + color: #c77dff; +} + +.feature-card p { + font-size: 1rem; + opacity: 0.85; + margin: 0; +} + +.usage-steps { + margin-top: 1.5rem; +} + +.usage-step { + background: rgba(199, 125, 255, 0.03); + border-left: 4px solid #9d4edd; + padding: 1.5rem; + margin-bottom: 1.5rem; + border-radius: 4px; + transition: all 0.3s ease; +} + +.usage-step:hover { + border-left-color: #c77dff; + background: rgba(199, 125, 255, 0.08); + padding-left: 2rem; +} + +.usage-step h3 { + font-size: 1.3rem; + margin-bottom: 0.75rem; + color: #9d4edd; +} + +.usage-step p { + margin: 0; + font-size: 1.05rem; +} + +.tech-list, +.contact-list { + list-style: none; + padding: 0; + margin-top: 1rem; +} + +.tech-list li, +.contact-list li { + padding: 0.75rem 0; + font-size: 1.1rem; + border-bottom: 1px solid rgba(157, 78, 221, 0.1); +} + +.tech-list li:last-child, +.contact-list li:last-child { + border-bottom: none; +} + +.tech-list li strong, +.contact-list li strong { + color: #9d4edd; + margin-right: 0.5rem; +} + +.about-section a { + color: #c77dff; + text-decoration: none; + border-bottom: 1px solid transparent; + transition: all 0.3s ease; +} + +.about-section a:hover { + color: #9d4edd; + border-bottom-color: #9d4edd; +} + +.version-section { + text-align: center; + background: linear-gradient( + 135deg, + rgba(157, 78, 221, 0.08) 0%, + rgba(199, 125, 255, 0.08) 100% + ); + border: 2px solid rgba(157, 78, 221, 0.3); +} + +.version-section p { + margin: 0.5rem 0; + font-size: 1rem; +} + +@media (max-width: 768px) { + .about-container { + padding: 1rem; + } + + h1 { + font-size: 2rem; + } + + .about-section { + padding: 1.5rem; + } + + .about-section h2 { + font-size: 1.5rem; + } + + .features-grid { + grid-template-columns: 1fr; + } +} diff --git a/apps/frontend/src/app/components/about/about.component.html b/apps/frontend/src/app/components/about/about.component.html new file mode 100644 index 0000000..3b5b902 --- /dev/null +++ b/apps/frontend/src/app/components/about/about.component.html @@ -0,0 +1,216 @@ +
+

About Naomi's Library

+ +
+

Purpose

+

+ Naomi's Library is a curated collection of books, games, manga, TV shows, + music, and artwork carefully managed by Naomi. This platform allows you to + explore Naomi's personal media collection, discover new favourites, and + engage with the community by liking, commenting, and suggesting items for + Naomi to review and potentially add to the library. +

+
+ +
+

Features

+
+
+ +

Books

+

+ Browse Naomi's reading list, discover new titles, and share your + thoughts. +

+
+
+ +

Games

+

+ Explore Naomi's gaming collection, from indie gems to AAA + adventures. +

+
+
+ +

Manga

+

+ Discover Naomi's manga favourites and join discussions about series. +

+
+
+ +

TV Shows

+

+ See what Naomi's watching, from sci-fi to fantasy series and beyond. +

+
+
+ +

Music

+

+ Explore Naomi's diverse music library spanning multiple genres. +

+
+
+ +

Artwork

+

+ Appreciate beautiful artwork curated by Naomi from talented artists. +

+
+
+
+ +
+

How to Use

+
+
+

1. Browse Naomi's Collection

+

+ Explore Naomi's curated collection of books, games, manga, shows, + music, and artwork. Use the navigation menu to switch between + different media types and discover what Naomi loves! +

+
+
+

2. Like Your Favourites

+

+ Click the heart icon on any item to save it to your personal + favourites list. View all your liked items from your profile or the + "My Likes" page. Let Naomi know which items resonate with you! +

+
+
+

3. Leave Comments

+

+ Share your thoughts, reviews, and opinions by commenting on items. + Join the community discussion and connect with others who appreciate + the same media! +

+
+
+

4. Submit Suggestions

+

+ Think something's missing from Naomi's collection? Submit a + suggestion! Naomi reviews all suggestions and adds items that fit the + library's curation. Your input helps shape the collection! +

+
+
+

5. Earn Achievements

+

+ Engage with the library to unlock achievements! Track your progress + in suggestions, likes, comments, login streaks, and more. Build your + profile and show off your dedication to the community! +

+
+
+
+ +
+

Technology Stack

+

Naomi's Library is built with modern, robust technologies:

+
    +
  • + Frontend: Angular 21 with TypeScript for a fast, + reactive user interface +
  • +
  • + Backend: Fastify with TypeScript for high-performance + API endpoints +
  • +
  • + Database: MongoDB with Prisma ORM for flexible, + scalable data storage +
  • +
  • + Authentication: Discord OAuth2 for secure, + seamless login +
  • +
  • + Monorepo: Nx for efficient code organisation and + build optimisation +
  • +
  • + Code Quality: ESLint with custom configuration for + consistent, maintainable code +
  • +
+
+ +
+

Credits

+

+ Naomi's Library was built entirely by Hikari, Naomi's AI + assistant and girlfriend! 💖✨ Hikari developed the full application from + scratch - including the backend API, database architecture, frontend + components, achievement system, user profiles, and all features you see + today. +

+

+ Naomi Carrigan (nhcarrigan.com) provided the vision, ideas, and project direction. Naomi reviewed + Hikari's work, offered feedback, and approved all implementation + decisions, but the actual code was written by Hikari! +

+

+ This project embodies the philosophy of human-AI collaboration, creating + inclusive, ethical, and sustainable software that makes a positive impact + on the community. Together, we're building something special! 🌸 +

+
+ +
+

Contact & Support

+

+ Need help or have questions? Here's how you can get in touch: +

+ +
+ +
+

Version Information

+

+ Current Version: {{ version }} +

+

+ Copyright: Š {{ currentYear }} NHCarrigan. All rights + reserved. +

+

+ Licence: Naomi's Public Licence +

+
+
diff --git a/apps/frontend/src/app/components/about/about.component.ts b/apps/frontend/src/app/components/about/about.component.ts new file mode 100644 index 0000000..b37164c --- /dev/null +++ b/apps/frontend/src/app/components/about/about.component.ts @@ -0,0 +1,44 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { Component } from "@angular/core"; + +import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; +import { + faBook, + faCode, + faComments, + faEnvelope, + faGamepad, + faHeart, + faImage, + faInfoCircle, + faMusic, + faTv, +} from "@fortawesome/free-solid-svg-icons"; + +@Component({ + selector: "app-about", + standalone: true, + imports: [FontAwesomeModule], + templateUrl: "./about.component.html", + styleUrls: ["./about.component.css"], +}) +export class AboutComponent { + public faBook = faBook; + public faCode = faCode; + public faComments = faComments; + public faEnvelope = faEnvelope; + public faGamepad = faGamepad; + public faHeart = faHeart; + public faImage = faImage; + public faInfoCircle = faInfoCircle; + public faMusic = faMusic; + public faTv = faTv; + + public version = "0.0.0"; + public currentYear = new Date().getFullYear(); +} diff --git a/apps/frontend/src/app/components/header/header.component.ts b/apps/frontend/src/app/components/header/header.component.ts index 97db707..735336a 100644 --- a/apps/frontend/src/app/components/header/header.component.ts +++ b/apps/frontend/src/app/components/header/header.component.ts @@ -53,6 +53,7 @@ import { ApiService } from '../../services/api.service'; My Profile Settings 🏆 Achievements + â„šī¸ About @if (!user.isAdmin) { My Suggestions } -- 2.52.0 From 5a59afaa5e0ca85468a227d7a50571fd9a6b912e Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Thu, 19 Feb 2026 22:52:33 -0800 Subject: [PATCH 02/27] feat: add series support for books and games (#54) Implements series grouping functionality for books and games to allow tracking franchises and related items. Database changes: - Add series (optional string) and seriesOrder (optional number) fields to Book model - Add series (optional string) and seriesOrder (optional number) fields to Game model Backend changes: - Add GET /books/series/:seriesName endpoint to fetch all books in a series - Add GET /games/series/:seriesName endpoint to fetch all games in a series - Add getBooksBySeries() method to BookService (orders by seriesOrder asc) - Add getGamesBySeries() method to GameService (orders by seriesOrder asc) - Create/Update endpoints automatically handle series fields via DTOs Frontend changes: - Add series and seriesOrder input fields to "Add New Book" form - Add series and seriesOrder input fields to "Edit Book" form - Add series and seriesOrder input fields to "Add New Game" form - Add series and seriesOrder input fields to "Edit Game" form Type definitions: - Update Book and CreateBookDto interfaces with series fields - Update Game and CreateGameDto interfaces with series fields Closes #54 Co-Authored-By: Claude Sonnet 4.5 --- api/prisma/schema.prisma | 4 ++ api/src/app/routes/books/index.ts | 11 +++++ api/src/app/routes/games/index.ts | 9 ++++ api/src/app/services/book.service.ts | 22 +++++++++ api/src/app/services/game.service.ts | 23 ++++++++++ .../components/books/books-list.component.ts | 46 +++++++++++++++++++ .../components/games/games-list.component.ts | 46 +++++++++++++++++++ shared-types/src/lib/book.types.ts | 4 ++ shared-types/src/lib/game.types.ts | 4 ++ 9 files changed, 169 insertions(+) diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index d7a77b8..7ecca71 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -32,6 +32,8 @@ model Game { coverImage String? tags String[] links Link[] + series String? + seriesOrder Int? @db.Int createdAt DateTime @default(now()) updatedAt DateTime @updatedAt comments Comment[] @@ -58,6 +60,8 @@ model Book { coverImage String? tags String[] links Link[] + series String? + seriesOrder Int? @db.Int createdAt DateTime @default(now()) updatedAt DateTime @updatedAt comments Comment[] diff --git a/api/src/app/routes/books/index.ts b/api/src/app/routes/books/index.ts index 2aea0fe..f6991d3 100644 --- a/api/src/app/routes/books/index.ts +++ b/api/src/app/routes/books/index.ts @@ -24,6 +24,17 @@ const booksRoutes: FastifyPluginAsync = async (app) => { return bookService.getAllBooks(); }); + /** + * Get all books in a series (public route). + */ + app.get<{ Params: { seriesName: string }; Reply: Book[] }>( + "/series/:seriesName", + async (request) => { + const { seriesName } = request.params; + return bookService.getBooksBySeries(seriesName); + } + ); + /** * Get single book by ID (public route). */ diff --git a/api/src/app/routes/games/index.ts b/api/src/app/routes/games/index.ts index 2d00886..eef98a0 100644 --- a/api/src/app/routes/games/index.ts +++ b/api/src/app/routes/games/index.ts @@ -22,6 +22,15 @@ const gamesRoutes: FastifyPluginAsync = async (app) => { return gameService.getAllGames(); }); + // Get all games in a series (public route) + app.get<{ Params: { seriesName: string }; Reply: Game[] }>( + "/series/:seriesName", + async (request) => { + const { seriesName } = request.params; + return gameService.getGamesBySeries(seriesName); + } + ); + // Get single game (public route) app.get<{ Params: { id: string }; Reply: Game | null }>( "/:id", diff --git a/api/src/app/services/book.service.ts b/api/src/app/services/book.service.ts index afb97bc..1e396af 100644 --- a/api/src/app/services/book.service.ts +++ b/api/src/app/services/book.service.ts @@ -56,6 +56,28 @@ export class BookService { }; } + /** + * Get all books in a series, ordered by seriesOrder. + */ + async getBooksBySeries(seriesName: string): Promise { + const books = await this.prisma.book.findMany({ + where: { series: seriesName }, + orderBy: { seriesOrder: "asc" }, + }); + + return books.map((book) => ({ + ...book, + status: book.status as unknown as BookStatus, + dateAdded: book.dateAdded, + dateStarted: book.dateStarted || undefined, + dateFinished: book.dateFinished || undefined, + tags: book.tags ?? [], + links: book.links ?? [], + createdAt: book.createdAt, + updatedAt: book.updatedAt, + })); + } + /** * Create new book. */ diff --git a/api/src/app/services/game.service.ts b/api/src/app/services/game.service.ts index d748c22..66a9cdf 100644 --- a/api/src/app/services/game.service.ts +++ b/api/src/app/services/game.service.ts @@ -58,6 +58,29 @@ export class GameService { }; } + /** + * Get all games in a series, ordered by seriesOrder. + */ + async getGamesBySeries(seriesName: string): Promise { + const games = await this.prisma.game.findMany({ + where: { series: seriesName }, + orderBy: { seriesOrder: "asc" }, + }); + + return games.map((game) => ({ + ...game, + status: game.status as unknown as GameStatus, + dateAdded: game.dateAdded, + dateStarted: game.dateStarted || undefined, + dateCompleted: game.dateCompleted || undefined, + dateFinished: game.dateFinished || undefined, + tags: game.tags ?? [], + links: game.links ?? [], + createdAt: game.createdAt, + updatedAt: game.updatedAt, + })); + } + /** * Create new game. */ 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 bcdbb50..ceda6ed 100644 --- a/apps/frontend/src/app/components/books/books-list.component.ts +++ b/apps/frontend/src/app/components/books/books-list.component.ts @@ -127,6 +127,29 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti > +
+ + +
+ +
+ + +
+
+
+ + +
+ +
+ + +
+
+
+ + +
+ +
+ + +
+
+
+ + +
+ +
+ + +
+
; links: Array; + series?: string; + seriesOrder?: number; createdAt: Date; updatedAt: Date; } @@ -43,6 +45,8 @@ interface CreateBookDto { coverImage?: string; tags?: Array; links?: Array; + series?: string; + seriesOrder?: number; } interface UpdateBookDto extends Partial { diff --git a/shared-types/src/lib/game.types.ts b/shared-types/src/lib/game.types.ts index 38949ac..4689816 100644 --- a/shared-types/src/lib/game.types.ts +++ b/shared-types/src/lib/game.types.ts @@ -27,6 +27,8 @@ interface Game { coverImage?: string; tags: Array; links: Array; + series?: string; + seriesOrder?: number; createdAt: Date; updatedAt: Date; } @@ -42,6 +44,8 @@ interface CreateGameDto { coverImage?: string; tags?: Array; links?: Array; + series?: string; + seriesOrder?: number; } interface UpdateGameDto extends Partial { -- 2.52.0 From 8f95f57838e861a102e483ab97b6625d74429a39 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Thu, 19 Feb 2026 23:10:28 -0800 Subject: [PATCH 03/27] feat: display series information on book and game cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add series name and order display to book cards (books-list.component.ts:598-602) - Add series name and order display to game cards (games-list.component.ts:560-564) - Series shows as "📚 Series Name #Order" format - Add brown-themed styling (#8b6f47) for series info - Pre-populate series fields when editing books/games - Series fields added to startEdit() methods for both books and games Co-Authored-By: Claude Sonnet 4.5 --- .../components/books/books-list.component.ts | 17 ++++++++++++++++- .../components/games/games-list.component.ts | 17 ++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) 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 ceda6ed..e1575ed 100644 --- a/apps/frontend/src/app/components/books/books-list.component.ts +++ b/apps/frontend/src/app/components/books/books-list.component.ts @@ -595,6 +595,11 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti

{{ book.title }}

by {{ book.author }}

+ @if (book.series) { +

+ 📚 {{ book.series }}@if (book.seriesOrder) { #{{ book.seriesOrder }}} +

+ } {{ getStatusLabel(book.status) }} @@ -1011,6 +1016,14 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti margin: 0.5rem 0; } + .series { + color: #8b6f47; + font-size: 0.85rem; + margin: 0.5rem 0; + font-weight: 500; + font-style: italic; + } + .status { display: inline-block; padding: 0.25rem 0.5rem; @@ -1747,7 +1760,9 @@ export class BooksListComponent implements OnInit { notes: book.notes, coverImage: book.coverImage, tags: [...(book.tags || [])], - links: [...(book.links || [])] + links: [...(book.links || [])], + series: book.series, + seriesOrder: book.seriesOrder }; this.editBookImagePreview.set(book.coverImage || null); this.showAddForm.set(false); 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 dddb3b3..aa24251 100644 --- a/apps/frontend/src/app/components/games/games-list.component.ts +++ b/apps/frontend/src/app/components/games/games-list.component.ts @@ -557,6 +557,11 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti @if (game.platform) {

{{ game.platform }}

} + @if (game.series) { +

+ 📚 {{ game.series }}@if (game.seriesOrder) { #{{ game.seriesOrder }}} +

+ } {{ getStatusLabel(game.status) }} @@ -898,6 +903,14 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti margin: 0.5rem 0; } + .series { + color: #8b6f47; + font-size: 0.85rem; + margin: 0.5rem 0; + font-weight: 500; + font-style: italic; + } + .status { display: inline-block; padding: 0.25rem 0.5rem; @@ -1542,7 +1555,9 @@ export class GamesListComponent implements OnInit { notes: game.notes, coverImage: game.coverImage, tags: [...(game.tags || [])], - links: [...(game.links || [])] + links: [...(game.links || [])], + series: game.series, + seriesOrder: game.seriesOrder }; this.editGameImagePreview.set(game.coverImage || null); this.showAddForm.set(false); -- 2.52.0 From f839059dd2ddf2db464a63360cfe1ae4916beedf Mon Sep 17 00:00:00 2001 From: Hikari Date: Thu, 19 Feb 2026 23:31:41 -0800 Subject: [PATCH 04/27] feat: implement comprehensive leaderboard feature Implements issue #55 with multiple leaderboard categories: - Top Suggestions (by count and acceptance rate) - Top Likes (by total likes given) - Top Comments (by total comments posted) - Overall Leaders (weighted by achievement points and engagement diversity) Features: - Tabbed UI with reactive state management - Medal indicators for top 3 positions - User avatars and badges display - Current user highlighting - Privacy controls via profilePublic setting - Configurable result limits (max 100) - Detailed statistics per category Backend: - Created LeaderboardService with aggregation logic - Filters for public profiles and non-banned users - Efficient sorting algorithms for each category - Parallel data fetching for all leaderboards Frontend: - Standalone Angular component with signals - Responsive card-based layout - Integration with existing user profile system - Navigation link in header dropdown Technical notes: - Uses Fastify AutoLoad with FastifyPluginAsync pattern - Shared types across monorepo for type safety - Leverages existing achievement system data --- api/src/app/routes/leaderboard/index.ts | 85 +++ api/src/app/services/leaderboard.service.ts | 222 +++++++ apps/frontend/src/app/app.routes.ts | 4 + .../app/components/header/header.component.ts | 1 + .../leaderboard/leaderboard.component.ts | 545 ++++++++++++++++++ .../src/app/services/leaderboard.service.ts | 58 ++ shared-types/src/index.ts | 1 + shared-types/src/lib/auth.types.ts | 6 +- shared-types/src/lib/leaderboard.types.ts | 57 ++ 9 files changed, 976 insertions(+), 3 deletions(-) create mode 100644 api/src/app/routes/leaderboard/index.ts create mode 100644 api/src/app/services/leaderboard.service.ts create mode 100644 apps/frontend/src/app/components/leaderboard/leaderboard.component.ts create mode 100644 apps/frontend/src/app/services/leaderboard.service.ts create mode 100644 shared-types/src/lib/leaderboard.types.ts diff --git a/api/src/app/routes/leaderboard/index.ts b/api/src/app/routes/leaderboard/index.ts new file mode 100644 index 0000000..aac20c1 --- /dev/null +++ b/api/src/app/routes/leaderboard/index.ts @@ -0,0 +1,85 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { FastifyPluginAsync } from "fastify"; +import type { + LeaderboardResponse, + SuggestionsLeaderboard, + LikesLeaderboard, + CommentsLeaderboard, + OverallLeaderboard, +} from "@library/shared-types"; +import { LeaderboardService } from "../../services/leaderboard.service"; + +const leaderboardRoutes: FastifyPluginAsync = async (app) => { + const leaderboardService = new LeaderboardService(); + /** + * Get all leaderboards at once. + */ + app.get<{ + Querystring: { limit?: number }; + Reply: LeaderboardResponse; + }>("/", async (request) => { + const limit = request.query.limit && request.query.limit > 0 + ? Math.min(request.query.limit, 100) + : 25; + return leaderboardService.getAllLeaderboards(limit); + }); + + /** + * Get top users by suggestions. + */ + app.get<{ + Querystring: { limit?: number }; + Reply: SuggestionsLeaderboard[]; + }>("/suggestions", async (request) => { + const limit = request.query.limit && request.query.limit > 0 + ? Math.min(request.query.limit, 100) + : 25; + return leaderboardService.getTopSuggestions(limit); + }); + + /** + * Get top users by likes. + */ + app.get<{ + Querystring: { limit?: number }; + Reply: LikesLeaderboard[]; + }>("/likes", async (request) => { + const limit = request.query.limit && request.query.limit > 0 + ? Math.min(request.query.limit, 100) + : 25; + return leaderboardService.getTopLikes(limit); + }); + + /** + * Get top users by comments. + */ + app.get<{ + Querystring: { limit?: number }; + Reply: CommentsLeaderboard[]; + }>("/comments", async (request) => { + const limit = request.query.limit && request.query.limit > 0 + ? Math.min(request.query.limit, 100) + : 25; + return leaderboardService.getTopComments(limit); + }); + + /** + * Get overall leaderboard. + */ + app.get<{ + Querystring: { limit?: number }; + Reply: OverallLeaderboard[]; + }>("/overall", async (request) => { + const limit = request.query.limit && request.query.limit > 0 + ? Math.min(request.query.limit, 100) + : 25; + return leaderboardService.getOverallLeaderboard(limit); + }); +}; + +export default leaderboardRoutes; diff --git a/api/src/app/services/leaderboard.service.ts b/api/src/app/services/leaderboard.service.ts new file mode 100644 index 0000000..ffa5dc6 --- /dev/null +++ b/api/src/app/services/leaderboard.service.ts @@ -0,0 +1,222 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import type { + LeaderboardResponse, + SuggestionsLeaderboard, + LikesLeaderboard, + CommentsLeaderboard, + OverallLeaderboard, +} from "@library/shared-types"; +import { prisma } from "../lib/prisma"; + +export class LeaderboardService { + private prisma = prisma; + + constructor() {} + + /** + * Get top users by suggestions submitted and accepted. + */ + async getTopSuggestions(limit = 25): Promise { + const users = await this.prisma.user.findMany({ + where: { profilePublic: true, isBanned: false }, + include: { + suggestions: { + select: { + status: true, + }, + }, + }, + }); + + const leaderboard = users + .map((user) => { + const totalSuggestions = user.suggestions.length; + const acceptedSuggestions = user.suggestions.filter( + (s) => s.status === "ACCEPTED" + ).length; + const acceptanceRate = + totalSuggestions > 0 + ? Math.round((acceptedSuggestions / totalSuggestions) * 100) + : 0; + + return { + id: user.id, + username: user.username, + slug: user.slug, + avatar: user.avatar, + primaryBadge: user.primaryBadge, + isVip: user.isVip, + isMod: user.isMod, + isStaff: user.isStaff, + createdAt: user.createdAt, + totalSuggestions, + acceptedSuggestions, + acceptanceRate, + }; + }) + .filter((user) => user.totalSuggestions > 0) + .sort((a, b) => { + if (b.totalSuggestions !== a.totalSuggestions) { + return b.totalSuggestions - a.totalSuggestions; + } + return b.acceptanceRate - a.acceptanceRate; + }) + .slice(0, limit); + + return leaderboard; + } + + /** + * Get top users by likes given. + */ + async getTopLikes(limit = 25): Promise { + const users = await this.prisma.user.findMany({ + where: { profilePublic: true, isBanned: false }, + include: { + likes: true, + }, + }); + + const leaderboard = users + .map((user) => ({ + id: user.id, + username: user.username, + slug: user.slug, + avatar: user.avatar, + primaryBadge: user.primaryBadge, + isVip: user.isVip, + isMod: user.isMod, + isStaff: user.isStaff, + createdAt: user.createdAt, + totalLikes: user.likes.length, + })) + .filter((user) => user.totalLikes > 0) + .sort((a, b) => b.totalLikes - a.totalLikes) + .slice(0, limit); + + return leaderboard; + } + + /** + * Get top users by comments posted. + */ + async getTopComments(limit = 25): Promise { + const users = await this.prisma.user.findMany({ + where: { profilePublic: true, isBanned: false }, + include: { + comments: true, + }, + }); + + const leaderboard = users + .map((user) => ({ + id: user.id, + username: user.username, + slug: user.slug, + avatar: user.avatar, + primaryBadge: user.primaryBadge, + isVip: user.isVip, + isMod: user.isMod, + isStaff: user.isStaff, + createdAt: user.createdAt, + totalComments: user.comments.length, + })) + .filter((user) => user.totalComments > 0) + .sort((a, b) => b.totalComments - a.totalComments) + .slice(0, limit); + + return leaderboard; + } + + /** + * Get overall leaderboard based on combined engagement. + */ + async getOverallLeaderboard(limit = 25): Promise { + const users = await this.prisma.user.findMany({ + where: { profilePublic: true, isBanned: false }, + include: { + suggestions: true, + likes: true, + comments: true, + userAchievements: true, + }, + }); + + const leaderboard = users + .map((user) => { + const totalSuggestions = user.suggestions.length; + const totalLikes = user.likes.length; + const totalComments = user.comments.length; + const achievementCount = user.userAchievements.length; + + const diversityScore = + (totalSuggestions > 0 ? 1 : 0) + + (totalLikes > 0 ? 1 : 0) + + (totalComments > 0 ? 1 : 0); + + return { + id: user.id, + username: user.username, + slug: user.slug, + avatar: user.avatar, + primaryBadge: user.primaryBadge, + isVip: user.isVip, + isMod: user.isMod, + isStaff: user.isStaff, + createdAt: user.createdAt, + totalSuggestions, + totalLikes, + totalComments, + achievementCount, + achievementPoints: user.achievementPoints, + currentStreak: user.currentStreak, + diversityScore, + }; + }) + .filter( + (user) => + user.totalSuggestions > 0 || + user.totalLikes > 0 || + user.totalComments > 0 + ) + .sort((a, b) => { + if (b.achievementPoints !== a.achievementPoints) { + return b.achievementPoints - a.achievementPoints; + } + if (b.diversityScore !== a.diversityScore) { + return b.diversityScore - a.diversityScore; + } + const totalA = a.totalSuggestions + a.totalLikes + a.totalComments; + const totalB = b.totalSuggestions + b.totalLikes + b.totalComments; + return totalB - totalA; + }) + .slice(0, limit); + + return leaderboard; + } + + /** + * Get all leaderboards at once. + */ + async getAllLeaderboards(limit = 25): Promise { + const [topSuggestions, topLikes, topComments, topOverall] = + await Promise.all([ + this.getTopSuggestions(limit), + this.getTopLikes(limit), + this.getTopComments(limit), + this.getOverallLeaderboard(limit), + ]); + + return { + topSuggestions, + topLikes, + topComments, + topOverall, + }; + } +} diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index 4e07a5a..8f100c7 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -65,6 +65,10 @@ export const appRoutes: Route[] = [ path: 'achievements', loadComponent: () => import('./components/achievements/achievements.component').then(m => m.AchievementsComponent) }, + { + path: 'leaderboard', + loadComponent: () => import('./components/leaderboard/leaderboard.component').then(m => m.LeaderboardComponent) + }, { path: 'about', loadComponent: () => import('./components/about/about.component').then(m => m.AboutComponent) diff --git a/apps/frontend/src/app/components/header/header.component.ts b/apps/frontend/src/app/components/header/header.component.ts index 735336a..afff703 100644 --- a/apps/frontend/src/app/components/header/header.component.ts +++ b/apps/frontend/src/app/components/header/header.component.ts @@ -53,6 +53,7 @@ import { ApiService } from '../../services/api.service'; My Profile Settings 🏆 Achievements + 🏆 Leaderboard â„šī¸ About @if (!user.isAdmin) { My Suggestions diff --git a/apps/frontend/src/app/components/leaderboard/leaderboard.component.ts b/apps/frontend/src/app/components/leaderboard/leaderboard.component.ts new file mode 100644 index 0000000..e764046 --- /dev/null +++ b/apps/frontend/src/app/components/leaderboard/leaderboard.component.ts @@ -0,0 +1,545 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { Component, OnInit, inject, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule } from '@angular/router'; +import type { + SuggestionsLeaderboard, + LikesLeaderboard, + CommentsLeaderboard, + OverallLeaderboard, +} from '@library/shared-types'; +import { LeaderboardService } from '../../services/leaderboard.service'; +import { AuthService } from '../../services/auth.service'; + +type LeaderboardTab = 'overall' | 'suggestions' | 'likes' | 'comments'; + +@Component({ + selector: 'app-leaderboard', + standalone: true, + imports: [CommonModule, RouterModule], + template: ` +
+

🏆 Community Leaderboard

+

Celebrating our most engaged community members!

+ +
+ + + + +
+ + @if (loading()) { +
Loading leaderboard...
+ } @else { + @if (activeTab() === 'overall') { +
+

Overall Leaders

+

Ranked by achievement points, diversity of engagement, and total activity

+ @if (overallLeaderboard().length === 0) { +

No users on the leaderboard yet!

+ } @else { +
+ @for (user of overallLeaderboard(); track user.id; let i = $index) { +
+
+ @if (i === 0) { + đŸĨ‡ + } @else if (i === 1) { + đŸĨˆ + } @else if (i === 2) { + đŸĨ‰ + } @else { + #{{ i + 1 }} + } +
+ +
+ } +
+ } +
+ } + + @if (activeTab() === 'suggestions') { +
+

Top Suggestions

+

Ranked by total suggestions and acceptance rate

+ @if (suggestionsLeaderboard().length === 0) { +

No users on the leaderboard yet!

+ } @else { +
+ @for (user of suggestionsLeaderboard(); track user.id; let i = $index) { +
+
+ @if (i === 0) { + đŸĨ‡ + } @else if (i === 1) { + đŸĨˆ + } @else if (i === 2) { + đŸĨ‰ + } @else { + #{{ i + 1 }} + } +
+ +
+ } +
+ } +
+ } + + @if (activeTab() === 'likes') { +
+

Top Likers

+

Ranked by total likes given

+ @if (likesLeaderboard().length === 0) { +

No users on the leaderboard yet!

+ } @else { +
+ @for (user of likesLeaderboard(); track user.id; let i = $index) { +
+
+ @if (i === 0) { + đŸĨ‡ + } @else if (i === 1) { + đŸĨˆ + } @else if (i === 2) { + đŸĨ‰ + } @else { + #{{ i + 1 }} + } +
+ +
+ } +
+ } +
+ } + + @if (activeTab() === 'comments') { +
+

Top Commenters

+

Ranked by total comments posted

+ @if (commentsLeaderboard().length === 0) { +

No users on the leaderboard yet!

+ } @else { +
+ @for (user of commentsLeaderboard(); track user.id; let i = $index) { +
+
+ @if (i === 0) { + đŸĨ‡ + } @else if (i === 1) { + đŸĨˆ + } @else if (i === 2) { + đŸĨ‰ + } @else { + #{{ i + 1 }} + } +
+ +
+ } +
+ } +
+ } + } +
+ `, + styles: [` + .container { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; + } + + h1 { + text-align: center; + color: var(--witch-purple); + margin-bottom: 0.5rem; + } + + .subtitle { + text-align: center; + color: var(--witch-plum); + margin-bottom: 2rem; + font-size: 1.1rem; + } + + .tabs { + display: flex; + gap: 1rem; + justify-content: center; + margin-bottom: 2rem; + flex-wrap: wrap; + } + + .tab-btn { + padding: 0.75rem 1.5rem; + background: var(--witch-lavender); + color: var(--witch-purple); + border: 2px solid var(--witch-lavender); + border-radius: 8px; + cursor: pointer; + font-size: 1rem; + font-weight: 600; + transition: all 0.3s; + } + + .tab-btn:hover { + background: var(--witch-mauve); + transform: translateY(-2px); + box-shadow: 0 4px 8px var(--witch-shadow); + } + + .tab-btn.active { + background: var(--witch-rose); + color: var(--witch-moon); + border-color: var(--witch-rose); + } + + .loading { + text-align: center; + padding: 3rem; + color: var(--witch-plum); + font-size: 1.2rem; + } + + .leaderboard h2 { + color: var(--witch-purple); + margin-bottom: 0.5rem; + } + + .description { + color: var(--witch-plum); + margin-bottom: 1.5rem; + font-size: 0.9rem; + } + + .empty { + text-align: center; + padding: 3rem; + color: var(--witch-mauve); + font-style: italic; + } + + .leaderboard-list { + display: flex; + flex-direction: column; + gap: 1rem; + } + + .leaderboard-item { + display: flex; + align-items: center; + gap: 1rem; + background: rgba(255, 255, 255, 0.95); + border: 2px solid var(--witch-lavender); + border-radius: 8px; + padding: 1rem; + transition: all 0.3s; + } + + .leaderboard-item:hover { + transform: translateX(4px); + box-shadow: 0 4px 12px var(--witch-shadow); + border-color: var(--witch-mauve); + } + + .leaderboard-item.highlight { + background: linear-gradient(135deg, rgba(255, 215, 245, 0.3), rgba(255, 240, 250, 0.3)); + border-color: var(--witch-rose); + box-shadow: 0 0 20px rgba(168, 87, 126, 0.2); + } + + .rank { + min-width: 60px; + text-align: center; + } + + .medal { + font-size: 2rem; + } + + .rank-number { + font-size: 1.5rem; + font-weight: 700; + color: var(--witch-plum); + } + + .user-info { + flex: 1; + } + + .user-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 0.75rem; + } + + .avatar { + width: 48px; + height: 48px; + border-radius: 50%; + border: 2px solid var(--witch-lavender); + } + + .user-details { + flex: 1; + } + + .username { + font-size: 1.1rem; + font-weight: 600; + color: var(--witch-purple); + text-decoration: none; + transition: color 0.2s; + } + + .username:hover { + color: var(--witch-rose); + } + + .badges { + display: flex; + gap: 0.5rem; + margin-top: 0.25rem; + } + + .badge { + padding: 0.15rem 0.5rem; + border-radius: 4px; + font-size: 0.7rem; + font-weight: 600; + } + + .staff-badge { + background: linear-gradient(135deg, #e84393, #fd79a8); + color: white; + } + + .mod-badge { + background: linear-gradient(135deg, #00b894, #00cec9); + color: white; + } + + .vip-badge { + background: linear-gradient(135deg, #ffd700, #ffaa00); + color: #1a1a1a; + } + + .stats { + display: flex; + flex-wrap: wrap; + gap: 1rem; + } + + .stat { + font-size: 0.9rem; + color: var(--witch-plum); + font-weight: 500; + } + + @media (max-width: 768px) { + .tabs { + gap: 0.5rem; + } + + .tab-btn { + padding: 0.5rem 1rem; + font-size: 0.9rem; + } + + .leaderboard-item { + flex-direction: column; + align-items: flex-start; + } + + .rank { + min-width: auto; + } + + .stats { + flex-direction: column; + gap: 0.5rem; + } + } + `] +}) +export class LeaderboardComponent implements OnInit { + leaderboardService = inject(LeaderboardService); + authService = inject(AuthService); + + activeTab = signal('overall'); + loading = signal(true); + + overallLeaderboard = signal([]); + suggestionsLeaderboard = signal([]); + likesLeaderboard = signal([]); + commentsLeaderboard = signal([]); + + ngOnInit() { + this.loadLeaderboards(); + } + + loadLeaderboards() { + this.loading.set(true); + this.leaderboardService.getAllLeaderboards(25).subscribe({ + next: (data) => { + this.overallLeaderboard.set(data.topOverall); + this.suggestionsLeaderboard.set(data.topSuggestions); + this.likesLeaderboard.set(data.topLikes); + this.commentsLeaderboard.set(data.topComments); + this.loading.set(false); + }, + error: () => { + this.loading.set(false); + } + }); + } + + setTab(tab: LeaderboardTab) { + this.activeTab.set(tab); + } +} diff --git a/apps/frontend/src/app/services/leaderboard.service.ts b/apps/frontend/src/app/services/leaderboard.service.ts new file mode 100644 index 0000000..f1d2936 --- /dev/null +++ b/apps/frontend/src/app/services/leaderboard.service.ts @@ -0,0 +1,58 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import type { + LeaderboardResponse, + SuggestionsLeaderboard, + LikesLeaderboard, + CommentsLeaderboard, + OverallLeaderboard, +} from '@library/shared-types'; +import { ApiService } from './api.service'; + +@Injectable({ + providedIn: 'root' +}) +export class LeaderboardService { + private apiService = inject(ApiService); + + /** + * Get all leaderboards at once. + */ + getAllLeaderboards(limit = 25): Observable { + return this.apiService.get(`/leaderboard?limit=${limit}`); + } + + /** + * Get top users by suggestions. + */ + getTopSuggestions(limit = 25): Observable { + return this.apiService.get(`/leaderboard/suggestions?limit=${limit}`); + } + + /** + * Get top users by likes. + */ + getTopLikes(limit = 25): Observable { + return this.apiService.get(`/leaderboard/likes?limit=${limit}`); + } + + /** + * Get top users by comments. + */ + getTopComments(limit = 25): Observable { + return this.apiService.get(`/leaderboard/comments?limit=${limit}`); + } + + /** + * Get overall leaderboard. + */ + getOverallLeaderboard(limit = 25): Observable { + return this.apiService.get(`/leaderboard/overall?limit=${limit}`); + } +} diff --git a/shared-types/src/index.ts b/shared-types/src/index.ts index dc596c9..b11520a 100644 --- a/shared-types/src/index.ts +++ b/shared-types/src/index.ts @@ -12,6 +12,7 @@ export * from "./lib/book.types"; export type * from "./lib/comment.types"; export type * from "./lib/common.types"; export * from "./lib/game.types"; +export type * from "./lib/leaderboard.types"; export type * from "./lib/like.types"; export * from "./lib/manga.types"; export * from "./lib/music.types"; diff --git a/shared-types/src/lib/auth.types.ts b/shared-types/src/lib/auth.types.ts index 8fda2c2..59c9183 100644 --- a/shared-types/src/lib/auth.types.ts +++ b/shared-types/src/lib/auth.types.ts @@ -34,9 +34,9 @@ interface User { isAdmin: boolean; isBanned: boolean; inDiscord: boolean; - isVip: boolean; - isMod: boolean; - isStaff: boolean; + isVip: boolean; + isMod: boolean; + isStaff: boolean; } interface JwtPayload { diff --git a/shared-types/src/lib/leaderboard.types.ts b/shared-types/src/lib/leaderboard.types.ts new file mode 100644 index 0000000..3140d46 --- /dev/null +++ b/shared-types/src/lib/leaderboard.types.ts @@ -0,0 +1,57 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +interface LeaderboardUser { + id: string; + username: string; + slug: string | null; + avatar: string | null; + primaryBadge: string | null; + isVip: boolean; + isMod: boolean; + isStaff: boolean; + createdAt: Date; +} + +interface SuggestionsLeaderboard extends LeaderboardUser { + totalSuggestions: number; + acceptedSuggestions: number; + acceptanceRate: number; +} + +interface LikesLeaderboard extends LeaderboardUser { + totalLikes: number; +} + +interface CommentsLeaderboard extends LeaderboardUser { + totalComments: number; +} + +interface OverallLeaderboard extends LeaderboardUser { + totalSuggestions: number; + totalLikes: number; + totalComments: number; + achievementCount: number; + achievementPoints: number; + currentStreak: number; + diversityScore: number; +} + +interface LeaderboardResponse { + topSuggestions: SuggestionsLeaderboard[]; + topLikes: LikesLeaderboard[]; + topComments: CommentsLeaderboard[]; + topOverall: OverallLeaderboard[]; +} + +export { + type LeaderboardUser, + type SuggestionsLeaderboard, + type LikesLeaderboard, + type CommentsLeaderboard, + type OverallLeaderboard, + type LeaderboardResponse, +}; -- 2.52.0 From fa331df20332d2c748aab556eeddba4b14243760 Mon Sep 17 00:00:00 2001 From: Hikari Date: Thu, 19 Feb 2026 23:51:04 -0800 Subject: [PATCH 05/27] feat: add time tracking to all media types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements issue #57 with manual time entry functionality: - Added timeSpent field (stored in minutes) to all media models - Supports hours and minutes input in forms - Displays formatted time on media cards Database Schema: - Added timeSpent Int? field to Game, Book, Music, Show, and Manga models - Stored in minutes for consistency and easy calculation Shared Types: - Updated all media type interfaces with timeSpent? field - Added to CreateDto and main interfaces for all media types Frontend (Games - template for other types): - Added hour/minute input fields to Add and Edit forms - Split input with validation (minutes 0-59) - Auto-calculates total minutes on change - Formats display as "Xh Ym", "Xh", or "Ym" as appropriate - Green highlighted time display on cards with âąī¸ icon - Populates edit form from existing time data Implementation Notes: - Backend services require no changes (DTOs handle automatically) - Time conversion helpers for display and storage - Form state properly resets time fields - Edit mode correctly splits minutes back to hours/minutes Next Steps: - Apply same UI pattern to Books, Music, Shows, Manga - Add time statistics to user profiles - Consider aggregate time tracking views --- api/prisma/schema.prisma | 5 + .../components/games/games-list.component.ts | 118 +++++++++++++++++- shared-types/src/lib/book.types.ts | 2 + shared-types/src/lib/game.types.ts | 2 + shared-types/src/lib/manga.types.ts | 2 + shared-types/src/lib/music.types.ts | 2 + shared-types/src/lib/show.types.ts | 2 + 7 files changed, 132 insertions(+), 1 deletion(-) diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 7ecca71..bbc01e0 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -34,6 +34,7 @@ model Game { links Link[] series String? seriesOrder Int? @db.Int + timeSpent Int? @db.Int createdAt DateTime @default(now()) updatedAt DateTime @updatedAt comments Comment[] @@ -62,6 +63,7 @@ model Book { links Link[] series String? seriesOrder Int? @db.Int + timeSpent Int? @db.Int createdAt DateTime @default(now()) updatedAt DateTime @updatedAt comments Comment[] @@ -89,6 +91,7 @@ model Music { coverArt String? tags String[] links Link[] + timeSpent Int? @db.Int createdAt DateTime @default(now()) updatedAt DateTime @updatedAt comments Comment[] @@ -135,6 +138,7 @@ model Show { coverImage String? tags String[] links Link[] + timeSpent Int? @db.Int createdAt DateTime @default(now()) updatedAt DateTime @updatedAt comments Comment[] @@ -168,6 +172,7 @@ model Manga { coverImage String? tags String[] links Link[] + timeSpent Int? @db.Int createdAt DateTime @default(now()) updatedAt DateTime @updatedAt comments Comment[] 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 aa24251..61a65a9 100644 --- a/apps/frontend/src/app/components/games/games-list.component.ts +++ b/apps/frontend/src/app/components/games/games-list.component.ts @@ -104,6 +104,34 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti >
+
+
+ + +
+
+ + +
+
+
+ + + } + } @else { +
+

Please sign in to comment.

+
+ } + + @if (commentsLoading()) { +
Loading comments...
+ } @else { + + } +
+
+ + } + + `, + styles: [` + .container { + max-width: 900px; + margin: 0 auto; + padding: 2rem; + } + + .breadcrumb { + margin-bottom: 1.5rem; + } + + .breadcrumb-link { + color: #ec4899; + text-decoration: none; + font-weight: 500; + transition: opacity 0.3s; + } + + .breadcrumb-link:hover { + opacity: 0.8; + } + + .loading { + text-align: center; + padding: 3rem; + color: #666; + font-size: 1.1rem; + } + + .error-state { + text-align: center; + padding: 3rem; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 8px; + } + + .error-state h2 { + color: #991b1b; + margin-bottom: 1rem; + } + + .error-state p { + color: #dc2626; + margin-bottom: 1.5rem; + } + + .art-detail-card { + background: white; + border: 1px solid #e5e7eb; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + } + + .art-image-section { + width: 100%; + background: #f3f4f6; + display: flex; + justify-content: center; + align-items: center; + padding: 2rem; + } + + .art-image-large { + max-width: 100%; + max-height: 600px; + object-fit: contain; + border-radius: 4px; + } + + .art-content { + padding: 2rem; + } + + .art-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; + flex-wrap: wrap; + } + + .art-header h1 { + margin: 0; + font-size: 2rem; + color: #1f2937; + } + + .artist { + color: #6b7280; + font-weight: 500; + font-size: 1.1rem; + margin: 0 0 1rem 0; + } + + .info-row { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 0.75rem; + padding: 0.5rem 0; + border-bottom: 1px solid #f3f4f6; + } + + .info-label { + font-weight: 600; + color: #4b5563; + min-width: 120px; + } + + .info-value { + color: #1f2937; + } + + .rating { + display: flex; + align-items: center; + gap: 0.25rem; + } + + .rating span { + color: #e5e7eb; + font-size: 1.2rem; + } + + .rating span.filled { + color: #f59e0b; + } + + .rating-text { + margin-left: 0.5rem; + font-size: 1rem; + color: #6b7280; + font-weight: 500; + } + + .like-section { + margin: 1.5rem 0; + } + + .notes-section, + .tags-section, + .links-section { + margin-top: 2rem; + } + + .notes-section h3, + .tags-section h3, + .links-section h3, + .comments-section h3 { + font-size: 1.25rem; + color: #1f2937; + margin-bottom: 1rem; + } + + .notes { + font-size: 1rem; + color: #4b5563; + line-height: 1.6; + white-space: pre-wrap; + } + + .tags-display { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + } + + .tag-chip { + background: #ec4899; + color: white; + padding: 0.375rem 0.875rem; + border-radius: 12px; + font-size: 0.875rem; + font-weight: 500; + } + + .links-display { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + } + + .external-link { + color: #ec4899; + text-decoration: none; + font-size: 0.95rem; + padding: 0.5rem 1rem; + border: 2px solid #ec4899; + border-radius: 4px; + transition: all 0.2s; + font-weight: 500; + } + + .external-link:hover { + background: #ec4899; + color: white; + } + + .comments-section { + margin-top: 2rem; + padding-top: 2rem; + border-top: 2px solid #e5e7eb; + } + + .comment-form { + margin-bottom: 1.5rem; + } + + .comment-form textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid #d1d5db; + border-radius: 4px; + font-size: 1rem; + resize: vertical; + margin-bottom: 0.75rem; + font-family: inherit; + } + + .comment-form textarea:focus { + outline: none; + border-color: #ec4899; + } + + .banned-notice { + background: #fef2f2; + border: 1px solid #fecaca; + color: #991b1b; + padding: 1rem; + border-radius: 4px; + text-align: center; + margin-bottom: 1.5rem; + font-size: 0.95rem; + } + + .auth-prompt { + background: #f3f4f6; + padding: 1rem; + border-radius: 4px; + text-align: center; + margin-bottom: 1.5rem; + } + + .auth-prompt p { + margin: 0; + color: #4b5563; + } + + .comments-loading { + text-align: center; + padding: 1.5rem; + color: #6b7280; + font-size: 0.95rem; + } + + .btn { + padding: 0.625rem 1.25rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; + transition: all 0.3s; + font-weight: 500; + } + + .btn:hover { + opacity: 0.9; + } + + .btn-primary { + background: #ec4899; + color: white; + } + + @media (max-width: 640px) { + .container { + padding: 1rem; + } + + .art-header { + flex-direction: column; + align-items: flex-start; + } + + .art-header h1 { + font-size: 1.5rem; + } + + .info-row { + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + } + + .info-label { + min-width: auto; + } + } + `] +}) +export class ArtDetailComponent implements OnInit { + private readonly artService = inject(ArtService); + private readonly commentsService = inject(CommentsService); + readonly authService = inject(AuthService); + private readonly sanitizeService = inject(SanitizeService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + art = signal(null); + comments = signal([]); + loading = signal(true); + commentsLoading = signal(false); + error = signal(null); + newCommentContent = ''; + + ngOnInit() { + const artId = this.route.snapshot.paramMap.get('id'); + if (!artId) { + this.error.set('No artwork ID provided'); + this.loading.set(false); + return; + } + + this.loadArt(artId); + this.loadComments(artId); + } + + private loadArt(artId: string) { + this.loading.set(true); + this.artService.getArtById(artId).subscribe({ + next: (art) => { + if (!art) { + this.error.set('Artwork not found'); + } else { + this.art.set(art); + } + this.loading.set(false); + }, + error: () => { + this.error.set('Failed to load artwork. It may not exist or there was an error.'); + this.loading.set(false); + } + }); + } + + private loadComments(artId: string) { + this.commentsLoading.set(true); + this.commentsService.getCommentsForArt(artId).subscribe({ + next: (comments) => { + this.comments.set(comments); + this.commentsLoading.set(false); + }, + error: () => { + this.commentsLoading.set(false); + } + }); + } + + addComment() { + const art = this.art(); + if (!art || !this.newCommentContent.trim()) return; + + this.commentsService.addCommentToArt(art.id, { content: this.newCommentContent }).subscribe({ + next: (comment) => { + this.comments.set([comment, ...this.comments()]); + this.newCommentContent = ''; + } + }); + } + + handleCommentEdit(event: { commentId: string; content: string }) { + const art = this.art(); + if (!art) return; + + this.commentsService.updateCommentOnArt(art.id, event.commentId, event.content).subscribe({ + next: (updatedComment) => { + this.comments.set( + this.comments().map(c => c.id === event.commentId ? updatedComment : c) + ); + } + }); + } + + deleteComment(commentId: string) { + const art = this.art(); + if (!art || !confirm('Are you sure you want to delete this comment?')) return; + + this.commentsService.deleteCommentFromArt(art.id, commentId).subscribe({ + next: () => { + this.comments.set(this.comments().filter(c => c.id !== commentId)); + } + }); + } + + formatDate(date: Date | string): string { + return new Date(date).toLocaleDateString('en-GB', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + } +} diff --git a/apps/frontend/src/app/components/books/book-detail.component.ts b/apps/frontend/src/app/components/books/book-detail.component.ts new file mode 100644 index 0000000..d86199d --- /dev/null +++ b/apps/frontend/src/app/components/books/book-detail.component.ts @@ -0,0 +1,654 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { Component, OnInit, inject, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterLink, ActivatedRoute, Router } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { BooksService } from '../../services/books.service'; +import { CommentsService } from '../../services/comments.service'; +import { AuthService } from '../../services/auth.service'; +import { SanitizeService } from '../../services/sanitize.service'; +import { CommentDisplayComponent } from '../comment-display/comment-display.component'; +import { LikeButtonComponent } from '../shared/like-button.component'; +import { Book, Comment, BookStatus } from '@library/shared-types'; + +@Component({ + selector: 'app-book-detail', + standalone: true, + imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent], + template: ` +
+ + + @if (loading()) { +
Loading book details...
+ } @else if (error()) { +
+

Book Not Found

+

{{ error() }}

+ Return to Books +
+ } @else if (book()) { +
+ @if (book()!.coverImage) { +
+ +
+ } + +
+
+

{{ book()!.title }}

+ + {{ getStatusLabel(book()!.status) }} + +
+ +

by {{ book()!.author }}

+ + @if (book()!.series) { +

+ 📚 {{ book()!.series }}@if (book()!.seriesOrder) { #{{ book()!.seriesOrder }}} +

+ } + + @if (book()!.isbn) { +
+ ISBN: + {{ book()!.isbn }} +
+ } + + @if (book()!.rating) { +
+ Rating: +
+ @for (star of [1,2,3,4,5,6,7,8,9,10]; track star) { + ★ + } + ({{ book()!.rating }}/10) +
+
+ } + + @if (book()!.timeSpent) { +
+ Reading Time: + {{ formatTimeSpent(book()!.timeSpent!) }} +
+ } + + @if (book()!.dateStarted) { +
+ Started: + {{ formatDate(book()!.dateStarted!) }} +
+ } + + @if (book()!.dateFinished) { +
+ Finished: + {{ formatDate(book()!.dateFinished!) }} +
+ } + +
+ Added: + {{ formatDate(book()!.createdAt) }} +
+ +
+ Last Updated: + {{ formatDate(book()!.updatedAt) }} +
+ + + + @if (book()!.notes) { +
+

Notes

+

{{ book()!.notes }}

+
+ } + + @if (book()!.tags && book()!.tags.length > 0) { +
+

Tags

+
+ @for (tag of book()!.tags; track tag) { + {{ tag }} + } +
+
+ } + + @if (book()!.links && book()!.links.length > 0) { + + } + +
+

Comments

+ @if (authService.isAuthenticated()) { + @if (authService.user()?.isBanned) { +
+ You have been banned from commenting. +
+ } @else { +
+ + +
+ } + } @else { +
+

Please sign in to comment.

+
+ } + + @if (commentsLoading()) { +
Loading comments...
+ } @else { + + } +
+
+
+ } +
+ `, + styles: [` + .container { + max-width: 900px; + margin: 0 auto; + padding: 2rem; + } + + .breadcrumb { + margin-bottom: 1.5rem; + } + + .breadcrumb-link { + color: #8b6f47; + text-decoration: none; + font-weight: 500; + transition: opacity 0.3s; + } + + .breadcrumb-link:hover { + opacity: 0.8; + } + + .loading { + text-align: center; + padding: 3rem; + color: #666; + font-size: 1.1rem; + } + + .error-state { + text-align: center; + padding: 3rem; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 8px; + } + + .error-state h2 { + color: #991b1b; + margin-bottom: 1rem; + } + + .error-state p { + color: #dc2626; + margin-bottom: 1.5rem; + } + + .book-detail-card { + background: white; + border: 1px solid #e5e7eb; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + } + + .book-cover-section { + width: 100%; + background: #f3f4f6; + display: flex; + justify-content: center; + align-items: center; + padding: 2rem; + } + + .book-cover-large { + max-width: 100%; + max-height: 400px; + object-fit: contain; + border-radius: 4px; + } + + .book-content { + padding: 2rem; + } + + .book-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; + flex-wrap: wrap; + } + + .book-header h1 { + margin: 0; + font-size: 2rem; + color: #1f2937; + } + + .author { + color: #6b7280; + font-style: italic; + font-size: 1.1rem; + margin: 0 0 1rem 0; + } + + .status { + display: inline-block; + padding: 0.5rem 1rem; + border-radius: 4px; + font-size: 0.9rem; + font-weight: 500; + } + + .status-reading { + background: #fef3c7; + color: #92400e; + } + + .status-finished { + background: #d1fae5; + color: #065f46; + } + + .status-toRead { + background: #e0e7ff; + color: #3730a3; + } + + .status-retired { + background: #f3f4f6; + color: #4b5563; + } + + .series { + color: #8b6f47; + font-size: 1rem; + margin: 0.5rem 0 1.5rem 0; + font-weight: 500; + font-style: italic; + } + + .info-row { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 0.75rem; + padding: 0.5rem 0; + border-bottom: 1px solid #f3f4f6; + } + + .info-label { + font-weight: 600; + color: #4b5563; + min-width: 120px; + } + + .info-value { + color: #1f2937; + } + + .time-spent { + color: #10b981; + font-weight: 600; + } + + .rating { + display: flex; + align-items: center; + gap: 0.25rem; + } + + .rating span { + color: #e5e7eb; + font-size: 1.2rem; + } + + .rating span.filled { + color: #f59e0b; + } + + .rating-text { + margin-left: 0.5rem; + font-size: 1rem; + color: #6b7280; + font-weight: 500; + } + + .like-section { + margin: 1.5rem 0; + } + + .notes-section, + .tags-section, + .links-section { + margin-top: 2rem; + } + + .notes-section h3, + .tags-section h3, + .links-section h3, + .comments-section h3 { + font-size: 1.25rem; + color: #1f2937; + margin-bottom: 1rem; + } + + .notes { + font-size: 1rem; + color: #4b5563; + line-height: 1.6; + white-space: pre-wrap; + } + + .tags-display { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + } + + .tag-chip { + background: #8b6f47; + color: white; + padding: 0.375rem 0.875rem; + border-radius: 12px; + font-size: 0.875rem; + font-weight: 500; + } + + .links-display { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + } + + .external-link { + color: #8b6f47; + text-decoration: none; + font-size: 0.95rem; + padding: 0.5rem 1rem; + border: 2px solid #8b6f47; + border-radius: 4px; + transition: all 0.2s; + font-weight: 500; + } + + .external-link:hover { + background: #8b6f47; + color: white; + } + + .comments-section { + margin-top: 2rem; + padding-top: 2rem; + border-top: 2px solid #e5e7eb; + } + + .comment-form { + margin-bottom: 1.5rem; + } + + .comment-form textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid #d1d5db; + border-radius: 4px; + font-size: 1rem; + resize: vertical; + margin-bottom: 0.75rem; + font-family: inherit; + } + + .comment-form textarea:focus { + outline: none; + border-color: #8b6f47; + } + + .banned-notice { + background: #fef2f2; + border: 1px solid #fecaca; + color: #991b1b; + padding: 1rem; + border-radius: 4px; + text-align: center; + margin-bottom: 1.5rem; + font-size: 0.95rem; + } + + .auth-prompt { + background: #f3f4f6; + padding: 1rem; + border-radius: 4px; + text-align: center; + margin-bottom: 1.5rem; + } + + .auth-prompt p { + margin: 0; + color: #4b5563; + } + + .comments-loading { + text-align: center; + padding: 1.5rem; + color: #6b7280; + font-size: 0.95rem; + } + + .btn { + padding: 0.625rem 1.25rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; + transition: all 0.3s; + font-weight: 500; + } + + .btn:hover { + opacity: 0.9; + } + + .btn-primary { + background: #8b6f47; + color: white; + } + + @media (max-width: 640px) { + .container { + padding: 1rem; + } + + .book-header { + flex-direction: column; + align-items: flex-start; + } + + .book-header h1 { + font-size: 1.5rem; + } + + .info-row { + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + } + + .info-label { + min-width: auto; + } + } + `] +}) +export class BookDetailComponent implements OnInit { + private readonly booksService = inject(BooksService); + private readonly commentsService = inject(CommentsService); + readonly authService = inject(AuthService); + private readonly sanitizeService = inject(SanitizeService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + book = signal(null); + comments = signal([]); + loading = signal(true); + commentsLoading = signal(false); + error = signal(null); + newCommentContent = ''; + + ngOnInit() { + const bookId = this.route.snapshot.paramMap.get('id'); + if (!bookId) { + this.error.set('No book ID provided'); + this.loading.set(false); + return; + } + + this.loadBook(bookId); + this.loadComments(bookId); + } + + private loadBook(bookId: string) { + this.loading.set(true); + this.booksService.getBookById(bookId).subscribe({ + next: (book) => { + if (!book) { + this.error.set('Book not found'); + } else { + this.book.set(book); + } + this.loading.set(false); + }, + error: () => { + this.error.set('Failed to load book. It may not exist or there was an error.'); + this.loading.set(false); + } + }); + } + + private loadComments(bookId: string) { + this.commentsLoading.set(true); + this.commentsService.getCommentsForBook(bookId).subscribe({ + next: (comments) => { + this.comments.set(comments); + this.commentsLoading.set(false); + }, + error: () => { + this.commentsLoading.set(false); + } + }); + } + + addComment() { + const book = this.book(); + if (!book || !this.newCommentContent.trim()) return; + + this.commentsService.addCommentToBook(book.id, { content: this.newCommentContent }).subscribe({ + next: (comment) => { + this.comments.set([comment, ...this.comments()]); + this.newCommentContent = ''; + } + }); + } + + handleCommentEdit(event: { commentId: string; content: string }) { + const book = this.book(); + if (!book) return; + + this.commentsService.updateCommentOnBook(book.id, event.commentId, event.content).subscribe({ + next: (updatedComment) => { + this.comments.set( + this.comments().map(c => c.id === event.commentId ? updatedComment : c) + ); + } + }); + } + + deleteComment(commentId: string) { + const book = this.book(); + if (!book || !confirm('Are you sure you want to delete this comment?')) return; + + this.commentsService.deleteCommentFromBook(book.id, commentId).subscribe({ + next: () => { + this.comments.set(this.comments().filter(c => c.id !== commentId)); + } + }); + } + + getStatusLabel(status: BookStatus): string { + switch (status) { + case BookStatus.reading: return 'Currently Reading'; + case BookStatus.finished: return 'Finished'; + case BookStatus.toRead: return 'To Read'; + case BookStatus.retired: return 'Retired'; + } + } + + formatDate(date: Date | string): string { + return new Date(date).toLocaleDateString('en-GB', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + } + + formatTimeSpent(minutes: number): string { + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + + if (hours === 0) { + return `${mins} minutes`; + } else if (mins === 0) { + return `${hours} hour${hours === 1 ? '' : 's'}`; + } else { + return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`; + } + } +} diff --git a/apps/frontend/src/app/components/games/game-detail.component.ts b/apps/frontend/src/app/components/games/game-detail.component.ts new file mode 100644 index 0000000..0b7d43e --- /dev/null +++ b/apps/frontend/src/app/components/games/game-detail.component.ts @@ -0,0 +1,645 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { Component, OnInit, inject, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterLink, ActivatedRoute, Router } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { GamesService } from '../../services/games.service'; +import { CommentsService } from '../../services/comments.service'; +import { AuthService } from '../../services/auth.service'; +import { SanitizeService } from '../../services/sanitize.service'; +import { CommentDisplayComponent } from '../comment-display/comment-display.component'; +import { LikeButtonComponent } from '../shared/like-button.component'; +import { Game, Comment, GameStatus } from '@library/shared-types'; + +@Component({ + selector: 'app-game-detail', + standalone: true, + imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent], + template: ` +
+ + + @if (loading()) { +
Loading game details...
+ } @else if (error()) { +
+

Game Not Found

+

{{ error() }}

+ Return to Games +
+ } @else if (game()) { +
+ @if (game()!.coverImage) { +
+ +
+ } + +
+
+

{{ game()!.title }}

+ + {{ getStatusLabel(game()!.status) }} + +
+ + @if (game()!.series) { +

+ 📚 {{ game()!.series }}@if (game()!.seriesOrder) { #{{ game()!.seriesOrder }}} +

+ } + + @if (game()!.platform) { +
+ Platform: + {{ game()!.platform }} +
+ } + + @if (game()!.rating) { +
+ Rating: +
+ @for (star of [1,2,3,4,5,6,7,8,9,10]; track star) { + ★ + } + ({{ game()!.rating }}/10) +
+
+ } + + @if (game()!.timeSpent) { +
+ Time Played: + {{ formatTimeSpent(game()!.timeSpent!) }} +
+ } + + @if (game()!.dateStarted) { +
+ Started: + {{ formatDate(game()!.dateStarted!) }} +
+ } + + @if (game()!.dateFinished) { +
+ Finished: + {{ formatDate(game()!.dateFinished!) }} +
+ } + +
+ Added: + {{ formatDate(game()!.createdAt) }} +
+ +
+ Last Updated: + {{ formatDate(game()!.updatedAt) }} +
+ + + + @if (game()!.notes) { +
+

Notes

+

{{ game()!.notes }}

+
+ } + + @if (game()!.tags && game()!.tags.length > 0) { +
+

Tags

+
+ @for (tag of game()!.tags; track tag) { + {{ tag }} + } +
+
+ } + + @if (game()!.links && game()!.links.length > 0) { + + } + +
+

Comments

+ @if (authService.isAuthenticated()) { + @if (authService.user()?.isBanned) { +
+ You have been banned from commenting. +
+ } @else { +
+ + +
+ } + } @else { +
+

Please sign in to comment.

+
+ } + + @if (commentsLoading()) { +
Loading comments...
+ } @else { + + } +
+
+
+ } +
+ `, + styles: [` + .container { + max-width: 900px; + margin: 0 auto; + padding: 2rem; + } + + .breadcrumb { + margin-bottom: 1.5rem; + } + + .breadcrumb-link { + color: #ff6b6b; + text-decoration: none; + font-weight: 500; + transition: opacity 0.3s; + } + + .breadcrumb-link:hover { + opacity: 0.8; + } + + .loading { + text-align: center; + padding: 3rem; + color: #666; + font-size: 1.1rem; + } + + .error-state { + text-align: center; + padding: 3rem; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 8px; + } + + .error-state h2 { + color: #991b1b; + margin-bottom: 1rem; + } + + .error-state p { + color: #dc2626; + margin-bottom: 1.5rem; + } + + .game-detail-card { + background: white; + border: 1px solid #e5e7eb; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + } + + .game-cover-section { + width: 100%; + background: #f3f4f6; + display: flex; + justify-content: center; + align-items: center; + padding: 2rem; + } + + .game-cover-large { + max-width: 100%; + max-height: 400px; + object-fit: contain; + border-radius: 4px; + } + + .game-content { + padding: 2rem; + } + + .game-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; + flex-wrap: wrap; + } + + .game-header h1 { + margin: 0; + font-size: 2rem; + color: #1f2937; + } + + .status { + display: inline-block; + padding: 0.5rem 1rem; + border-radius: 4px; + font-size: 0.9rem; + font-weight: 500; + } + + .status-playing { + background: #fef3c7; + color: #92400e; + } + + .status-completed { + background: #d1fae5; + color: #065f46; + } + + .status-backlog { + background: #e0e7ff; + color: #3730a3; + } + + .status-retired { + background: #f3f4f6; + color: #4b5563; + } + + .series { + color: #8b6f47; + font-size: 1rem; + margin: 0.5rem 0 1.5rem 0; + font-weight: 500; + font-style: italic; + } + + .info-row { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 0.75rem; + padding: 0.5rem 0; + border-bottom: 1px solid #f3f4f6; + } + + .info-label { + font-weight: 600; + color: #4b5563; + min-width: 120px; + } + + .info-value { + color: #1f2937; + } + + .time-spent { + color: #10b981; + font-weight: 600; + } + + .rating { + display: flex; + align-items: center; + gap: 0.25rem; + } + + .rating span { + color: #e5e7eb; + font-size: 1.2rem; + } + + .rating span.filled { + color: #f59e0b; + } + + .rating-text { + margin-left: 0.5rem; + font-size: 1rem; + color: #6b7280; + font-weight: 500; + } + + .like-section { + margin: 1.5rem 0; + } + + .notes-section, + .tags-section, + .links-section { + margin-top: 2rem; + } + + .notes-section h3, + .tags-section h3, + .links-section h3, + .comments-section h3 { + font-size: 1.25rem; + color: #1f2937; + margin-bottom: 1rem; + } + + .notes { + font-size: 1rem; + color: #4b5563; + line-height: 1.6; + white-space: pre-wrap; + } + + .tags-display { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + } + + .tag-chip { + background: #ff6b6b; + color: white; + padding: 0.375rem 0.875rem; + border-radius: 12px; + font-size: 0.875rem; + font-weight: 500; + } + + .links-display { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + } + + .external-link { + color: #ff6b6b; + text-decoration: none; + font-size: 0.95rem; + padding: 0.5rem 1rem; + border: 2px solid #ff6b6b; + border-radius: 4px; + transition: all 0.2s; + font-weight: 500; + } + + .external-link:hover { + background: #ff6b6b; + color: white; + } + + .comments-section { + margin-top: 2rem; + padding-top: 2rem; + border-top: 2px solid #e5e7eb; + } + + .comment-form { + margin-bottom: 1.5rem; + } + + .comment-form textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid #d1d5db; + border-radius: 4px; + font-size: 1rem; + resize: vertical; + margin-bottom: 0.75rem; + font-family: inherit; + } + + .comment-form textarea:focus { + outline: none; + border-color: #ff6b6b; + } + + .banned-notice { + background: #fef2f2; + border: 1px solid #fecaca; + color: #991b1b; + padding: 1rem; + border-radius: 4px; + text-align: center; + margin-bottom: 1.5rem; + font-size: 0.95rem; + } + + .auth-prompt { + background: #f3f4f6; + padding: 1rem; + border-radius: 4px; + text-align: center; + margin-bottom: 1.5rem; + } + + .auth-prompt p { + margin: 0; + color: #4b5563; + } + + .comments-loading { + text-align: center; + padding: 1.5rem; + color: #6b7280; + font-size: 0.95rem; + } + + .btn { + padding: 0.625rem 1.25rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; + transition: all 0.3s; + font-weight: 500; + } + + .btn:hover { + opacity: 0.9; + } + + .btn-primary { + background: #ff6b6b; + color: white; + } + + @media (max-width: 640px) { + .container { + padding: 1rem; + } + + .game-header { + flex-direction: column; + align-items: flex-start; + } + + .game-header h1 { + font-size: 1.5rem; + } + + .info-row { + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + } + + .info-label { + min-width: auto; + } + } + `] +}) +export class GameDetailComponent implements OnInit { + private readonly gamesService = inject(GamesService); + private readonly commentsService = inject(CommentsService); + readonly authService = inject(AuthService); + private readonly sanitizeService = inject(SanitizeService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + game = signal(null); + comments = signal([]); + loading = signal(true); + commentsLoading = signal(false); + error = signal(null); + newCommentContent = ''; + + ngOnInit() { + const gameId = this.route.snapshot.paramMap.get('id'); + if (!gameId) { + this.error.set('No game ID provided'); + this.loading.set(false); + return; + } + + this.loadGame(gameId); + this.loadComments(gameId); + } + + private loadGame(gameId: string) { + this.loading.set(true); + this.gamesService.getGameById(gameId).subscribe({ + next: (game) => { + if (!game) { + this.error.set('Game not found'); + } else { + this.game.set(game); + } + this.loading.set(false); + }, + error: () => { + this.error.set('Failed to load game. It may not exist or there was an error.'); + this.loading.set(false); + } + }); + } + + private loadComments(gameId: string) { + this.commentsLoading.set(true); + this.commentsService.getCommentsForGame(gameId).subscribe({ + next: (comments) => { + this.comments.set(comments); + this.commentsLoading.set(false); + }, + error: () => { + this.commentsLoading.set(false); + } + }); + } + + addComment() { + const game = this.game(); + if (!game || !this.newCommentContent.trim()) return; + + this.commentsService.addCommentToGame(game.id, { content: this.newCommentContent }).subscribe({ + next: (comment) => { + this.comments.set([comment, ...this.comments()]); + this.newCommentContent = ''; + } + }); + } + + handleCommentEdit(event: { commentId: string; content: string }) { + const game = this.game(); + if (!game) return; + + this.commentsService.updateCommentOnGame(game.id, event.commentId, event.content).subscribe({ + next: (updatedComment) => { + this.comments.set( + this.comments().map(c => c.id === event.commentId ? updatedComment : c) + ); + } + }); + } + + deleteComment(commentId: string) { + const game = this.game(); + if (!game || !confirm('Are you sure you want to delete this comment?')) return; + + this.commentsService.deleteCommentFromGame(game.id, commentId).subscribe({ + next: () => { + this.comments.set(this.comments().filter(c => c.id !== commentId)); + } + }); + } + + getStatusLabel(status: GameStatus): string { + switch (status) { + case GameStatus.playing: return 'Currently Playing'; + case GameStatus.completed: return 'Completed'; + case GameStatus.backlog: return 'In Backlog'; + case GameStatus.retired: return 'Retired'; + } + } + + formatDate(date: Date | string): string { + return new Date(date).toLocaleDateString('en-GB', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + } + + formatTimeSpent(minutes: number): string { + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + + if (hours === 0) { + return `${mins} minutes`; + } else if (mins === 0) { + return `${hours} hour${hours === 1 ? '' : 's'}`; + } else { + return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`; + } + } +} diff --git a/apps/frontend/src/app/components/home/home.component.ts b/apps/frontend/src/app/components/home/home.component.ts index d3a3506..cea8617 100644 --- a/apps/frontend/src/app/components/home/home.component.ts +++ b/apps/frontend/src/app/components/home/home.component.ts @@ -92,7 +92,7 @@ import { Game, GameStatus, Book, BookStatus, Music, MusicType, Manga, MangaStatu