From 9902c5ad45beec06341de597fb2bc8f17b0a6476 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Wed, 4 Feb 2026 19:09:28 -0800 Subject: [PATCH] feat: add suggestion feature --- api/prisma/schema.prisma | 59 +- api/src/app/middleware/banned-guard.ts | 2 +- api/src/app/routes/suggestions/index.ts | 173 +++++ api/src/app/services/auth.service.ts | 4 +- api/src/app/services/index.ts | 3 +- api/src/app/services/suggestion.service.ts | 367 ++++++++++ api/src/app/services/user.service.ts | 8 +- apps/frontend/src/app/app.routes.ts | 8 + .../admin/admin-suggestions.component.ts | 634 ++++++++++++++++++ .../components/admin/admin-users.component.ts | 4 +- .../components/art/art-gallery.component.ts | 129 +++- .../components/books/books-list.component.ts | 161 ++++- .../components/games/games-list.component.ts | 146 +++- .../app/components/header/header.component.ts | 18 + .../components/manga/manga-list.component.ts | 147 +++- .../components/music/music-list.component.ts | 159 ++++- .../my-suggestions.component.ts | 377 +++++++++++ .../components/shows/shows-list.component.ts | 145 +++- .../src/app/services/suggestion.service.ts | 55 ++ shared-types/src/index.ts | 3 +- shared-types/src/lib/auth.types.ts | 2 +- shared-types/src/lib/suggestion.types.ts | 111 +++ 22 files changed, 2666 insertions(+), 49 deletions(-) create mode 100644 api/src/app/routes/suggestions/index.ts create mode 100644 api/src/app/services/suggestion.service.ts create mode 100644 apps/frontend/src/app/components/admin/admin-suggestions.component.ts create mode 100644 apps/frontend/src/app/components/my-suggestions/my-suggestions.component.ts create mode 100644 apps/frontend/src/app/services/suggestion.service.ts create mode 100644 shared-types/src/lib/suggestion.types.ts diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 1e0efa4..b28fc59 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -146,20 +146,21 @@ enum MangaStatus { } model User { - id String @id @default(auto()) @map("_id") @db.ObjectId - discordId String @unique + id String @id @default(auto()) @map("_id") @db.ObjectId + discordId String @unique username String - email String @unique + email String @unique avatar String? - isAdmin Boolean @default(false) - isBanned Boolean @default(false) - inDiscord Boolean @default(false) - isVip Boolean @default(false) - isMod Boolean @default(false) - isStaff Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + isAdmin Boolean @default(false) + isBanned Boolean @default(false) + inDiscord Boolean @default(false) + isVip Boolean @default(false) + isMod Boolean @default(false) + isStaff Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt comments Comment[] + suggestions Suggestion[] } model Comment { @@ -221,3 +222,39 @@ enum AuditCategory { ADMIN SECURITY } + +model Suggestion { + id String @id @default(auto()) @map("_id") @db.ObjectId + userId String @db.ObjectId + user User @relation(fields: [userId], references: [id]) + entityType SuggestionEntity + status SuggestionStatus @default(UNREVIEWED) + declineReason String? + + // Data for the suggested item (stored as JSON) + title String + gameData Json? + bookData Json? + musicData Json? + artData Json? + showData Json? + mangaData Json? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +enum SuggestionEntity { + GAME + BOOK + MUSIC + ART + SHOW + MANGA +} + +enum SuggestionStatus { + UNREVIEWED + ACCEPTED + DECLINED +} diff --git a/api/src/app/middleware/banned-guard.ts b/api/src/app/middleware/banned-guard.ts index 02aedfb..8864175 100644 --- a/api/src/app/middleware/banned-guard.ts +++ b/api/src/app/middleware/banned-guard.ts @@ -25,6 +25,6 @@ export async function bannedGuard( const isBanned = await userService.isUserBanned(user.id); if (isBanned) { - return reply.code(403).send({ error: "You have been banned from commenting" }); + return reply.code(403).send({ error: "You have been banned" }); } } diff --git a/api/src/app/routes/suggestions/index.ts b/api/src/app/routes/suggestions/index.ts new file mode 100644 index 0000000..7d191dd --- /dev/null +++ b/api/src/app/routes/suggestions/index.ts @@ -0,0 +1,173 @@ +import type { FastifyInstance } from "fastify"; +import { SuggestionService } from "../../services/suggestion.service"; +import { AuditService } from "../../services/audit.service"; +import { AuditAction, AuditCategory } from "@library/shared-types"; +import type { + SuggestionStatus, + SuggestionEntity, + CreateSuggestionDto, + DeclineSuggestionDto, +} from "@library/shared-types"; +import { adminGuard } from "../../middleware/admin-guard"; +import { bannedGuard } from "../../middleware/banned-guard"; + +export default async function (app: FastifyInstance): Promise { + // Get all suggestions (admin only) + app.get<{ + Querystring: { status?: SuggestionStatus; entityType?: SuggestionEntity }; + }>( + "/", + { + preHandler: [app.authenticate, adminGuard], + }, + async (request, reply) => { + const { status, entityType } = request.query; + + const suggestions = await SuggestionService.getAllSuggestions({ + status, + entityType, + }); + + reply.send(suggestions); + } + ); + + // Get current user's suggestions + app.get( + "/my", + { + preHandler: [app.authenticate], + }, + async (request, reply) => { + const userId = request.user.id; + const suggestions = await SuggestionService.getUserSuggestions(userId); + reply.send(suggestions); + } + ); + + // Get a single suggestion by ID + app.get<{ Params: { id: string } }>( + "/:id", + { + preHandler: [app.authenticate], + }, + async (request, reply) => { + const { id } = request.params; + const suggestion = await SuggestionService.getSuggestionById(id); + + if (!suggestion) { + return reply.notFound("Suggestion not found"); + } + + // Non-admins can only view their own suggestions + if (!request.user.isAdmin && suggestion.userId !== request.user.id) { + return reply.forbidden("You can only view your own suggestions"); + } + + reply.send(suggestion); + } + ); + + // Create a new suggestion (any authenticated non-banned user) + app.post<{ Body: CreateSuggestionDto }>( + "/", + { + preHandler: [app.authenticate, bannedGuard, app.csrfProtection], + }, + async (request, reply) => { + const userId = request.user.id; + + try { + const suggestion = await SuggestionService.createSuggestion( + userId, + request.body + ); + + await AuditService.log( + { + action: AuditAction.ENTRY_CREATE, + category: AuditCategory.CONTENT, + resourceType: "Suggestion", + resourceId: suggestion.id, + details: `Created ${suggestion.entityType} suggestion: ${suggestion.title}`, + success: true, + }, + request + ); + + reply.send(suggestion); + } catch (error) { + return reply.badRequest( + error instanceof Error ? error.message : "Failed to create suggestion" + ); + } + } + ); + + // Accept a suggestion (admin only) + app.put<{ Params: { id: string } }>( + "/:id/accept", + { + preHandler: [app.authenticate, adminGuard, app.csrfProtection], + }, + async (request, reply) => { + const { id } = request.params; + + try { + const suggestion = await SuggestionService.acceptSuggestion(id); + + await AuditService.log( + { + action: AuditAction.ENTRY_UPDATE, + category: AuditCategory.ADMIN, + resourceType: "Suggestion", + resourceId: suggestion.id, + details: `Accepted ${suggestion.entityType} suggestion: ${suggestion.title}`, + success: true, + }, + request + ); + + reply.send(suggestion); + } catch (error) { + return reply.badRequest( + error instanceof Error ? error.message : "Failed to accept suggestion" + ); + } + } + ); + + // Decline a suggestion (admin only) + app.put<{ Params: { id: string }; Body: DeclineSuggestionDto }>( + "/:id/decline", + { + preHandler: [app.authenticate, adminGuard, app.csrfProtection], + }, + async (request, reply) => { + const { id } = request.params; + const { reason } = request.body; + + try { + const suggestion = await SuggestionService.declineSuggestion(id, reason); + + await AuditService.log( + { + action: AuditAction.ENTRY_UPDATE, + category: AuditCategory.ADMIN, + resourceType: "Suggestion", + resourceId: suggestion.id, + details: `Declined ${suggestion.entityType} suggestion: ${suggestion.title}${reason ? ` (Reason: ${reason})` : ""}`, + success: true, + }, + request + ); + + reply.send(suggestion); + } catch (error) { + return reply.badRequest( + error instanceof Error ? error.message : "Failed to decline suggestion" + ); + } + } + ); +} diff --git a/api/src/app/services/auth.service.ts b/api/src/app/services/auth.service.ts index 2fc879e..a05dae4 100644 --- a/api/src/app/services/auth.service.ts +++ b/api/src/app/services/auth.service.ts @@ -47,7 +47,7 @@ export class AuthService { discordId: dbUser.discordId, username: dbUser.username, email: dbUser.email, - avatarUrl: dbUser.avatar || undefined, + avatar: dbUser.avatar || undefined, isAdmin: dbUser.isAdmin, isBanned: dbUser.isBanned, inDiscord: dbUser.inDiscord, @@ -97,7 +97,7 @@ export class AuthService { discordId: dbUser.discordId, username: dbUser.username, email: dbUser.email, - avatarUrl: dbUser.avatar || undefined, + avatar: dbUser.avatar || undefined, isAdmin: dbUser.isAdmin, isBanned: dbUser.isBanned, inDiscord: dbUser.inDiscord, diff --git a/api/src/app/services/index.ts b/api/src/app/services/index.ts index 5efb715..9c3ed98 100644 --- a/api/src/app/services/index.ts +++ b/api/src/app/services/index.ts @@ -10,4 +10,5 @@ export { BookService } from "./book.service"; export { MusicService } from "./music.service"; export { ShowService } from "./show.service"; export { MangaService } from "./manga.service"; -export { AuditService } from "./audit.service"; \ No newline at end of file +export { AuditService } from "./audit.service"; +export { SuggestionService } from "./suggestion.service"; \ No newline at end of file diff --git a/api/src/app/services/suggestion.service.ts b/api/src/app/services/suggestion.service.ts new file mode 100644 index 0000000..b62866c --- /dev/null +++ b/api/src/app/services/suggestion.service.ts @@ -0,0 +1,367 @@ +import { prisma } from "../lib/prisma"; +import type { + Suggestion, + SuggestionStatus, + SuggestionEntity, + CreateSuggestionDto, +} from "@library/shared-types"; +import { + GameStatus, + BookStatus, + MusicType, + MusicStatus, + ShowType, + ShowStatus, + MangaStatus, +} from "@library/shared-types"; +import { GameService } from "./game.service"; +import { BookService } from "./book.service"; +import { MusicService } from "./music.service"; +import { ArtService } from "./art.service"; +import { ShowService } from "./show.service"; +import { MangaService } from "./manga.service"; + +const gameService = new GameService(); +const bookService = new BookService(); +const musicService = new MusicService(); +const artService = new ArtService(); +const showService = new ShowService(); +const mangaService = new MangaService(); + +interface SuggestionUser { + id: string; + username: string; + avatar: string | null; + inDiscord: boolean; + isVip: boolean; + isMod: boolean; + isStaff: boolean; +} + +function mapUser(user: { + id: string; + username: string; + avatar: string | null; + inDiscord: boolean; + isVip: boolean; + isMod: boolean; + isStaff: boolean; +}): SuggestionUser { + return { + id: user.id, + username: user.username, + avatar: user.avatar, + inDiscord: user.inDiscord, + isVip: user.isVip, + isMod: user.isMod, + isStaff: user.isStaff, + }; +} + +function mapSuggestion(suggestion: { + id: string; + userId: string; + user: { + id: string; + username: string; + avatar: string | null; + inDiscord: boolean; + isVip: boolean; + isMod: boolean; + isStaff: boolean; + }; + entityType: string; + status: string; + declineReason: string | null; + title: string; + gameData: unknown; + bookData: unknown; + musicData: unknown; + artData: unknown; + showData: unknown; + mangaData: unknown; + createdAt: Date; + updatedAt: Date; +}): Suggestion { + return { + id: suggestion.id, + userId: suggestion.userId, + user: mapUser(suggestion.user) as Suggestion["user"], + entityType: suggestion.entityType as SuggestionEntity, + status: suggestion.status as SuggestionStatus, + declineReason: suggestion.declineReason ?? undefined, + title: suggestion.title, + gameData: suggestion.gameData as Suggestion["gameData"], + bookData: suggestion.bookData as Suggestion["bookData"], + musicData: suggestion.musicData as Suggestion["musicData"], + artData: suggestion.artData as Suggestion["artData"], + showData: suggestion.showData as Suggestion["showData"], + mangaData: suggestion.mangaData as Suggestion["mangaData"], + createdAt: suggestion.createdAt, + updatedAt: suggestion.updatedAt, + }; +} + +function getMusicType(type?: string): MusicType { + switch (type) { + case "ALBUM": + return MusicType.album; + case "SINGLE": + return MusicType.single; + case "EP": + return MusicType.ep; + default: + return MusicType.album; + } +} + +function getShowType(type?: string): ShowType { + switch (type) { + case "TV_SERIES": + return ShowType.tvSeries; + case "ANIME": + return ShowType.anime; + case "FILM": + return ShowType.film; + case "DOCUMENTARY": + return ShowType.documentary; + default: + return ShowType.tvSeries; + } +} + +export const SuggestionService = { + async getAllSuggestions(filters?: { + status?: SuggestionStatus; + entityType?: SuggestionEntity; + }): Promise { + const suggestions = await prisma.suggestion.findMany({ + where: { + ...(filters?.status && { status: filters.status }), + ...(filters?.entityType && { entityType: filters.entityType }), + }, + include: { + user: true, + }, + orderBy: { + createdAt: "desc", + }, + }); + + return suggestions.map(mapSuggestion); + }, + + async getUserSuggestions(userId: string): Promise { + const suggestions = await prisma.suggestion.findMany({ + where: { + userId, + }, + include: { + user: true, + }, + orderBy: { + createdAt: "desc", + }, + }); + + return suggestions.map(mapSuggestion); + }, + + async getSuggestionById(id: string): Promise { + const suggestion = await prisma.suggestion.findUnique({ + where: { id }, + include: { + user: true, + }, + }); + + if (!suggestion) { + return null; + } + + return mapSuggestion(suggestion); + }, + + async createSuggestion( + userId: string, + data: CreateSuggestionDto + ): Promise { + const pendingCount = await prisma.suggestion.count({ + where: { + userId, + status: "UNREVIEWED", + }, + }); + + if (pendingCount >= 5) { + throw new Error("You can only have 5 pending suggestions at a time"); + } + + const entityDataField = `${data.entityType.toLowerCase()}Data`; + + const suggestionData: Record = { + userId, + entityType: data.entityType, + title: data.title, + }; + + // Extract the data without entityType and title + const { entityType: _et, title: _t, ...entityData } = data; + suggestionData[entityDataField] = entityData; + + const suggestion = await prisma.suggestion.create({ + data: suggestionData as Parameters[0]["data"], + include: { + user: true, + }, + }); + + return mapSuggestion(suggestion); + }, + + async acceptSuggestion(id: string): Promise { + const suggestion = await prisma.suggestion.findUnique({ + where: { id }, + include: { user: true }, + }); + + if (!suggestion) { + throw new Error("Suggestion not found"); + } + + if (suggestion.status !== "UNREVIEWED") { + throw new Error("Suggestion has already been reviewed"); + } + + // Create the entity based on type with "Want to X" status + switch (suggestion.entityType) { + case "GAME": { + const gameData = suggestion.gameData as { + platform?: string; + notes?: string; + coverImage?: string; + } | null; + await gameService.createGame({ + title: suggestion.title, + platform: gameData?.platform, + status: GameStatus.backlog, + notes: gameData?.notes, + coverImage: gameData?.coverImage, + }); + break; + } + case "BOOK": { + const bookData = suggestion.bookData as { + author: string; + isbn?: string; + notes?: string; + coverImage?: string; + } | null; + await bookService.createBook({ + title: suggestion.title, + author: bookData?.author ?? "Unknown", + isbn: bookData?.isbn, + status: BookStatus.toRead, + notes: bookData?.notes, + coverImage: bookData?.coverImage, + }); + break; + } + case "MUSIC": { + const musicData = suggestion.musicData as { + artist: string; + type: string; + notes?: string; + coverArt?: string; + } | null; + await musicService.createMusic({ + title: suggestion.title, + artist: musicData?.artist ?? "Unknown", + type: getMusicType(musicData?.type), + status: MusicStatus.wantToListen, + notes: musicData?.notes, + coverArt: musicData?.coverArt, + }); + break; + } + case "ART": { + const artData = suggestion.artData as { + artist: string; + description?: string; + imageUrl: string; + } | null; + await artService.createArt({ + title: suggestion.title, + artist: artData?.artist ?? "Unknown", + description: artData?.description, + imageUrl: artData?.imageUrl ?? "", + }); + break; + } + case "SHOW": { + const showData = suggestion.showData as { + type: string; + notes?: string; + coverImage?: string; + } | null; + await showService.createShow({ + title: suggestion.title, + type: getShowType(showData?.type), + status: ShowStatus.wantToWatch, + notes: showData?.notes, + coverImage: showData?.coverImage, + }); + break; + } + case "MANGA": { + const mangaData = suggestion.mangaData as { + author: string; + notes?: string; + coverImage?: string; + } | null; + await mangaService.createManga({ + title: suggestion.title, + author: mangaData?.author ?? "Unknown", + status: MangaStatus.wantToRead, + notes: mangaData?.notes, + coverImage: mangaData?.coverImage, + }); + break; + } + } + + // Update suggestion status + const updatedSuggestion = await prisma.suggestion.update({ + where: { id }, + data: { status: "ACCEPTED" }, + include: { user: true }, + }); + + return mapSuggestion(updatedSuggestion); + }, + + async declineSuggestion(id: string, reason?: string): Promise { + const suggestion = await prisma.suggestion.findUnique({ + where: { id }, + }); + + if (!suggestion) { + throw new Error("Suggestion not found"); + } + + if (suggestion.status !== "UNREVIEWED") { + throw new Error("Suggestion has already been reviewed"); + } + + const updatedSuggestion = await prisma.suggestion.update({ + where: { id }, + data: { + status: "DECLINED", + declineReason: reason, + }, + include: { user: true }, + }); + + return mapSuggestion(updatedSuggestion); + }, +}; diff --git a/api/src/app/services/user.service.ts b/api/src/app/services/user.service.ts index c07b57d..6d825aa 100644 --- a/api/src/app/services/user.service.ts +++ b/api/src/app/services/user.service.ts @@ -20,7 +20,7 @@ export class UserService { discordId: user.discordId, username: user.username, email: user.email, - avatarUrl: user.avatar || undefined, + avatar: user.avatar || undefined, isAdmin: user.isAdmin, isBanned: user.isBanned, inDiscord: user.inDiscord, @@ -44,7 +44,7 @@ export class UserService { discordId: user.discordId, username: user.username, email: user.email, - avatarUrl: user.avatar || undefined, + avatar: user.avatar || undefined, isAdmin: user.isAdmin, isBanned: user.isBanned, inDiscord: user.inDiscord, @@ -65,7 +65,7 @@ export class UserService { discordId: user.discordId, username: user.username, email: user.email, - avatarUrl: user.avatar || undefined, + avatar: user.avatar || undefined, isAdmin: user.isAdmin, isBanned: user.isBanned, inDiscord: user.inDiscord, @@ -86,7 +86,7 @@ export class UserService { discordId: user.discordId, username: user.username, email: user.email, - avatarUrl: user.avatar || undefined, + avatar: user.avatar || undefined, isAdmin: user.isAdmin, isBanned: user.isBanned, inDiscord: user.inDiscord, diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index 93d97c8..7172663 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -37,6 +37,14 @@ export const appRoutes: Route[] = [ path: 'admin/audit', loadComponent: () => import('./components/admin/admin-audit.component').then(m => m.AdminAuditComponent) }, + { + path: 'admin/suggestions', + loadComponent: () => import('./components/admin/admin-suggestions.component').then(m => m.AdminSuggestionsComponent) + }, + { + path: 'my-suggestions', + loadComponent: () => import('./components/my-suggestions/my-suggestions.component').then(m => m.MySuggestionsComponent) + }, { path: '**', redirectTo: '' diff --git a/apps/frontend/src/app/components/admin/admin-suggestions.component.ts b/apps/frontend/src/app/components/admin/admin-suggestions.component.ts new file mode 100644 index 0000000..6790f3e --- /dev/null +++ b/apps/frontend/src/app/components/admin/admin-suggestions.component.ts @@ -0,0 +1,634 @@ +/** + * @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 { FormsModule } from '@angular/forms'; +import { SuggestionService } from '../../services/suggestion.service'; +import { AuthService } from '../../services/auth.service'; +import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-types'; + +@Component({ + selector: 'app-admin-suggestions', + standalone: true, + imports: [CommonModule, FormsModule], + template: ` +
+
+

Manage Suggestions

+

Review and respond to community suggestions

+
+ + @if (!authService.isAdmin()) { +
+

You don't have permission to view this page.

+
+ } @else if (loading()) { +
Loading suggestions...
+ } @else { +
+ + + + +
+ + @if (filteredSuggestions().length === 0) { +
+

No suggestions found.

+
+ } @else { +
+ @for (suggestion of filteredSuggestions(); track suggestion.id) { +
+
+
+ + {{ getEntityIcon(suggestion.entityType) }} {{ suggestion.entityType }} + + + {{ getStatusLabel(suggestion.status) }} + +
+ +
+ +

{{ suggestion.title }}

+ +
+ @if (suggestion.gameData) { + @if (suggestion.gameData.platform) { +

Platform: {{ suggestion.gameData.platform }}

+ } + @if (suggestion.gameData.notes) { +

Notes: {{ suggestion.gameData.notes }}

+ } + @if (suggestion.gameData.coverImage) { + Cover + } + } + @if (suggestion.bookData) { + @if (suggestion.bookData.author) { +

Author: {{ suggestion.bookData.author }}

+ } + @if (suggestion.bookData.isbn) { +

ISBN: {{ suggestion.bookData.isbn }}

+ } + @if (suggestion.bookData.notes) { +

Notes: {{ suggestion.bookData.notes }}

+ } + @if (suggestion.bookData.coverImage) { + Cover + } + } + @if (suggestion.musicData) { + @if (suggestion.musicData.artist) { +

Artist: {{ suggestion.musicData.artist }}

+ } + @if (suggestion.musicData.type) { +

Type: {{ suggestion.musicData.type }}

+ } + @if (suggestion.musicData.notes) { +

Notes: {{ suggestion.musicData.notes }}

+ } + @if (suggestion.musicData.coverArt) { + Cover + } + } + @if (suggestion.artData) { + @if (suggestion.artData.artist) { +

Artist: {{ suggestion.artData.artist }}

+ } + @if (suggestion.artData.description) { +

Description: {{ suggestion.artData.description }}

+ } + @if (suggestion.artData.imageUrl) { + Artwork + } + } + @if (suggestion.showData) { + @if (suggestion.showData.type) { +

Type: {{ suggestion.showData.type }}

+ } + @if (suggestion.showData.notes) { +

Notes: {{ suggestion.showData.notes }}

+ } + @if (suggestion.showData.coverImage) { + Cover + } + } + @if (suggestion.mangaData) { + @if (suggestion.mangaData.author) { +

Author: {{ suggestion.mangaData.author }}

+ } + @if (suggestion.mangaData.notes) { +

Notes: {{ suggestion.mangaData.notes }}

+ } + @if (suggestion.mangaData.coverImage) { + Cover + } + } +
+ + @if (suggestion.status === SuggestionStatus.DECLINED && suggestion.declineReason) { +
+ Decline reason: {{ suggestion.declineReason }} +
+ } + + +
+ } +
+ } + } + + @if (showDeclineModal()) { + + } +
+ `, + styles: [` + .container { + max-width: 900px; + margin: 0 auto; + padding: 2rem 1rem; + } + + .header-section { + margin-bottom: 2rem; + } + + .header-section h2 { + margin: 0 0 0.5rem 0; + } + + .subtitle { + color: #666; + margin: 0; + } + + .not-authorized, + .loading, + .empty-state { + text-align: center; + padding: 3rem; + color: #666; + background: #f8f9fa; + border-radius: 8px; + } + + .filters { + display: flex; + gap: 0.75rem; + 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 { + color: white; + } + + .filter-btn.active, + .filter-btn.active.pending { + background: #f59e0b; + } + + .filter-btn.active.accepted { + background: #10b981; + } + + .filter-btn.active.declined { + background: #ef4444; + } + + .suggestions-list { + display: flex; + flex-direction: column; + gap: 1rem; + } + + .suggestion-card { + background: white; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 1.25rem; + transition: box-shadow 0.3s; + } + + .suggestion-card:hover { + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + } + + .suggestion-card.status-accepted { + border-left: 4px solid #10b981; + } + + .suggestion-card.status-declined { + border-left: 4px solid #ef4444; + } + + .suggestion-card.status-unreviewed { + border-left: 4px solid #f59e0b; + } + + .suggestion-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; + flex-wrap: wrap; + gap: 0.5rem; + } + + .badges { + display: flex; + gap: 0.5rem; + } + + .user-info { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: #666; + } + + .user-avatar { + width: 24px; + height: 24px; + border-radius: 50%; + } + + .entity-badge, + .status-badge { + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + } + + .entity-badge { + background: #e5e7eb; + color: #374151; + } + + .entity-badge.entity-game { background: #fff1f1; color: #dc2626; } + .entity-badge.entity-book { background: #fdf4ed; color: #8b6f47; } + .entity-badge.entity-music { background: #eff6ff; color: #2563eb; } + .entity-badge.entity-manga { background: #ecfdf5; color: #059669; } + .entity-badge.entity-show { background: #fdf2f8; color: #db2777; } + .entity-badge.entity-art { background: #fefce8; color: #ca8a04; } + + .status-badge.status-unreviewed { + background: #fef3c7; + color: #92400e; + } + + .status-badge.status-accepted { + background: #d1fae5; + color: #065f46; + } + + .status-badge.status-declined { + background: #fee2e2; + color: #991b1b; + } + + .suggestion-title { + margin: 0 0 0.75rem 0; + font-size: 1.1rem; + } + + .suggestion-details { + font-size: 0.9rem; + color: #4b5563; + } + + .suggestion-details p { + margin: 0.25rem 0; + } + + .suggestion-image { + max-width: 150px; + max-height: 200px; + border-radius: 4px; + margin-top: 0.5rem; + border: 2px solid #e5e7eb; + } + + .decline-reason { + background: #fee2e2; + border: 1px solid #fecaca; + border-radius: 4px; + padding: 0.75rem; + margin-top: 0.75rem; + font-size: 0.9rem; + color: #991b1b; + } + + .suggestion-footer { + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid #e5e7eb; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + } + + .date { + font-size: 0.8rem; + color: #9ca3af; + } + + .actions { + display: flex; + gap: 0.5rem; + } + + .btn { + padding: 0.5rem 1rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: 500; + transition: all 0.3s; + } + + .btn-accept { + background: #10b981; + color: white; + } + + .btn-accept:hover { + background: #059669; + } + + .btn-decline { + background: #ef4444; + color: white; + } + + .btn-decline:hover { + background: #dc2626; + } + + .btn-secondary { + background: #e5e7eb; + color: #374151; + } + + .btn-secondary:hover { + background: #d1d5db; + } + + /* Modal styles */ + .modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + } + + .modal { + background: white; + border-radius: 8px; + padding: 1.5rem; + max-width: 500px; + width: 90%; + max-height: 90vh; + overflow-y: auto; + } + + .modal h3 { + margin: 0 0 1rem 0; + } + + .modal p { + margin: 0 0 1rem 0; + color: #666; + } + + .form-group { + margin-bottom: 1rem; + } + + .form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + } + + .form-group textarea { + width: 100%; + padding: 0.5rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; + resize: vertical; + } + + .modal-actions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; + } + `] +}) +export class AdminSuggestionsComponent implements OnInit { + suggestionService = inject(SuggestionService); + authService = inject(AuthService); + + suggestions = signal([]); + loading = signal(true); + statusFilter = signal<'all' | SuggestionStatus>('all'); + showDeclineModal = signal(false); + decliningsuggestion = signal(null); + declineReason = ''; + + SuggestionStatus = SuggestionStatus; + + unreviewedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.UNREVIEWED).length; + acceptedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.ACCEPTED).length; + declinedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.DECLINED).length; + + filteredSuggestions = () => { + const filter = this.statusFilter(); + if (filter === 'all') { + return this.suggestions(); + } + return this.suggestions().filter(s => s.status === filter); + }; + + ngOnInit() { + if (this.authService.isAdmin()) { + this.loadSuggestions(); + } else { + this.loading.set(false); + } + } + + async loadSuggestions() { + this.loading.set(true); + try { + const suggestions = await this.suggestionService.getAllSuggestions(); + this.suggestions.set(suggestions); + } catch { + // Handle error silently + } finally { + this.loading.set(false); + } + } + + setFilter(filter: 'all' | SuggestionStatus) { + this.statusFilter.set(filter); + } + + getStatusLabel(status: SuggestionStatus): string { + switch (status) { + case SuggestionStatus.UNREVIEWED: return 'Pending'; + case SuggestionStatus.ACCEPTED: return 'Accepted'; + case SuggestionStatus.DECLINED: return 'Declined'; + } + } + + getEntityIcon(entityType: SuggestionEntity): string { + switch (entityType) { + case SuggestionEntity.GAME: return '🎮'; + case SuggestionEntity.BOOK: return '📚'; + case SuggestionEntity.MUSIC: return '🎵'; + case SuggestionEntity.MANGA: return '📖'; + case SuggestionEntity.SHOW: return '📺'; + case SuggestionEntity.ART: return '🎨'; + } + } + + formatDate(date: Date | string): string { + return new Date(date).toLocaleDateString(); + } + + async acceptSuggestion(suggestion: Suggestion) { + if (!confirm(`Accept "${suggestion.title}"? This will add it to your collection.`)) return; + + try { + await this.suggestionService.acceptSuggestion(suggestion.id); + alert(`"${suggestion.title}" has been added to your collection!`); + this.loadSuggestions(); + } catch { + alert('Failed to accept suggestion. Please try again.'); + } + } + + openDeclineModal(suggestion: Suggestion) { + this.decliningsuggestion.set(suggestion); + this.declineReason = ''; + this.showDeclineModal.set(true); + } + + closeDeclineModal() { + this.showDeclineModal.set(false); + this.decliningsuggestion.set(null); + this.declineReason = ''; + } + + async confirmDecline() { + const suggestion = this.decliningsuggestion(); + if (!suggestion) return; + + try { + await this.suggestionService.declineSuggestion(suggestion.id, { + reason: this.declineReason || undefined + }); + this.closeDeclineModal(); + this.loadSuggestions(); + } catch { + alert('Failed to decline suggestion. Please try again.'); + } + } +} diff --git a/apps/frontend/src/app/components/admin/admin-users.component.ts b/apps/frontend/src/app/components/admin/admin-users.component.ts index 9529c6a..23d43d3 100644 --- a/apps/frontend/src/app/components/admin/admin-users.component.ts +++ b/apps/frontend/src/app/components/admin/admin-users.component.ts @@ -28,8 +28,8 @@ import { User } from '@library/shared-types'; @for (user of users(); track user.id) {
@@ -104,6 +109,71 @@ import { Art, CreateArtDto, UpdateArtDto, Comment } from '@library/shared-types' } + @if (showSuggestForm() && !authService.isAdmin() && authService.isAuthenticated()) { +
+

Suggest Art

+

Your suggestion will be reviewed by Naomi. If accepted, it will be added to the gallery!

+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + @if (suggestedArt.imageUrl) { +
+

Preview:

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

Edit Artwork

@@ -367,6 +437,18 @@ import { Art, CreateArtDto, UpdateArtDto, Comment } from '@library/shared-types' margin-right: auto; } + .suggest-form { + border: 2px solid #fdcb6e; + background: #fffdf5; + } + + .suggest-note { + color: #666; + font-size: 0.9rem; + margin-bottom: 1rem; + font-style: italic; + } + .form-group { margin-bottom: 1rem; } @@ -737,6 +819,7 @@ export class ArtGalleryComponent implements OnInit { authService = inject(AuthService); commentsService = inject(CommentsService); sanitizeService = inject(SanitizeService); + suggestionService = inject(SuggestionService); artPieces = signal([]); loading = signal(true); @@ -752,6 +835,15 @@ export class ArtGalleryComponent implements OnInit { editingCommentId = signal(null); editCommentContent = ''; + // Suggestion state + showSuggestForm = signal(false); + suggestedArt: { title: string; artist: string; imageUrl: string; description?: string } = { + title: '', + artist: '', + imageUrl: '', + description: '' + }; + newArt: Partial = { title: '', artist: '', @@ -971,4 +1063,39 @@ export class ArtGalleryComponent implements OnInit { } }); } + + // Suggestion methods + toggleSuggestForm() { + this.showSuggestForm.update(v => !v); + if (!this.showSuggestForm()) { + this.resetSuggestForm(); + } + } + + resetSuggestForm() { + this.suggestedArt = { + title: '', + artist: '', + imageUrl: '', + description: '' + }; + } + + async submitSuggestion() { + if (!this.suggestedArt.title || !this.suggestedArt.artist || !this.suggestedArt.imageUrl) return; + + try { + await this.suggestionService.createSuggestion({ + entityType: SuggestionEntity.ART, + title: this.suggestedArt.title, + artist: this.suggestedArt.artist, + imageUrl: this.suggestedArt.imageUrl, + description: this.suggestedArt.description + }); + alert('Thank you for your suggestion! It will be reviewed soon.'); + this.toggleSuggestForm(); + } catch { + alert('Failed to submit suggestion. Please try again.'); + } + } } 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 e745e8f..1552531 100644 --- a/apps/frontend/src/app/components/books/books-list.component.ts +++ b/apps/frontend/src/app/components/books/books-list.component.ts @@ -11,7 +11,8 @@ import { BooksService } from '../../services/books.service'; import { AuthService } from '../../services/auth.service'; import { CommentsService } from '../../services/comments.service'; import { SanitizeService } from '../../services/sanitize.service'; -import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment } from '@library/shared-types'; +import { SuggestionService } from '../../services/suggestion.service'; +import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEntity } from '@library/shared-types'; @Component({ selector: 'app-books-list', @@ -25,6 +26,10 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment } from '@librar + } @else if (authService.isAuthenticated() && !authService.user()?.isBanned) { + }
@@ -222,6 +227,83 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment } from '@librar } + @if (showSuggestForm() && !authService.isAdmin() && authService.isAuthenticated()) { +
+

Suggest a Book

+

Your suggestion will be reviewed by Naomi. If accepted, it will be added to the reading list!

+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + @if (suggestBookImagePreview()) { +
+ Cover preview + +
+ } + @if (imageError()) { + {{ imageError() }} + } +
+ +
+ + +
+
+ } +
+ } @else if (authService.isAuthenticated() && !authService.user()?.isBanned) { + }
@@ -198,6 +203,71 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment } from '@librar } + @if (showSuggestForm() && !authService.isAdmin() && authService.isAuthenticated()) { +
+

Suggest a Game

+

Your suggestion will be reviewed by Naomi. If accepted, it will be added to the backlog!

+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + @if (suggestGameImagePreview()) { +
+ Box art preview + +
+ } + @if (imageError()) { + {{ imageError() }} + } +
+ +
+ + +
+
+ } +
} @else { @@ -122,6 +126,20 @@ import { AuthService } from '../../services/auth.service'; transform: translateY(-2px); } + .user-link { + color: var(--witch-lavender); + text-decoration: none; + font-size: 0.9rem; + padding: 0.25rem 0.5rem; + border-radius: 4px; + transition: all 0.3s; + } + + .user-link:hover { + background-color: var(--witch-plum); + color: var(--witch-moon); + } + .btn { padding: 0.5rem 1rem; border: none; diff --git a/apps/frontend/src/app/components/manga/manga-list.component.ts b/apps/frontend/src/app/components/manga/manga-list.component.ts index 552a01a..9f8f040 100644 --- a/apps/frontend/src/app/components/manga/manga-list.component.ts +++ b/apps/frontend/src/app/components/manga/manga-list.component.ts @@ -11,7 +11,8 @@ import { MangaService } from '../../services/manga.service'; import { AuthService } from '../../services/auth.service'; import { CommentsService } from '../../services/comments.service'; import { SanitizeService } from '../../services/sanitize.service'; -import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment } from '@library/shared-types'; +import { SuggestionService } from '../../services/suggestion.service'; +import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, SuggestionEntity } from '@library/shared-types'; @Component({ selector: 'app-manga-list', @@ -25,6 +26,10 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment } from '@li + } @else if (authService.isAuthenticated() && !authService.user()?.isBanned) { + }
@@ -200,6 +205,72 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment } from '@li } + @if (showSuggestForm() && !authService.isAdmin() && authService.isAuthenticated()) { +
+

Suggest a Manga

+

Your suggestion will be reviewed by Naomi. If accepted, it will be added to the reading list!

+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + @if (suggestMangaImagePreview()) { +
+ Cover preview + +
+ } + @if (imageError()) { + {{ imageError() }} + } +
+ +
+ + +
+
+ } +
+ } @else if (authService.isAuthenticated() && !authService.user()?.isBanned) { + }
@@ -218,6 +223,81 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment } + @if (showSuggestForm() && !authService.isAdmin() && authService.isAuthenticated()) { +
+

Suggest Music

+

Your suggestion will be reviewed by Naomi. If accepted, it will be added to the listening list!

+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + @if (suggestMusicImagePreview()) { +
+ Album art preview + +
+ } + @if (imageError()) { + {{ imageError() }} + } +
+ +
+ + +
+
+ } +
Type: @@ -460,6 +540,18 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment backdrop-filter: blur(10px); } + .suggest-form { + border: 2px solid #74b9ff; + background: #f5faff; + } + + .suggest-note { + color: #666; + font-size: 0.9rem; + margin-bottom: 1rem; + font-style: italic; + } + .form-group { margin-bottom: 1rem; } @@ -923,6 +1015,7 @@ export class MusicListComponent implements OnInit { authService = inject(AuthService); commentsService = inject(CommentsService); sanitizeService = inject(SanitizeService); + suggestionService = inject(SuggestionService); music = signal([]); loading = signal(true); @@ -942,9 +1035,20 @@ export class MusicListComponent implements OnInit { // Image upload state newMusicImagePreview = signal(null); editMusicImagePreview = signal(null); + suggestMusicImagePreview = signal(null); imageError = signal(null); private readonly MAX_IMAGE_SIZE = 500 * 1024; // 500KB + // Suggestion state + showSuggestForm = signal(false); + suggestedMusic: { title: string; artist: string; type: MusicType; notes?: string; coverArt?: string } = { + title: '', + artist: '', + type: MusicType.album, + notes: '', + coverArt: undefined + }; + // Expose enums to template MusicType = MusicType; MusicStatus = MusicStatus; @@ -1113,7 +1217,7 @@ export class MusicListComponent implements OnInit { } // Image handling methods - onImageSelected(event: Event, target: 'new' | 'edit') { + onImageSelected(event: Event, target: 'new' | 'edit' | 'suggest') { const input = event.target as HTMLInputElement; const file = input.files?.[0]; @@ -1139,21 +1243,27 @@ export class MusicListComponent implements OnInit { if (target === 'new') { this.newMusicImagePreview.set(base64); this.newMusic.coverArt = base64; - } else { + } else if (target === 'edit') { this.editMusicImagePreview.set(base64); this.editMusicData.coverArt = base64; + } else { + this.suggestMusicImagePreview.set(base64); + this.suggestedMusic.coverArt = base64; } }; reader.readAsDataURL(file); } - clearImage(target: 'new' | 'edit') { + clearImage(target: 'new' | 'edit' | 'suggest') { if (target === 'new') { this.newMusicImagePreview.set(null); this.newMusic.coverArt = undefined; - } else { + } else if (target === 'edit') { this.editMusicImagePreview.set(null); this.editMusicData.coverArt = undefined; + } else { + this.suggestMusicImagePreview.set(null); + this.suggestedMusic.coverArt = undefined; } this.imageError.set(null); } @@ -1268,4 +1378,43 @@ export class MusicListComponent implements OnInit { } }); } + + // Suggestion methods + toggleSuggestForm() { + this.showSuggestForm.update(v => !v); + if (!this.showSuggestForm()) { + this.resetSuggestForm(); + } + } + + resetSuggestForm() { + this.suggestedMusic = { + title: '', + artist: '', + type: MusicType.album, + notes: '', + coverArt: undefined + }; + this.suggestMusicImagePreview.set(null); + this.imageError.set(null); + } + + async submitSuggestion() { + if (!this.suggestedMusic.title || !this.suggestedMusic.artist) return; + + try { + await this.suggestionService.createSuggestion({ + entityType: SuggestionEntity.MUSIC, + title: this.suggestedMusic.title, + artist: this.suggestedMusic.artist, + type: this.suggestedMusic.type, + notes: this.suggestedMusic.notes, + coverArt: this.suggestedMusic.coverArt + }); + alert('Thank you for your suggestion! It will be reviewed soon.'); + this.toggleSuggestForm(); + } catch { + alert('Failed to submit suggestion. Please try again.'); + } + } } \ No newline at end of file diff --git a/apps/frontend/src/app/components/my-suggestions/my-suggestions.component.ts b/apps/frontend/src/app/components/my-suggestions/my-suggestions.component.ts new file mode 100644 index 0000000..8c4a927 --- /dev/null +++ b/apps/frontend/src/app/components/my-suggestions/my-suggestions.component.ts @@ -0,0 +1,377 @@ +/** + * @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 { SuggestionService } from '../../services/suggestion.service'; +import { AuthService } from '../../services/auth.service'; +import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-types'; + +@Component({ + selector: 'app-my-suggestions', + standalone: true, + imports: [CommonModule], + template: ` +
+
+

My Suggestions

+

Track the status of items you've suggested to Naomi

+
+ + @if (!authService.isAuthenticated()) { +
+

Please log in to view your suggestions.

+
+ } @else if (loading()) { +
Loading your suggestions...
+ } @else if (suggestions().length === 0) { +
+

You haven't made any suggestions yet!

+

Visit any collection page and click "Suggest a..." to recommend something to Naomi.

+
+ } @else { +
+ + + + +
+ +
+ @for (suggestion of filteredSuggestions(); track suggestion.id) { +
+
+ + {{ getEntityIcon(suggestion.entityType) }} {{ suggestion.entityType }} + + + {{ getStatusLabel(suggestion.status) }} + +
+ +

{{ suggestion.title }}

+ +
+ @if (suggestion.gameData) { + @if (suggestion.gameData.platform) { +

Platform: {{ suggestion.gameData.platform }}

+ } + } + @if (suggestion.bookData) { + @if (suggestion.bookData.author) { +

Author: {{ suggestion.bookData.author }}

+ } + } + @if (suggestion.musicData) { + @if (suggestion.musicData.artist) { +

Artist: {{ suggestion.musicData.artist }}

+ } + } + @if (suggestion.artData) { + @if (suggestion.artData.artist) { +

Artist: {{ suggestion.artData.artist }}

+ } + } + @if (suggestion.showData) { + @if (suggestion.showData.type) { +

Type: {{ suggestion.showData.type }}

+ } + } + @if (suggestion.mangaData) { + @if (suggestion.mangaData.author) { +

Author: {{ suggestion.mangaData.author }}

+ } + } +
+ + @if (suggestion.status === SuggestionStatus.DECLINED && suggestion.declineReason) { +
+ Reason: {{ suggestion.declineReason }} +
+ } + + +
+ } +
+ } +
+ `, + styles: [` + .container { + max-width: 900px; + margin: 0 auto; + padding: 2rem 1rem; + } + + .header-section { + margin-bottom: 2rem; + } + + .header-section h2 { + margin: 0 0 0.5rem 0; + } + + .subtitle { + color: #666; + margin: 0; + } + + .not-authenticated, + .loading, + .empty-state { + text-align: center; + padding: 3rem; + color: #666; + background: #f8f9fa; + border-radius: 8px; + } + + .hint { + font-size: 0.9rem; + color: #888; + } + + .filters { + display: flex; + gap: 0.75rem; + 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 { + color: white; + } + + .filter-btn.active, + .filter-btn.active.pending { + background: #f59e0b; + } + + .filter-btn.active.accepted { + background: #10b981; + } + + .filter-btn.active.declined { + background: #ef4444; + } + + .suggestions-list { + display: flex; + flex-direction: column; + gap: 1rem; + } + + .suggestion-card { + background: white; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 1.25rem; + transition: box-shadow 0.3s; + } + + .suggestion-card:hover { + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + } + + .suggestion-card.status-accepted { + border-left: 4px solid #10b981; + } + + .suggestion-card.status-declined { + border-left: 4px solid #ef4444; + } + + .suggestion-card.status-unreviewed { + border-left: 4px solid #f59e0b; + } + + .suggestion-header { + display: flex; + gap: 0.75rem; + margin-bottom: 0.75rem; + } + + .entity-badge, + .status-badge { + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + } + + .entity-badge { + background: #e5e7eb; + color: #374151; + } + + .entity-badge.entity-game { background: #fff1f1; color: #dc2626; } + .entity-badge.entity-book { background: #fdf4ed; color: #8b6f47; } + .entity-badge.entity-music { background: #eff6ff; color: #2563eb; } + .entity-badge.entity-manga { background: #ecfdf5; color: #059669; } + .entity-badge.entity-show { background: #fdf2f8; color: #db2777; } + .entity-badge.entity-art { background: #fefce8; color: #ca8a04; } + + .status-badge.status-unreviewed { + background: #fef3c7; + color: #92400e; + } + + .status-badge.status-accepted { + background: #d1fae5; + color: #065f46; + } + + .status-badge.status-declined { + background: #fee2e2; + color: #991b1b; + } + + .suggestion-title { + margin: 0 0 0.75rem 0; + font-size: 1.1rem; + } + + .suggestion-details { + font-size: 0.9rem; + color: #4b5563; + } + + .suggestion-details p { + margin: 0.25rem 0; + } + + .decline-reason { + background: #fee2e2; + border: 1px solid #fecaca; + border-radius: 4px; + padding: 0.75rem; + margin-top: 0.75rem; + font-size: 0.9rem; + color: #991b1b; + } + + .suggestion-footer { + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid #e5e7eb; + } + + .date { + font-size: 0.8rem; + color: #9ca3af; + } + `] +}) +export class MySuggestionsComponent implements OnInit { + suggestionService = inject(SuggestionService); + authService = inject(AuthService); + + suggestions = signal([]); + loading = signal(true); + statusFilter = signal<'all' | SuggestionStatus>('all'); + + SuggestionStatus = SuggestionStatus; + + unreviewedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.UNREVIEWED).length; + acceptedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.ACCEPTED).length; + declinedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.DECLINED).length; + + filteredSuggestions = () => { + const filter = this.statusFilter(); + if (filter === 'all') { + return this.suggestions(); + } + return this.suggestions().filter(s => s.status === filter); + }; + + ngOnInit() { + if (this.authService.isAuthenticated()) { + this.loadSuggestions(); + } else { + this.loading.set(false); + } + } + + async loadSuggestions() { + this.loading.set(true); + try { + const suggestions = await this.suggestionService.getMySuggestions(); + this.suggestions.set(suggestions); + } catch { + // Handle error silently + } finally { + this.loading.set(false); + } + } + + setFilter(filter: 'all' | SuggestionStatus) { + this.statusFilter.set(filter); + } + + getStatusLabel(status: SuggestionStatus): string { + switch (status) { + case SuggestionStatus.UNREVIEWED: return 'Pending Review'; + case SuggestionStatus.ACCEPTED: return 'Accepted'; + case SuggestionStatus.DECLINED: return 'Declined'; + } + } + + getEntityIcon(entityType: SuggestionEntity): string { + switch (entityType) { + case SuggestionEntity.GAME: return '🎮'; + case SuggestionEntity.BOOK: return '📚'; + case SuggestionEntity.MUSIC: return '🎵'; + case SuggestionEntity.MANGA: return '📖'; + case SuggestionEntity.SHOW: return '📺'; + case SuggestionEntity.ART: return '🎨'; + } + } + + formatDate(date: Date | string): string { + return new Date(date).toLocaleDateString(); + } +} diff --git a/apps/frontend/src/app/components/shows/shows-list.component.ts b/apps/frontend/src/app/components/shows/shows-list.component.ts index 86bd73b..d4ee4e6 100644 --- a/apps/frontend/src/app/components/shows/shows-list.component.ts +++ b/apps/frontend/src/app/components/shows/shows-list.component.ts @@ -11,7 +11,8 @@ import { ShowsService } from '../../services/shows.service'; import { AuthService } from '../../services/auth.service'; import { CommentsService } from '../../services/comments.service'; import { SanitizeService } from '../../services/sanitize.service'; -import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment } from '@library/shared-types'; +import { SuggestionService } from '../../services/suggestion.service'; +import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, SuggestionEntity } from '@library/shared-types'; @Component({ selector: 'app-shows-list', @@ -25,6 +26,10 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment } fro + } @else if (authService.isAuthenticated() && !authService.user()?.isBanned) { + }
@@ -196,6 +201,70 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment } fro } + @if (showSuggestForm() && !authService.isAdmin() && authService.isAuthenticated()) { +
+

Suggest a Show

+

Your suggestion will be reviewed by Naomi. If accepted, it will be added to the watch list!

+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + @if (suggestShowImagePreview()) { +
+ Cover preview + +
+ } + @if (imageError()) { + {{ imageError() }} + } +
+ +
+ + +
+
+ } +