generated from nhcarrigan/template
feat: initial prototype works
I can log in and create a book! Woo!
This commit is contained in:
@@ -5,12 +5,12 @@
|
|||||||
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
|
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client"
|
provider = "prisma-client-js"
|
||||||
output = "../generated/prisma"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "mongodb"
|
provider = "mongodb"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Game {
|
model Game {
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
# Database
|
|
||||||
DATABASE_URL=op://Personal/MongoDB Atlas - Library/connection string
|
|
||||||
|
|
||||||
# JWT Secret
|
|
||||||
JWT_SECRET=op://Personal/Library API Secrets/jwt_secret
|
|
||||||
|
|
||||||
# Discord OAuth
|
|
||||||
DISCORD_CLIENT_ID=op://Personal/Library Discord OAuth/client_id
|
|
||||||
DISCORD_CLIENT_SECRET=op://Personal/Library Discord OAuth/client_secret
|
|
||||||
|
|
||||||
# Admin Configuration
|
|
||||||
ADMIN_DISCORD_ID=op://Personal/Library API Secrets/admin_discord_id
|
|
||||||
|
|
||||||
# API Configuration
|
|
||||||
API_URL=op://Personal/Library API Secrets/api_url
|
|
||||||
FRONTEND_URL=op://Personal/Library API Secrets/frontend_url
|
|
||||||
+1
-1
@@ -16,7 +16,7 @@
|
|||||||
"bundle": false,
|
"bundle": false,
|
||||||
"main": "api/src/main.ts",
|
"main": "api/src/main.ts",
|
||||||
"tsConfig": "api/tsconfig.app.json",
|
"tsConfig": "api/tsconfig.app.json",
|
||||||
"assets": ["api/src/assets"],
|
"assets": ["api/src/assets", "api/generated"],
|
||||||
"generatePackageJson": true,
|
"generatePackageJson": true,
|
||||||
"esbuildOptions": {
|
"esbuildOptions": {
|
||||||
"sourcemap": true,
|
"sourcemap": true,
|
||||||
|
|||||||
+1
-1
@@ -22,6 +22,6 @@ export async function app(fastify: FastifyInstance, opts: AppOptions) {
|
|||||||
// define your routes in one of these
|
// define your routes in one of these
|
||||||
fastify.register(AutoLoad, {
|
fastify.register(AutoLoad, {
|
||||||
dir: path.join(__dirname, 'routes'),
|
dir: path.join(__dirname, 'routes'),
|
||||||
options: { ...opts },
|
options: { ...opts, prefix: '/api' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
export const prisma = new PrismaClient();
|
||||||
@@ -9,9 +9,6 @@ declare module "fastify" {
|
|||||||
authenticate: (request: FastifyRequest) => Promise<void>;
|
authenticate: (request: FastifyRequest) => Promise<void>;
|
||||||
oauth2Discord: any;
|
oauth2Discord: any;
|
||||||
}
|
}
|
||||||
interface FastifyRequest {
|
|
||||||
user?: any;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const authPlugin: FastifyPluginAsync = async (app) => {
|
const authPlugin: FastifyPluginAsync = async (app) => {
|
||||||
@@ -30,6 +27,7 @@ const authPlugin: FastifyPluginAsync = async (app) => {
|
|||||||
// Register Discord OAuth2
|
// Register Discord OAuth2
|
||||||
app.register(fastifyOauth2, {
|
app.register(fastifyOauth2, {
|
||||||
name: "oauth2Discord",
|
name: "oauth2Discord",
|
||||||
|
scope: ["identify", "email"],
|
||||||
credentials: {
|
credentials: {
|
||||||
client: {
|
client: {
|
||||||
id: process.env.DISCORD_CLIENT_ID || "",
|
id: process.env.DISCORD_CLIENT_ID || "",
|
||||||
@@ -38,7 +36,7 @@ const authPlugin: FastifyPluginAsync = async (app) => {
|
|||||||
auth: fastifyOauth2.DISCORD_CONFIGURATION,
|
auth: fastifyOauth2.DISCORD_CONFIGURATION,
|
||||||
},
|
},
|
||||||
startRedirectPath: "/api/auth/login",
|
startRedirectPath: "/api/auth/login",
|
||||||
callbackUri: `${process.env.API_URL || "http://localhost:3000"}/api/auth/callback`,
|
callbackUri: `${process.env.BASE_URL || "http://localhost:3000"}/api/auth/callback`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Authentication decorator
|
// Authentication decorator
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import fastifyStatic from "@fastify/static";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export default async function staticPlugin(app: FastifyInstance) {
|
||||||
|
const isProduction = process.env.NODE_ENV === "production";
|
||||||
|
|
||||||
|
if (isProduction) {
|
||||||
|
// Serve the built Angular app from dist directory
|
||||||
|
await app.register(fastifyStatic, {
|
||||||
|
root: path.join(__dirname, "../../../../../../dist/apps/frontend"),
|
||||||
|
prefix: "/", // Serve at root
|
||||||
|
wildcard: false, // Disable wildcard routes to avoid conflicts
|
||||||
|
});
|
||||||
|
|
||||||
|
// Catch-all route for Angular SPA routing (must be registered after API routes)
|
||||||
|
app.setNotFoundHandler((request, reply) => {
|
||||||
|
// Only catch routes that don't start with /api
|
||||||
|
if (!request.url.startsWith("/api")) {
|
||||||
|
reply.sendFile("index.html");
|
||||||
|
} else {
|
||||||
|
reply.code(404).send({ error: "Not Found" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,32 +1,31 @@
|
|||||||
import { FastifyPluginAsync } from "fastify";
|
import { FastifyPluginAsync } from "fastify";
|
||||||
import { AuthService } from "../services/auth.service";
|
import { AuthService } from "../../services/auth.service";
|
||||||
import { AuthResponse } from "@library/shared-types";
|
import { AuthResponse } from "@library/shared-types";
|
||||||
|
|
||||||
const authRoutes: FastifyPluginAsync = async (app) => {
|
const authRoutes: FastifyPluginAsync = async (app) => {
|
||||||
const authService = new AuthService(app);
|
const authService = new AuthService(app);
|
||||||
|
|
||||||
/**
|
|
||||||
* Initiate Discord OAuth login.
|
|
||||||
*/
|
|
||||||
app.get("/login", async (request, reply) => {
|
|
||||||
const authUrl = app.oauth2Discord.generateAuthorizationUri({
|
|
||||||
scope: ["identify", "email"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return reply.redirect(authUrl);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Discord OAuth callback.
|
* Discord OAuth callback.
|
||||||
*/
|
*/
|
||||||
app.get("/callback", async (request, reply) => {
|
app.get("/callback", async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const token = await app.oauth2Discord.getAccessTokenFromAuthorizationCodeFlow(
|
const tokenResult = await app.oauth2Discord.getAccessTokenFromAuthorizationCodeFlow(
|
||||||
request
|
request
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get user data from Discord
|
// Get user data from Discord API
|
||||||
const userData = await app.oauth2Discord.userinfo(token.access_token);
|
const discordResponse = await fetch("https://discord.com/api/users/@me", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${tokenResult.token.access_token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!discordResponse.ok) {
|
||||||
|
throw new Error("Failed to fetch Discord user data");
|
||||||
|
}
|
||||||
|
|
||||||
|
const userData = await discordResponse.json();
|
||||||
|
|
||||||
// Create or update user in database
|
// Create or update user in database
|
||||||
const user = await authService.createOrUpdateUserFromDiscord(userData);
|
const user = await authService.createOrUpdateUserFromDiscord(userData);
|
||||||
@@ -43,12 +42,12 @@ const authRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
maxAge: 7 * 24 * 60 * 60, // 7 days
|
maxAge: 7 * 24 * 60 * 60, // 7 days
|
||||||
})
|
})
|
||||||
.redirect(process.env.FRONTEND_URL || "http://localhost:4200");
|
.redirect("/"); // Redirect to root since API serves frontend
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
app.log.error(error);
|
app.log.error({ err: error }, "Auth callback error");
|
||||||
reply
|
reply
|
||||||
.code(401)
|
.code(401)
|
||||||
.send({ error: "Authentication failed" });
|
.send({ error: "Authentication failed", details: error instanceof Error ? error.message : String(error) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FastifyPluginAsync } from "fastify";
|
||||||
|
import { Book, CreateBookDto, UpdateBookDto } from "@library/shared-types";
|
||||||
|
import { BookService } from "../../services/book.service";
|
||||||
|
import { adminGuard } from "../../middleware/admin-guard";
|
||||||
|
|
||||||
|
const booksRoutes: FastifyPluginAsync = async (app) => {
|
||||||
|
const bookService = new BookService();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all books (public route).
|
||||||
|
*/
|
||||||
|
app.get<{ Reply: Book[] }>("/", async () => {
|
||||||
|
return bookService.getAllBooks();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get single book by ID (public route).
|
||||||
|
*/
|
||||||
|
app.get<{ Params: { id: string }; Reply: Book | null }>(
|
||||||
|
"/:id",
|
||||||
|
async (request) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
return bookService.getBookById(id);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create new book (admin only).
|
||||||
|
*/
|
||||||
|
app.post<{ Body: CreateBookDto; Reply: Book }>(
|
||||||
|
"/",
|
||||||
|
{
|
||||||
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
return bookService.createBook(request.body);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update book by ID (admin only).
|
||||||
|
*/
|
||||||
|
app.put<{
|
||||||
|
Params: { id: string };
|
||||||
|
Body: UpdateBookDto;
|
||||||
|
Reply: Book | null;
|
||||||
|
}>(
|
||||||
|
"/:id",
|
||||||
|
{
|
||||||
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
return bookService.updateBook(id, request.body);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete book by ID (admin only).
|
||||||
|
*/
|
||||||
|
app.delete<{ Params: { id: string }; Reply: { success: boolean } }>(
|
||||||
|
"/:id",
|
||||||
|
{
|
||||||
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
await bookService.deleteBook(id);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default booksRoutes;
|
||||||
@@ -5,22 +5,16 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { FastifyPluginAsync } from "fastify";
|
import { FastifyPluginAsync } from "fastify";
|
||||||
import { PrismaClient } from "../../../generated/prisma";
|
import { Game, CreateGameDto, UpdateGameDto } from "@library/shared-types";
|
||||||
import { Game, GameStatus } from "@library/shared-types";
|
import { GameService } from "../../services/game.service";
|
||||||
import { adminGuard } from "../../middleware/admin-guard";
|
import { adminGuard } from "../../middleware/admin-guard";
|
||||||
|
|
||||||
const gamesRoutes: FastifyPluginAsync = async (app) => {
|
const gamesRoutes: FastifyPluginAsync = async (app) => {
|
||||||
const prisma = new PrismaClient();
|
const gameService = new GameService();
|
||||||
|
|
||||||
// Get all games (public route)
|
// Get all games (public route)
|
||||||
app.get<{ Reply: Game[] }>("/", async () => {
|
app.get<{ Reply: Game[] }>("/", async () => {
|
||||||
const games = await prisma.game.findMany({
|
return gameService.getAllGames();
|
||||||
orderBy: { updatedAt: "desc" },
|
|
||||||
});
|
|
||||||
return games.map((game) => ({
|
|
||||||
...game,
|
|
||||||
status: game.status.toLowerCase() as GameStatus,
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get single game (public route)
|
// Get single game (public route)
|
||||||
@@ -28,64 +22,34 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
"/:id",
|
"/:id",
|
||||||
async (request) => {
|
async (request) => {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
const game = await prisma.game.findUnique({
|
return gameService.getGameById(id);
|
||||||
where: { id },
|
|
||||||
});
|
|
||||||
if (!game) return null;
|
|
||||||
return {
|
|
||||||
...game,
|
|
||||||
status: game.status.toLowerCase() as GameStatus,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create game (protected admin route)
|
// Create game (protected admin route)
|
||||||
app.post<{ Body: Omit<Game, "id" | "createdAt" | "updatedAt">; Reply: Game }>(
|
app.post<{ Body: CreateGameDto; Reply: Game }>(
|
||||||
"/",
|
"/",
|
||||||
{
|
{
|
||||||
preValidation: [app.authenticate, adminGuard],
|
preValidation: [app.authenticate, adminGuard],
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request) => {
|
||||||
|
return gameService.createGame(request.body);
|
||||||
const game = await prisma.game.create({
|
|
||||||
data: {
|
|
||||||
...request.body,
|
|
||||||
status: request.body.status.toUpperCase() as any,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
...game,
|
|
||||||
status: game.status.toLowerCase() as GameStatus,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update game (protected admin route)
|
// Update game (protected admin route)
|
||||||
app.put<{
|
app.put<{
|
||||||
Params: { id: string };
|
Params: { id: string };
|
||||||
Body: Partial<Omit<Game, "id" | "createdAt" | "updatedAt">>;
|
Body: UpdateGameDto;
|
||||||
Reply: Game | null;
|
Reply: Game | null;
|
||||||
}>(
|
}>(
|
||||||
"/:id",
|
"/:id",
|
||||||
{
|
{
|
||||||
preValidation: [app.authenticate, adminGuard],
|
preValidation: [app.authenticate, adminGuard],
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request) => {
|
||||||
|
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
const updateData = { ...request.body };
|
return gameService.updateGame(id, request.body);
|
||||||
if (updateData.status) {
|
|
||||||
updateData.status = updateData.status.toUpperCase() as any;
|
|
||||||
}
|
|
||||||
|
|
||||||
const game = await prisma.game.update({
|
|
||||||
where: { id },
|
|
||||||
data: updateData,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
...game,
|
|
||||||
status: game.status.toLowerCase() as GameStatus,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -95,12 +59,9 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
{
|
{
|
||||||
preValidation: [app.authenticate, adminGuard],
|
preValidation: [app.authenticate, adminGuard],
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request) => {
|
||||||
|
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
await prisma.game.delete({
|
await gameService.deleteGame(id);
|
||||||
where: { id },
|
|
||||||
});
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FastifyPluginAsync } from "fastify";
|
||||||
|
import { Music, CreateMusicDto, UpdateMusicDto } from "@library/shared-types";
|
||||||
|
import { MusicService } from "../../services/music.service";
|
||||||
|
import { adminGuard } from "../../middleware/admin-guard";
|
||||||
|
|
||||||
|
const musicRoutes: FastifyPluginAsync = async (app) => {
|
||||||
|
const musicService = new MusicService();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all music (public route).
|
||||||
|
*/
|
||||||
|
app.get<{ Reply: Music[] }>("/", async () => {
|
||||||
|
return musicService.getAllMusic();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get single music item by ID (public route).
|
||||||
|
*/
|
||||||
|
app.get<{ Params: { id: string }; Reply: Music | null }>(
|
||||||
|
"/:id",
|
||||||
|
async (request) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
return musicService.getMusicById(id);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create new music item (admin only).
|
||||||
|
*/
|
||||||
|
app.post<{ Body: CreateMusicDto; Reply: Music }>(
|
||||||
|
"/",
|
||||||
|
{
|
||||||
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
return musicService.createMusic(request.body);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update music item by ID (admin only).
|
||||||
|
*/
|
||||||
|
app.put<{
|
||||||
|
Params: { id: string };
|
||||||
|
Body: UpdateMusicDto;
|
||||||
|
Reply: Music | null;
|
||||||
|
}>(
|
||||||
|
"/:id",
|
||||||
|
{
|
||||||
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
return musicService.updateMusic(id, request.body);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete music item by ID (admin only).
|
||||||
|
*/
|
||||||
|
app.delete<{ Params: { id: string }; Reply: { success: boolean } }>(
|
||||||
|
"/:id",
|
||||||
|
{
|
||||||
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
await musicService.deleteMusic(id);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default musicRoutes;
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
import { JwtPayload, User } from "@library/shared-types";
|
import { JwtPayload, User } from "@library/shared-types";
|
||||||
import { PrismaClient } from "../../generated/prisma";
|
import { prisma } from "../lib/prisma";
|
||||||
|
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private prisma: PrismaClient;
|
private prisma = prisma;
|
||||||
|
|
||||||
constructor(private readonly app: FastifyInstance) {
|
constructor(private readonly app: FastifyInstance) {}
|
||||||
this.prisma = new PrismaClient();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate JWT token for user.
|
* Generate JWT token for user.
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Book, BookStatus, CreateBookDto, UpdateBookDto } from "@library/shared-types";
|
||||||
|
import { prisma } from "../lib/prisma";
|
||||||
|
|
||||||
|
export class BookService {
|
||||||
|
private prisma = prisma;
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all books.
|
||||||
|
*/
|
||||||
|
async getAllBooks(): Promise<Book[]> {
|
||||||
|
const books = await this.prisma.book.findMany({
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return books.map((book) => ({
|
||||||
|
...book,
|
||||||
|
status: book.status.toLowerCase() as BookStatus,
|
||||||
|
dateAdded: book.dateAdded,
|
||||||
|
dateFinished: book.dateFinished || undefined,
|
||||||
|
createdAt: book.createdAt,
|
||||||
|
updatedAt: book.updatedAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get book by ID.
|
||||||
|
*/
|
||||||
|
async getBookById(id: string): Promise<Book | null> {
|
||||||
|
const book = await this.prisma.book.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!book) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...book,
|
||||||
|
status: book.status.toLowerCase() as BookStatus,
|
||||||
|
dateAdded: book.dateAdded,
|
||||||
|
dateFinished: book.dateFinished || undefined,
|
||||||
|
createdAt: book.createdAt,
|
||||||
|
updatedAt: book.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create new book.
|
||||||
|
*/
|
||||||
|
async createBook(data: CreateBookDto): Promise<Book> {
|
||||||
|
const book = await this.prisma.book.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
status: data.status.toUpperCase() as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...book,
|
||||||
|
status: book.status.toLowerCase() as BookStatus,
|
||||||
|
dateAdded: book.dateAdded,
|
||||||
|
dateFinished: book.dateFinished || undefined,
|
||||||
|
createdAt: book.createdAt,
|
||||||
|
updatedAt: book.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update book by ID.
|
||||||
|
*/
|
||||||
|
async updateBook(id: string, data: UpdateBookDto): Promise<Book> {
|
||||||
|
const updateData = { ...data };
|
||||||
|
if (updateData.status) {
|
||||||
|
updateData.status = updateData.status.toUpperCase() as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const book = await this.prisma.book.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...book,
|
||||||
|
status: book.status.toLowerCase() as BookStatus,
|
||||||
|
dateAdded: book.dateAdded,
|
||||||
|
dateFinished: book.dateFinished || undefined,
|
||||||
|
createdAt: book.createdAt,
|
||||||
|
updatedAt: book.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete book by ID.
|
||||||
|
*/
|
||||||
|
async deleteBook(id: string): Promise<void> {
|
||||||
|
await this.prisma.book.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Game, GameStatus, CreateGameDto, UpdateGameDto } from "@library/shared-types";
|
||||||
|
import { prisma } from "../lib/prisma";
|
||||||
|
|
||||||
|
export class GameService {
|
||||||
|
private prisma = prisma;
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all games.
|
||||||
|
*/
|
||||||
|
async getAllGames(): Promise<Game[]> {
|
||||||
|
const games = await this.prisma.game.findMany({
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return games.map((game) => ({
|
||||||
|
...game,
|
||||||
|
status: game.status.toLowerCase() as GameStatus,
|
||||||
|
dateAdded: game.dateAdded,
|
||||||
|
dateCompleted: game.dateCompleted || undefined,
|
||||||
|
createdAt: game.createdAt,
|
||||||
|
updatedAt: game.updatedAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get game by ID.
|
||||||
|
*/
|
||||||
|
async getGameById(id: string): Promise<Game | null> {
|
||||||
|
const game = await this.prisma.game.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!game) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...game,
|
||||||
|
status: game.status.toLowerCase() as GameStatus,
|
||||||
|
dateAdded: game.dateAdded,
|
||||||
|
dateCompleted: game.dateCompleted || undefined,
|
||||||
|
createdAt: game.createdAt,
|
||||||
|
updatedAt: game.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create new game.
|
||||||
|
*/
|
||||||
|
async createGame(data: CreateGameDto): Promise<Game> {
|
||||||
|
const game = await this.prisma.game.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
status: data.status.toUpperCase() as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...game,
|
||||||
|
status: game.status.toLowerCase() as GameStatus,
|
||||||
|
dateAdded: game.dateAdded,
|
||||||
|
dateCompleted: game.dateCompleted || undefined,
|
||||||
|
createdAt: game.createdAt,
|
||||||
|
updatedAt: game.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update game by ID.
|
||||||
|
*/
|
||||||
|
async updateGame(id: string, data: UpdateGameDto): Promise<Game> {
|
||||||
|
const updateData = { ...data };
|
||||||
|
if (updateData.status) {
|
||||||
|
updateData.status = updateData.status.toUpperCase() as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const game = await this.prisma.game.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...game,
|
||||||
|
status: game.status.toLowerCase() as GameStatus,
|
||||||
|
dateAdded: game.dateAdded,
|
||||||
|
dateCompleted: game.dateCompleted || undefined,
|
||||||
|
createdAt: game.createdAt,
|
||||||
|
updatedAt: game.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete game by ID.
|
||||||
|
*/
|
||||||
|
async deleteGame(id: string): Promise<void> {
|
||||||
|
await this.prisma.game.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { AuthService } from "./auth.service";
|
||||||
|
export { GameService } from "./game.service";
|
||||||
|
export { BookService } from "./book.service";
|
||||||
|
export { MusicService } from "./music.service";
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto } from "@library/shared-types";
|
||||||
|
import { prisma } from "../lib/prisma";
|
||||||
|
|
||||||
|
export class MusicService {
|
||||||
|
private prisma = prisma;
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all music.
|
||||||
|
*/
|
||||||
|
async getAllMusic(): Promise<Music[]> {
|
||||||
|
const musicItems = await this.prisma.music.findMany({
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return musicItems.map((music) => ({
|
||||||
|
...music,
|
||||||
|
type: music.type.toLowerCase() as MusicType,
|
||||||
|
status: music.status.toLowerCase() as MusicStatus,
|
||||||
|
dateAdded: music.dateAdded,
|
||||||
|
dateCompleted: music.dateCompleted || undefined,
|
||||||
|
createdAt: music.createdAt,
|
||||||
|
updatedAt: music.updatedAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get music by ID.
|
||||||
|
*/
|
||||||
|
async getMusicById(id: string): Promise<Music | null> {
|
||||||
|
const music = await this.prisma.music.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!music) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...music,
|
||||||
|
type: music.type.toLowerCase() as MusicType,
|
||||||
|
status: music.status.toLowerCase() as MusicStatus,
|
||||||
|
dateAdded: music.dateAdded,
|
||||||
|
dateCompleted: music.dateCompleted || undefined,
|
||||||
|
createdAt: music.createdAt,
|
||||||
|
updatedAt: music.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create new music.
|
||||||
|
*/
|
||||||
|
async createMusic(data: CreateMusicDto): Promise<Music> {
|
||||||
|
const music = await this.prisma.music.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
type: data.type.toUpperCase() as any,
|
||||||
|
status: data.status.toUpperCase() as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...music,
|
||||||
|
type: music.type.toLowerCase() as MusicType,
|
||||||
|
status: music.status.toLowerCase() as MusicStatus,
|
||||||
|
dateAdded: music.dateAdded,
|
||||||
|
dateCompleted: music.dateCompleted || undefined,
|
||||||
|
createdAt: music.createdAt,
|
||||||
|
updatedAt: music.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update music by ID.
|
||||||
|
*/
|
||||||
|
async updateMusic(id: string, data: UpdateMusicDto): Promise<Music> {
|
||||||
|
const updateData = { ...data };
|
||||||
|
if (updateData.type) {
|
||||||
|
updateData.type = updateData.type.toUpperCase() as any;
|
||||||
|
}
|
||||||
|
if (updateData.status) {
|
||||||
|
updateData.status = updateData.status.toUpperCase() as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const music = await this.prisma.music.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...music,
|
||||||
|
type: music.type.toLowerCase() as MusicType,
|
||||||
|
status: music.status.toLowerCase() as MusicStatus,
|
||||||
|
dateAdded: music.dateAdded,
|
||||||
|
dateCompleted: music.dateCompleted || undefined,
|
||||||
|
createdAt: music.createdAt,
|
||||||
|
updatedAt: music.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete music by ID.
|
||||||
|
*/
|
||||||
|
async deleteMusic(id: string): Promise<void> {
|
||||||
|
await this.prisma.music.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,13 @@
|
|||||||
"maximumError": "8kb"
|
"maximumError": "8kb"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputHashing": "all"
|
"outputHashing": "all",
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "apps/frontend/src/environments/environment.ts",
|
||||||
|
"with": "apps/frontend/src/environments/environment.prod.ts"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
"buildOptimizer": false,
|
"buildOptimizer": false,
|
||||||
|
|||||||
@@ -1,10 +1,30 @@
|
|||||||
import {
|
import {
|
||||||
ApplicationConfig,
|
ApplicationConfig,
|
||||||
provideBrowserGlobalErrorListeners,
|
provideBrowserGlobalErrorListeners,
|
||||||
|
APP_INITIALIZER,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { provideHttpClient, withInterceptors, HTTP_INTERCEPTORS } from '@angular/common/http';
|
||||||
import { appRoutes } from './app.routes';
|
import { appRoutes } from './app.routes';
|
||||||
|
import { AuthService } from './services/auth.service';
|
||||||
|
import { AuthInterceptor } from './interceptors/auth.interceptor';
|
||||||
|
import { initializeAuth } from './initializers/auth.initializer';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [provideBrowserGlobalErrorListeners(), provideRouter(appRoutes)],
|
providers: [
|
||||||
|
provideBrowserGlobalErrorListeners(),
|
||||||
|
provideRouter(appRoutes),
|
||||||
|
provideHttpClient(),
|
||||||
|
{
|
||||||
|
provide: HTTP_INTERCEPTORS,
|
||||||
|
useClass: AuthInterceptor,
|
||||||
|
multi: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: APP_INITIALIZER,
|
||||||
|
useFactory: initializeAuth,
|
||||||
|
deps: [AuthService],
|
||||||
|
multi: true
|
||||||
|
}
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
<app-nx-welcome></app-nx-welcome>
|
<app-header></app-header>
|
||||||
<router-outlet></router-outlet>
|
<main class="main-content">
|
||||||
|
<router-outlet></router-outlet>
|
||||||
|
</main>
|
||||||
|
|||||||
@@ -1,3 +1,24 @@
|
|||||||
import { Route } from '@angular/router';
|
import { Route } from '@angular/router';
|
||||||
|
|
||||||
export const appRoutes: Route[] = [];
|
export const appRoutes: Route[] = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
loadComponent: () => import('./components/home/home.component').then(m => m.HomeComponent)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'games',
|
||||||
|
loadComponent: () => import('./components/games/games-list.component').then(m => m.GamesListComponent)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'books',
|
||||||
|
loadComponent: () => import('./components/books/books-list.component').then(m => m.BooksListComponent)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'music',
|
||||||
|
loadComponent: () => import('./components/music/music-list.component').then(m => m.MusicListComponent)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '**',
|
||||||
|
redirectTo: ''
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.main-content {
|
||||||
|
min-height: calc(100vh - 60px); // Assuming header is ~60px
|
||||||
|
background-color: transparent; // Let the body background show through
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
import { NxWelcome } from './nx-welcome';
|
import { HeaderComponent } from './components/header/header.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
imports: [NxWelcome, RouterModule],
|
imports: [RouterModule, HeaderComponent],
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss',
|
styleUrl: './app.scss',
|
||||||
})
|
})
|
||||||
export class App {
|
export class App {
|
||||||
protected title = 'frontend';
|
protected title = 'Naomi\'s Library';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,566 @@
|
|||||||
|
/**
|
||||||
|
* @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 { BooksService } from '../../services/books.service';
|
||||||
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
import { Book, BookStatus, CreateBookDto } from '@library/shared-types';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-books-list',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
template: `
|
||||||
|
<div class="container">
|
||||||
|
<div class="header-section">
|
||||||
|
<h2>My Book Collection</h2>
|
||||||
|
@if (authService.isAdmin()) {
|
||||||
|
<button (click)="toggleAddForm()" class="btn btn-primary">
|
||||||
|
{{ showAddForm() ? 'Cancel' : 'Add Book' }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (showAddForm() && authService.isAdmin()) {
|
||||||
|
<form (ngSubmit)="addBook()" class="add-form">
|
||||||
|
<h3>Add New Book</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="title">Title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="title"
|
||||||
|
[(ngModel)]="newBook.title"
|
||||||
|
name="title"
|
||||||
|
required
|
||||||
|
placeholder="Enter book title"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="author">Author</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="author"
|
||||||
|
[(ngModel)]="newBook.author"
|
||||||
|
name="author"
|
||||||
|
required
|
||||||
|
placeholder="Enter author name"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="isbn">ISBN</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="isbn"
|
||||||
|
[(ngModel)]="newBook.isbn"
|
||||||
|
name="isbn"
|
||||||
|
placeholder="ISBN (optional)"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="status">Status</label>
|
||||||
|
<select id="status" [(ngModel)]="newBook.status" name="status" required>
|
||||||
|
<option value="reading">Currently Reading</option>
|
||||||
|
<option value="finished">Finished</option>
|
||||||
|
<option value="toRead">To Read</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="rating">Rating (0-5)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="rating"
|
||||||
|
[(ngModel)]="newBook.rating"
|
||||||
|
name="rating"
|
||||||
|
min="0"
|
||||||
|
max="5"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="notes">Notes</label>
|
||||||
|
<textarea
|
||||||
|
id="notes"
|
||||||
|
[(ngModel)]="newBook.notes"
|
||||||
|
name="notes"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Your thoughts about the book..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Add Book</button>
|
||||||
|
<button type="button" (click)="toggleAddForm()" class="btn btn-secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<button
|
||||||
|
(click)="setFilter('all')"
|
||||||
|
[class.active]="statusFilter() === 'all'"
|
||||||
|
class="filter-btn"
|
||||||
|
>
|
||||||
|
All ({{ books().length }})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="setFilter(BookStatus.reading)"
|
||||||
|
[class.active]="statusFilter() === BookStatus.reading"
|
||||||
|
class="filter-btn"
|
||||||
|
>
|
||||||
|
Reading ({{ getCountByStatus(BookStatus.reading) }})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="setFilter(BookStatus.finished)"
|
||||||
|
[class.active]="statusFilter() === BookStatus.finished"
|
||||||
|
class="filter-btn"
|
||||||
|
>
|
||||||
|
Finished ({{ getCountByStatus(BookStatus.finished) }})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="setFilter(BookStatus.toRead)"
|
||||||
|
[class.active]="statusFilter() === BookStatus.toRead"
|
||||||
|
class="filter-btn"
|
||||||
|
>
|
||||||
|
To Read ({{ getCountByStatus(BookStatus.toRead) }})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="loading">Loading books...</div>
|
||||||
|
} @else if (filteredBooks().length === 0) {
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>No books found in this category.</p>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="books-grid">
|
||||||
|
@for (book of filteredBooks(); track book.id) {
|
||||||
|
<div class="book-card" [class.finished]="book.status === BookStatus.finished">
|
||||||
|
@if (book.coverImage) {
|
||||||
|
<img [src]="book.coverImage" [alt]="book.title" class="book-cover">
|
||||||
|
} @else {
|
||||||
|
<div class="book-cover placeholder">📚</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="book-info">
|
||||||
|
<h3>{{ book.title }}</h3>
|
||||||
|
<p class="author">by {{ book.author }}</p>
|
||||||
|
|
||||||
|
<span class="status status-{{ book.status }}">
|
||||||
|
{{ getStatusLabel(book.status) }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
@if (book.rating) {
|
||||||
|
<div class="rating">
|
||||||
|
@for (star of [1,2,3,4,5]; track star) {
|
||||||
|
<span [class.filled]="star <= book.rating">★</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (book.isbn) {
|
||||||
|
<p class="isbn">ISBN: {{ book.isbn }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (book.notes) {
|
||||||
|
<p class="notes">{{ book.notes }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (book.dateFinished) {
|
||||||
|
<p class="date-finished">
|
||||||
|
Finished: {{ formatDate(book.dateFinished) }}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (authService.isAdmin()) {
|
||||||
|
<div class="actions">
|
||||||
|
<button (click)="deleteBook(book)" class="btn btn-danger btn-sm">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-section {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-form {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
border: 2px solid var(--witch-lavender);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 2px solid var(--witch-lavender);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
background-color: var(--witch-moon);
|
||||||
|
color: var(--witch-purple);
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus,
|
||||||
|
.form-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--witch-rose);
|
||||||
|
box-shadow: 0 0 0 3px rgba(168, 87, 126, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--witch-lavender);
|
||||||
|
color: var(--witch-purple);
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn:hover {
|
||||||
|
background: var(--witch-mauve);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 8px var(--witch-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn.active {
|
||||||
|
background: var(--witch-plum);
|
||||||
|
color: var(--witch-moon);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--witch-plum);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: var(--witch-plum);
|
||||||
|
}
|
||||||
|
|
||||||
|
.books-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-card {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border: 2px solid var(--witch-lavender);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px var(--witch-shadow);
|
||||||
|
border-color: var(--witch-mauve);
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-card.finished {
|
||||||
|
opacity: 0.9;
|
||||||
|
border-color: var(--witch-mauve);
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 250px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-cover.placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--witch-lavender);
|
||||||
|
font-size: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-info {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-info h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author {
|
||||||
|
color: var(--witch-plum);
|
||||||
|
font-style: italic;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-reading {
|
||||||
|
background: var(--witch-rose);
|
||||||
|
color: var(--witch-moon);
|
||||||
|
}
|
||||||
|
.status-finished {
|
||||||
|
background: var(--witch-mauve);
|
||||||
|
color: var(--witch-purple);
|
||||||
|
}
|
||||||
|
.status-toRead {
|
||||||
|
background: var(--witch-lavender);
|
||||||
|
color: var(--witch-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating span {
|
||||||
|
color: var(--witch-lavender);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating span.filled {
|
||||||
|
color: var(--witch-rose);
|
||||||
|
}
|
||||||
|
|
||||||
|
.isbn {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--witch-mauve);
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--witch-plum);
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-finished {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--witch-plum);
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--witch-rose);
|
||||||
|
color: var(--witch-moon);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--witch-plum);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 8px var(--witch-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--witch-mauve);
|
||||||
|
color: var(--witch-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--witch-rose);
|
||||||
|
color: var(--witch-moon);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--witch-plum);
|
||||||
|
color: var(--witch-moon);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: var(--witch-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm { padding: 0.25rem 0.75rem; font-size: 0.85rem; }
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class BooksListComponent implements OnInit {
|
||||||
|
booksService = inject(BooksService);
|
||||||
|
authService = inject(AuthService);
|
||||||
|
|
||||||
|
books = signal<Book[]>([]);
|
||||||
|
loading = signal(true);
|
||||||
|
showAddForm = signal(false);
|
||||||
|
statusFilter = signal<'all' | BookStatus>('all');
|
||||||
|
|
||||||
|
// Expose BookStatus enum to template
|
||||||
|
BookStatus = BookStatus;
|
||||||
|
|
||||||
|
newBook: Partial<CreateBookDto> = {
|
||||||
|
title: '',
|
||||||
|
author: '',
|
||||||
|
isbn: '',
|
||||||
|
status: BookStatus.toRead,
|
||||||
|
rating: undefined,
|
||||||
|
notes: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.loadBooks();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadBooks() {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.booksService.getAllBooks().subscribe({
|
||||||
|
next: (books) => {
|
||||||
|
this.books.set(books);
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredBooks() {
|
||||||
|
const filter = this.statusFilter();
|
||||||
|
if (filter === 'all') {
|
||||||
|
return this.books();
|
||||||
|
}
|
||||||
|
return this.books().filter(book => book.status === filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCountByStatus(status: BookStatus): number {
|
||||||
|
return this.books().filter(book => book.status === status).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilter(filter: 'all' | BookStatus) {
|
||||||
|
this.statusFilter.set(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusLabel(status: BookStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case BookStatus.reading: return 'Currently Reading';
|
||||||
|
case BookStatus.finished: return 'Finished';
|
||||||
|
case BookStatus.toRead: return 'To Read';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleAddForm() {
|
||||||
|
this.showAddForm.update(v => !v);
|
||||||
|
if (!this.showAddForm()) {
|
||||||
|
this.resetForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetForm() {
|
||||||
|
this.newBook = {
|
||||||
|
title: '',
|
||||||
|
author: '',
|
||||||
|
isbn: '',
|
||||||
|
status: BookStatus.toRead,
|
||||||
|
rating: undefined,
|
||||||
|
notes: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
addBook() {
|
||||||
|
if (!this.newBook.title || !this.newBook.author || !this.newBook.status) return;
|
||||||
|
|
||||||
|
const bookToAdd: CreateBookDto = {
|
||||||
|
title: this.newBook.title,
|
||||||
|
author: this.newBook.author,
|
||||||
|
isbn: this.newBook.isbn,
|
||||||
|
status: this.newBook.status,
|
||||||
|
rating: this.newBook.rating,
|
||||||
|
notes: this.newBook.notes
|
||||||
|
};
|
||||||
|
|
||||||
|
this.booksService.createBook(bookToAdd).subscribe(() => {
|
||||||
|
this.loadBooks();
|
||||||
|
this.toggleAddForm();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteBook(book: Book) {
|
||||||
|
if (confirm(`Are you sure you want to delete "${book.title}"?`)) {
|
||||||
|
this.booksService.deleteBook(book.id).subscribe(() => {
|
||||||
|
this.loadBooks();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(date: Date | string): string {
|
||||||
|
return new Date(date).toLocaleDateString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// Theme overrides for games component
|
||||||
|
.add-form {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 2px solid var(--witch-lavender);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
background: var(--witch-lavender);
|
||||||
|
color: var(--witch-purple);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--witch-mauve);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--witch-rose);
|
||||||
|
color: var(--witch-moon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.games-grid {
|
||||||
|
.game-card {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border: 2px solid var(--witch-lavender);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--witch-rose);
|
||||||
|
box-shadow: 0 4px 12px var(--witch-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.completed {
|
||||||
|
opacity: 0.8;
|
||||||
|
border-color: var(--witch-mauve);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
&-playing {
|
||||||
|
background: var(--witch-lavender);
|
||||||
|
color: var(--witch-purple);
|
||||||
|
}
|
||||||
|
&-completed {
|
||||||
|
background: var(--witch-mauve);
|
||||||
|
color: var(--witch-purple);
|
||||||
|
}
|
||||||
|
&-backlog {
|
||||||
|
background: var(--witch-rose);
|
||||||
|
color: var(--witch-moon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--witch-rose);
|
||||||
|
color: var(--witch-moon);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--witch-plum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--witch-mauve);
|
||||||
|
color: var(--witch-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--witch-plum);
|
||||||
|
color: var(--witch-moon);
|
||||||
|
}
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
/**
|
||||||
|
* @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 { GamesService } from '../../services/games.service';
|
||||||
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
import { Game, GameStatus, CreateGameDto } from '@library/shared-types';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-games-list',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
template: `
|
||||||
|
<div class="container">
|
||||||
|
<div class="header-section">
|
||||||
|
<h2>My Game Collection</h2>
|
||||||
|
@if (authService.isAdmin()) {
|
||||||
|
<button (click)="toggleAddForm()" class="btn btn-primary">
|
||||||
|
{{ showAddForm() ? 'Cancel' : 'Add Game' }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (showAddForm() && authService.isAdmin()) {
|
||||||
|
<form (ngSubmit)="addGame()" class="add-form">
|
||||||
|
<h3>Add New Game</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="title">Title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="title"
|
||||||
|
[(ngModel)]="newGame.title"
|
||||||
|
name="title"
|
||||||
|
required
|
||||||
|
placeholder="Enter game title"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="platform">Platform</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="platform"
|
||||||
|
[(ngModel)]="newGame.platform"
|
||||||
|
name="platform"
|
||||||
|
placeholder="PC, PS5, Xbox, Switch, etc."
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="status">Status</label>
|
||||||
|
<select id="status" [(ngModel)]="newGame.status" name="status" required>
|
||||||
|
<option value="playing">Currently Playing</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
<option value="backlog">In Backlog</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="rating">Rating (0-10)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="rating"
|
||||||
|
[(ngModel)]="newGame.rating"
|
||||||
|
name="rating"
|
||||||
|
min="0"
|
||||||
|
max="10"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="notes">Notes</label>
|
||||||
|
<textarea
|
||||||
|
id="notes"
|
||||||
|
[(ngModel)]="newGame.notes"
|
||||||
|
name="notes"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Any thoughts about the game..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Add Game</button>
|
||||||
|
<button type="button" (click)="toggleAddForm()" class="btn btn-secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<button
|
||||||
|
(click)="setFilter('all')"
|
||||||
|
[class.active]="statusFilter() === 'all'"
|
||||||
|
class="filter-btn"
|
||||||
|
>
|
||||||
|
All ({{ games().length }})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="setFilter(GameStatus.playing)"
|
||||||
|
[class.active]="statusFilter() === GameStatus.playing"
|
||||||
|
class="filter-btn"
|
||||||
|
>
|
||||||
|
Playing ({{ getCountByStatus(GameStatus.playing) }})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="setFilter(GameStatus.completed)"
|
||||||
|
[class.active]="statusFilter() === GameStatus.completed"
|
||||||
|
class="filter-btn"
|
||||||
|
>
|
||||||
|
Completed ({{ getCountByStatus(GameStatus.completed) }})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="setFilter(GameStatus.backlog)"
|
||||||
|
[class.active]="statusFilter() === GameStatus.backlog"
|
||||||
|
class="filter-btn"
|
||||||
|
>
|
||||||
|
Backlog ({{ getCountByStatus(GameStatus.backlog) }})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="loading">Loading games...</div>
|
||||||
|
} @else if (filteredGames().length === 0) {
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>No games found in this category.</p>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="games-grid">
|
||||||
|
@for (game of filteredGames(); track game.id) {
|
||||||
|
<div class="game-card" [class.completed]="game.status === GameStatus.completed">
|
||||||
|
@if (game.coverImage) {
|
||||||
|
<img [src]="game.coverImage" [alt]="game.title" class="game-cover">
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="game-info">
|
||||||
|
<h3>{{ game.title }}</h3>
|
||||||
|
@if (game.platform) {
|
||||||
|
<p class="platform">{{ game.platform }}</p>
|
||||||
|
}
|
||||||
|
<span class="status status-{{ game.status }}">
|
||||||
|
{{ getStatusLabel(game.status) }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
@if (game.rating) {
|
||||||
|
<div class="rating">
|
||||||
|
⭐ {{ game.rating }}/10
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (game.notes) {
|
||||||
|
<p class="notes">{{ game.notes }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (authService.isAdmin()) {
|
||||||
|
<div class="actions">
|
||||||
|
<button (click)="deleteGame(game)" class="btn btn-danger btn-sm">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-section {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-form {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #e5e7eb;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn:hover {
|
||||||
|
background: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn.active {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.games-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform 0.3s, box-shadow 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-card.completed {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-info {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-info h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-playing { background: #fef3c7; color: #92400e; }
|
||||||
|
.status-completed { background: #d1fae5; color: #065f46; }
|
||||||
|
.status-backlog { background: #e0e7ff; color: #3730a3; }
|
||||||
|
|
||||||
|
.rating {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #4b5563;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary { background: #3b82f6; color: white; }
|
||||||
|
.btn-secondary { background: #6b7280; color: white; }
|
||||||
|
.btn-danger { background: #ef4444; color: white; }
|
||||||
|
.btn-sm { padding: 0.25rem 0.75rem; font-size: 0.85rem; }
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class GamesListComponent implements OnInit {
|
||||||
|
gamesService = inject(GamesService);
|
||||||
|
authService = inject(AuthService);
|
||||||
|
|
||||||
|
games = signal<Game[]>([]);
|
||||||
|
loading = signal(true);
|
||||||
|
showAddForm = signal(false);
|
||||||
|
statusFilter = signal<'all' | GameStatus>('all');
|
||||||
|
|
||||||
|
// Expose GameStatus enum to template
|
||||||
|
GameStatus = GameStatus;
|
||||||
|
|
||||||
|
newGame: Partial<CreateGameDto> = {
|
||||||
|
title: '',
|
||||||
|
platform: '',
|
||||||
|
status: GameStatus.backlog,
|
||||||
|
rating: undefined,
|
||||||
|
notes: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.loadGames();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadGames() {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.gamesService.getAllGames().subscribe({
|
||||||
|
next: (games) => {
|
||||||
|
this.games.set(games);
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredGames() {
|
||||||
|
const filter = this.statusFilter();
|
||||||
|
if (filter === 'all') {
|
||||||
|
return this.games();
|
||||||
|
}
|
||||||
|
return this.games().filter(game => game.status === filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCountByStatus(status: GameStatus): number {
|
||||||
|
return this.games().filter(game => game.status === status).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilter(filter: 'all' | GameStatus) {
|
||||||
|
this.statusFilter.set(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusLabel(status: GameStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case GameStatus.playing: return 'Currently Playing';
|
||||||
|
case GameStatus.completed: return 'Completed';
|
||||||
|
case GameStatus.backlog: return 'In Backlog';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleAddForm() {
|
||||||
|
this.showAddForm.update(v => !v);
|
||||||
|
if (!this.showAddForm()) {
|
||||||
|
this.resetForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetForm() {
|
||||||
|
this.newGame = {
|
||||||
|
title: '',
|
||||||
|
platform: '',
|
||||||
|
status: GameStatus.backlog,
|
||||||
|
rating: undefined,
|
||||||
|
notes: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
addGame() {
|
||||||
|
if (!this.newGame.title || !this.newGame.status) return;
|
||||||
|
|
||||||
|
const gameToAdd: CreateGameDto = {
|
||||||
|
title: this.newGame.title,
|
||||||
|
platform: this.newGame.platform,
|
||||||
|
status: this.newGame.status,
|
||||||
|
rating: this.newGame.rating,
|
||||||
|
notes: this.newGame.notes
|
||||||
|
};
|
||||||
|
|
||||||
|
this.gamesService.createGame(gameToAdd).subscribe(() => {
|
||||||
|
this.loadGames();
|
||||||
|
this.toggleAddForm();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteGame(game: Game) {
|
||||||
|
if (confirm(`Are you sure you want to delete "${game.title}"?`)) {
|
||||||
|
this.gamesService.deleteGame(game.id).subscribe(() => {
|
||||||
|
this.loadGames();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-header',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, RouterModule],
|
||||||
|
template: `
|
||||||
|
<header class="header">
|
||||||
|
<nav class="navbar">
|
||||||
|
<div class="nav-brand">
|
||||||
|
<h1><a routerLink="/">Naomi's Library</a></h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="nav-links">
|
||||||
|
<li><a routerLink="/games" routerLinkActive="active">Games</a></li>
|
||||||
|
<li><a routerLink="/books" routerLinkActive="active">Books</a></li>
|
||||||
|
<li><a routerLink="/music" routerLinkActive="active">Music</a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="auth-section">
|
||||||
|
@if (authService.user(); as user) {
|
||||||
|
<span class="welcome">Welcome, {{ user.username }}!</span>
|
||||||
|
@if (user.isAdmin) {
|
||||||
|
<span class="admin-badge">Admin</span>
|
||||||
|
}
|
||||||
|
<button (click)="logout()" class="btn btn-secondary">Logout</button>
|
||||||
|
} @else {
|
||||||
|
<button (click)="login()" class="btn btn-primary">Login with Discord</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.header {
|
||||||
|
background-color: var(--witch-purple);
|
||||||
|
color: var(--witch-moon);
|
||||||
|
padding: 0;
|
||||||
|
box-shadow: 0 2px 8px var(--witch-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-brand h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-brand a {
|
||||||
|
color: var(--witch-moon);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
list-style: none;
|
||||||
|
gap: 2rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
color: var(--witch-lavender);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a:hover,
|
||||||
|
.nav-links a.active {
|
||||||
|
background-color: var(--witch-plum);
|
||||||
|
color: var(--witch-moon);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--witch-lavender);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-badge {
|
||||||
|
background-color: var(--witch-rose);
|
||||||
|
color: var(--witch-moon);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 8px var(--witch-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--witch-rose);
|
||||||
|
color: var(--witch-moon);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: var(--witch-plum);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: var(--witch-mauve);
|
||||||
|
color: var(--witch-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: var(--witch-rose);
|
||||||
|
color: var(--witch-moon);
|
||||||
|
}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class HeaderComponent {
|
||||||
|
authService = inject(AuthService);
|
||||||
|
|
||||||
|
login() {
|
||||||
|
this.authService.login();
|
||||||
|
}
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
this.authService.logout().subscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
/**
|
||||||
|
* @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 { GamesService } from '../../services/games.service';
|
||||||
|
import { BooksService } from '../../services/books.service';
|
||||||
|
import { MusicService } from '../../services/music.service';
|
||||||
|
import { Game, GameStatus, Book, BookStatus, Music, MusicType } from '@library/shared-types';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-home',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, RouterModule],
|
||||||
|
template: `
|
||||||
|
<div class="container">
|
||||||
|
<div class="hero">
|
||||||
|
<h1>Welcome to Naomi's Library</h1>
|
||||||
|
<p class="tagline">A personal collection of games, books, and music</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<a routerLink="/games" class="stat-card games-card">
|
||||||
|
<div class="icon">🎮</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>Games</h3>
|
||||||
|
<div class="count">{{ gamesCount() }}</div>
|
||||||
|
<p>{{ currentlyPlayingCount() }} currently playing</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a routerLink="/books" class="stat-card books-card">
|
||||||
|
<div class="icon">📚</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>Books</h3>
|
||||||
|
<div class="count">{{ booksCount() }}</div>
|
||||||
|
<p>{{ currentlyReadingCount() }} currently reading</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a routerLink="/music" class="stat-card music-card">
|
||||||
|
<div class="icon">🎵</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>Music</h3>
|
||||||
|
<div class="count">{{ musicCount() }}</div>
|
||||||
|
<p>{{ albumsCount() }} albums, {{ singlesCount() }} singles</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="recent-section">
|
||||||
|
<h2>Recent Additions</h2>
|
||||||
|
|
||||||
|
<div class="recent-grid">
|
||||||
|
@if (recentGames().length > 0) {
|
||||||
|
<div class="recent-category">
|
||||||
|
<h3>🎮 Latest Games</h3>
|
||||||
|
<ul class="recent-list">
|
||||||
|
@for (game of recentGames(); track game.id) {
|
||||||
|
<li>
|
||||||
|
<a routerLink="/games">{{ game.title }}</a>
|
||||||
|
@if (game.platform) {
|
||||||
|
<span class="platform">({{ game.platform }})</span>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (recentBooks().length > 0) {
|
||||||
|
<div class="recent-category">
|
||||||
|
<h3>📚 Latest Books</h3>
|
||||||
|
<ul class="recent-list">
|
||||||
|
@for (book of recentBooks(); track book.id) {
|
||||||
|
<li>
|
||||||
|
<a routerLink="/books">{{ book.title }}</a>
|
||||||
|
<span class="author">by {{ book.author }}</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (recentMusic().length > 0) {
|
||||||
|
<div class="recent-category">
|
||||||
|
<h3>🎵 Latest Music</h3>
|
||||||
|
<ul class="recent-list">
|
||||||
|
@for (music of recentMusic(); track music.id) {
|
||||||
|
<li>
|
||||||
|
<a routerLink="/music">{{ music.title }}</a>
|
||||||
|
<span class="artist">by {{ music.artist }}</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 0;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--witch-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--witch-plum);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border: 2px solid var(--witch-mauve);
|
||||||
|
border-radius: 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
transition: all 0.3s;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 8px 16px var(--witch-shadow);
|
||||||
|
background: var(--witch-moon);
|
||||||
|
}
|
||||||
|
|
||||||
|
.games-card:hover { border-color: var(--witch-rose); }
|
||||||
|
.books-card:hover { border-color: var(--witch-plum); }
|
||||||
|
.music-card:hover { border-color: var(--witch-purple); }
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-info h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--witch-plum);
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.games-card .count { color: var(--witch-rose); }
|
||||||
|
.books-card .count { color: var(--witch-plum); }
|
||||||
|
.music-card .count { color: var(--witch-purple); }
|
||||||
|
|
||||||
|
.stat-info p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--witch-plum);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-section {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 2px solid var(--witch-lavender);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-section h2 {
|
||||||
|
margin: 0 0 1.5rem 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-category h3 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--witch-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-list li {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--witch-lavender);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-list li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-list a {
|
||||||
|
color: var(--witch-rose);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-list a:hover {
|
||||||
|
color: var(--witch-plum);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform,
|
||||||
|
.author,
|
||||||
|
.artist {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--witch-plum);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class HomeComponent implements OnInit {
|
||||||
|
gamesService = inject(GamesService);
|
||||||
|
booksService = inject(BooksService);
|
||||||
|
musicService = inject(MusicService);
|
||||||
|
|
||||||
|
games = signal<Game[]>([]);
|
||||||
|
books = signal<Book[]>([]);
|
||||||
|
music = signal<Music[]>([]);
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
// Load all data
|
||||||
|
this.gamesService.getAllGames().subscribe(games => this.games.set(games));
|
||||||
|
this.booksService.getAllBooks().subscribe(books => this.books.set(books));
|
||||||
|
this.musicService.getAllMusic().subscribe(music => this.music.set(music));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Games stats
|
||||||
|
gamesCount() {
|
||||||
|
return this.games().length;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentlyPlayingCount() {
|
||||||
|
return this.games().filter(g => g.status === GameStatus.playing).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
recentGames() {
|
||||||
|
return this.games().slice(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Books stats
|
||||||
|
booksCount() {
|
||||||
|
return this.books().length;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentlyReadingCount() {
|
||||||
|
return this.books().filter(b => b.status === BookStatus.reading).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
recentBooks() {
|
||||||
|
return this.books().slice(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Music stats
|
||||||
|
musicCount() {
|
||||||
|
return this.music().length;
|
||||||
|
}
|
||||||
|
|
||||||
|
albumsCount() {
|
||||||
|
return this.music().filter(m => m.type === MusicType.album).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
singlesCount() {
|
||||||
|
return this.music().filter(m => m.type === MusicType.single).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
recentMusic() {
|
||||||
|
return this.music().slice(0, 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,669 @@
|
|||||||
|
/**
|
||||||
|
* @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 { MusicService } from '../../services/music.service';
|
||||||
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
import { Music, MusicStatus, MusicType, CreateMusicDto } from '@library/shared-types';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-music-list',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
template: `
|
||||||
|
<div class="container">
|
||||||
|
<div class="header-section">
|
||||||
|
<h2>My Music Collection</h2>
|
||||||
|
@if (authService.isAdmin()) {
|
||||||
|
<button (click)="toggleAddForm()" class="btn btn-primary">
|
||||||
|
{{ showAddForm() ? 'Cancel' : 'Add Music' }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (showAddForm() && authService.isAdmin()) {
|
||||||
|
<form (ngSubmit)="addMusic()" class="add-form">
|
||||||
|
<h3>Add New Music</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="title">Title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="title"
|
||||||
|
[(ngModel)]="newMusic.title"
|
||||||
|
name="title"
|
||||||
|
required
|
||||||
|
placeholder="Album/Single/EP title"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="artist">Artist</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="artist"
|
||||||
|
[(ngModel)]="newMusic.artist"
|
||||||
|
name="artist"
|
||||||
|
required
|
||||||
|
placeholder="Artist name"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="type">Type</label>
|
||||||
|
<select id="type" [(ngModel)]="newMusic.type" name="type" required>
|
||||||
|
<option value="album">Album</option>
|
||||||
|
<option value="single">Single</option>
|
||||||
|
<option value="ep">EP</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="status">Status</label>
|
||||||
|
<select id="status" [(ngModel)]="newMusic.status" name="status" required>
|
||||||
|
<option value="listening">Currently Listening</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
<option value="wantToListen">Want to Listen</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="rating">Rating (0-5)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="rating"
|
||||||
|
[(ngModel)]="newMusic.rating"
|
||||||
|
name="rating"
|
||||||
|
min="0"
|
||||||
|
max="5"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="notes">Notes</label>
|
||||||
|
<textarea
|
||||||
|
id="notes"
|
||||||
|
[(ngModel)]="newMusic.notes"
|
||||||
|
name="notes"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Your thoughts about this music..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Add Music</button>
|
||||||
|
<button type="button" (click)="toggleAddForm()" class="btn btn-secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<div class="filter-group">
|
||||||
|
<strong>Type:</strong>
|
||||||
|
<button
|
||||||
|
(click)="setTypeFilter('all')"
|
||||||
|
[class.active]="typeFilter() === 'all'"
|
||||||
|
class="filter-btn"
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="setTypeFilter(MusicType.album)"
|
||||||
|
[class.active]="typeFilter() === MusicType.album"
|
||||||
|
class="filter-btn"
|
||||||
|
>
|
||||||
|
Albums ({{ getCountByType(MusicType.album) }})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="setTypeFilter(MusicType.single)"
|
||||||
|
[class.active]="typeFilter() === MusicType.single"
|
||||||
|
class="filter-btn"
|
||||||
|
>
|
||||||
|
Singles ({{ getCountByType(MusicType.single) }})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="setTypeFilter(MusicType.ep)"
|
||||||
|
[class.active]="typeFilter() === MusicType.ep"
|
||||||
|
class="filter-btn"
|
||||||
|
>
|
||||||
|
EPs ({{ getCountByType(MusicType.ep) }})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-group">
|
||||||
|
<strong>Status:</strong>
|
||||||
|
<button
|
||||||
|
(click)="setStatusFilter('all')"
|
||||||
|
[class.active]="statusFilter() === 'all'"
|
||||||
|
class="filter-btn"
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="setStatusFilter(MusicStatus.listening)"
|
||||||
|
[class.active]="statusFilter() === MusicStatus.listening"
|
||||||
|
class="filter-btn"
|
||||||
|
>
|
||||||
|
Listening ({{ getCountByStatus(MusicStatus.listening) }})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="setStatusFilter(MusicStatus.completed)"
|
||||||
|
[class.active]="statusFilter() === MusicStatus.completed"
|
||||||
|
class="filter-btn"
|
||||||
|
>
|
||||||
|
Completed ({{ getCountByStatus(MusicStatus.completed) }})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="setStatusFilter(MusicStatus.wantToListen)"
|
||||||
|
[class.active]="statusFilter() === MusicStatus.wantToListen"
|
||||||
|
class="filter-btn"
|
||||||
|
>
|
||||||
|
Want to Listen ({{ getCountByStatus(MusicStatus.wantToListen) }})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="loading">Loading music...</div>
|
||||||
|
} @else if (filteredMusic().length === 0) {
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>No music found with these filters.</p>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="music-grid">
|
||||||
|
@for (music of filteredMusic(); track music.id) {
|
||||||
|
<div class="music-card" [class.completed]="music.status === MusicStatus.completed">
|
||||||
|
@if (music.coverArt) {
|
||||||
|
<img [src]="music.coverArt" [alt]="music.title" class="music-cover">
|
||||||
|
} @else {
|
||||||
|
<div class="music-cover placeholder">
|
||||||
|
@switch (music.type) {
|
||||||
|
@case (MusicType.album) { 💿 }
|
||||||
|
@case (MusicType.single) { 🎵 }
|
||||||
|
@case (MusicType.ep) { 🎶 }
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="music-info">
|
||||||
|
<h3>{{ music.title }}</h3>
|
||||||
|
<p class="artist">{{ music.artist }}</p>
|
||||||
|
|
||||||
|
<div class="badges">
|
||||||
|
<span class="type-badge type-{{ music.type }}">
|
||||||
|
{{ getTypeLabel(music.type) }}
|
||||||
|
</span>
|
||||||
|
<span class="status status-{{ music.status }}">
|
||||||
|
{{ getStatusLabel(music.status) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (music.rating) {
|
||||||
|
<div class="rating">
|
||||||
|
@for (star of [1,2,3,4,5]; track star) {
|
||||||
|
<span [class.filled]="star <= music.rating">★</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (music.notes) {
|
||||||
|
<p class="notes">{{ music.notes }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (music.dateCompleted) {
|
||||||
|
<p class="date-completed">
|
||||||
|
Completed: {{ formatDate(music.dateCompleted) }}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (authService.isAdmin()) {
|
||||||
|
<div class="actions">
|
||||||
|
<button (click)="deleteMusic(music)" class="btn btn-danger btn-sm">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-section {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-form {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
border: 2px solid var(--witch-lavender);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 2px solid var(--witch-lavender);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
background-color: var(--witch-moon);
|
||||||
|
color: var(--witch-purple);
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus,
|
||||||
|
.form-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--witch-rose);
|
||||||
|
box-shadow: 0 0 0 3px rgba(168, 87, 126, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group strong {
|
||||||
|
min-width: 60px;
|
||||||
|
color: var(--witch-plum);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--witch-lavender);
|
||||||
|
color: var(--witch-purple);
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn:hover {
|
||||||
|
background: var(--witch-mauve);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 8px var(--witch-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn.active {
|
||||||
|
background: var(--witch-rose);
|
||||||
|
color: var(--witch-moon);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--witch-plum);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: var(--witch-plum);
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-card {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border: 2px solid var(--witch-lavender);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px var(--witch-shadow);
|
||||||
|
border-color: var(--witch-mauve);
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-card.completed {
|
||||||
|
opacity: 0.9;
|
||||||
|
border-color: var(--witch-mauve);
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 250px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-cover.placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--witch-lavender);
|
||||||
|
font-size: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-info {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-info h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist {
|
||||||
|
color: var(--witch-plum);
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-album {
|
||||||
|
background: var(--witch-purple);
|
||||||
|
color: var(--witch-moon);
|
||||||
|
}
|
||||||
|
.type-single {
|
||||||
|
background: var(--witch-rose);
|
||||||
|
color: var(--witch-moon);
|
||||||
|
}
|
||||||
|
.type-ep {
|
||||||
|
background: var(--witch-plum);
|
||||||
|
color: var(--witch-moon);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-listening {
|
||||||
|
background: var(--witch-rose);
|
||||||
|
color: var(--witch-moon);
|
||||||
|
}
|
||||||
|
.status-completed {
|
||||||
|
background: var(--witch-mauve);
|
||||||
|
color: var(--witch-purple);
|
||||||
|
}
|
||||||
|
.status-wantToListen {
|
||||||
|
background: var(--witch-lavender);
|
||||||
|
color: var(--witch-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating span {
|
||||||
|
color: var(--witch-lavender);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating span.filled {
|
||||||
|
color: var(--witch-rose);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--witch-plum);
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-completed {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--witch-plum);
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--witch-rose);
|
||||||
|
color: var(--witch-moon);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--witch-plum);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 8px var(--witch-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--witch-mauve);
|
||||||
|
color: var(--witch-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--witch-rose);
|
||||||
|
color: var(--witch-moon);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--witch-plum);
|
||||||
|
color: var(--witch-moon);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: var(--witch-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class MusicListComponent implements OnInit {
|
||||||
|
musicService = inject(MusicService);
|
||||||
|
authService = inject(AuthService);
|
||||||
|
|
||||||
|
music = signal<Music[]>([]);
|
||||||
|
loading = signal(true);
|
||||||
|
showAddForm = signal(false);
|
||||||
|
typeFilter = signal<'all' | MusicType>('all');
|
||||||
|
statusFilter = signal<'all' | MusicStatus>('all');
|
||||||
|
|
||||||
|
// Expose enums to template
|
||||||
|
MusicType = MusicType;
|
||||||
|
MusicStatus = MusicStatus;
|
||||||
|
|
||||||
|
newMusic: Partial<CreateMusicDto> = {
|
||||||
|
title: '',
|
||||||
|
artist: '',
|
||||||
|
type: MusicType.album,
|
||||||
|
status: MusicStatus.wantToListen,
|
||||||
|
rating: undefined,
|
||||||
|
notes: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.loadMusic();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadMusic() {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.musicService.getAllMusic().subscribe({
|
||||||
|
next: (music) => {
|
||||||
|
this.music.set(music);
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredMusic() {
|
||||||
|
let filtered = this.music();
|
||||||
|
|
||||||
|
const typeFilter = this.typeFilter();
|
||||||
|
if (typeFilter !== 'all') {
|
||||||
|
filtered = filtered.filter(music => music.type === typeFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusFilter = this.statusFilter();
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
filtered = filtered.filter(music => music.status === statusFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCountByType(type: MusicType): number {
|
||||||
|
return this.music().filter(music => music.type === type).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCountByStatus(status: MusicStatus): number {
|
||||||
|
return this.music().filter(music => music.status === status).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTypeFilter(filter: 'all' | MusicType) {
|
||||||
|
this.typeFilter.set(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatusFilter(filter: 'all' | MusicStatus) {
|
||||||
|
this.statusFilter.set(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
getTypeLabel(type: MusicType): string {
|
||||||
|
switch (type) {
|
||||||
|
case MusicType.album: return 'Album';
|
||||||
|
case MusicType.single: return 'Single';
|
||||||
|
case MusicType.ep: return 'EP';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusLabel(status: MusicStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case MusicStatus.listening: return 'Currently Listening';
|
||||||
|
case MusicStatus.completed: return 'Completed';
|
||||||
|
case MusicStatus.wantToListen: return 'Want to Listen';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleAddForm() {
|
||||||
|
this.showAddForm.update(v => !v);
|
||||||
|
if (!this.showAddForm()) {
|
||||||
|
this.resetForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetForm() {
|
||||||
|
this.newMusic = {
|
||||||
|
title: '',
|
||||||
|
artist: '',
|
||||||
|
type: MusicType.album,
|
||||||
|
status: MusicStatus.wantToListen,
|
||||||
|
rating: undefined,
|
||||||
|
notes: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
addMusic() {
|
||||||
|
if (!this.newMusic.title || !this.newMusic.artist || !this.newMusic.type || !this.newMusic.status) return;
|
||||||
|
|
||||||
|
const musicToAdd: CreateMusicDto = {
|
||||||
|
title: this.newMusic.title,
|
||||||
|
artist: this.newMusic.artist,
|
||||||
|
type: this.newMusic.type,
|
||||||
|
status: this.newMusic.status,
|
||||||
|
rating: this.newMusic.rating,
|
||||||
|
notes: this.newMusic.notes
|
||||||
|
};
|
||||||
|
|
||||||
|
this.musicService.createMusic(musicToAdd).subscribe(() => {
|
||||||
|
this.loadMusic();
|
||||||
|
this.toggleAddForm();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteMusic(music: Music) {
|
||||||
|
if (confirm(`Are you sure you want to delete "${music.title}" by ${music.artist}?`)) {
|
||||||
|
this.musicService.deleteMusic(music.id).subscribe(() => {
|
||||||
|
this.loadMusic();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(date: Date | string): string {
|
||||||
|
return new Date(date).toLocaleDateString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# Frontend Service Usage Examples
|
||||||
|
|
||||||
|
## Auth Service
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { AuthService } from './services/auth.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-header',
|
||||||
|
template: `
|
||||||
|
<div>
|
||||||
|
@if (authService.user(); as user) {
|
||||||
|
<p>Welcome, {{ user.username }}!</p>
|
||||||
|
@if (user.isAdmin) {
|
||||||
|
<button (click)="logout()">Logout</button>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<button (click)="login()">Login with Discord</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class HeaderComponent {
|
||||||
|
authService = inject(AuthService);
|
||||||
|
|
||||||
|
login() {
|
||||||
|
this.authService.login();
|
||||||
|
}
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
this.authService.logout().subscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Games Service
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||||
|
import { GamesService } from './services/games.service';
|
||||||
|
import { AuthService } from './services/auth.service';
|
||||||
|
import { Game } from '@library/shared-types';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-games',
|
||||||
|
template: `
|
||||||
|
<div>
|
||||||
|
<h2>My Games</h2>
|
||||||
|
|
||||||
|
@if (authService.isAdmin()) {
|
||||||
|
<button (click)="showAddForm = true">Add Game</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="games-grid">
|
||||||
|
@for (game of games(); track game.id) {
|
||||||
|
<div class="game-card">
|
||||||
|
<h3>{{ game.title }}</h3>
|
||||||
|
<p>{{ game.platform }}</p>
|
||||||
|
<p>Status: {{ game.status }}</p>
|
||||||
|
|
||||||
|
@if (authService.isAdmin()) {
|
||||||
|
<button (click)="editGame(game)">Edit</button>
|
||||||
|
<button (click)="deleteGame(game.id)">Delete</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class GamesComponent implements OnInit {
|
||||||
|
gamesService = inject(GamesService);
|
||||||
|
authService = inject(AuthService);
|
||||||
|
|
||||||
|
games = signal<Game[]>([]);
|
||||||
|
showAddForm = false;
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.loadGames();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadGames() {
|
||||||
|
this.gamesService.getAllGames().subscribe(games => {
|
||||||
|
this.games.set(games);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteGame(id: string) {
|
||||||
|
if (confirm('Are you sure?')) {
|
||||||
|
this.gamesService.deleteGame(id).subscribe(() => {
|
||||||
|
this.loadGames();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
editGame(game: Game) {
|
||||||
|
// Navigate to edit form or open modal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Books Service
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Similar pattern to games
|
||||||
|
import { BooksService } from './services/books.service';
|
||||||
|
|
||||||
|
// In component
|
||||||
|
booksService = inject(BooksService);
|
||||||
|
books = signal<Book[]>([]);
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.booksService.getAllBooks().subscribe(books => {
|
||||||
|
this.books.set(books);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addBook(book: CreateBookDto) {
|
||||||
|
this.booksService.createBook(book).subscribe(() => {
|
||||||
|
this.loadBooks();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Music Service
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Similar pattern
|
||||||
|
import { MusicService } from './services/music.service';
|
||||||
|
|
||||||
|
musicService = inject(MusicService);
|
||||||
|
|
||||||
|
// Filter by type example
|
||||||
|
getAlbums() {
|
||||||
|
this.musicService.getAllMusic().subscribe(music => {
|
||||||
|
const albums = music.filter(m => m.type === 'album');
|
||||||
|
this.albums.set(albums);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AuthService } from '../services/auth.service';
|
||||||
|
import { catchError, of } from 'rxjs';
|
||||||
|
|
||||||
|
export function initializeAuth(authService: AuthService) {
|
||||||
|
return () => authService.getCurrentUser().pipe(
|
||||||
|
catchError(() => {
|
||||||
|
// If not authenticated, that's okay - just continue
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import {
|
||||||
|
HttpRequest,
|
||||||
|
HttpHandler,
|
||||||
|
HttpEvent,
|
||||||
|
HttpInterceptor,
|
||||||
|
HttpErrorResponse
|
||||||
|
} from '@angular/common/http';
|
||||||
|
import { Observable, catchError, throwError } from 'rxjs';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthInterceptor implements HttpInterceptor {
|
||||||
|
constructor(private router: Router) {}
|
||||||
|
|
||||||
|
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||||
|
// Clone the request to add withCredentials
|
||||||
|
// This ensures cookies are sent with every request
|
||||||
|
const authReq = request.clone({
|
||||||
|
withCredentials: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return next.handle(authReq).pipe(
|
||||||
|
catchError((error: HttpErrorResponse) => {
|
||||||
|
if (error.status === 401) {
|
||||||
|
// Unauthorized - redirect to home
|
||||||
|
this.router.navigate(['/']);
|
||||||
|
}
|
||||||
|
return throwError(() => error);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ApiService {
|
||||||
|
private readonly apiUrl = environment.apiUrl;
|
||||||
|
|
||||||
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
|
private getHeaders(): HttpHeaders {
|
||||||
|
// Auth token is sent as httpOnly cookie, no need to add manually
|
||||||
|
return new HttpHeaders({
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get<T>(endpoint: string): Observable<T> {
|
||||||
|
return this.http.get<T>(`${this.apiUrl}${endpoint}`, {
|
||||||
|
headers: this.getHeaders(),
|
||||||
|
withCredentials: true // Important for cookies
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
post<T>(endpoint: string, body: any): Observable<T> {
|
||||||
|
return this.http.post<T>(`${this.apiUrl}${endpoint}`, body, {
|
||||||
|
headers: this.getHeaders(),
|
||||||
|
withCredentials: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
put<T>(endpoint: string, body: any): Observable<T> {
|
||||||
|
return this.http.put<T>(`${this.apiUrl}${endpoint}`, body, {
|
||||||
|
headers: this.getHeaders(),
|
||||||
|
withCredentials: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
delete<T>(endpoint: string): Observable<T> {
|
||||||
|
return this.http.delete<T>(`${this.apiUrl}${endpoint}`, {
|
||||||
|
headers: this.getHeaders(),
|
||||||
|
withCredentials: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, signal } from '@angular/core';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { Observable, tap } from 'rxjs';
|
||||||
|
import { ApiService } from './api.service';
|
||||||
|
import { AuthResponse, User } from '@library/shared-types';
|
||||||
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class AuthService {
|
||||||
|
private currentUser = signal<User | null>(null);
|
||||||
|
public readonly user = this.currentUser.asReadonly();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private api: ApiService,
|
||||||
|
private router: Router
|
||||||
|
) {}
|
||||||
|
|
||||||
|
login(): void {
|
||||||
|
// Redirect to API login endpoint
|
||||||
|
window.location.href = `${environment.apiUrl}/auth/login`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentUser(): Observable<AuthResponse> {
|
||||||
|
return this.api.get<AuthResponse>('/auth/me').pipe(
|
||||||
|
tap(response => {
|
||||||
|
this.currentUser.set(response.user);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logout(): Observable<{ message: string }> {
|
||||||
|
return this.api.post<{ message: string }>('/auth/logout', {}).pipe(
|
||||||
|
tap(() => {
|
||||||
|
this.currentUser.set(null);
|
||||||
|
this.router.navigate(['/']);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
isAuthenticated(): boolean {
|
||||||
|
return this.user() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdmin(): boolean {
|
||||||
|
return this.user()?.isAdmin === true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { ApiService } from './api.service';
|
||||||
|
import { Book, CreateBookDto, UpdateBookDto } from '@library/shared-types';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class BooksService {
|
||||||
|
constructor(private api: ApiService) {}
|
||||||
|
|
||||||
|
getAllBooks(): Observable<Book[]> {
|
||||||
|
return this.api.get<Book[]>('/books');
|
||||||
|
}
|
||||||
|
|
||||||
|
getBookById(id: string): Observable<Book | null> {
|
||||||
|
return this.api.get<Book | null>(`/books/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
createBook(book: CreateBookDto): Observable<Book> {
|
||||||
|
return this.api.post<Book>('/books', book);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBook(id: string, book: UpdateBookDto): Observable<Book> {
|
||||||
|
return this.api.put<Book>(`/books/${id}`, book);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteBook(id: string): Observable<{ success: boolean }> {
|
||||||
|
return this.api.delete<{ success: boolean }>(`/books/${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { ApiService } from './api.service';
|
||||||
|
import { Game, CreateGameDto, UpdateGameDto } from '@library/shared-types';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class GamesService {
|
||||||
|
constructor(private api: ApiService) {}
|
||||||
|
|
||||||
|
getAllGames(): Observable<Game[]> {
|
||||||
|
return this.api.get<Game[]>('/games');
|
||||||
|
}
|
||||||
|
|
||||||
|
getGameById(id: string): Observable<Game | null> {
|
||||||
|
return this.api.get<Game | null>(`/games/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
createGame(game: CreateGameDto): Observable<Game> {
|
||||||
|
return this.api.post<Game>('/games', game);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateGame(id: string, game: UpdateGameDto): Observable<Game> {
|
||||||
|
return this.api.put<Game>(`/games/${id}`, game);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteGame(id: string): Observable<{ success: boolean }> {
|
||||||
|
return this.api.delete<{ success: boolean }>(`/games/${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { ApiService } from './api.service';
|
||||||
|
export { AuthService } from './auth.service';
|
||||||
|
export { BooksService } from './books.service';
|
||||||
|
export { GamesService } from './games.service';
|
||||||
|
export { MusicService } from './music.service';
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { ApiService } from './api.service';
|
||||||
|
import { Music, CreateMusicDto, UpdateMusicDto } from '@library/shared-types';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class MusicService {
|
||||||
|
constructor(private api: ApiService) {}
|
||||||
|
|
||||||
|
getAllMusic(): Observable<Music[]> {
|
||||||
|
return this.api.get<Music[]>('/music');
|
||||||
|
}
|
||||||
|
|
||||||
|
getMusicById(id: string): Observable<Music | null> {
|
||||||
|
return this.api.get<Music | null>(`/music/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
createMusic(music: CreateMusicDto): Observable<Music> {
|
||||||
|
return this.api.post<Music>('/music', music);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMusic(id: string, music: UpdateMusicDto): Observable<Music> {
|
||||||
|
return this.api.put<Music>(`/music/${id}`, music);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteMusic(id: string): Observable<{ success: boolean }> {
|
||||||
|
return this.api.delete<{ success: boolean }>(`/music/${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// Witchy theme mixins and common styles
|
||||||
|
|
||||||
|
@mixin card-style {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border: 2px solid var(--witch-lavender);
|
||||||
|
border-radius: 8px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px var(--witch-shadow);
|
||||||
|
border-color: var(--witch-mauve);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin button-style($bg-color: var(--witch-rose), $text-color: var(--witch-moon)) {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background-color: $bg-color;
|
||||||
|
color: $text-color;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 8px var(--witch-shadow);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin form-input-style {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: var(--witch-moon);
|
||||||
|
border: 2px solid var(--witch-lavender);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: inherit;
|
||||||
|
color: var(--witch-purple);
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--witch-rose);
|
||||||
|
box-shadow: 0 0 0 3px rgba(168, 87, 126, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common component styles
|
||||||
|
.btn-primary {
|
||||||
|
@include button-style(var(--witch-rose), var(--witch-moon));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
@include button-style(var(--witch-mauve), var(--witch-purple));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
@include button-style(var(--witch-plum), var(--witch-moon));
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
@include button-style(var(--witch-lavender), var(--witch-purple));
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background-color: var(--witch-rose);
|
||||||
|
color: var(--witch-moon);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const environment = {
|
||||||
|
production: true,
|
||||||
|
apiUrl: '/api' // Since API serves frontend, use relative path
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const environment = {
|
||||||
|
production: false,
|
||||||
|
apiUrl: 'http://localhost:3000/api'
|
||||||
|
};
|
||||||
@@ -1 +1,137 @@
|
|||||||
/* You can add global styles to this file, and also import other style files */
|
/* You can add global styles to this file, and also import other style files */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Witchy Purple Rose Palette */
|
||||||
|
--witch-purple: #2B1B3D;
|
||||||
|
--witch-plum: #44275A;
|
||||||
|
--witch-rose: #A8577E;
|
||||||
|
--witch-mauve: #D4A5C7;
|
||||||
|
--witch-lavender: #E8D5E8;
|
||||||
|
--witch-black: #0A0009;
|
||||||
|
--witch-silver: #C0C0C0;
|
||||||
|
--witch-moon: #F5F5F5;
|
||||||
|
--witch-shadow: rgba(10, 0, 9, 0.7);
|
||||||
|
|
||||||
|
/* Theme variables */
|
||||||
|
--foreground: var(--witch-purple);
|
||||||
|
--background: var(--witch-moon);
|
||||||
|
--accent: var(--witch-rose);
|
||||||
|
--border: var(--witch-plum);
|
||||||
|
--highlight: var(--witch-mauve);
|
||||||
|
|
||||||
|
font-size: 14pt;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset and base styles
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
font-size: 14pt;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--foreground);
|
||||||
|
background-color: var(--background);
|
||||||
|
position: relative;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Witchy mystical background */
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: url(https://cdn.nhcarrigan.com/background.png);
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
z-index: -2;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Semi-transparent overlay for readability */
|
||||||
|
body::after {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(245, 245, 245, 0.9);
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
margin-top: 0;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--witch-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--witch-rose);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--witch-plum);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility classes
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form styles
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
background-color: var(--witch-moon);
|
||||||
|
border: 2px solid var(--witch-mauve);
|
||||||
|
color: var(--witch-purple);
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
textarea:focus,
|
||||||
|
select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--witch-rose);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Button base styles
|
||||||
|
button {
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrollbar styling
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--witch-lavender);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--witch-rose);
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--witch-plum);
|
||||||
|
}
|
||||||
|
|||||||
+12
-4
@@ -5,7 +5,14 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "nx run-many --target=lint --all",
|
"lint": "nx run-many --target=lint --all",
|
||||||
"build": "nx run-many --target=build --all",
|
"build": "nx run-many --target=build --all",
|
||||||
"test": "nx run-many --target=test --all --passWithNoTests"
|
"test": "nx run-many --target=test --all --passWithNoTests",
|
||||||
|
"build:frontend": "nx build frontend --configuration=production",
|
||||||
|
"build:api": "nx build api",
|
||||||
|
"build:prod": "npm run build:frontend && npm run build:api",
|
||||||
|
"start:api:dev": "nx serve api",
|
||||||
|
"start:frontend:dev": "nx serve frontend",
|
||||||
|
"start:prod": "NODE_ENV=production node dist/api/main.js",
|
||||||
|
"start": "NODE_ENV=production op run --env-file=prod.env -- node dist/api/main.js"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -19,8 +26,9 @@
|
|||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/jwt": "^10.0.0",
|
"@fastify/jwt": "^10.0.0",
|
||||||
"@fastify/oauth2": "^8.1.2",
|
"@fastify/oauth2": "^8.1.2",
|
||||||
"@fastify/sensible": "5.6.0",
|
"@fastify/sensible": "6.0.4",
|
||||||
"@prisma/client": "7.3.0",
|
"@fastify/static": "^9.0.0",
|
||||||
|
"@prisma/client": "6.19.2",
|
||||||
"fastify": "5.2.2",
|
"fastify": "5.2.2",
|
||||||
"fastify-plugin": "5.0.1",
|
"fastify-plugin": "5.0.1",
|
||||||
"rxjs": "7.8.2"
|
"rxjs": "7.8.2"
|
||||||
@@ -61,7 +69,7 @@
|
|||||||
"jest-environment-node": "30.2.0",
|
"jest-environment-node": "30.2.0",
|
||||||
"jest-util": "30.2.0",
|
"jest-util": "30.2.0",
|
||||||
"nx": "22.4.4",
|
"nx": "22.4.4",
|
||||||
"prisma": "7.3.0",
|
"prisma": "6.19.2",
|
||||||
"ts-jest": "29.4.6",
|
"ts-jest": "29.4.6",
|
||||||
"ts-node": "10.9.1",
|
"ts-node": "10.9.1",
|
||||||
"tslib": "2.8.1",
|
"tslib": "2.8.1",
|
||||||
|
|||||||
Generated
+151
-372
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,15 @@
|
|||||||
# MongoDB connection string from 1Password
|
# Database
|
||||||
DATABASE_URL="op://NHCarrigan/MongoDB Atlas Library/connection-string"
|
DATABASE_URL="op://Environment Variables - Naomi/Library/mongo url"
|
||||||
|
|
||||||
# Discord OAuth secrets
|
# JWT Secret
|
||||||
DISCORD_CLIENT_ID="op://NHCarrigan/Library OAuth Discord/client-id"
|
JWT_SECRET="op://Environment Variables - Naomi/Library/jwt secret"
|
||||||
DISCORD_CLIENT_SECRET="op://NHCarrigan/Library OAuth Discord/client-secret"
|
|
||||||
|
|
||||||
# JWT secret for session management
|
# Discord OAuth
|
||||||
JWT_SECRET="op://NHCarrigan/Library App/jwt-secret"
|
DISCORD_CLIENT_ID="op://Environment Variables - Naomi/Library/discord client id"
|
||||||
|
DISCORD_CLIENT_SECRET="op://Environment Variables - Naomi/Library/discord client secret"
|
||||||
|
|
||||||
# Application URLs
|
# Admin Configuration
|
||||||
FRONTEND_URL="http://localhost:4200"
|
ADMIN_DISCORD_ID="op://Environment Variables - Naomi/Library/admin discord id"
|
||||||
API_URL="http://localhost:3000"
|
|
||||||
|
# Application URL
|
||||||
|
BASE_URL="op://Environment Variables - Naomi/Library/base url"
|
||||||
Reference in New Issue
Block a user