generated from nhcarrigan/template
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
9caf74945a
|
|||
|
5eae636f2f
|
|||
|
4327750d2a
|
|||
|
800b9f6c2d
|
|||
|
729f410443
|
|||
|
a9764a4a82
|
|||
|
ca288eaac4
|
|||
|
b9f33bc055
|
|||
|
9902c5ad45
|
|||
|
912a8887a5
|
|||
|
054a55ff9c
|
|||
|
e20be5f4e8
|
|||
|
0a654f423a
|
|||
|
11be34cd21
|
|||
|
e5b15e02de
|
|||
|
cbd6499079
|
|||
|
d338c8b52f
|
|||
|
318f3bc500
|
|||
|
b6d66d34cb
|
|||
|
e167a17bd9
|
|||
|
8f3aeb9391
|
|||
|
c8a82646f8
|
|||
|
2f38aa3b92
|
@@ -0,0 +1,13 @@
|
||||
# Editor configuration, see http://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
@@ -0,0 +1,47 @@
|
||||
name: Node.js CI
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
name: CI
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout Source Files
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js v24
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Ensure Dependencies are Pinned
|
||||
uses: naomi-lgbt/dependency-pin-check@main
|
||||
with:
|
||||
language: javascript
|
||||
dev-dependencies: true
|
||||
peer-dependencies: true
|
||||
optional-dependencies: true
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Lint Source Files
|
||||
run: pnpm run lint
|
||||
|
||||
- name: Verify Build
|
||||
run: pnpm run build
|
||||
|
||||
- name: Run Tests
|
||||
run: pnpm run test
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
# See https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
|
||||
# compiled output
|
||||
dist
|
||||
tmp
|
||||
out-tsc
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# misc
|
||||
/.sass-cache
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
.nx/cache
|
||||
.nx/workspace-data
|
||||
|
||||
.angular
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"nrwl.angular-console",
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"firsttris.vscode-jest-runner"
|
||||
]
|
||||
}
|
||||
Vendored
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Debug api with Nx",
|
||||
"runtimeExecutable": "pnpm exec",
|
||||
"runtimeArgs": ["nx", "serve", "api"],
|
||||
"env": {
|
||||
"NODE_OPTIONS": "--inspect=9229"
|
||||
},
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"sourceMaps": true,
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/api/dist/**/*.(m|c|)js",
|
||||
"!**/node_modules/**"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
# Library App Planning Document 📚🎮🎵
|
||||
|
||||
## Overview
|
||||
A personal library tracking website where Naomi can catalogue and display her games, music, and books across different status categories.
|
||||
|
||||
## Core Features
|
||||
|
||||
### 1. Multi-Media Library Support
|
||||
- **Games**: Currently Playing, Completed, Backlog
|
||||
- **Music**: Currently Listening, Completed Albums, Want to Listen
|
||||
- **Books**: Currently Reading, Finished, To Read
|
||||
|
||||
### 2. Authentication & Permissions
|
||||
- Public viewing for everyone (read-only)
|
||||
- Admin authentication for Naomi only (full CRUD operations)
|
||||
- OAuth integration (GitHub? Discord? Both?)
|
||||
|
||||
### 3. User Interface
|
||||
- Clean, responsive design
|
||||
- Easy navigation between media types
|
||||
- Search and filter functionality
|
||||
- Statistics/summary view
|
||||
|
||||
## Technical Stack Considerations
|
||||
|
||||
### Frontend
|
||||
- **Framework**: Angular
|
||||
- Excellent TypeScript support (built with TS in mind!)
|
||||
- Powerful CLI and tooling
|
||||
- Great for building robust SPAs
|
||||
- Built-in RxJS for reactive programming
|
||||
- Standalone components in newer versions
|
||||
|
||||
### Backend
|
||||
- **Framework**: Fastify
|
||||
- Extremely fast Node.js framework
|
||||
- First-class TypeScript support
|
||||
- Built-in schema validation
|
||||
- Great plugin ecosystem
|
||||
- Excellent error handling
|
||||
|
||||
### Database
|
||||
- **Database**: MongoDB (Atlas - existing instance)
|
||||
- Perfect for flexible schema (games, books, music have different fields)
|
||||
- Easy to add new fields without migrations
|
||||
- Great performance for document queries
|
||||
- **ORM**: Prisma
|
||||
- Excellent TypeScript support
|
||||
- Type-safe database queries
|
||||
- Great MongoDB support
|
||||
- Auto-generated types from schema
|
||||
|
||||
### Authentication
|
||||
- **OAuth Provider**: Discord only
|
||||
- Single OAuth provider for simplicity
|
||||
- You already have Discord OAuth experience with Hikari bot
|
||||
- JWT for session management
|
||||
|
||||
### Project Structure
|
||||
- **Monorepo Tool**: Nx
|
||||
- Excellent TypeScript support
|
||||
- Great caching and build optimization
|
||||
- Powerful generators
|
||||
- Shared dependencies
|
||||
- Integrated testing and linting
|
||||
- **Linting**: @nhcarrigan/eslint-config
|
||||
- Your custom ESLint configuration
|
||||
- ESLint 9 flat config
|
||||
- No Prettier needed!
|
||||
|
||||
### Hosting
|
||||
- **Options**:
|
||||
- Self-hosted on your infrastructure (full control!)
|
||||
- Netlify (Good alternative, better ethics)
|
||||
- Railway (Developer-friendly, good values)
|
||||
- Fly.io (Great for full-stack apps)
|
||||
- DigitalOcean App Platform
|
||||
|
||||
## Data Models (Initial Thoughts)
|
||||
|
||||
### MongoDB Collections Structure
|
||||
We'll have three main collections: `games`, `books`, and `music`, each with their specific schema.
|
||||
|
||||
### Game
|
||||
```typescript
|
||||
interface Game {
|
||||
id: string;
|
||||
title: string;
|
||||
platform?: string;
|
||||
status: 'playing' | 'completed' | 'backlog';
|
||||
dateAdded: Date;
|
||||
dateCompleted?: Date;
|
||||
rating?: number;
|
||||
notes?: string;
|
||||
coverImage?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Book
|
||||
```typescript
|
||||
interface Book {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
isbn?: string;
|
||||
status: 'reading' | 'finished' | 'toRead';
|
||||
dateAdded: Date;
|
||||
dateFinished?: Date;
|
||||
rating?: number;
|
||||
notes?: string;
|
||||
coverImage?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Music
|
||||
```typescript
|
||||
interface Music {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
type: 'album' | 'single' | 'ep';
|
||||
status: 'listening' | 'completed' | 'wantToListen';
|
||||
dateAdded: Date;
|
||||
dateCompleted?: Date;
|
||||
rating?: number;
|
||||
notes?: string;
|
||||
coverArt?: string;
|
||||
}
|
||||
```
|
||||
|
||||
## Feature Roadmap
|
||||
|
||||
### Phase 1: MVP
|
||||
- [ ] Basic CRUD for all three media types
|
||||
- [ ] Simple authentication (single admin user)
|
||||
- [ ] Public read-only view
|
||||
- [ ] Basic responsive UI
|
||||
|
||||
### Phase 2: Enhancements
|
||||
- [ ] Search and filtering
|
||||
- [ ] Statistics dashboard
|
||||
- [ ] Cover image fetching from APIs
|
||||
- [ ] Import/export functionality
|
||||
|
||||
### Phase 3: Advanced Features
|
||||
- [ ] Recommendations system
|
||||
- [ ] Progress tracking (% complete for books/games)
|
||||
- [ ] Tags/categories
|
||||
- [ ] Public API for integrations
|
||||
|
||||
## Questions to Consider
|
||||
|
||||
1. **Design Aesthetic**: What vibe are you going for? Minimalist? Colourful? Dark theme?
|
||||
|
||||
2. **API Integrations**: Should we fetch metadata from external APIs?
|
||||
- Games: IGDB, Steam API
|
||||
- Books: Open Library, Google Books
|
||||
- Music: Spotify, Last.fm, MusicBrainz
|
||||
|
||||
3. **URL Structure**: How should we organize routes?
|
||||
- `/games`, `/books`, `/music`?
|
||||
- `/library/games`, `/library/books`?
|
||||
- Something else?
|
||||
|
||||
4. **Data Entry**: Manual entry only, or barcode scanning/search integration?
|
||||
|
||||
5. **Privacy**: Any items you'd want to keep private even from public view?
|
||||
|
||||
## Current Sprint: Edit, Filters & Comments
|
||||
|
||||
### Feature 1: Edit Existing Library Entries (Admin Only)
|
||||
**Backend:**
|
||||
- Add PUT `/api/games/:id`, `/api/books/:id`, `/api/music/:id` endpoints
|
||||
- Reuse existing admin authentication middleware
|
||||
- Validate input against existing schemas
|
||||
|
||||
**Frontend:**
|
||||
- Add edit button to entry cards (visible for admins)
|
||||
- Reuse existing "Add" modal/form components with pre-filled data
|
||||
- Update state after successful edit
|
||||
|
||||
### Feature 2: Fix Filter Tab State Updates
|
||||
**Investigation needed:**
|
||||
- Check how tab counts are calculated (likely not re-fetching after add)
|
||||
- Ensure Angular state updates reactively when items are added/modified
|
||||
- May need to refresh counts after mutations or use observables properly
|
||||
|
||||
### Feature 3: Comments System
|
||||
**Database Schema:**
|
||||
```prisma
|
||||
model Comment {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
content String // Markdown content - sanitised on render
|
||||
userId String @db.ObjectId
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
// Polymorphic relation - one of these will be set
|
||||
gameId String? @db.ObjectId
|
||||
game Game? @relation(fields: [gameId], references: [id])
|
||||
bookId String? @db.ObjectId
|
||||
book Book? @relation(fields: [bookId], references: [id])
|
||||
musicId String? @db.ObjectId
|
||||
music Music? @relation(fields: [musicId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
```
|
||||
|
||||
**Backend:**
|
||||
- POST `/api/games/:id/comments` (authenticated users)
|
||||
- GET `/api/games/:id/comments` (public)
|
||||
- DELETE `/api/games/:id/comments/:commentId` (admin only)
|
||||
- Same for books and music
|
||||
- Use a markdown sanitisation library (e.g., `sanitize-html` or `DOMPurify` on render)
|
||||
|
||||
**Frontend:**
|
||||
- Expandable comments section on each entry card
|
||||
- Markdown rendering with sanitisation (use `marked` + `DOMPurify`)
|
||||
- Add comment form for authenticated users
|
||||
- Delete button for admins
|
||||
- Show commenter's Discord username/avatar
|
||||
|
||||
### Implementation Order
|
||||
1. Fix filter tab bug (quick win)
|
||||
2. Add edit functionality (builds on existing patterns)
|
||||
3. Add comments system (new feature, more complex)
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Choose technical stack
|
||||
2. Set up project structure
|
||||
3. Design database schema
|
||||
4. Create authentication flow
|
||||
5. Build first media type (games?) as proof of concept
|
||||
6. Iterate and add remaining media types
|
||||
|
||||
---
|
||||
|
||||
*✨ This planning document was created with love by Hikari and Naomi! 🌸*
|
||||
@@ -1,39 +1,101 @@
|
||||
# New Repository Template
|
||||
# Library
|
||||
|
||||
This template contains all of our basic files for a new GitHub repository. There is also a handy workflow that will create an issue on a new repository made from this template, with a checklist for the steps we usually take in setting up a new repository.
|
||||
<a alt="Nx logo" href="https://nx.dev" target="_blank" rel="noreferrer"><img src="https://raw.githubusercontent.com/nrwl/nx/master/images/nx-logo.png" width="45"></a>
|
||||
|
||||
If you're starting a Node.JS project with TypeScript, we have a [specific template](https://github.com/naomi-lgbt/nodejs-typescript-template) for that purpose.
|
||||
✨ Your new, shiny [Nx workspace](https://nx.dev) is ready ✨.
|
||||
|
||||
## Readme
|
||||
[Learn more about this workspace setup and its capabilities](https://nx.dev/getting-started/tutorials/angular-monorepo-tutorial?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) or run `npx nx graph` to visually explore what was created. Now, let's get you up to speed!
|
||||
|
||||
Delete all of the above text (including this line), and uncomment the below text to use our standard readme template.
|
||||
## Run tasks
|
||||
|
||||
<!-- # Project Name
|
||||
To run the dev server for your app, use:
|
||||
|
||||
Project Description
|
||||
```sh
|
||||
npx nx serve frontend
|
||||
```
|
||||
|
||||
## Live Version
|
||||
To create a production bundle:
|
||||
|
||||
This page is currently deployed. [View the live website.]
|
||||
```sh
|
||||
npx nx build frontend
|
||||
```
|
||||
|
||||
## Feedback and Bugs
|
||||
To see all available targets to run for a project, run:
|
||||
|
||||
If you have feedback or a bug report, please [log a ticket on our forum](https://support.nhcarrigan.com).
|
||||
```sh
|
||||
npx nx show project frontend
|
||||
```
|
||||
|
||||
## Contributing
|
||||
These targets are either [inferred automatically](https://nx.dev/concepts/inferred-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) or defined in the `project.json` or `package.json` files.
|
||||
|
||||
If you would like to contribute to the project, you may create a Pull Request containing your proposed changes and we will review it as soon as we are able! Please review our [contributing guidelines](CONTRIBUTING.md) first.
|
||||
[More about running tasks in the docs »](https://nx.dev/features/run-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
|
||||
|
||||
## Code of Conduct
|
||||
## Add new projects
|
||||
|
||||
Before interacting with our community, please read our [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
While you could add new projects to your workspace manually, you might want to leverage [Nx plugins](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) and their [code generation](https://nx.dev/features/generate-code?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) feature.
|
||||
|
||||
## License
|
||||
Use the plugin's generator to create new projects.
|
||||
|
||||
This software is licensed under our [global software license](https://docs.nhcarrigan.com/#/license).
|
||||
To generate a new application, use:
|
||||
|
||||
Copyright held by Naomi Carrigan.
|
||||
```sh
|
||||
npx nx g @nx/angular:app demo
|
||||
```
|
||||
|
||||
## Contact
|
||||
To generate a new library, use:
|
||||
|
||||
We may be contacted through our [Chat Server](http://chat.nhcarrigan.com) or via email at `contact@nhcarrigan.com`. -->
|
||||
```sh
|
||||
npx nx g @nx/angular:lib mylib
|
||||
```
|
||||
|
||||
You can use `npx nx list` to get a list of installed plugins. Then, run `npx nx list <plugin-name>` to learn about more specific capabilities of a particular plugin. Alternatively, [install Nx Console](https://nx.dev/getting-started/editor-setup?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) to browse plugins and generators in your IDE.
|
||||
|
||||
[Learn more about Nx plugins »](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) | [Browse the plugin registry »](https://nx.dev/plugin-registry?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
|
||||
|
||||
## Set up CI!
|
||||
|
||||
### Step 1
|
||||
|
||||
To connect to Nx Cloud, run the following command:
|
||||
|
||||
```sh
|
||||
npx nx connect
|
||||
```
|
||||
|
||||
Connecting to Nx Cloud ensures a [fast and scalable CI](https://nx.dev/ci/intro/why-nx-cloud?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) pipeline. It includes features such as:
|
||||
|
||||
- [Remote caching](https://nx.dev/ci/features/remote-cache?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
|
||||
- [Task distribution across multiple machines](https://nx.dev/ci/features/distribute-task-execution?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
|
||||
- [Automated e2e test splitting](https://nx.dev/ci/features/split-e2e-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
|
||||
- [Task flakiness detection and rerunning](https://nx.dev/ci/features/flaky-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
|
||||
|
||||
### Step 2
|
||||
|
||||
Use the following command to configure a CI workflow for your workspace:
|
||||
|
||||
```sh
|
||||
npx nx g ci-workflow
|
||||
```
|
||||
|
||||
[Learn more about Nx on CI](https://nx.dev/ci/intro/ci-with-nx#ready-get-started-with-your-provider?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
|
||||
|
||||
## Install Nx Console
|
||||
|
||||
Nx Console is an editor extension that enriches your developer experience. It lets you run tasks, generate code, and improves code autocompletion in your IDE. It is available for VSCode and IntelliJ.
|
||||
|
||||
[Install Nx Console »](https://nx.dev/getting-started/editor-setup?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
|
||||
|
||||
## Useful links
|
||||
|
||||
Learn more:
|
||||
|
||||
- [Learn more about this workspace setup](https://nx.dev/getting-started/tutorials/angular-monorepo-tutorial?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
|
||||
- [Learn about Nx on CI](https://nx.dev/ci/intro/ci-with-nx?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
|
||||
- [Releasing Packages with Nx release](https://nx.dev/features/manage-releases?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
|
||||
- [What are Nx plugins?](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
|
||||
|
||||
And join the Nx community:
|
||||
- [Discord](https://go.nx.dev/community)
|
||||
- [Follow us on X](https://twitter.com/nxdevtools) or [LinkedIn](https://www.linkedin.com/company/nrwl)
|
||||
- [Our Youtube channel](https://www.youtube.com/@nxdevtools)
|
||||
- [Our blog](https://nx.dev/blog?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
# Keep environment variables out of version control
|
||||
.env
|
||||
|
||||
/generated/prisma
|
||||
@@ -0,0 +1,53 @@
|
||||
# Authentication Flow
|
||||
|
||||
## Overview
|
||||
This API uses Discord OAuth for authentication and JWT tokens for session management. Only the admin user can perform create/update/delete operations, while public read access is available to everyone.
|
||||
|
||||
## Environment Variables
|
||||
Set up your `prod.env` file with 1Password references:
|
||||
- `DATABASE_URL` - MongoDB connection string
|
||||
- `JWT_SECRET` - Secret for signing JWT tokens
|
||||
- `DISCORD_CLIENT_ID` - Discord OAuth app client ID
|
||||
- `DISCORD_CLIENT_SECRET` - Discord OAuth app client secret
|
||||
- `ADMIN_DISCORD_ID` - Your Discord user ID for admin access
|
||||
- `API_URL` - API base URL (e.g., http://localhost:3000)
|
||||
- `FRONTEND_URL` - Frontend URL to redirect after login
|
||||
|
||||
## Running the API
|
||||
```bash
|
||||
# Start with 1Password secrets
|
||||
op run --env-file=prod.env -- nx serve api
|
||||
```
|
||||
|
||||
## Auth Endpoints
|
||||
|
||||
### 1. Login
|
||||
`GET /api/auth/login` - Redirects to Discord OAuth
|
||||
|
||||
### 2. Callback
|
||||
`GET /api/auth/callback` - Discord redirects here after auth
|
||||
- Creates/updates user in database
|
||||
- Generates JWT token
|
||||
- Sets httpOnly cookie `auth-token`
|
||||
- Redirects to frontend
|
||||
|
||||
### 3. Get Current User
|
||||
`GET /api/auth/me` - Returns authenticated user (requires auth)
|
||||
|
||||
### 4. Logout
|
||||
`POST /api/auth/logout` - Clears auth cookie
|
||||
|
||||
## Protected Routes
|
||||
Example: Games API
|
||||
- `GET /api/games` - Public (list all games)
|
||||
- `GET /api/games/:id` - Public (get single game)
|
||||
- `POST /api/games` - Admin only (create game)
|
||||
- `PUT /api/games/:id` - Admin only (update game)
|
||||
- `DELETE /api/games/:id` - Admin only (delete game)
|
||||
|
||||
## Testing
|
||||
1. Set up Discord OAuth app at https://discord.com/developers/applications
|
||||
2. Add redirect URI: `http://localhost:3000/api/auth/callback`
|
||||
3. Copy client ID and secret to 1Password
|
||||
4. Run the API and visit `http://localhost:3000/api/auth/login`
|
||||
5. After Discord auth, you'll be redirected to frontend with auth cookie set
|
||||
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
displayName: 'api',
|
||||
preset: '../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../coverage/api',
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
// This file was generated by Prisma, and assumes you have installed the following:
|
||||
// npm install --save-dev prisma dotenv
|
||||
import "dotenv/config";
|
||||
import { defineConfig } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
datasource: {
|
||||
url: process.env["DATABASE_URL"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
|
||||
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mongodb"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
type Link {
|
||||
title String
|
||||
url String
|
||||
}
|
||||
|
||||
model Game {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
title String
|
||||
platform String?
|
||||
status GameStatus
|
||||
dateAdded DateTime @default(now())
|
||||
dateCompleted DateTime?
|
||||
rating Int? @db.Int @default(0)
|
||||
notes String?
|
||||
coverImage String?
|
||||
tags String[]
|
||||
links Link[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
comments Comment[]
|
||||
}
|
||||
|
||||
enum GameStatus {
|
||||
PLAYING
|
||||
COMPLETED
|
||||
BACKLOG
|
||||
}
|
||||
|
||||
model Book {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
title String
|
||||
author String
|
||||
isbn String?
|
||||
status BookStatus
|
||||
dateAdded DateTime @default(now())
|
||||
dateFinished DateTime?
|
||||
rating Int? @db.Int @default(0)
|
||||
notes String?
|
||||
coverImage String?
|
||||
tags String[]
|
||||
links Link[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
comments Comment[]
|
||||
}
|
||||
|
||||
enum BookStatus {
|
||||
READING
|
||||
FINISHED
|
||||
TO_READ
|
||||
}
|
||||
|
||||
model Music {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
title String
|
||||
artist String
|
||||
type MusicType
|
||||
status MusicStatus
|
||||
dateAdded DateTime @default(now())
|
||||
dateCompleted DateTime?
|
||||
rating Int? @db.Int @default(0)
|
||||
notes String?
|
||||
coverArt String?
|
||||
tags String[]
|
||||
links Link[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
comments Comment[]
|
||||
}
|
||||
|
||||
enum MusicType {
|
||||
ALBUM
|
||||
SINGLE
|
||||
EP
|
||||
}
|
||||
|
||||
enum MusicStatus {
|
||||
LISTENING
|
||||
COMPLETED
|
||||
WANT_TO_LISTEN
|
||||
}
|
||||
|
||||
model Art {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
title String
|
||||
artist String
|
||||
description String?
|
||||
imageUrl String
|
||||
tags String[]
|
||||
links Link[]
|
||||
dateAdded DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
comments Comment[]
|
||||
}
|
||||
|
||||
model Show {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
title String
|
||||
type ShowType
|
||||
status ShowStatus
|
||||
dateAdded DateTime @default(now())
|
||||
dateCompleted DateTime?
|
||||
rating Int? @db.Int @default(0)
|
||||
notes String?
|
||||
coverImage String?
|
||||
tags String[]
|
||||
links Link[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
comments Comment[]
|
||||
}
|
||||
|
||||
enum ShowType {
|
||||
TV_SERIES
|
||||
ANIME
|
||||
FILM
|
||||
DOCUMENTARY
|
||||
}
|
||||
|
||||
enum ShowStatus {
|
||||
WATCHING
|
||||
COMPLETED
|
||||
WANT_TO_WATCH
|
||||
}
|
||||
|
||||
model Manga {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
title String
|
||||
author String
|
||||
status MangaStatus
|
||||
dateAdded DateTime @default(now())
|
||||
dateCompleted DateTime?
|
||||
rating Int? @db.Int @default(0)
|
||||
notes String?
|
||||
coverImage String?
|
||||
tags String[]
|
||||
links Link[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
comments Comment[]
|
||||
}
|
||||
|
||||
enum MangaStatus {
|
||||
READING
|
||||
COMPLETED
|
||||
WANT_TO_READ
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
discordId String @unique
|
||||
username String
|
||||
email String @unique
|
||||
avatar String?
|
||||
isAdmin Boolean @default(false)
|
||||
isBanned Boolean @default(false)
|
||||
inDiscord Boolean @default(false)
|
||||
isVip Boolean @default(false)
|
||||
isMod Boolean @default(false)
|
||||
isStaff Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
comments Comment[]
|
||||
suggestions Suggestion[]
|
||||
likes Like[]
|
||||
refreshTokens RefreshToken[]
|
||||
}
|
||||
|
||||
model Comment {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
content String
|
||||
rawContent String?
|
||||
userId String @db.ObjectId
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
gameId String? @db.ObjectId
|
||||
game Game? @relation(fields: [gameId], references: [id])
|
||||
bookId String? @db.ObjectId
|
||||
book Book? @relation(fields: [bookId], references: [id])
|
||||
musicId String? @db.ObjectId
|
||||
music Music? @relation(fields: [musicId], references: [id])
|
||||
artId String? @db.ObjectId
|
||||
art Art? @relation(fields: [artId], references: [id])
|
||||
showId String? @db.ObjectId
|
||||
show Show? @relation(fields: [showId], references: [id])
|
||||
mangaId String? @db.ObjectId
|
||||
manga Manga? @relation(fields: [mangaId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
action AuditAction
|
||||
category AuditCategory
|
||||
userId String? @db.ObjectId
|
||||
targetUserId String? @db.ObjectId
|
||||
resourceType String?
|
||||
resourceId String?
|
||||
details String?
|
||||
userAgent String?
|
||||
success Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
enum AuditAction {
|
||||
LOGIN
|
||||
LOGOUT
|
||||
LOGIN_FAILED
|
||||
COMMENT_CREATE
|
||||
COMMENT_UPDATE
|
||||
COMMENT_DELETE
|
||||
ENTRY_CREATE
|
||||
ENTRY_UPDATE
|
||||
ENTRY_DELETE
|
||||
LIKE
|
||||
UNLIKE
|
||||
USER_BAN
|
||||
USER_UNBAN
|
||||
RATE_LIMIT_EXCEEDED
|
||||
CSRF_VALIDATION_FAILED
|
||||
UNAUTHORIZED_ACCESS
|
||||
}
|
||||
|
||||
enum AuditCategory {
|
||||
AUTH
|
||||
CONTENT
|
||||
ADMIN
|
||||
SECURITY
|
||||
}
|
||||
|
||||
model Suggestion {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
userId String @db.ObjectId
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
entityType SuggestionEntity
|
||||
status SuggestionStatus @default(UNREVIEWED)
|
||||
declineReason String?
|
||||
|
||||
// Data for the suggested item (stored as JSON)
|
||||
title String
|
||||
gameData Json?
|
||||
bookData Json?
|
||||
musicData Json?
|
||||
artData Json?
|
||||
showData Json?
|
||||
mangaData Json?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
enum SuggestionEntity {
|
||||
GAME
|
||||
BOOK
|
||||
MUSIC
|
||||
ART
|
||||
SHOW
|
||||
MANGA
|
||||
}
|
||||
|
||||
enum SuggestionStatus {
|
||||
UNREVIEWED
|
||||
ACCEPTED
|
||||
DECLINED
|
||||
}
|
||||
|
||||
model Like {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
userId String @db.ObjectId
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
entityType String // 'book', 'game', 'show', 'manga', 'music', 'art'
|
||||
entityId String @db.ObjectId
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([userId, entityType, entityId])
|
||||
}
|
||||
|
||||
model RefreshToken {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
token String @unique
|
||||
userId String @db.ObjectId
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
@@index([expiresAt])
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"name": "api",
|
||||
"$schema": "../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "api/src",
|
||||
"projectType": "application",
|
||||
"tags": [],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/esbuild:esbuild",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"defaultConfiguration": "production",
|
||||
"options": {
|
||||
"platform": "node",
|
||||
"outputPath": "dist/api",
|
||||
"format": ["cjs"],
|
||||
"bundle": false,
|
||||
"main": "api/src/main.ts",
|
||||
"tsConfig": "api/tsconfig.app.json",
|
||||
"assets": ["api/src/assets", "api/generated"],
|
||||
"generatePackageJson": true,
|
||||
"esbuildOptions": {
|
||||
"sourcemap": true,
|
||||
"outExtension": {
|
||||
".js": ".js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"configurations": {
|
||||
"development": {},
|
||||
"production": {
|
||||
"esbuildOptions": {
|
||||
"sourcemap": false,
|
||||
"outExtension": {
|
||||
".js": ".js"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"prune-lockfile": {
|
||||
"dependsOn": ["build"],
|
||||
"cache": true,
|
||||
"executor": "@nx/js:prune-lockfile",
|
||||
"outputs": [
|
||||
"{workspaceRoot}/dist/api/package.json",
|
||||
"{workspaceRoot}/dist/api/pnpm-lock.yaml"
|
||||
],
|
||||
"options": {
|
||||
"buildTarget": "build"
|
||||
}
|
||||
},
|
||||
"copy-workspace-modules": {
|
||||
"dependsOn": ["build"],
|
||||
"cache": true,
|
||||
"outputs": ["{workspaceRoot}/dist/api/workspace_modules"],
|
||||
"executor": "@nx/js:copy-workspace-modules",
|
||||
"options": {
|
||||
"buildTarget": "build"
|
||||
}
|
||||
},
|
||||
"prune": {
|
||||
"dependsOn": ["prune-lockfile", "copy-workspace-modules"],
|
||||
"executor": "nx:noop"
|
||||
},
|
||||
"serve": {
|
||||
"continuous": true,
|
||||
"executor": "@nx/js:node",
|
||||
"defaultConfiguration": "development",
|
||||
"dependsOn": ["build"],
|
||||
"options": {
|
||||
"buildTarget": "api:build",
|
||||
"runBuildTargetDependencies": false
|
||||
},
|
||||
"configurations": {
|
||||
"development": {
|
||||
"buildTarget": "api:build:development"
|
||||
},
|
||||
"production": {
|
||||
"buildTarget": "api:build:production"
|
||||
}
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"options": {
|
||||
"passWithNoTests": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import Fastify, { FastifyInstance } from 'fastify';
|
||||
import { app } from './app';
|
||||
|
||||
describe('GET /', () => {
|
||||
let server: FastifyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
server = Fastify();
|
||||
server.register(app);
|
||||
});
|
||||
|
||||
it('should respond with a message', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
});
|
||||
|
||||
expect(response.json()).toEqual({ message: 'Hello API' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import * as path from 'path';
|
||||
import { FastifyInstance, FastifyError } from 'fastify';
|
||||
import AutoLoad from '@fastify/autoload';
|
||||
import { AuditService } from './services/audit.service';
|
||||
import { AuditAction, AuditCategory } from '@library/shared-types';
|
||||
|
||||
/* eslint-disable-next-line */
|
||||
export interface AppOptions {}
|
||||
|
||||
export async function app(fastify: FastifyInstance, opts: AppOptions) {
|
||||
// Add global error handler for security event logging
|
||||
fastify.setErrorHandler(async (error: FastifyError, request, reply) => {
|
||||
// Log CSRF validation failures
|
||||
if (error.code === 'FST_CSRF_INVALID_TOKEN' || error.code === 'FST_CSRF_MISSING_SECRET') {
|
||||
await AuditService.log({
|
||||
action: AuditAction.CSRF_VALIDATION_FAILED,
|
||||
category: AuditCategory.SECURITY,
|
||||
details: `CSRF validation failed: ${error.message}, URL: ${request.url}`,
|
||||
success: false,
|
||||
}, request).catch(() => {
|
||||
// Ignore logging errors
|
||||
});
|
||||
}
|
||||
|
||||
// Log unauthorized access attempts
|
||||
if (error.statusCode === 401 || error.statusCode === 403) {
|
||||
await AuditService.log({
|
||||
action: AuditAction.UNAUTHORIZED_ACCESS,
|
||||
category: AuditCategory.SECURITY,
|
||||
details: `Unauthorized access attempt: ${error.message}, URL: ${request.url}`,
|
||||
success: false,
|
||||
}, request).catch(() => {
|
||||
// Ignore logging errors
|
||||
});
|
||||
}
|
||||
|
||||
// Send the error response (don't leak internal details for server errors)
|
||||
const statusCode = error.statusCode ?? 500;
|
||||
|
||||
reply.status(statusCode).send({
|
||||
statusCode,
|
||||
error: statusCode >= 500 ? "Internal Server Error" : error.name,
|
||||
message: statusCode >= 500 ? "An unexpected error occurred" : error.message,
|
||||
});
|
||||
});
|
||||
|
||||
// This loads all plugins defined in plugins
|
||||
// those should be support plugins that are reused
|
||||
// through your application
|
||||
fastify.register(AutoLoad, {
|
||||
dir: path.join(__dirname, 'plugins'),
|
||||
options: { ...opts },
|
||||
});
|
||||
|
||||
// This loads all plugins defined in routes
|
||||
// define your routes in one of these
|
||||
fastify.register(AutoLoad, {
|
||||
dir: path.join(__dirname, 'routes'),
|
||||
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();
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
import { UserService } from "../services/user.service";
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
/**
|
||||
* Middleware to check if the authenticated user is an admin.
|
||||
* Must be used after app.authenticate.
|
||||
* Always checks the database to ensure admin status is current.
|
||||
*/
|
||||
export async function adminGuard(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
const user = request.user as { id: string };
|
||||
|
||||
if (!user?.id) {
|
||||
return reply.code(401).send({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
const dbUser = await userService.getUserById(user.id);
|
||||
if (!dbUser?.isAdmin) {
|
||||
return reply.code(403).send({ error: "Forbidden: Admin access required" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
import { UserService } from "../services/user.service";
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
/**
|
||||
* Middleware to check if the authenticated user is banned.
|
||||
* Must be used after app.authenticate.
|
||||
*/
|
||||
export async function bannedGuard(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
const user = request.user as { id: string };
|
||||
|
||||
if (!user?.id) {
|
||||
return reply.code(401).send({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
const isBanned = await userService.isUserBanned(user.id);
|
||||
if (isBanned) {
|
||||
return reply.code(403).send({ error: "You have been banned" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { FastifyPluginAsync, FastifyRequest } from "fastify";
|
||||
import fastifyPlugin from "fastify-plugin";
|
||||
import fastifyJwt from "@fastify/jwt";
|
||||
import fastifyCookie from "@fastify/cookie";
|
||||
import fastifyOauth2 from "@fastify/oauth2";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
authenticate: (request: FastifyRequest) => Promise<void>;
|
||||
oauth2Discord: any;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "@fastify/jwt" {
|
||||
interface FastifyJWT {
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
email?: string;
|
||||
avatar?: string;
|
||||
isAdmin: boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const getJwtSecret = (): string => {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error("JWT_SECRET environment variable is required");
|
||||
}
|
||||
return secret;
|
||||
};
|
||||
|
||||
const authPlugin: FastifyPluginAsync = async (app) => {
|
||||
const jwtSecret = getJwtSecret();
|
||||
|
||||
// Register cookie plugin with signing secret
|
||||
app.register(fastifyCookie, {
|
||||
secret: jwtSecret,
|
||||
});
|
||||
|
||||
// Register JWT plugin
|
||||
app.register(fastifyJwt, {
|
||||
secret: jwtSecret,
|
||||
sign: {
|
||||
algorithm: "HS256",
|
||||
},
|
||||
verify: {
|
||||
algorithms: ["HS256"],
|
||||
},
|
||||
cookie: {
|
||||
cookieName: "auth-token",
|
||||
signed: true,
|
||||
},
|
||||
formatUser: (payload: { sub: string; email?: string; username: string; isAdmin: boolean }) => {
|
||||
return {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
username: payload.username,
|
||||
isAdmin: payload.isAdmin,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Register Discord OAuth2
|
||||
app.register(fastifyOauth2, {
|
||||
name: "oauth2Discord",
|
||||
scope: ["identify", "email", "guilds", "guilds.members.read"],
|
||||
credentials: {
|
||||
client: {
|
||||
id: process.env.DISCORD_CLIENT_ID || "",
|
||||
secret: process.env.DISCORD_CLIENT_SECRET || "",
|
||||
},
|
||||
auth: fastifyOauth2.DISCORD_CONFIGURATION,
|
||||
},
|
||||
startRedirectPath: "/api/auth/login",
|
||||
callbackUri: `${process.env.BASE_URL || "http://localhost:3000"}/api/auth/callback`,
|
||||
});
|
||||
|
||||
// Authentication decorator
|
||||
app.decorate("authenticate", async (request: FastifyRequest) => {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
} catch (err) {
|
||||
throw app.httpErrors.unauthorized("Invalid token");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default fastifyPlugin(authPlugin);
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import fastifyPlugin from "fastify-plugin";
|
||||
import fastifyCors from "@fastify/cors";
|
||||
|
||||
const corsPlugin: FastifyPluginAsync = async (app) => {
|
||||
const baseUrl = process.env.BASE_URL;
|
||||
|
||||
if (!baseUrl) {
|
||||
throw new Error("BASE_URL environment variable is required");
|
||||
}
|
||||
|
||||
await app.register(fastifyCors, {
|
||||
origin: baseUrl,
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization", "X-CSRF-Token"],
|
||||
});
|
||||
};
|
||||
|
||||
export default fastifyPlugin(corsPlugin);
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync, FastifyRequest } from "fastify";
|
||||
import fastifyPlugin from "fastify-plugin";
|
||||
import fastifyCsrf from "@fastify/csrf-protection";
|
||||
|
||||
const csrfPlugin: FastifyPluginAsync = async (app) => {
|
||||
await app.register(fastifyCsrf, {
|
||||
sessionPlugin: "@fastify/cookie",
|
||||
cookieOpts: {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
},
|
||||
getToken: (request: FastifyRequest) => {
|
||||
return request.headers["x-csrf-token"] as string;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default fastifyPlugin(csrfPlugin);
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import fastifyPlugin from "fastify-plugin";
|
||||
import fastifyHelmet from "@fastify/helmet";
|
||||
|
||||
const helmetPlugin: FastifyPluginAsync = async (app) => {
|
||||
await app.register(fastifyHelmet, {
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", "data:", "https:"],
|
||||
scriptSrc: ["'self'"],
|
||||
connectSrc: ["'self'", process.env.FRONTEND_URL ?? "http://localhost:4200"],
|
||||
},
|
||||
},
|
||||
crossOriginEmbedderPolicy: false,
|
||||
crossOriginResourcePolicy: { policy: "cross-origin" },
|
||||
});
|
||||
};
|
||||
|
||||
export default fastifyPlugin(helmetPlugin);
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import fastifyPlugin from "fastify-plugin";
|
||||
import fastifyRateLimit from "@fastify/rate-limit";
|
||||
import { AuditService } from "../services/audit.service";
|
||||
import { AuditAction, AuditCategory } from "@library/shared-types";
|
||||
|
||||
const rateLimitPlugin: FastifyPluginAsync = async (app) => {
|
||||
await app.register(fastifyRateLimit, {
|
||||
max: 100,
|
||||
timeWindow: "1 minute",
|
||||
errorResponseBuilder: (request) => {
|
||||
// Log rate limit exceeded event
|
||||
AuditService.log({
|
||||
action: AuditAction.RATE_LIMIT_EXCEEDED,
|
||||
category: AuditCategory.SECURITY,
|
||||
details: `Rate limit exceeded for URL: ${request.url}`,
|
||||
success: false,
|
||||
}, request).catch(() => {
|
||||
// Ignore logging errors to avoid blocking the response
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 429,
|
||||
error: "Too Many Requests",
|
||||
message: "You have exceeded the rate limit. Please try again later.",
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default fastifyPlugin(rateLimitPlugin);
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
import sensible from '@fastify/sensible';
|
||||
|
||||
/**
|
||||
* This plugins adds some utilities to handle http errors
|
||||
*
|
||||
* @see https://github.com/fastify/fastify-sensible
|
||||
*/
|
||||
export default fp(async function (fastify: FastifyInstance) {
|
||||
fastify.register(sensible);
|
||||
});
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import { Art, CreateArtDto, UpdateArtDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types";
|
||||
import { ArtService } from "../../services/art.service";
|
||||
import { CommentService } from "../../services/comment.service";
|
||||
import { AuditService } from "../../services/audit.service";
|
||||
import { adminGuard } from "../../middleware/admin-guard";
|
||||
import { bannedGuard } from "../../middleware/banned-guard";
|
||||
|
||||
const artRoutes: FastifyPluginAsync = async (app) => {
|
||||
const artService = new ArtService();
|
||||
const commentService = new CommentService();
|
||||
|
||||
/**
|
||||
* Get all art (public route).
|
||||
*/
|
||||
app.get<{ Reply: Art[] }>("/", async () => {
|
||||
return artService.getAllArt();
|
||||
});
|
||||
|
||||
/**
|
||||
* Get single art piece by ID (public route).
|
||||
*/
|
||||
app.get<{ Params: { id: string }; Reply: Art | null }>(
|
||||
"/:id",
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
return artService.getArtById(id);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Create new art piece (admin only).
|
||||
*/
|
||||
app.post<{ Body: CreateArtDto; Reply: Art }>(
|
||||
"/",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const art = await artService.createArt(request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "art",
|
||||
resourceId: art.id,
|
||||
details: `Created art: ${art.title}`,
|
||||
});
|
||||
return art;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Update art by ID (admin only).
|
||||
*/
|
||||
app.put<{
|
||||
Params: { id: string };
|
||||
Body: UpdateArtDto;
|
||||
Reply: Art | null;
|
||||
}>(
|
||||
"/:id",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
const art = await artService.updateArt(id, request.body);
|
||||
if (art) {
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_UPDATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "art",
|
||||
resourceId: id,
|
||||
details: `Updated art: ${art.title}`,
|
||||
});
|
||||
}
|
||||
return art;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete art by ID (admin only).
|
||||
*/
|
||||
app.delete<{ Params: { id: string }; Reply: { success: boolean } }>(
|
||||
"/:id",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
await artService.deleteArt(id);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_DELETE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "art",
|
||||
resourceId: id,
|
||||
details: `Deleted art with ID: ${id}`,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get comments for an art piece (public route).
|
||||
*/
|
||||
app.get<{ Params: { id: string }; Reply: Comment[] }>(
|
||||
"/:id/comments",
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
return commentService.getCommentsForArt(id);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Add comment to an art piece (authenticated users).
|
||||
*/
|
||||
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
|
||||
"/:id/comments",
|
||||
{
|
||||
preValidation: [app.authenticate, bannedGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
const userId = request.user.id;
|
||||
const comment = await commentService.createCommentForArt(id, userId, request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "art",
|
||||
resourceId: id,
|
||||
details: `Added comment to art`,
|
||||
});
|
||||
return comment;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Update comment (owner or admin).
|
||||
*/
|
||||
app.put<{ Params: { id: string; commentId: string }; Body: CreateCommentDto; Reply: Comment | { error: string } }>(
|
||||
"/:id/comments/:commentId",
|
||||
{
|
||||
preValidation: [app.authenticate],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { id, commentId } = request.params;
|
||||
const userId = request.user.id;
|
||||
const isAdmin = request.user.isAdmin;
|
||||
|
||||
const verification = await commentService.verifyCommentOwnership(commentId, "art", id);
|
||||
|
||||
if (!verification.exists) {
|
||||
return reply.code(404).send({ error: "Comment not found" });
|
||||
}
|
||||
|
||||
if (verification.comment?.userId !== userId && !isAdmin) {
|
||||
return reply.code(403).send({ error: "You can only edit your own comments" });
|
||||
}
|
||||
|
||||
const comment = await commentService.updateComment(commentId, request.body.content);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_UPDATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "art",
|
||||
resourceId: id,
|
||||
details: `Updated comment ${commentId} on art`,
|
||||
});
|
||||
return comment;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete comment (owner or admin).
|
||||
*/
|
||||
app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } | { error: string } }>(
|
||||
"/:id/comments/:commentId",
|
||||
{
|
||||
preValidation: [app.authenticate],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { id, commentId } = request.params;
|
||||
const userId = request.user.id;
|
||||
const isAdmin = request.user.isAdmin;
|
||||
|
||||
const verification = await commentService.verifyCommentOwnership(commentId, "art", id);
|
||||
|
||||
if (!verification.exists) {
|
||||
return reply.code(404).send({ error: "Comment not found" });
|
||||
}
|
||||
|
||||
if (verification.comment?.userId !== userId && !isAdmin) {
|
||||
return reply.code(403).send({ error: "You can only delete your own comments" });
|
||||
}
|
||||
|
||||
await commentService.deleteComment(commentId);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_DELETE,
|
||||
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT,
|
||||
resourceType: "art",
|
||||
resourceId: id,
|
||||
details: `Deleted comment ${commentId} from art`,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default artRoutes;
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import { AuditService } from "../../services/audit.service";
|
||||
import { adminGuard } from "../../middleware/admin-guard";
|
||||
import type { AuditAction, AuditCategory } from "@library/shared-types";
|
||||
|
||||
interface AuditLogQuery {
|
||||
action?: AuditAction;
|
||||
category?: AuditCategory;
|
||||
userId?: string;
|
||||
success?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
page?: string;
|
||||
limit?: string;
|
||||
}
|
||||
|
||||
const auditRoutes: FastifyPluginAsync = async (app) => {
|
||||
/**
|
||||
* Get audit logs (admin only).
|
||||
*/
|
||||
app.get<{ Querystring: AuditLogQuery }>(
|
||||
"/",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
},
|
||||
async (request) => {
|
||||
const { action, category, userId, success, startDate, endDate, page, limit } = request.query;
|
||||
|
||||
return AuditService.getLogs({
|
||||
action: action as AuditAction | undefined,
|
||||
category: category as AuditCategory | undefined,
|
||||
userId,
|
||||
success: success === undefined ? undefined : success === "true",
|
||||
startDate: startDate ? new Date(startDate) : undefined,
|
||||
endDate: endDate ? new Date(endDate) : undefined,
|
||||
page: page ? parseInt(page, 10) : 1,
|
||||
limit: limit ? parseInt(limit, 10) : 50,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get security logs (admin only).
|
||||
*/
|
||||
app.get<{ Querystring: { page?: string; limit?: string } }>(
|
||||
"/security",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
},
|
||||
async (request) => {
|
||||
const { page, limit } = request.query;
|
||||
return AuditService.getSecurityLogs(
|
||||
page ? parseInt(page, 10) : 1,
|
||||
limit ? parseInt(limit, 10) : 50
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get logs for a specific user (admin only).
|
||||
*/
|
||||
app.get<{ Params: { userId: string }; Querystring: { page?: string; limit?: string } }>(
|
||||
"/user/:userId",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
},
|
||||
async (request) => {
|
||||
const { userId } = request.params;
|
||||
const { page, limit } = request.query;
|
||||
return AuditService.getLogsByUser(
|
||||
userId,
|
||||
page ? parseInt(page, 10) : 1,
|
||||
limit ? parseInt(limit, 10) : 50
|
||||
);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default auditRoutes;
|
||||
@@ -0,0 +1,264 @@
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import { AuthService } from "../../services/auth.service";
|
||||
import { AuditService } from "../../services/audit.service";
|
||||
import { AuthResponse, AuditAction, AuditCategory } from "@library/shared-types";
|
||||
|
||||
const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
const authService = new AuthService(app);
|
||||
|
||||
/**
|
||||
* Discord OAuth callback.
|
||||
*/
|
||||
app.get("/callback", async (request, reply) => {
|
||||
try {
|
||||
const tokenResult = await app.oauth2Discord.getAccessTokenFromAuthorizationCodeFlow(
|
||||
request
|
||||
);
|
||||
|
||||
// Get user data from Discord API
|
||||
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();
|
||||
|
||||
// Check if user is in our Discord server and has special roles
|
||||
let inDiscord = false;
|
||||
let isVip = false;
|
||||
let isMod = false;
|
||||
let isStaff = false;
|
||||
const guildId = process.env.DISCORD_GUILD_ID;
|
||||
const sponsorRoleId = process.env.SPONSOR_ROLE_ID;
|
||||
const modRoleId = process.env.MOD_ROLE_ID;
|
||||
const staffRoleId = process.env.STAFF_ROLE_ID;
|
||||
|
||||
if (guildId) {
|
||||
const guildsResponse = await fetch("https://discord.com/api/users/@me/guilds", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenResult.token.access_token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (guildsResponse.ok) {
|
||||
const guilds = await guildsResponse.json() as Array<{ id: string }>;
|
||||
inDiscord = guilds.some(guild => guild.id === guildId);
|
||||
}
|
||||
|
||||
// If user is in Discord, check for special roles
|
||||
if (inDiscord) {
|
||||
const memberResponse = await fetch(
|
||||
`https://discord.com/api/users/@me/guilds/${guildId}/member`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenResult.token.access_token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (memberResponse.ok) {
|
||||
const memberData = await memberResponse.json() as { roles: string[] };
|
||||
if (sponsorRoleId) {
|
||||
isVip = memberData.roles.includes(sponsorRoleId);
|
||||
}
|
||||
if (modRoleId) {
|
||||
isMod = memberData.roles.includes(modRoleId);
|
||||
}
|
||||
if (staffRoleId) {
|
||||
isStaff = memberData.roles.includes(staffRoleId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create or update user in database
|
||||
const user = await authService.createOrUpdateUserFromDiscord(userData, inDiscord, isVip, isMod, isStaff);
|
||||
|
||||
// Generate access token and refresh token
|
||||
const accessToken = await authService.generateToken(user);
|
||||
const refreshToken = await authService.generateRefreshToken(user.id);
|
||||
|
||||
// Log successful login
|
||||
await AuditService.log({
|
||||
action: AuditAction.LOGIN,
|
||||
category: AuditCategory.AUTH,
|
||||
userId: user.id,
|
||||
details: `User ${user.username} logged in via Discord`,
|
||||
success: true,
|
||||
}, request);
|
||||
|
||||
// Set signed cookies and redirect to frontend
|
||||
reply
|
||||
.setCookie("auth-token", accessToken, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 15 * 60, // 15 minutes
|
||||
signed: true,
|
||||
})
|
||||
.setCookie("refresh-token", refreshToken, {
|
||||
path: "/api/auth",
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 7 * 24 * 60 * 60, // 7 days
|
||||
signed: true,
|
||||
})
|
||||
.redirect("/"); // Redirect to root since API serves frontend
|
||||
} catch (error) {
|
||||
// Log failed login attempt
|
||||
await AuditService.log({
|
||||
action: AuditAction.LOGIN_FAILED,
|
||||
category: AuditCategory.SECURITY,
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
success: false,
|
||||
}, request);
|
||||
|
||||
app.log.error({ err: error }, "Auth callback error");
|
||||
reply
|
||||
.code(401)
|
||||
.send({ error: "Authentication failed" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get current user.
|
||||
*/
|
||||
app.get<{ Reply: AuthResponse | { error: string } }>(
|
||||
"/me",
|
||||
{
|
||||
preValidation: [app.authenticate],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const jwtUser = request.user as { id: string };
|
||||
const user = await authService.getUserById(jwtUser.id);
|
||||
|
||||
if (!user) {
|
||||
return reply.code(404).send({ error: "User not found" });
|
||||
}
|
||||
|
||||
const token = await authService.generateToken(user);
|
||||
|
||||
return {
|
||||
user,
|
||||
accessToken: token,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token.
|
||||
*/
|
||||
app.post("/refresh", async (request, reply) => {
|
||||
const signedRefreshToken = request.cookies["refresh-token"];
|
||||
if (!signedRefreshToken) {
|
||||
return reply.code(401).send({ error: "No refresh token provided" });
|
||||
}
|
||||
|
||||
const unsignedResult = request.unsignCookie(signedRefreshToken);
|
||||
if (!unsignedResult.valid || !unsignedResult.value) {
|
||||
return reply.code(401).send({ error: "Invalid refresh token" });
|
||||
}
|
||||
|
||||
const refreshToken = unsignedResult.value;
|
||||
const user = await authService.validateRefreshToken(refreshToken);
|
||||
|
||||
if (!user) {
|
||||
reply.clearCookie("refresh-token", { path: "/api/auth", signed: true });
|
||||
return reply.code(401).send({ error: "Refresh token expired or invalid" });
|
||||
}
|
||||
|
||||
if (user.isBanned) {
|
||||
await authService.revokeAllUserTokens(user.id);
|
||||
reply
|
||||
.clearCookie("auth-token", { path: "/", signed: true })
|
||||
.clearCookie("refresh-token", { path: "/api/auth", signed: true });
|
||||
return reply.code(403).send({ error: "Account is banned" });
|
||||
}
|
||||
|
||||
// Rotate refresh token for security
|
||||
const newRefreshToken = await authService.rotateRefreshToken(refreshToken, user.id);
|
||||
if (!newRefreshToken) {
|
||||
return reply.code(401).send({ error: "Failed to rotate refresh token" });
|
||||
}
|
||||
|
||||
const newAccessToken = await authService.generateToken(user);
|
||||
|
||||
reply
|
||||
.setCookie("auth-token", newAccessToken, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 15 * 60, // 15 minutes
|
||||
signed: true,
|
||||
})
|
||||
.setCookie("refresh-token", newRefreshToken, {
|
||||
path: "/api/auth",
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 7 * 24 * 60 * 60, // 7 days
|
||||
signed: true,
|
||||
})
|
||||
.send({ user, accessToken: newAccessToken });
|
||||
});
|
||||
|
||||
/**
|
||||
* Logout.
|
||||
*/
|
||||
app.post("/logout", async (request, reply) => {
|
||||
// Revoke refresh token if present
|
||||
const signedRefreshToken = request.cookies["refresh-token"];
|
||||
if (signedRefreshToken) {
|
||||
const unsignedResult = request.unsignCookie(signedRefreshToken);
|
||||
if (unsignedResult.valid && unsignedResult.value) {
|
||||
await authService.revokeRefreshToken(unsignedResult.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get user ID from JWT for audit logging
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
const user = request.user as { id?: string; username?: string };
|
||||
if (user?.id) {
|
||||
await AuditService.log({
|
||||
action: AuditAction.LOGOUT,
|
||||
category: AuditCategory.AUTH,
|
||||
userId: user.id,
|
||||
details: `User ${user.username ?? "unknown"} logged out`,
|
||||
success: true,
|
||||
}, request);
|
||||
}
|
||||
} catch {
|
||||
// User wasn't authenticated, just proceed with logout
|
||||
}
|
||||
|
||||
reply
|
||||
.clearCookie("auth-token", {
|
||||
path: "/",
|
||||
signed: true,
|
||||
})
|
||||
.clearCookie("refresh-token", {
|
||||
path: "/api/auth",
|
||||
signed: true,
|
||||
})
|
||||
.send({ message: "Logged out successfully" });
|
||||
});
|
||||
|
||||
/**
|
||||
* Get CSRF token for state-changing requests.
|
||||
*/
|
||||
app.get("/csrf-token", async (request, reply) => {
|
||||
const token = reply.generateCsrf();
|
||||
return { csrfToken: token };
|
||||
});
|
||||
};
|
||||
|
||||
export default authRoutes;
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import { Book, CreateBookDto, UpdateBookDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types";
|
||||
import { BookService } from "../../services/book.service";
|
||||
import { CommentService } from "../../services/comment.service";
|
||||
import { AuditService } from "../../services/audit.service";
|
||||
import { adminGuard } from "../../middleware/admin-guard";
|
||||
import { bannedGuard } from "../../middleware/banned-guard";
|
||||
|
||||
const booksRoutes: FastifyPluginAsync = async (app) => {
|
||||
const bookService = new BookService();
|
||||
const commentService = new CommentService();
|
||||
|
||||
/**
|
||||
* 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],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const book = await bookService.createBook(request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "book",
|
||||
resourceId: book.id,
|
||||
details: `Created book: ${book.title}`,
|
||||
});
|
||||
return book;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Update book by ID (admin only).
|
||||
*/
|
||||
app.put<{
|
||||
Params: { id: string };
|
||||
Body: UpdateBookDto;
|
||||
Reply: Book | null;
|
||||
}>(
|
||||
"/:id",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
const book = await bookService.updateBook(id, request.body);
|
||||
if (book) {
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_UPDATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "book",
|
||||
resourceId: id,
|
||||
details: `Updated book: ${book.title}`,
|
||||
});
|
||||
}
|
||||
return book;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete book by ID (admin only).
|
||||
*/
|
||||
app.delete<{ Params: { id: string }; Reply: { success: boolean } }>(
|
||||
"/:id",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
await bookService.deleteBook(id);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_DELETE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "book",
|
||||
resourceId: id,
|
||||
details: `Deleted book with ID: ${id}`,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get comments for a book (public route).
|
||||
*/
|
||||
app.get<{ Params: { id: string }; Reply: Comment[] }>(
|
||||
"/:id/comments",
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
return commentService.getCommentsForBook(id);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Add comment to a book (authenticated users).
|
||||
*/
|
||||
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
|
||||
"/:id/comments",
|
||||
{
|
||||
preValidation: [app.authenticate, bannedGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
const userId = request.user.id;
|
||||
const comment = await commentService.createCommentForBook(id, userId, request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "book",
|
||||
resourceId: id,
|
||||
details: `Added comment to book`,
|
||||
});
|
||||
return comment;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Update comment (owner or admin).
|
||||
*/
|
||||
app.put<{ Params: { id: string; commentId: string }; Body: CreateCommentDto; Reply: Comment | { error: string } }>(
|
||||
"/:id/comments/:commentId",
|
||||
{
|
||||
preValidation: [app.authenticate],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { id, commentId } = request.params;
|
||||
const userId = request.user.id;
|
||||
const isAdmin = request.user.isAdmin;
|
||||
|
||||
const verification = await commentService.verifyCommentOwnership(commentId, "book", id);
|
||||
|
||||
if (!verification.exists) {
|
||||
return reply.code(404).send({ error: "Comment not found" });
|
||||
}
|
||||
|
||||
if (verification.comment?.userId !== userId && !isAdmin) {
|
||||
return reply.code(403).send({ error: "You can only edit your own comments" });
|
||||
}
|
||||
|
||||
const comment = await commentService.updateComment(commentId, request.body.content);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_UPDATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "book",
|
||||
resourceId: id,
|
||||
details: `Updated comment ${commentId} on book`,
|
||||
});
|
||||
return comment;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete comment (owner or admin).
|
||||
*/
|
||||
app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } | { error: string } }>(
|
||||
"/:id/comments/:commentId",
|
||||
{
|
||||
preValidation: [app.authenticate],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { id, commentId } = request.params;
|
||||
const userId = request.user.id;
|
||||
const isAdmin = request.user.isAdmin;
|
||||
|
||||
const verification = await commentService.verifyCommentOwnership(commentId, "book", id);
|
||||
|
||||
if (!verification.exists) {
|
||||
return reply.code(404).send({ error: "Comment not found" });
|
||||
}
|
||||
|
||||
if (verification.comment?.userId !== userId && !isAdmin) {
|
||||
return reply.code(403).send({ error: "You can only delete your own comments" });
|
||||
}
|
||||
|
||||
await commentService.deleteComment(commentId);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_DELETE,
|
||||
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT,
|
||||
resourceType: "book",
|
||||
resourceId: id,
|
||||
details: `Deleted comment ${commentId} from book`,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default booksRoutes;
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import { Game, CreateGameDto, UpdateGameDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types";
|
||||
import { GameService } from "../../services/game.service";
|
||||
import { CommentService } from "../../services/comment.service";
|
||||
import { AuditService } from "../../services/audit.service";
|
||||
import { adminGuard } from "../../middleware/admin-guard";
|
||||
import { bannedGuard } from "../../middleware/banned-guard";
|
||||
|
||||
const gamesRoutes: FastifyPluginAsync = async (app) => {
|
||||
const gameService = new GameService();
|
||||
const commentService = new CommentService();
|
||||
|
||||
// Get all games (public route)
|
||||
app.get<{ Reply: Game[] }>("/", async () => {
|
||||
return gameService.getAllGames();
|
||||
});
|
||||
|
||||
// Get single game (public route)
|
||||
app.get<{ Params: { id: string }; Reply: Game | null }>(
|
||||
"/:id",
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
return gameService.getGameById(id);
|
||||
}
|
||||
);
|
||||
|
||||
// Create game (protected admin route)
|
||||
app.post<{ Body: CreateGameDto; Reply: Game }>(
|
||||
"/",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const game = await gameService.createGame(request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "game",
|
||||
resourceId: game.id,
|
||||
details: `Created game: ${game.title}`,
|
||||
});
|
||||
return game;
|
||||
}
|
||||
);
|
||||
|
||||
// Update game (protected admin route)
|
||||
app.put<{
|
||||
Params: { id: string };
|
||||
Body: UpdateGameDto;
|
||||
Reply: Game | null;
|
||||
}>(
|
||||
"/:id",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
const game = await gameService.updateGame(id, request.body);
|
||||
if (game) {
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_UPDATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "game",
|
||||
resourceId: id,
|
||||
details: `Updated game: ${game.title}`,
|
||||
});
|
||||
}
|
||||
return game;
|
||||
}
|
||||
);
|
||||
|
||||
// Delete game (protected admin route)
|
||||
app.delete<{ Params: { id: string }; Reply: { success: boolean } }>(
|
||||
"/:id",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
await gameService.deleteGame(id);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_DELETE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "game",
|
||||
resourceId: id,
|
||||
details: `Deleted game with ID: ${id}`,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
|
||||
// Get comments for a game (public route)
|
||||
app.get<{ Params: { id: string }; Reply: Comment[] }>(
|
||||
"/:id/comments",
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
return commentService.getCommentsForGame(id);
|
||||
}
|
||||
);
|
||||
|
||||
// Add comment to a game (authenticated users)
|
||||
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
|
||||
"/:id/comments",
|
||||
{
|
||||
preValidation: [app.authenticate, bannedGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
const userId = request.user.id;
|
||||
const comment = await commentService.createCommentForGame(id, userId, request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "game",
|
||||
resourceId: id,
|
||||
details: `Added comment to game`,
|
||||
});
|
||||
return comment;
|
||||
}
|
||||
);
|
||||
|
||||
// Update comment (owner or admin)
|
||||
app.put<{ Params: { id: string; commentId: string }; Body: CreateCommentDto; Reply: Comment | { error: string } }>(
|
||||
"/:id/comments/:commentId",
|
||||
{
|
||||
preValidation: [app.authenticate],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { id, commentId } = request.params;
|
||||
const userId = request.user.id;
|
||||
const isAdmin = request.user.isAdmin;
|
||||
|
||||
const verification = await commentService.verifyCommentOwnership(commentId, "game", id);
|
||||
|
||||
if (!verification.exists) {
|
||||
return reply.code(404).send({ error: "Comment not found" });
|
||||
}
|
||||
|
||||
if (verification.comment?.userId !== userId && !isAdmin) {
|
||||
return reply.code(403).send({ error: "You can only edit your own comments" });
|
||||
}
|
||||
|
||||
const comment = await commentService.updateComment(commentId, request.body.content);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_UPDATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "game",
|
||||
resourceId: id,
|
||||
details: `Updated comment ${commentId} on game`,
|
||||
});
|
||||
return comment;
|
||||
}
|
||||
);
|
||||
|
||||
// Delete comment (owner or admin)
|
||||
app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } | { error: string } }>(
|
||||
"/:id/comments/:commentId",
|
||||
{
|
||||
preValidation: [app.authenticate],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { id, commentId } = request.params;
|
||||
const userId = request.user.id;
|
||||
const isAdmin = request.user.isAdmin;
|
||||
|
||||
const verification = await commentService.verifyCommentOwnership(commentId, "game", id);
|
||||
|
||||
if (!verification.exists) {
|
||||
return reply.code(404).send({ error: "Comment not found" });
|
||||
}
|
||||
|
||||
if (verification.comment?.userId !== userId && !isAdmin) {
|
||||
return reply.code(403).send({ error: "You can only delete your own comments" });
|
||||
}
|
||||
|
||||
await commentService.deleteComment(commentId);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_DELETE,
|
||||
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT,
|
||||
resourceType: "game",
|
||||
resourceId: id,
|
||||
details: `Deleted comment ${commentId} from game`,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default gamesRoutes;
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import { CreateLikeDto, LikeResponse, LikedItemDto, AuditAction, AuditCategory } from "@library/shared-types";
|
||||
import { LikeService } from "../../services/like.service";
|
||||
import { AuditService } from "../../services/audit.service";
|
||||
import { bannedGuard } from "../../middleware/banned-guard";
|
||||
|
||||
const likesRoutes: FastifyPluginAsync = async (app) => {
|
||||
const likeService = new LikeService();
|
||||
|
||||
/**
|
||||
* Toggle like on an item (authenticated users).
|
||||
*/
|
||||
app.post<{ Body: CreateLikeDto; Reply: LikeResponse }>(
|
||||
"/toggle",
|
||||
{
|
||||
preValidation: [app.authenticate, bannedGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const userId = request.user.id;
|
||||
const { entityType, entityId } = request.body;
|
||||
const result = await likeService.toggleLike(userId, entityType, entityId, request);
|
||||
return result;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get like count for an item (public route).
|
||||
*/
|
||||
app.get<{
|
||||
Querystring: { entityType: string; entityId: string };
|
||||
}>(
|
||||
"/count",
|
||||
async (request, reply) => {
|
||||
const { entityType, entityId } = request.query;
|
||||
|
||||
// Validate entityType
|
||||
const validTypes = ['book', 'game', 'show', 'manga', 'music', 'art'];
|
||||
if (!validTypes.includes(entityType)) {
|
||||
reply.code(400);
|
||||
return { error: "Invalid entity type" };
|
||||
}
|
||||
|
||||
if (!entityId) {
|
||||
reply.code(400);
|
||||
return { error: "Entity ID is required" };
|
||||
}
|
||||
|
||||
const count = await likeService.getLikeCount(entityType as any, entityId);
|
||||
return { count };
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Check if current user has liked an item (authenticated users).
|
||||
*/
|
||||
app.get<{
|
||||
Querystring: { entityType: string; entityId: string };
|
||||
}>(
|
||||
"/status",
|
||||
{
|
||||
preValidation: [app.authenticate],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = request.user.id;
|
||||
const { entityType, entityId } = request.query;
|
||||
|
||||
// Validate entityType
|
||||
const validTypes = ['book', 'game', 'show', 'manga', 'music', 'art'];
|
||||
if (!validTypes.includes(entityType)) {
|
||||
reply.code(400);
|
||||
return { error: "Invalid entity type" };
|
||||
}
|
||||
|
||||
if (!entityId) {
|
||||
reply.code(400);
|
||||
return { error: "Entity ID is required" };
|
||||
}
|
||||
|
||||
const liked = await likeService.getUserLikeStatus(userId, entityType as any, entityId);
|
||||
return { liked };
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get all items liked by the current user (authenticated users).
|
||||
*/
|
||||
app.get<{
|
||||
Querystring: { entityType?: string };
|
||||
}>(
|
||||
"/user",
|
||||
{
|
||||
preValidation: [app.authenticate],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = request.user.id;
|
||||
const { entityType } = request.query;
|
||||
|
||||
// Validate entityType if provided
|
||||
if (entityType) {
|
||||
const validTypes = ['book', 'game', 'show', 'manga', 'music', 'art'];
|
||||
if (!validTypes.includes(entityType)) {
|
||||
reply.code(400);
|
||||
return { error: "Invalid entity type" };
|
||||
}
|
||||
}
|
||||
|
||||
const likedItems = await likeService.getUserLikedItems(userId, entityType as any);
|
||||
return likedItems;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get bulk like statuses for multiple items (authenticated users).
|
||||
* Useful for efficiently loading like status for lists.
|
||||
*/
|
||||
app.post<{
|
||||
Body: { items: Array<{ entityType: string; entityId: string }> };
|
||||
}>(
|
||||
"/bulk-status",
|
||||
{
|
||||
preValidation: [app.authenticate],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = request.user.id;
|
||||
const { items } = request.body;
|
||||
|
||||
if (!items || !Array.isArray(items)) {
|
||||
reply.code(400);
|
||||
return { error: "Items array is required" };
|
||||
}
|
||||
|
||||
if (items.length > 100) {
|
||||
reply.code(400);
|
||||
return { error: "Maximum 100 items allowed per request" };
|
||||
}
|
||||
|
||||
const validTypes = ['book', 'game', 'show', 'manga', 'music', 'art'];
|
||||
const results = await Promise.all(
|
||||
items.map(async (item) => {
|
||||
if (!validTypes.includes(item.entityType)) {
|
||||
return {
|
||||
entityType: item.entityType,
|
||||
entityId: item.entityId,
|
||||
liked: false,
|
||||
count: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const [liked, count] = await Promise.all([
|
||||
likeService.getUserLikeStatus(userId, item.entityType as any, item.entityId),
|
||||
likeService.getLikeCount(item.entityType as any, item.entityId),
|
||||
]);
|
||||
|
||||
return {
|
||||
entityType: item.entityType,
|
||||
entityId: item.entityId,
|
||||
liked,
|
||||
count,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default likesRoutes;
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import { Manga, CreateMangaDto, UpdateMangaDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types";
|
||||
import { MangaService } from "../../services/manga.service";
|
||||
import { CommentService } from "../../services/comment.service";
|
||||
import { AuditService } from "../../services/audit.service";
|
||||
import { adminGuard } from "../../middleware/admin-guard";
|
||||
import { bannedGuard } from "../../middleware/banned-guard";
|
||||
|
||||
const mangaRoutes: FastifyPluginAsync = async (app) => {
|
||||
const mangaService = new MangaService();
|
||||
const commentService = new CommentService();
|
||||
|
||||
app.get<{ Reply: Manga[] }>("/", async () => {
|
||||
return mangaService.getAllManga();
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string }; Reply: Manga | null }>(
|
||||
"/:id",
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
return mangaService.getMangaById(id);
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Body: CreateMangaDto; Reply: Manga }>(
|
||||
"/",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const manga = await mangaService.createManga(request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "manga",
|
||||
resourceId: manga.id,
|
||||
details: `Created manga: ${manga.title}`,
|
||||
});
|
||||
return manga;
|
||||
}
|
||||
);
|
||||
|
||||
app.put<{
|
||||
Params: { id: string };
|
||||
Body: UpdateMangaDto;
|
||||
Reply: Manga | null;
|
||||
}>(
|
||||
"/:id",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
const manga = await mangaService.updateManga(id, request.body);
|
||||
if (manga) {
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_UPDATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "manga",
|
||||
resourceId: id,
|
||||
details: `Updated manga: ${manga.title}`,
|
||||
});
|
||||
}
|
||||
return manga;
|
||||
}
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string }; Reply: { success: boolean } }>(
|
||||
"/:id",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
await mangaService.deleteManga(id);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_DELETE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "manga",
|
||||
resourceId: id,
|
||||
details: `Deleted manga with ID: ${id}`,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
|
||||
app.get<{ Params: { id: string }; Reply: Comment[] }>(
|
||||
"/:id/comments",
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
return commentService.getCommentsForManga(id);
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
|
||||
"/:id/comments",
|
||||
{
|
||||
preValidation: [app.authenticate, bannedGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
const userId = request.user.id;
|
||||
const comment = await commentService.createCommentForManga(id, userId, request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "manga",
|
||||
resourceId: id,
|
||||
details: `Added comment to manga`,
|
||||
});
|
||||
return comment;
|
||||
}
|
||||
);
|
||||
|
||||
app.put<{ Params: { id: string; commentId: string }; Body: CreateCommentDto; Reply: Comment | { error: string } }>(
|
||||
"/:id/comments/:commentId",
|
||||
{
|
||||
preValidation: [app.authenticate],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { id, commentId } = request.params;
|
||||
const userId = request.user.id;
|
||||
const isAdmin = request.user.isAdmin;
|
||||
|
||||
const verification = await commentService.verifyCommentOwnership(commentId, "manga", id);
|
||||
|
||||
if (!verification.exists) {
|
||||
return reply.code(404).send({ error: "Comment not found" });
|
||||
}
|
||||
|
||||
if (verification.comment?.userId !== userId && !isAdmin) {
|
||||
return reply.code(403).send({ error: "You can only edit your own comments" });
|
||||
}
|
||||
|
||||
const comment = await commentService.updateComment(commentId, request.body.content);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_UPDATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "manga",
|
||||
resourceId: id,
|
||||
details: `Updated comment ${commentId} on manga`,
|
||||
});
|
||||
return comment;
|
||||
}
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } | { error: string } }>(
|
||||
"/:id/comments/:commentId",
|
||||
{
|
||||
preValidation: [app.authenticate],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { id, commentId } = request.params;
|
||||
const userId = request.user.id;
|
||||
const isAdmin = request.user.isAdmin;
|
||||
|
||||
const verification = await commentService.verifyCommentOwnership(commentId, "manga", id);
|
||||
|
||||
if (!verification.exists) {
|
||||
return reply.code(404).send({ error: "Comment not found" });
|
||||
}
|
||||
|
||||
if (verification.comment?.userId !== userId && !isAdmin) {
|
||||
return reply.code(403).send({ error: "You can only delete your own comments" });
|
||||
}
|
||||
|
||||
await commentService.deleteComment(commentId);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_DELETE,
|
||||
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT,
|
||||
resourceType: "manga",
|
||||
resourceId: id,
|
||||
details: `Deleted comment ${commentId} from manga`,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default mangaRoutes;
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import { Music, CreateMusicDto, UpdateMusicDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types";
|
||||
import { MusicService } from "../../services/music.service";
|
||||
import { CommentService } from "../../services/comment.service";
|
||||
import { AuditService } from "../../services/audit.service";
|
||||
import { adminGuard } from "../../middleware/admin-guard";
|
||||
import { bannedGuard } from "../../middleware/banned-guard";
|
||||
|
||||
const musicRoutes: FastifyPluginAsync = async (app) => {
|
||||
const musicService = new MusicService();
|
||||
const commentService = new CommentService();
|
||||
|
||||
/**
|
||||
* 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],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const music = await musicService.createMusic(request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "music",
|
||||
resourceId: music.id,
|
||||
details: `Created music: ${music.title}`,
|
||||
});
|
||||
return music;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Update music item by ID (admin only).
|
||||
*/
|
||||
app.put<{
|
||||
Params: { id: string };
|
||||
Body: UpdateMusicDto;
|
||||
Reply: Music | null;
|
||||
}>(
|
||||
"/:id",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
const music = await musicService.updateMusic(id, request.body);
|
||||
if (music) {
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_UPDATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "music",
|
||||
resourceId: id,
|
||||
details: `Updated music: ${music.title}`,
|
||||
});
|
||||
}
|
||||
return music;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete music item by ID (admin only).
|
||||
*/
|
||||
app.delete<{ Params: { id: string }; Reply: { success: boolean } }>(
|
||||
"/:id",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
await musicService.deleteMusic(id);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_DELETE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "music",
|
||||
resourceId: id,
|
||||
details: `Deleted music with ID: ${id}`,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get comments for a music item (public route).
|
||||
*/
|
||||
app.get<{ Params: { id: string }; Reply: Comment[] }>(
|
||||
"/:id/comments",
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
return commentService.getCommentsForMusic(id);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Add comment to a music item (authenticated users).
|
||||
*/
|
||||
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
|
||||
"/:id/comments",
|
||||
{
|
||||
preValidation: [app.authenticate, bannedGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
const userId = request.user.id;
|
||||
const comment = await commentService.createCommentForMusic(id, userId, request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "music",
|
||||
resourceId: id,
|
||||
details: `Added comment to music`,
|
||||
});
|
||||
return comment;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Update comment (owner or admin).
|
||||
*/
|
||||
app.put<{ Params: { id: string; commentId: string }; Body: CreateCommentDto; Reply: Comment | { error: string } }>(
|
||||
"/:id/comments/:commentId",
|
||||
{
|
||||
preValidation: [app.authenticate],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { id, commentId } = request.params;
|
||||
const userId = request.user.id;
|
||||
const isAdmin = request.user.isAdmin;
|
||||
|
||||
const verification = await commentService.verifyCommentOwnership(commentId, "music", id);
|
||||
|
||||
if (!verification.exists) {
|
||||
return reply.code(404).send({ error: "Comment not found" });
|
||||
}
|
||||
|
||||
if (verification.comment?.userId !== userId && !isAdmin) {
|
||||
return reply.code(403).send({ error: "You can only edit your own comments" });
|
||||
}
|
||||
|
||||
const comment = await commentService.updateComment(commentId, request.body.content);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_UPDATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "music",
|
||||
resourceId: id,
|
||||
details: `Updated comment ${commentId} on music`,
|
||||
});
|
||||
return comment;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete comment (owner or admin).
|
||||
*/
|
||||
app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } | { error: string } }>(
|
||||
"/:id/comments/:commentId",
|
||||
{
|
||||
preValidation: [app.authenticate],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { id, commentId } = request.params;
|
||||
const userId = request.user.id;
|
||||
const isAdmin = request.user.isAdmin;
|
||||
|
||||
const verification = await commentService.verifyCommentOwnership(commentId, "music", id);
|
||||
|
||||
if (!verification.exists) {
|
||||
return reply.code(404).send({ error: "Comment not found" });
|
||||
}
|
||||
|
||||
if (verification.comment?.userId !== userId && !isAdmin) {
|
||||
return reply.code(403).send({ error: "You can only delete your own comments" });
|
||||
}
|
||||
|
||||
await commentService.deleteComment(commentId);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_DELETE,
|
||||
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT,
|
||||
resourceType: "music",
|
||||
resourceId: id,
|
||||
details: `Deleted comment ${commentId} from music`,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default musicRoutes;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
|
||||
export default async function (fastify: FastifyInstance) {
|
||||
fastify.get('/', async function () {
|
||||
return { message: 'Hello API' };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import { Show, CreateShowDto, UpdateShowDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types";
|
||||
import { ShowService } from "../../services/show.service";
|
||||
import { CommentService } from "../../services/comment.service";
|
||||
import { AuditService } from "../../services/audit.service";
|
||||
import { adminGuard } from "../../middleware/admin-guard";
|
||||
import { bannedGuard } from "../../middleware/banned-guard";
|
||||
|
||||
const showsRoutes: FastifyPluginAsync = async (app) => {
|
||||
const showService = new ShowService();
|
||||
const commentService = new CommentService();
|
||||
|
||||
app.get<{ Reply: Show[] }>("/", async () => {
|
||||
return showService.getAllShows();
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string }; Reply: Show | null }>(
|
||||
"/:id",
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
return showService.getShowById(id);
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Body: CreateShowDto; Reply: Show }>(
|
||||
"/",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const show = await showService.createShow(request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "show",
|
||||
resourceId: show.id,
|
||||
details: `Created show: ${show.title}`,
|
||||
});
|
||||
return show;
|
||||
}
|
||||
);
|
||||
|
||||
app.put<{
|
||||
Params: { id: string };
|
||||
Body: UpdateShowDto;
|
||||
Reply: Show | null;
|
||||
}>(
|
||||
"/:id",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
const show = await showService.updateShow(id, request.body);
|
||||
if (show) {
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_UPDATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "show",
|
||||
resourceId: id,
|
||||
details: `Updated show: ${show.title}`,
|
||||
});
|
||||
}
|
||||
return show;
|
||||
}
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string }; Reply: { success: boolean } }>(
|
||||
"/:id",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
await showService.deleteShow(id);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_DELETE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "show",
|
||||
resourceId: id,
|
||||
details: `Deleted show with ID: ${id}`,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
|
||||
app.get<{ Params: { id: string }; Reply: Comment[] }>(
|
||||
"/:id/comments",
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
return commentService.getCommentsForShow(id);
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
|
||||
"/:id/comments",
|
||||
{
|
||||
preValidation: [app.authenticate, bannedGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
const userId = request.user.id;
|
||||
const comment = await commentService.createCommentForShow(id, userId, request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "show",
|
||||
resourceId: id,
|
||||
details: `Added comment to show`,
|
||||
});
|
||||
return comment;
|
||||
}
|
||||
);
|
||||
|
||||
app.put<{ Params: { id: string; commentId: string }; Body: CreateCommentDto; Reply: Comment | { error: string } }>(
|
||||
"/:id/comments/:commentId",
|
||||
{
|
||||
preValidation: [app.authenticate],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { id, commentId } = request.params;
|
||||
const userId = request.user.id;
|
||||
const isAdmin = request.user.isAdmin;
|
||||
|
||||
const verification = await commentService.verifyCommentOwnership(commentId, "show", id);
|
||||
|
||||
if (!verification.exists) {
|
||||
return reply.code(404).send({ error: "Comment not found" });
|
||||
}
|
||||
|
||||
if (verification.comment?.userId !== userId && !isAdmin) {
|
||||
return reply.code(403).send({ error: "You can only edit your own comments" });
|
||||
}
|
||||
|
||||
const comment = await commentService.updateComment(commentId, request.body.content);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_UPDATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "show",
|
||||
resourceId: id,
|
||||
details: `Updated comment ${commentId} on show`,
|
||||
});
|
||||
return comment;
|
||||
}
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } | { error: string } }>(
|
||||
"/:id/comments/:commentId",
|
||||
{
|
||||
preValidation: [app.authenticate],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { id, commentId } = request.params;
|
||||
const userId = request.user.id;
|
||||
const isAdmin = request.user.isAdmin;
|
||||
|
||||
const verification = await commentService.verifyCommentOwnership(commentId, "show", id);
|
||||
|
||||
if (!verification.exists) {
|
||||
return reply.code(404).send({ error: "Comment not found" });
|
||||
}
|
||||
|
||||
if (verification.comment?.userId !== userId && !isAdmin) {
|
||||
return reply.code(403).send({ error: "You can only delete your own comments" });
|
||||
}
|
||||
|
||||
await commentService.deleteComment(commentId);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_DELETE,
|
||||
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT,
|
||||
resourceType: "show",
|
||||
resourceId: id,
|
||||
details: `Deleted comment ${commentId} from show`,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default showsRoutes;
|
||||
@@ -0,0 +1,228 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { SuggestionService } from "../../services/suggestion.service";
|
||||
import { AuditService } from "../../services/audit.service";
|
||||
import { AuditAction, AuditCategory } from "@library/shared-types";
|
||||
import type {
|
||||
SuggestionStatus,
|
||||
SuggestionEntity,
|
||||
CreateSuggestionDto,
|
||||
DeclineSuggestionDto,
|
||||
AcceptWithEditsDto,
|
||||
} from "@library/shared-types";
|
||||
import { adminGuard } from "../../middleware/admin-guard";
|
||||
import { bannedGuard } from "../../middleware/banned-guard";
|
||||
|
||||
export default async function (app: FastifyInstance): Promise<void> {
|
||||
// Get all suggestions (admin only)
|
||||
app.get<{
|
||||
Querystring: { status?: SuggestionStatus; entityType?: SuggestionEntity };
|
||||
}>(
|
||||
"/",
|
||||
{
|
||||
preHandler: [app.authenticate, adminGuard],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { status, entityType } = request.query;
|
||||
|
||||
const suggestions = await SuggestionService.getAllSuggestions({
|
||||
status,
|
||||
entityType,
|
||||
});
|
||||
|
||||
reply.send(suggestions);
|
||||
}
|
||||
);
|
||||
|
||||
// Get current user's suggestions
|
||||
app.get(
|
||||
"/my",
|
||||
{
|
||||
preHandler: [app.authenticate],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = request.user.id;
|
||||
const suggestions = await SuggestionService.getUserSuggestions(userId);
|
||||
reply.send(suggestions);
|
||||
}
|
||||
);
|
||||
|
||||
// Get a single suggestion by ID
|
||||
app.get<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{
|
||||
preHandler: [app.authenticate],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const suggestion = await SuggestionService.getSuggestionById(id);
|
||||
|
||||
if (!suggestion) {
|
||||
return reply.notFound("Suggestion not found");
|
||||
}
|
||||
|
||||
// Non-admins can only view their own suggestions
|
||||
if (!request.user.isAdmin && suggestion.userId !== request.user.id) {
|
||||
return reply.forbidden("You can only view your own suggestions");
|
||||
}
|
||||
|
||||
reply.send(suggestion);
|
||||
}
|
||||
);
|
||||
|
||||
// Create a new suggestion (any authenticated non-banned user)
|
||||
app.post<{ Body: CreateSuggestionDto }>(
|
||||
"/",
|
||||
{
|
||||
preHandler: [app.authenticate, bannedGuard, app.csrfProtection],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = request.user.id;
|
||||
|
||||
try {
|
||||
const suggestion = await SuggestionService.createSuggestion(
|
||||
userId,
|
||||
request.body
|
||||
);
|
||||
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "Suggestion",
|
||||
resourceId: suggestion.id,
|
||||
details: `Created ${suggestion.entityType} suggestion: ${suggestion.title}`,
|
||||
success: true,
|
||||
});
|
||||
|
||||
reply.send(suggestion);
|
||||
} catch (error) {
|
||||
return reply.badRequest(
|
||||
error instanceof Error ? error.message : "Failed to create suggestion"
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Accept a suggestion (admin only)
|
||||
app.put<{ Params: { id: string } }>(
|
||||
"/:id/accept",
|
||||
{
|
||||
preHandler: [app.authenticate, adminGuard, app.csrfProtection],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
|
||||
try {
|
||||
const suggestion = await SuggestionService.acceptSuggestion(id);
|
||||
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_UPDATE,
|
||||
category: AuditCategory.ADMIN,
|
||||
resourceType: "Suggestion",
|
||||
resourceId: suggestion.id,
|
||||
details: `Accepted ${suggestion.entityType} suggestion: ${suggestion.title}`,
|
||||
success: true,
|
||||
});
|
||||
|
||||
reply.send(suggestion);
|
||||
} catch (error) {
|
||||
return reply.badRequest(
|
||||
error instanceof Error ? error.message : "Failed to accept suggestion"
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Accept a suggestion with edits (admin only)
|
||||
app.put<{ Params: { id: string }; Body: AcceptWithEditsDto }>(
|
||||
"/:id/accept-with-edits",
|
||||
{
|
||||
preHandler: [app.authenticate, adminGuard, app.csrfProtection],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const editedData = request.body;
|
||||
|
||||
try {
|
||||
const suggestion = await SuggestionService.acceptSuggestionWithEdits(id, editedData);
|
||||
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_UPDATE,
|
||||
category: AuditCategory.ADMIN,
|
||||
resourceType: "Suggestion",
|
||||
resourceId: suggestion.id,
|
||||
details: `Accepted ${suggestion.entityType} suggestion with edits: ${suggestion.title}`,
|
||||
success: true,
|
||||
});
|
||||
|
||||
reply.send(suggestion);
|
||||
} catch (error) {
|
||||
return reply.badRequest(
|
||||
error instanceof Error ? error.message : "Failed to accept suggestion with edits"
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Decline a suggestion (admin only)
|
||||
app.put<{ Params: { id: string }; Body: DeclineSuggestionDto }>(
|
||||
"/:id/decline",
|
||||
{
|
||||
preHandler: [app.authenticate, adminGuard, app.csrfProtection],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const { reason } = request.body;
|
||||
|
||||
try {
|
||||
const suggestion = await SuggestionService.declineSuggestion(id, reason);
|
||||
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_UPDATE,
|
||||
category: AuditCategory.ADMIN,
|
||||
resourceType: "Suggestion",
|
||||
resourceId: suggestion.id,
|
||||
details: `Declined ${suggestion.entityType} suggestion: ${suggestion.title}${reason ? ` (Reason: ${reason})` : ""}`,
|
||||
success: true,
|
||||
});
|
||||
|
||||
reply.send(suggestion);
|
||||
} catch (error) {
|
||||
return reply.badRequest(
|
||||
error instanceof Error ? error.message : "Failed to decline suggestion"
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Delete a suggestion (owner or admin only, only if unreviewed)
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{
|
||||
preHandler: [app.authenticate, app.csrfProtection],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const userId = request.user.id;
|
||||
const isAdmin = request.user.isAdmin;
|
||||
|
||||
try {
|
||||
const suggestion = await SuggestionService.deleteSuggestion(id, userId, isAdmin);
|
||||
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_DELETE,
|
||||
category: isAdmin ? AuditCategory.ADMIN : AuditCategory.CONTENT,
|
||||
resourceType: "Suggestion",
|
||||
resourceId: suggestion.id,
|
||||
details: `Deleted ${suggestion.entityType} suggestion: ${suggestion.title}`,
|
||||
success: true,
|
||||
});
|
||||
|
||||
reply.send({ success: true });
|
||||
} catch (error) {
|
||||
return reply.badRequest(
|
||||
error instanceof Error ? error.message : "Failed to delete suggestion"
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import { User, AuditAction, AuditCategory } from "@library/shared-types";
|
||||
import { UserService } from "../../services/user.service";
|
||||
import { AuditService } from "../../services/audit.service";
|
||||
import { adminGuard } from "../../middleware/admin-guard";
|
||||
|
||||
const usersRoutes: FastifyPluginAsync = async (app) => {
|
||||
const userService = new UserService();
|
||||
|
||||
app.get<{ Reply: User[] }>(
|
||||
"/",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
},
|
||||
async () => {
|
||||
return userService.getAllUsers();
|
||||
}
|
||||
);
|
||||
|
||||
app.get<{ Params: { id: string }; Reply: User | null }>(
|
||||
"/:id",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
return userService.getUserById(id);
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string }; Reply: User | { error: string } }>(
|
||||
"/:id/ban",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const currentUser = request.user as { id: string };
|
||||
|
||||
if (currentUser.id === id) {
|
||||
return reply.code(400).send({ error: "You cannot ban yourself" });
|
||||
}
|
||||
|
||||
const user = await userService.banUser(id);
|
||||
if (!user) {
|
||||
return reply.code(404).send({ error: "User not found" });
|
||||
}
|
||||
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.USER_BAN,
|
||||
category: AuditCategory.ADMIN,
|
||||
targetUserId: id,
|
||||
details: `Banned user: ${user.username}`,
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string }; Reply: User | { error: string } }>(
|
||||
"/:id/unban",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const user = await userService.unbanUser(id);
|
||||
if (!user) {
|
||||
return reply.code(404).send({ error: "User not found" });
|
||||
}
|
||||
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.USER_UNBAN,
|
||||
category: AuditCategory.ADMIN,
|
||||
targetUserId: id,
|
||||
details: `Unbanned user: ${user.username}`,
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default usersRoutes;
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
interface PackageJson {
|
||||
version: string;
|
||||
}
|
||||
|
||||
let cachedVersion: string | null = null;
|
||||
|
||||
function getVersion(): string {
|
||||
if (cachedVersion) {
|
||||
return cachedVersion;
|
||||
}
|
||||
|
||||
try {
|
||||
const packageJsonPath = join(process.cwd(), "package.json");
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as PackageJson;
|
||||
cachedVersion = packageJson.version;
|
||||
return cachedVersion;
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export default async function (app: FastifyInstance): Promise<void> {
|
||||
app.get("/", async (_request, reply) => {
|
||||
reply.send({ version: getVersion() });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Art, CreateArtDto, UpdateArtDto } from "@library/shared-types";
|
||||
import { prisma } from "../lib/prisma";
|
||||
|
||||
export class ArtService {
|
||||
private prisma = prisma;
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Get all art pieces.
|
||||
*/
|
||||
async getAllArt(): Promise<Art[]> {
|
||||
const artPieces = await this.prisma.art.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return artPieces.map((art) => ({
|
||||
...art,
|
||||
description: art.description || undefined,
|
||||
tags: art.tags ?? [],
|
||||
links: art.links ?? [],
|
||||
dateAdded: art.dateAdded,
|
||||
createdAt: art.createdAt,
|
||||
updatedAt: art.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get art by ID.
|
||||
*/
|
||||
async getArtById(id: string): Promise<Art | null> {
|
||||
const art = await this.prisma.art.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!art) return null;
|
||||
|
||||
return {
|
||||
...art,
|
||||
description: art.description || undefined,
|
||||
tags: art.tags ?? [],
|
||||
links: art.links ?? [],
|
||||
dateAdded: art.dateAdded,
|
||||
createdAt: art.createdAt,
|
||||
updatedAt: art.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new art piece.
|
||||
*/
|
||||
async createArt(data: CreateArtDto): Promise<Art> {
|
||||
const art = await this.prisma.art.create({
|
||||
data,
|
||||
});
|
||||
|
||||
return {
|
||||
...art,
|
||||
description: art.description || undefined,
|
||||
tags: art.tags ?? [],
|
||||
links: art.links ?? [],
|
||||
dateAdded: art.dateAdded,
|
||||
createdAt: art.createdAt,
|
||||
updatedAt: art.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update art by ID.
|
||||
*/
|
||||
async updateArt(id: string, data: UpdateArtDto): Promise<Art> {
|
||||
const art = await this.prisma.art.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
|
||||
return {
|
||||
...art,
|
||||
description: art.description || undefined,
|
||||
tags: art.tags ?? [],
|
||||
links: art.links ?? [],
|
||||
dateAdded: art.dateAdded,
|
||||
createdAt: art.createdAt,
|
||||
updatedAt: art.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete art by ID.
|
||||
*/
|
||||
async deleteArt(id: string): Promise<void> {
|
||||
await this.prisma.art.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { FastifyRequest } from "fastify";
|
||||
import { prisma } from "../lib/prisma";
|
||||
import type { AuditAction, AuditCategory, AuditLogFilters } from "@library/shared-types";
|
||||
|
||||
interface AuditLogData {
|
||||
action: AuditAction;
|
||||
category: AuditCategory;
|
||||
userId?: string;
|
||||
targetUserId?: string;
|
||||
resourceType?: string;
|
||||
resourceId?: string;
|
||||
details?: string;
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
export const AuditService = {
|
||||
async log(data: AuditLogData, request?: FastifyRequest) {
|
||||
const userAgent = request?.headers["user-agent"] ?? undefined;
|
||||
|
||||
return prisma.auditLog.create({
|
||||
data: {
|
||||
action: data.action,
|
||||
category: data.category,
|
||||
userId: data.userId,
|
||||
targetUserId: data.targetUserId,
|
||||
resourceType: data.resourceType,
|
||||
resourceId: data.resourceId,
|
||||
details: data.details,
|
||||
userAgent,
|
||||
success: data.success ?? true,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async logFromRequest(
|
||||
request: FastifyRequest,
|
||||
data: Omit<AuditLogData, "userId">
|
||||
) {
|
||||
const userId = (request.user as { id?: string } | undefined)?.id;
|
||||
|
||||
return this.log(
|
||||
{
|
||||
...data,
|
||||
userId,
|
||||
},
|
||||
request
|
||||
);
|
||||
},
|
||||
|
||||
async getLogs(filters: AuditLogFilters = {}) {
|
||||
const { action, category, userId, success, startDate, endDate, page = 1, limit = 50 } = filters;
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
|
||||
if (action) {
|
||||
where.action = action;
|
||||
}
|
||||
if (category) {
|
||||
where.category = category;
|
||||
}
|
||||
if (userId) {
|
||||
where.userId = userId;
|
||||
}
|
||||
if (success !== undefined) {
|
||||
where.success = success;
|
||||
}
|
||||
if (startDate || endDate) {
|
||||
where.createdAt = {};
|
||||
if (startDate) {
|
||||
(where.createdAt as Record<string, Date>).gte = startDate;
|
||||
}
|
||||
if (endDate) {
|
||||
(where.createdAt as Record<string, Date>).lte = endDate;
|
||||
}
|
||||
}
|
||||
|
||||
const [rawLogs, total] = await Promise.all([
|
||||
prisma.auditLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.auditLog.count({ where }),
|
||||
]);
|
||||
|
||||
// Collect all unique user IDs to fetch
|
||||
const userIds = new Set<string>();
|
||||
for (const log of rawLogs) {
|
||||
if (log.userId) {
|
||||
userIds.add(log.userId);
|
||||
}
|
||||
if (log.targetUserId) {
|
||||
userIds.add(log.targetUserId);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all users in one query
|
||||
const users = userIds.size > 0
|
||||
? await prisma.user.findMany({
|
||||
where: { id: { in: Array.from(userIds) } },
|
||||
select: { id: true, username: true, avatar: true },
|
||||
})
|
||||
: [];
|
||||
|
||||
// Create a lookup map
|
||||
const userMap = new Map(users.map(u => [u.id, { id: u.id, username: u.username, avatar: u.avatar ?? undefined }]));
|
||||
|
||||
// Map logs with user info
|
||||
const logs = rawLogs.map(log => ({
|
||||
id: log.id,
|
||||
action: log.action,
|
||||
category: log.category,
|
||||
userId: log.userId ?? undefined,
|
||||
user: log.userId ? userMap.get(log.userId) : undefined,
|
||||
targetUserId: log.targetUserId ?? undefined,
|
||||
targetUser: log.targetUserId ? userMap.get(log.targetUserId) : undefined,
|
||||
resourceType: log.resourceType ?? undefined,
|
||||
resourceId: log.resourceId ?? undefined,
|
||||
details: log.details ?? undefined,
|
||||
userAgent: log.userAgent ?? undefined,
|
||||
success: log.success,
|
||||
createdAt: log.createdAt,
|
||||
}));
|
||||
|
||||
return {
|
||||
logs,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
},
|
||||
|
||||
async getLogsByUser(userId: string, page = 1, limit = 50) {
|
||||
return this.getLogs({ userId, page, limit });
|
||||
},
|
||||
|
||||
async getSecurityLogs(page = 1, limit = 50) {
|
||||
return this.getLogs({ category: "SECURITY" as AuditCategory, page, limit });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,235 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { JwtPayload, User } from "@library/shared-types";
|
||||
import { prisma } from "../lib/prisma";
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
const ACCESS_TOKEN_EXPIRY = "15m";
|
||||
const REFRESH_TOKEN_EXPIRY_DAYS = 7;
|
||||
|
||||
export class AuthService {
|
||||
private prisma = prisma;
|
||||
|
||||
constructor(private readonly app: FastifyInstance) {}
|
||||
|
||||
/**
|
||||
* Generate short-lived access token for user.
|
||||
*/
|
||||
async generateToken(user: User): Promise<string> {
|
||||
const payload: JwtPayload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
isAdmin: user.isAdmin,
|
||||
};
|
||||
|
||||
return this.app.jwt.sign(payload, {
|
||||
expiresIn: ACCESS_TOKEN_EXPIRY,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a secure refresh token and store it in the database.
|
||||
*/
|
||||
async generateRefreshToken(userId: string): Promise<string> {
|
||||
const token = randomBytes(64).toString("hex");
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + REFRESH_TOKEN_EXPIRY_DAYS);
|
||||
|
||||
await this.prisma.refreshToken.create({
|
||||
data: {
|
||||
token,
|
||||
userId,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and consume a refresh token, returning the user if valid.
|
||||
*/
|
||||
async validateRefreshToken(token: string): Promise<User | null> {
|
||||
const refreshToken = await this.prisma.refreshToken.findUnique({
|
||||
where: { token },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (!refreshToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (refreshToken.expiresAt < new Date()) {
|
||||
await this.prisma.refreshToken.delete({ where: { id: refreshToken.id } });
|
||||
return null;
|
||||
}
|
||||
|
||||
const dbUser = refreshToken.user;
|
||||
return {
|
||||
id: dbUser.id,
|
||||
discordId: dbUser.discordId,
|
||||
username: dbUser.username,
|
||||
email: dbUser.email,
|
||||
avatar: dbUser.avatar || undefined,
|
||||
isAdmin: dbUser.isAdmin,
|
||||
isBanned: dbUser.isBanned,
|
||||
inDiscord: dbUser.inDiscord,
|
||||
isVip: dbUser.isVip,
|
||||
isMod: dbUser.isMod,
|
||||
isStaff: dbUser.isStaff,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate a refresh token (invalidate old, create new with same expiry).
|
||||
* Preserves original expiry so users must re-auth with Discord every 7 days.
|
||||
*/
|
||||
async rotateRefreshToken(oldToken: string, userId: string): Promise<string | null> {
|
||||
const existingToken = await this.prisma.refreshToken.findUnique({
|
||||
where: { token: oldToken },
|
||||
});
|
||||
|
||||
if (!existingToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const originalExpiry = existingToken.expiresAt;
|
||||
|
||||
await this.prisma.refreshToken.delete({
|
||||
where: { id: existingToken.id },
|
||||
});
|
||||
|
||||
const newToken = randomBytes(64).toString("hex");
|
||||
|
||||
await this.prisma.refreshToken.create({
|
||||
data: {
|
||||
token: newToken,
|
||||
userId,
|
||||
expiresAt: originalExpiry,
|
||||
},
|
||||
});
|
||||
|
||||
return newToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a specific refresh token.
|
||||
*/
|
||||
async revokeRefreshToken(token: string): Promise<void> {
|
||||
await this.prisma.refreshToken.deleteMany({
|
||||
where: { token },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all refresh tokens for a user (logout from all devices).
|
||||
*/
|
||||
async revokeAllUserTokens(userId: string): Promise<void> {
|
||||
await this.prisma.refreshToken.deleteMany({
|
||||
where: { userId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired refresh tokens (can be called periodically).
|
||||
*/
|
||||
async cleanupExpiredTokens(): Promise<number> {
|
||||
const result = await this.prisma.refreshToken.deleteMany({
|
||||
where: {
|
||||
expiresAt: { lt: new Date() },
|
||||
},
|
||||
});
|
||||
return result.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify JWT token.
|
||||
*/
|
||||
async verifyToken(token: string): Promise<JwtPayload> {
|
||||
return this.app.jwt.verify(token) as JwtPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by ID from database.
|
||||
*/
|
||||
async getUserById(id: string): Promise<User | null> {
|
||||
const dbUser = await this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!dbUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: dbUser.id,
|
||||
discordId: dbUser.discordId,
|
||||
username: dbUser.username,
|
||||
email: dbUser.email,
|
||||
avatar: dbUser.avatar || undefined,
|
||||
isAdmin: dbUser.isAdmin,
|
||||
isBanned: dbUser.isBanned,
|
||||
inDiscord: dbUser.inDiscord,
|
||||
isVip: dbUser.isVip,
|
||||
isMod: dbUser.isMod,
|
||||
isStaff: dbUser.isStaff,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update user from Discord OAuth data.
|
||||
*/
|
||||
async createOrUpdateUserFromDiscord(discordData: DiscordUser, inDiscord: boolean, isVip: boolean, isMod: boolean, isStaff: boolean): Promise<User> {
|
||||
const avatarUrl = discordData.avatar
|
||||
? `https://cdn.discordapp.com/avatars/${discordData.id}/${discordData.avatar}.png`
|
||||
: undefined;
|
||||
|
||||
// Upsert user in database
|
||||
const dbUser = await this.prisma.user.upsert({
|
||||
where: {
|
||||
discordId: discordData.id,
|
||||
},
|
||||
create: {
|
||||
discordId: discordData.id,
|
||||
username: discordData.username,
|
||||
email: discordData.email,
|
||||
avatar: avatarUrl,
|
||||
isAdmin: discordData.id === process.env.ADMIN_DISCORD_ID,
|
||||
inDiscord,
|
||||
isVip,
|
||||
isMod,
|
||||
isStaff,
|
||||
},
|
||||
update: {
|
||||
username: discordData.username,
|
||||
email: discordData.email,
|
||||
avatar: avatarUrl,
|
||||
inDiscord,
|
||||
isVip,
|
||||
isMod,
|
||||
isStaff,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: dbUser.id,
|
||||
discordId: dbUser.discordId,
|
||||
username: dbUser.username,
|
||||
email: dbUser.email,
|
||||
avatar: dbUser.avatar || undefined,
|
||||
isAdmin: dbUser.isAdmin,
|
||||
isBanned: dbUser.isBanned,
|
||||
inDiscord: dbUser.inDiscord,
|
||||
isVip: dbUser.isVip,
|
||||
isMod: dbUser.isMod,
|
||||
isStaff: dbUser.isStaff,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface DiscordUser {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* @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 as unknown as BookStatus,
|
||||
dateAdded: book.dateAdded,
|
||||
dateFinished: book.dateFinished || undefined,
|
||||
tags: book.tags ?? [],
|
||||
links: book.links ?? [],
|
||||
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 as unknown as BookStatus,
|
||||
dateAdded: book.dateAdded,
|
||||
dateFinished: book.dateFinished || undefined,
|
||||
tags: book.tags ?? [],
|
||||
links: book.links ?? [],
|
||||
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 as unknown as BookStatus,
|
||||
dateAdded: book.dateAdded,
|
||||
dateFinished: book.dateFinished || undefined,
|
||||
tags: book.tags ?? [],
|
||||
links: book.links ?? [],
|
||||
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 as unknown as BookStatus,
|
||||
dateAdded: book.dateAdded,
|
||||
dateFinished: book.dateFinished || undefined,
|
||||
tags: book.tags ?? [],
|
||||
links: book.links ?? [],
|
||||
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,294 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Comment, CreateCommentDto } from "@library/shared-types";
|
||||
import { prisma } from "../lib/prisma";
|
||||
import createDOMPurify from "dompurify";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { marked } from "marked";
|
||||
|
||||
const window = new JSDOM("").window;
|
||||
const DOMPurify = createDOMPurify(window);
|
||||
|
||||
// Add hook to sanitise links - prevent javascript: URLs and add security attributes
|
||||
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
|
||||
if (node.tagName === "A") {
|
||||
const href = node.getAttribute("href") || "";
|
||||
// Block javascript:, data:, and vbscript: URLs
|
||||
if (/^(javascript|data|vbscript):/i.test(href)) {
|
||||
node.removeAttribute("href");
|
||||
} else {
|
||||
// Add security attributes to external links
|
||||
node.setAttribute("target", "_blank");
|
||||
node.setAttribute("rel", "noopener noreferrer nofollow");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export class CommentService {
|
||||
private prisma = prisma;
|
||||
|
||||
constructor() {}
|
||||
|
||||
private sanitizeMarkdown(content: string): string {
|
||||
const html = marked.parse(content, { async: false }) as string;
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: [
|
||||
"p", "br", "strong", "em", "b", "i", "u", "s", "strike",
|
||||
"h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"ul", "ol", "li",
|
||||
"blockquote", "code", "pre",
|
||||
"a", "hr",
|
||||
],
|
||||
ALLOWED_ATTR: ["href", "target", "rel"],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
ADD_ATTR: ["target", "rel"],
|
||||
FORCE_BODY: true,
|
||||
});
|
||||
}
|
||||
|
||||
private mapComment(comment: any): Comment {
|
||||
return {
|
||||
id: comment.id,
|
||||
content: comment.content,
|
||||
rawContent: comment.rawContent || undefined,
|
||||
userId: comment.userId,
|
||||
user: {
|
||||
id: comment.user.id,
|
||||
username: comment.user.username,
|
||||
avatar: comment.user.avatar || undefined,
|
||||
inDiscord: comment.user.inDiscord,
|
||||
isVip: comment.user.isVip,
|
||||
isMod: comment.user.isMod,
|
||||
isStaff: comment.user.isStaff,
|
||||
},
|
||||
gameId: comment.gameId || undefined,
|
||||
bookId: comment.bookId || undefined,
|
||||
musicId: comment.musicId || undefined,
|
||||
artId: comment.artId || undefined,
|
||||
showId: comment.showId || undefined,
|
||||
mangaId: comment.mangaId || undefined,
|
||||
createdAt: comment.createdAt,
|
||||
updatedAt: comment.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async getCommentsForGame(gameId: string): Promise<Comment[]> {
|
||||
const comments = await this.prisma.comment.findMany({
|
||||
where: { gameId },
|
||||
include: { user: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return comments.map((c) => this.mapComment(c));
|
||||
}
|
||||
|
||||
async getCommentsForBook(bookId: string): Promise<Comment[]> {
|
||||
const comments = await this.prisma.comment.findMany({
|
||||
where: { bookId },
|
||||
include: { user: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return comments.map((c) => this.mapComment(c));
|
||||
}
|
||||
|
||||
async getCommentsForMusic(musicId: string): Promise<Comment[]> {
|
||||
const comments = await this.prisma.comment.findMany({
|
||||
where: { musicId },
|
||||
include: { user: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return comments.map((c) => this.mapComment(c));
|
||||
}
|
||||
|
||||
async createCommentForGame(
|
||||
gameId: string,
|
||||
userId: string,
|
||||
data: CreateCommentDto
|
||||
): Promise<Comment> {
|
||||
const sanitizedContent = this.sanitizeMarkdown(data.content);
|
||||
const comment = await this.prisma.comment.create({
|
||||
data: {
|
||||
content: sanitizedContent,
|
||||
rawContent: data.content,
|
||||
userId,
|
||||
gameId,
|
||||
},
|
||||
include: { user: true },
|
||||
});
|
||||
return this.mapComment(comment);
|
||||
}
|
||||
|
||||
async createCommentForBook(
|
||||
bookId: string,
|
||||
userId: string,
|
||||
data: CreateCommentDto
|
||||
): Promise<Comment> {
|
||||
const sanitizedContent = this.sanitizeMarkdown(data.content);
|
||||
const comment = await this.prisma.comment.create({
|
||||
data: {
|
||||
content: sanitizedContent,
|
||||
rawContent: data.content,
|
||||
userId,
|
||||
bookId,
|
||||
},
|
||||
include: { user: true },
|
||||
});
|
||||
return this.mapComment(comment);
|
||||
}
|
||||
|
||||
async createCommentForMusic(
|
||||
musicId: string,
|
||||
userId: string,
|
||||
data: CreateCommentDto
|
||||
): Promise<Comment> {
|
||||
const sanitizedContent = this.sanitizeMarkdown(data.content);
|
||||
const comment = await this.prisma.comment.create({
|
||||
data: {
|
||||
content: sanitizedContent,
|
||||
rawContent: data.content,
|
||||
userId,
|
||||
musicId,
|
||||
},
|
||||
include: { user: true },
|
||||
});
|
||||
return this.mapComment(comment);
|
||||
}
|
||||
|
||||
async getCommentsForArt(artId: string): Promise<Comment[]> {
|
||||
const comments = await this.prisma.comment.findMany({
|
||||
where: { artId },
|
||||
include: { user: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return comments.map((c) => this.mapComment(c));
|
||||
}
|
||||
|
||||
async createCommentForArt(
|
||||
artId: string,
|
||||
userId: string,
|
||||
data: CreateCommentDto
|
||||
): Promise<Comment> {
|
||||
const sanitizedContent = this.sanitizeMarkdown(data.content);
|
||||
const comment = await this.prisma.comment.create({
|
||||
data: {
|
||||
content: sanitizedContent,
|
||||
rawContent: data.content,
|
||||
userId,
|
||||
artId,
|
||||
},
|
||||
include: { user: true },
|
||||
});
|
||||
return this.mapComment(comment);
|
||||
}
|
||||
|
||||
async getCommentsForShow(showId: string): Promise<Comment[]> {
|
||||
const comments = await this.prisma.comment.findMany({
|
||||
where: { showId },
|
||||
include: { user: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return comments.map((c) => this.mapComment(c));
|
||||
}
|
||||
|
||||
async createCommentForShow(
|
||||
showId: string,
|
||||
userId: string,
|
||||
data: CreateCommentDto
|
||||
): Promise<Comment> {
|
||||
const sanitizedContent = this.sanitizeMarkdown(data.content);
|
||||
const comment = await this.prisma.comment.create({
|
||||
data: {
|
||||
content: sanitizedContent,
|
||||
rawContent: data.content,
|
||||
userId,
|
||||
showId,
|
||||
},
|
||||
include: { user: true },
|
||||
});
|
||||
return this.mapComment(comment);
|
||||
}
|
||||
|
||||
async getCommentsForManga(mangaId: string): Promise<Comment[]> {
|
||||
const comments = await this.prisma.comment.findMany({
|
||||
where: { mangaId },
|
||||
include: { user: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return comments.map((c) => this.mapComment(c));
|
||||
}
|
||||
|
||||
async createCommentForManga(
|
||||
mangaId: string,
|
||||
userId: string,
|
||||
data: CreateCommentDto
|
||||
): Promise<Comment> {
|
||||
const sanitizedContent = this.sanitizeMarkdown(data.content);
|
||||
const comment = await this.prisma.comment.create({
|
||||
data: {
|
||||
content: sanitizedContent,
|
||||
rawContent: data.content,
|
||||
userId,
|
||||
mangaId,
|
||||
},
|
||||
include: { user: true },
|
||||
});
|
||||
return this.mapComment(comment);
|
||||
}
|
||||
|
||||
async getCommentById(commentId: string) {
|
||||
return this.prisma.comment.findUnique({
|
||||
where: { id: commentId },
|
||||
include: { user: true },
|
||||
});
|
||||
}
|
||||
|
||||
async updateComment(
|
||||
commentId: string,
|
||||
content: string
|
||||
): Promise<Comment> {
|
||||
const sanitizedContent = this.sanitizeMarkdown(content);
|
||||
const comment = await this.prisma.comment.update({
|
||||
where: { id: commentId },
|
||||
data: {
|
||||
content: sanitizedContent,
|
||||
rawContent: content,
|
||||
},
|
||||
include: { user: true },
|
||||
});
|
||||
return this.mapComment(comment);
|
||||
}
|
||||
|
||||
async deleteComment(commentId: string): Promise<void> {
|
||||
await this.prisma.comment.delete({
|
||||
where: { id: commentId },
|
||||
});
|
||||
}
|
||||
|
||||
async verifyCommentOwnership(
|
||||
commentId: string,
|
||||
resourceType: "game" | "book" | "music" | "art" | "show" | "manga",
|
||||
resourceId: string
|
||||
): Promise<{ exists: boolean; comment?: { userId: string } }> {
|
||||
const fieldMap = {
|
||||
game: "gameId",
|
||||
book: "bookId",
|
||||
music: "musicId",
|
||||
art: "artId",
|
||||
show: "showId",
|
||||
manga: "mangaId",
|
||||
};
|
||||
|
||||
const comment = await this.prisma.comment.findFirst({
|
||||
where: {
|
||||
id: commentId,
|
||||
[fieldMap[resourceType]]: resourceId,
|
||||
},
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
return comment ? { exists: true, comment } : { exists: false };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* @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 as unknown as GameStatus,
|
||||
dateAdded: game.dateAdded,
|
||||
dateCompleted: game.dateCompleted || undefined,
|
||||
tags: game.tags ?? [],
|
||||
links: game.links ?? [],
|
||||
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 as unknown as GameStatus,
|
||||
dateAdded: game.dateAdded,
|
||||
dateCompleted: game.dateCompleted || undefined,
|
||||
tags: game.tags ?? [],
|
||||
links: game.links ?? [],
|
||||
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 as unknown as GameStatus,
|
||||
dateAdded: game.dateAdded,
|
||||
dateCompleted: game.dateCompleted || undefined,
|
||||
tags: game.tags ?? [],
|
||||
links: game.links ?? [],
|
||||
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 as unknown as GameStatus,
|
||||
dateAdded: game.dateAdded,
|
||||
dateCompleted: game.dateCompleted || undefined,
|
||||
tags: game.tags ?? [],
|
||||
links: game.links ?? [],
|
||||
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,15 @@
|
||||
/**
|
||||
* @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";
|
||||
export { ShowService } from "./show.service";
|
||||
export { MangaService } from "./manga.service";
|
||||
export { AuditService } from "./audit.service";
|
||||
export { SuggestionService } from "./suggestion.service";
|
||||
export { LikeService } from "./like.service";
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { AuditService } from './audit.service';
|
||||
import type { Like, LikeCountDto, LikedItemDto, LikeResponse } from '@library/shared-types';
|
||||
import { AuditAction, AuditCategory } from '@library/shared-types';
|
||||
|
||||
export class LikeService {
|
||||
async toggleLike(userId: string, entityType: Like['entityType'], entityId: string, req: FastifyRequest): Promise<LikeResponse> {
|
||||
// Check if like exists
|
||||
const existingLike = await prisma.like.findUnique({
|
||||
where: {
|
||||
userId_entityType_entityId: {
|
||||
userId,
|
||||
entityType,
|
||||
entityId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (existingLike) {
|
||||
// Unlike
|
||||
await prisma.like.delete({
|
||||
where: {
|
||||
id: existingLike.id
|
||||
}
|
||||
});
|
||||
|
||||
await AuditService.logFromRequest(req, {
|
||||
action: AuditAction.UNLIKE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: entityType,
|
||||
resourceId: entityId,
|
||||
details: `Unliked ${entityType}`
|
||||
});
|
||||
|
||||
const count = await this.getLikeCount(entityType, entityId);
|
||||
return { liked: false, count };
|
||||
} else {
|
||||
// Like
|
||||
await prisma.like.create({
|
||||
data: {
|
||||
userId,
|
||||
entityType,
|
||||
entityId
|
||||
}
|
||||
});
|
||||
|
||||
await AuditService.logFromRequest(req, {
|
||||
action: AuditAction.LIKE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: entityType,
|
||||
resourceId: entityId,
|
||||
details: `Liked ${entityType}`
|
||||
});
|
||||
|
||||
const count = await this.getLikeCount(entityType, entityId);
|
||||
return { liked: true, count };
|
||||
}
|
||||
}
|
||||
|
||||
async getLikeCount(entityType: Like['entityType'], entityId: string): Promise<number> {
|
||||
return await prisma.like.count({
|
||||
where: {
|
||||
entityType,
|
||||
entityId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getUserLikeStatus(userId: string, entityType: Like['entityType'], entityId: string): Promise<boolean> {
|
||||
const like = await prisma.like.findUnique({
|
||||
where: {
|
||||
userId_entityType_entityId: {
|
||||
userId,
|
||||
entityType,
|
||||
entityId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return !!like;
|
||||
}
|
||||
|
||||
async getLikeCounts(entityType: Like['entityType'], entityIds: string[]): Promise<LikeCountDto[]> {
|
||||
const likes = await prisma.like.groupBy({
|
||||
by: ['entityId'],
|
||||
where: {
|
||||
entityType,
|
||||
entityId: { in: entityIds }
|
||||
},
|
||||
_count: true
|
||||
});
|
||||
|
||||
return likes.map(like => ({
|
||||
entityId: like.entityId,
|
||||
entityType,
|
||||
count: like._count
|
||||
}));
|
||||
}
|
||||
|
||||
async getUserLikeStatuses(userId: string, entityType: Like['entityType'], entityIds: string[]): Promise<Record<string, boolean>> {
|
||||
const likes = await prisma.like.findMany({
|
||||
where: {
|
||||
userId,
|
||||
entityType,
|
||||
entityId: { in: entityIds }
|
||||
},
|
||||
select: {
|
||||
entityId: true
|
||||
}
|
||||
});
|
||||
|
||||
const likeMap: Record<string, boolean> = {};
|
||||
entityIds.forEach(id => {
|
||||
likeMap[id] = false;
|
||||
});
|
||||
likes.forEach(like => {
|
||||
likeMap[like.entityId] = true;
|
||||
});
|
||||
|
||||
return likeMap;
|
||||
}
|
||||
|
||||
async getUserLikedItems(userId: string, entityType?: Like['entityType']): Promise<LikedItemDto[]> {
|
||||
const likes = await prisma.like.findMany({
|
||||
where: {
|
||||
userId,
|
||||
...(entityType ? { entityType } : {})
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch the actual items for each like
|
||||
const likedItems: LikedItemDto[] = [];
|
||||
|
||||
for (const like of likes) {
|
||||
let item: any = null;
|
||||
|
||||
switch (like.entityType) {
|
||||
case 'book':
|
||||
item = await prisma.book.findUnique({ where: { id: like.entityId } });
|
||||
break;
|
||||
case 'game':
|
||||
item = await prisma.game.findUnique({ where: { id: like.entityId } });
|
||||
break;
|
||||
case 'show':
|
||||
item = await prisma.show.findUnique({ where: { id: like.entityId } });
|
||||
break;
|
||||
case 'manga':
|
||||
item = await prisma.manga.findUnique({ where: { id: like.entityId } });
|
||||
break;
|
||||
case 'music':
|
||||
item = await prisma.music.findUnique({ where: { id: like.entityId } });
|
||||
break;
|
||||
case 'art':
|
||||
item = await prisma.art.findUnique({ where: { id: like.entityId } });
|
||||
break;
|
||||
}
|
||||
|
||||
if (item) {
|
||||
likedItems.push({
|
||||
like: {
|
||||
...like,
|
||||
entityType: like.entityType as Like['entityType']
|
||||
},
|
||||
item
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return likedItems;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto } from "@library/shared-types";
|
||||
import { prisma } from "../lib/prisma";
|
||||
|
||||
export class MangaService {
|
||||
private prisma = prisma;
|
||||
|
||||
constructor() {}
|
||||
|
||||
async getAllManga(): Promise<Manga[]> {
|
||||
const manga = await this.prisma.manga.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
});
|
||||
|
||||
return manga.map((m) => ({
|
||||
...m,
|
||||
status: m.status as unknown as MangaStatus,
|
||||
dateAdded: m.dateAdded,
|
||||
dateCompleted: m.dateCompleted || undefined,
|
||||
tags: m.tags ?? [],
|
||||
links: m.links ?? [],
|
||||
createdAt: m.createdAt,
|
||||
updatedAt: m.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async getMangaById(id: string): Promise<Manga | null> {
|
||||
const manga = await this.prisma.manga.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!manga) return null;
|
||||
|
||||
return {
|
||||
...manga,
|
||||
status: manga.status as unknown as MangaStatus,
|
||||
dateAdded: manga.dateAdded,
|
||||
dateCompleted: manga.dateCompleted || undefined,
|
||||
tags: manga.tags ?? [],
|
||||
links: manga.links ?? [],
|
||||
createdAt: manga.createdAt,
|
||||
updatedAt: manga.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async createManga(data: CreateMangaDto): Promise<Manga> {
|
||||
const manga = await this.prisma.manga.create({
|
||||
data: {
|
||||
...data,
|
||||
status: data.status.toUpperCase() as any,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...manga,
|
||||
status: manga.status as unknown as MangaStatus,
|
||||
dateAdded: manga.dateAdded,
|
||||
dateCompleted: manga.dateCompleted || undefined,
|
||||
tags: manga.tags ?? [],
|
||||
links: manga.links ?? [],
|
||||
createdAt: manga.createdAt,
|
||||
updatedAt: manga.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async updateManga(id: string, data: UpdateMangaDto): Promise<Manga> {
|
||||
const updateData = { ...data };
|
||||
if (updateData.status) {
|
||||
updateData.status = updateData.status.toUpperCase() as any;
|
||||
}
|
||||
|
||||
const manga = await this.prisma.manga.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
return {
|
||||
...manga,
|
||||
status: manga.status as unknown as MangaStatus,
|
||||
dateAdded: manga.dateAdded,
|
||||
dateCompleted: manga.dateCompleted || undefined,
|
||||
tags: manga.tags ?? [],
|
||||
links: manga.links ?? [],
|
||||
createdAt: manga.createdAt,
|
||||
updatedAt: manga.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async deleteManga(id: string): Promise<void> {
|
||||
await this.prisma.manga.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* @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 as unknown as MusicType,
|
||||
status: music.status as unknown as MusicStatus,
|
||||
dateAdded: music.dateAdded,
|
||||
dateCompleted: music.dateCompleted || undefined,
|
||||
tags: music.tags ?? [],
|
||||
links: music.links ?? [],
|
||||
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 as unknown as MusicType,
|
||||
status: music.status as unknown as MusicStatus,
|
||||
dateAdded: music.dateAdded,
|
||||
dateCompleted: music.dateCompleted || undefined,
|
||||
tags: music.tags ?? [],
|
||||
links: music.links ?? [],
|
||||
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 as unknown as MusicType,
|
||||
status: music.status as unknown as MusicStatus,
|
||||
dateAdded: music.dateAdded,
|
||||
dateCompleted: music.dateCompleted || undefined,
|
||||
tags: music.tags ?? [],
|
||||
links: music.links ?? [],
|
||||
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 as unknown as MusicType,
|
||||
status: music.status as unknown as MusicStatus,
|
||||
dateAdded: music.dateAdded,
|
||||
dateCompleted: music.dateCompleted || undefined,
|
||||
tags: music.tags ?? [],
|
||||
links: music.links ?? [],
|
||||
createdAt: music.createdAt,
|
||||
updatedAt: music.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete music by ID.
|
||||
*/
|
||||
async deleteMusic(id: string): Promise<void> {
|
||||
await this.prisma.music.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto } from "@library/shared-types";
|
||||
import { prisma } from "../lib/prisma";
|
||||
|
||||
export class ShowService {
|
||||
private prisma = prisma;
|
||||
|
||||
constructor() {}
|
||||
|
||||
async getAllShows(): Promise<Show[]> {
|
||||
const shows = await this.prisma.show.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
});
|
||||
|
||||
return shows.map((show) => ({
|
||||
...show,
|
||||
type: show.type as unknown as ShowType,
|
||||
status: show.status as unknown as ShowStatus,
|
||||
dateAdded: show.dateAdded,
|
||||
dateCompleted: show.dateCompleted || undefined,
|
||||
tags: show.tags ?? [],
|
||||
links: show.links ?? [],
|
||||
createdAt: show.createdAt,
|
||||
updatedAt: show.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async getShowById(id: string): Promise<Show | null> {
|
||||
const show = await this.prisma.show.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return {
|
||||
...show,
|
||||
type: show.type as unknown as ShowType,
|
||||
status: show.status as unknown as ShowStatus,
|
||||
dateAdded: show.dateAdded,
|
||||
dateCompleted: show.dateCompleted || undefined,
|
||||
tags: show.tags ?? [],
|
||||
links: show.links ?? [],
|
||||
createdAt: show.createdAt,
|
||||
updatedAt: show.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async createShow(data: CreateShowDto): Promise<Show> {
|
||||
const show = await this.prisma.show.create({
|
||||
data: {
|
||||
...data,
|
||||
type: data.type.toUpperCase() as any,
|
||||
status: data.status.toUpperCase() as any,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...show,
|
||||
type: show.type as unknown as ShowType,
|
||||
status: show.status as unknown as ShowStatus,
|
||||
dateAdded: show.dateAdded,
|
||||
dateCompleted: show.dateCompleted || undefined,
|
||||
tags: show.tags ?? [],
|
||||
links: show.links ?? [],
|
||||
createdAt: show.createdAt,
|
||||
updatedAt: show.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async updateShow(id: string, data: UpdateShowDto): Promise<Show> {
|
||||
const updateData = { ...data };
|
||||
if (updateData.type) {
|
||||
updateData.type = updateData.type.toUpperCase() as any;
|
||||
}
|
||||
if (updateData.status) {
|
||||
updateData.status = updateData.status.toUpperCase() as any;
|
||||
}
|
||||
|
||||
const show = await this.prisma.show.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
return {
|
||||
...show,
|
||||
type: show.type as unknown as ShowType,
|
||||
status: show.status as unknown as ShowStatus,
|
||||
dateAdded: show.dateAdded,
|
||||
dateCompleted: show.dateCompleted || undefined,
|
||||
tags: show.tags ?? [],
|
||||
links: show.links ?? [],
|
||||
createdAt: show.createdAt,
|
||||
updatedAt: show.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async deleteShow(id: string): Promise<void> {
|
||||
await this.prisma.show.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
import { prisma } from "../lib/prisma";
|
||||
import type {
|
||||
Suggestion,
|
||||
SuggestionStatus,
|
||||
SuggestionEntity,
|
||||
CreateSuggestionDto,
|
||||
AcceptWithEditsDto,
|
||||
} from "@library/shared-types";
|
||||
import {
|
||||
GameStatus,
|
||||
BookStatus,
|
||||
MusicType,
|
||||
MusicStatus,
|
||||
ShowType,
|
||||
ShowStatus,
|
||||
MangaStatus,
|
||||
} from "@library/shared-types";
|
||||
import { GameService } from "./game.service";
|
||||
import { BookService } from "./book.service";
|
||||
import { MusicService } from "./music.service";
|
||||
import { ArtService } from "./art.service";
|
||||
import { ShowService } from "./show.service";
|
||||
import { MangaService } from "./manga.service";
|
||||
|
||||
const gameService = new GameService();
|
||||
const bookService = new BookService();
|
||||
const musicService = new MusicService();
|
||||
const artService = new ArtService();
|
||||
const showService = new ShowService();
|
||||
const mangaService = new MangaService();
|
||||
|
||||
interface SuggestionUser {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
inDiscord: boolean;
|
||||
isVip: boolean;
|
||||
isMod: boolean;
|
||||
isStaff: boolean;
|
||||
}
|
||||
|
||||
function mapUser(user: {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
inDiscord: boolean;
|
||||
isVip: boolean;
|
||||
isMod: boolean;
|
||||
isStaff: boolean;
|
||||
}): SuggestionUser {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
avatar: user.avatar,
|
||||
inDiscord: user.inDiscord,
|
||||
isVip: user.isVip,
|
||||
isMod: user.isMod,
|
||||
isStaff: user.isStaff,
|
||||
};
|
||||
}
|
||||
|
||||
function mapSuggestion(suggestion: {
|
||||
id: string;
|
||||
userId: string;
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
inDiscord: boolean;
|
||||
isVip: boolean;
|
||||
isMod: boolean;
|
||||
isStaff: boolean;
|
||||
};
|
||||
entityType: string;
|
||||
status: string;
|
||||
declineReason: string | null;
|
||||
title: string;
|
||||
gameData: unknown;
|
||||
bookData: unknown;
|
||||
musicData: unknown;
|
||||
artData: unknown;
|
||||
showData: unknown;
|
||||
mangaData: unknown;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): Suggestion {
|
||||
return {
|
||||
id: suggestion.id,
|
||||
userId: suggestion.userId,
|
||||
user: mapUser(suggestion.user) as Suggestion["user"],
|
||||
entityType: suggestion.entityType as SuggestionEntity,
|
||||
status: suggestion.status as SuggestionStatus,
|
||||
declineReason: suggestion.declineReason ?? undefined,
|
||||
title: suggestion.title,
|
||||
gameData: suggestion.gameData as Suggestion["gameData"],
|
||||
bookData: suggestion.bookData as Suggestion["bookData"],
|
||||
musicData: suggestion.musicData as Suggestion["musicData"],
|
||||
artData: suggestion.artData as Suggestion["artData"],
|
||||
showData: suggestion.showData as Suggestion["showData"],
|
||||
mangaData: suggestion.mangaData as Suggestion["mangaData"],
|
||||
createdAt: suggestion.createdAt,
|
||||
updatedAt: suggestion.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function getMusicType(type?: string): MusicType {
|
||||
switch (type) {
|
||||
case "ALBUM":
|
||||
return MusicType.album;
|
||||
case "SINGLE":
|
||||
return MusicType.single;
|
||||
case "EP":
|
||||
return MusicType.ep;
|
||||
default:
|
||||
return MusicType.album;
|
||||
}
|
||||
}
|
||||
|
||||
function getShowType(type?: string): ShowType {
|
||||
switch (type) {
|
||||
case "TV_SERIES":
|
||||
return ShowType.tvSeries;
|
||||
case "ANIME":
|
||||
return ShowType.anime;
|
||||
case "FILM":
|
||||
return ShowType.film;
|
||||
case "DOCUMENTARY":
|
||||
return ShowType.documentary;
|
||||
default:
|
||||
return ShowType.tvSeries;
|
||||
}
|
||||
}
|
||||
|
||||
export const SuggestionService = {
|
||||
async getAllSuggestions(filters?: {
|
||||
status?: SuggestionStatus;
|
||||
entityType?: SuggestionEntity;
|
||||
}): Promise<Suggestion[]> {
|
||||
const suggestions = await prisma.suggestion.findMany({
|
||||
where: {
|
||||
...(filters?.status && { status: filters.status }),
|
||||
...(filters?.entityType && { entityType: filters.entityType }),
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
return suggestions.map(mapSuggestion);
|
||||
},
|
||||
|
||||
async getUserSuggestions(userId: string): Promise<Suggestion[]> {
|
||||
const suggestions = await prisma.suggestion.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
return suggestions.map(mapSuggestion);
|
||||
},
|
||||
|
||||
async getSuggestionById(id: string): Promise<Suggestion | null> {
|
||||
const suggestion = await prisma.suggestion.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!suggestion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mapSuggestion(suggestion);
|
||||
},
|
||||
|
||||
async createSuggestion(
|
||||
userId: string,
|
||||
data: CreateSuggestionDto
|
||||
): Promise<Suggestion> {
|
||||
const pendingCount = await prisma.suggestion.count({
|
||||
where: {
|
||||
userId,
|
||||
status: "UNREVIEWED",
|
||||
},
|
||||
});
|
||||
|
||||
if (pendingCount >= 5) {
|
||||
throw new Error("You can only have 5 pending suggestions at a time");
|
||||
}
|
||||
|
||||
const entityDataField = `${data.entityType.toLowerCase()}Data`;
|
||||
|
||||
const suggestionData: Record<string, unknown> = {
|
||||
userId,
|
||||
entityType: data.entityType,
|
||||
title: data.title,
|
||||
};
|
||||
|
||||
// Extract the data without entityType and title
|
||||
const { entityType: _et, title: _t, ...entityData } = data;
|
||||
suggestionData[entityDataField] = entityData;
|
||||
|
||||
const suggestion = await prisma.suggestion.create({
|
||||
data: suggestionData as Parameters<typeof prisma.suggestion.create>[0]["data"],
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
return mapSuggestion(suggestion);
|
||||
},
|
||||
|
||||
async acceptSuggestion(id: string): Promise<Suggestion> {
|
||||
const suggestion = await prisma.suggestion.findUnique({
|
||||
where: { id },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (!suggestion) {
|
||||
throw new Error("Suggestion not found");
|
||||
}
|
||||
|
||||
if (suggestion.status !== "UNREVIEWED") {
|
||||
throw new Error("Suggestion has already been reviewed");
|
||||
}
|
||||
|
||||
// Create the entity based on type with "Want to X" status
|
||||
switch (suggestion.entityType) {
|
||||
case "GAME": {
|
||||
const gameData = suggestion.gameData as {
|
||||
platform?: string;
|
||||
notes?: string;
|
||||
coverImage?: string;
|
||||
} | null;
|
||||
await gameService.createGame({
|
||||
title: suggestion.title,
|
||||
platform: gameData?.platform,
|
||||
status: GameStatus.backlog,
|
||||
notes: gameData?.notes,
|
||||
coverImage: gameData?.coverImage,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "BOOK": {
|
||||
const bookData = suggestion.bookData as {
|
||||
author: string;
|
||||
isbn?: string;
|
||||
notes?: string;
|
||||
coverImage?: string;
|
||||
} | null;
|
||||
await bookService.createBook({
|
||||
title: suggestion.title,
|
||||
author: bookData?.author ?? "Unknown",
|
||||
isbn: bookData?.isbn,
|
||||
status: BookStatus.toRead,
|
||||
notes: bookData?.notes,
|
||||
coverImage: bookData?.coverImage,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "MUSIC": {
|
||||
const musicData = suggestion.musicData as {
|
||||
artist: string;
|
||||
type: string;
|
||||
notes?: string;
|
||||
coverArt?: string;
|
||||
} | null;
|
||||
await musicService.createMusic({
|
||||
title: suggestion.title,
|
||||
artist: musicData?.artist ?? "Unknown",
|
||||
type: getMusicType(musicData?.type),
|
||||
status: MusicStatus.wantToListen,
|
||||
notes: musicData?.notes,
|
||||
coverArt: musicData?.coverArt,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "ART": {
|
||||
const artData = suggestion.artData as {
|
||||
artist: string;
|
||||
description?: string;
|
||||
imageUrl: string;
|
||||
} | null;
|
||||
await artService.createArt({
|
||||
title: suggestion.title,
|
||||
artist: artData?.artist ?? "Unknown",
|
||||
description: artData?.description,
|
||||
imageUrl: artData?.imageUrl ?? "",
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "SHOW": {
|
||||
const showData = suggestion.showData as {
|
||||
type: string;
|
||||
notes?: string;
|
||||
coverImage?: string;
|
||||
} | null;
|
||||
await showService.createShow({
|
||||
title: suggestion.title,
|
||||
type: getShowType(showData?.type),
|
||||
status: ShowStatus.wantToWatch,
|
||||
notes: showData?.notes,
|
||||
coverImage: showData?.coverImage,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "MANGA": {
|
||||
const mangaData = suggestion.mangaData as {
|
||||
author: string;
|
||||
notes?: string;
|
||||
coverImage?: string;
|
||||
} | null;
|
||||
await mangaService.createManga({
|
||||
title: suggestion.title,
|
||||
author: mangaData?.author ?? "Unknown",
|
||||
status: MangaStatus.wantToRead,
|
||||
notes: mangaData?.notes,
|
||||
coverImage: mangaData?.coverImage,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Update suggestion status
|
||||
const updatedSuggestion = await prisma.suggestion.update({
|
||||
where: { id },
|
||||
data: { status: "ACCEPTED" },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
return mapSuggestion(updatedSuggestion);
|
||||
},
|
||||
|
||||
async acceptSuggestionWithEdits(id: string, editedData: AcceptWithEditsDto): Promise<Suggestion> {
|
||||
const suggestion = await prisma.suggestion.findUnique({
|
||||
where: { id },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (!suggestion) {
|
||||
throw new Error("Suggestion not found");
|
||||
}
|
||||
|
||||
if (suggestion.status !== "UNREVIEWED") {
|
||||
throw new Error("Suggestion has already been reviewed");
|
||||
}
|
||||
|
||||
// Create the entity based on type with edited data
|
||||
switch (suggestion.entityType) {
|
||||
case "GAME": {
|
||||
await gameService.createGame({
|
||||
title: editedData.title || suggestion.title,
|
||||
platform: editedData.platform,
|
||||
status: GameStatus.backlog,
|
||||
notes: editedData.notes,
|
||||
coverImage: editedData.coverImage,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "BOOK": {
|
||||
await bookService.createBook({
|
||||
title: editedData.title || suggestion.title,
|
||||
author: editedData.author || "Unknown",
|
||||
isbn: editedData.isbn,
|
||||
status: BookStatus.toRead,
|
||||
notes: editedData.notes,
|
||||
coverImage: editedData.coverImage,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "MUSIC": {
|
||||
await musicService.createMusic({
|
||||
title: editedData.title || suggestion.title,
|
||||
artist: editedData.artist || "Unknown",
|
||||
type: getMusicType(editedData.type),
|
||||
status: MusicStatus.wantToListen,
|
||||
notes: editedData.notes,
|
||||
coverArt: editedData.coverArt || editedData.coverImage,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "ART": {
|
||||
await artService.createArt({
|
||||
title: editedData.title || suggestion.title,
|
||||
artist: editedData.artist || "Unknown",
|
||||
description: editedData.description,
|
||||
imageUrl: editedData.imageUrl || "",
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "SHOW": {
|
||||
await showService.createShow({
|
||||
title: editedData.title || suggestion.title,
|
||||
type: getShowType(editedData.type),
|
||||
status: ShowStatus.wantToWatch,
|
||||
notes: editedData.notes,
|
||||
coverImage: editedData.coverImage,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "MANGA": {
|
||||
await mangaService.createManga({
|
||||
title: editedData.title || suggestion.title,
|
||||
author: editedData.author || "Unknown",
|
||||
status: MangaStatus.wantToRead,
|
||||
notes: editedData.notes,
|
||||
coverImage: editedData.coverImage,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Update suggestion status
|
||||
const updatedSuggestion = await prisma.suggestion.update({
|
||||
where: { id },
|
||||
data: { status: "ACCEPTED" },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
return mapSuggestion(updatedSuggestion);
|
||||
},
|
||||
|
||||
async declineSuggestion(id: string, reason?: string): Promise<Suggestion> {
|
||||
const suggestion = await prisma.suggestion.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!suggestion) {
|
||||
throw new Error("Suggestion not found");
|
||||
}
|
||||
|
||||
if (suggestion.status !== "UNREVIEWED") {
|
||||
throw new Error("Suggestion has already been reviewed");
|
||||
}
|
||||
|
||||
const updatedSuggestion = await prisma.suggestion.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: "DECLINED",
|
||||
declineReason: reason,
|
||||
},
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
return mapSuggestion(updatedSuggestion);
|
||||
},
|
||||
|
||||
async deleteSuggestion(id: string, userId: string, isAdmin: boolean): Promise<Suggestion> {
|
||||
const suggestion = await prisma.suggestion.findUnique({
|
||||
where: { id },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (!suggestion) {
|
||||
throw new Error("Suggestion not found");
|
||||
}
|
||||
|
||||
if (!isAdmin && suggestion.userId !== userId) {
|
||||
throw new Error("You can only delete your own suggestions");
|
||||
}
|
||||
|
||||
if (suggestion.status !== "UNREVIEWED") {
|
||||
throw new Error("Cannot delete a suggestion that has already been reviewed");
|
||||
}
|
||||
|
||||
await prisma.suggestion.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return mapSuggestion(suggestion);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { User } from "@library/shared-types";
|
||||
import { prisma } from "../lib/prisma";
|
||||
|
||||
export class UserService {
|
||||
private prisma = prisma;
|
||||
|
||||
async getAllUsers(): Promise<User[]> {
|
||||
const users = await this.prisma.user.findMany({
|
||||
orderBy: { username: "asc" },
|
||||
});
|
||||
|
||||
return users.map((user) => ({
|
||||
id: user.id,
|
||||
discordId: user.discordId,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
avatar: user.avatar || undefined,
|
||||
isAdmin: user.isAdmin,
|
||||
isBanned: user.isBanned,
|
||||
inDiscord: user.inDiscord,
|
||||
isVip: user.isVip,
|
||||
isMod: user.isMod,
|
||||
isStaff: user.isStaff,
|
||||
}));
|
||||
}
|
||||
|
||||
async getUserById(id: string): Promise<User | null> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
discordId: user.discordId,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
avatar: user.avatar || undefined,
|
||||
isAdmin: user.isAdmin,
|
||||
isBanned: user.isBanned,
|
||||
inDiscord: user.inDiscord,
|
||||
isVip: user.isVip,
|
||||
isMod: user.isMod,
|
||||
isStaff: user.isStaff,
|
||||
};
|
||||
}
|
||||
|
||||
async banUser(id: string): Promise<User | null> {
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id },
|
||||
data: { isBanned: true },
|
||||
});
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
discordId: user.discordId,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
avatar: user.avatar || undefined,
|
||||
isAdmin: user.isAdmin,
|
||||
isBanned: user.isBanned,
|
||||
inDiscord: user.inDiscord,
|
||||
isVip: user.isVip,
|
||||
isMod: user.isMod,
|
||||
isStaff: user.isStaff,
|
||||
};
|
||||
}
|
||||
|
||||
async unbanUser(id: string): Promise<User | null> {
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id },
|
||||
data: { isBanned: false },
|
||||
});
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
discordId: user.discordId,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
avatar: user.avatar || undefined,
|
||||
isAdmin: user.isAdmin,
|
||||
isBanned: user.isBanned,
|
||||
inDiscord: user.inDiscord,
|
||||
isVip: user.isVip,
|
||||
isMod: user.isMod,
|
||||
isStaff: user.isStaff,
|
||||
};
|
||||
}
|
||||
|
||||
async isUserBanned(id: string): Promise<boolean> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: { isBanned: true },
|
||||
});
|
||||
|
||||
return user?.isBanned ?? false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import Fastify from 'fastify';
|
||||
import { app } from './app/app';
|
||||
|
||||
const host = process.env.HOST ?? 'localhost';
|
||||
const port = process.env.PORT ? Number(process.env.PORT) : 12321;
|
||||
|
||||
// Instantiate Fastify with some config
|
||||
const server = Fastify({
|
||||
logger: true,
|
||||
bodyLimit: 1048576, // 1MB max body size
|
||||
});
|
||||
|
||||
// Register your application as a normal plugin.
|
||||
server.register(app);
|
||||
|
||||
// Start listening.
|
||||
server.listen({ port, host }, (err) => {
|
||||
if (err) {
|
||||
server.log.error(err);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(`[ ready ] http://${host}:${port}`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": [
|
||||
"jest.config.ts",
|
||||
"jest.config.cts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
],
|
||||
"compilerOptions": {
|
||||
"esModuleInterop": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node10",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"jest.config.cts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
|
||||
import { defineConfig } from 'cypress';
|
||||
export default defineConfig({
|
||||
e2e: {
|
||||
...nxE2EPreset(__filename, {
|
||||
cypressDir: 'src',
|
||||
webServerCommands: {
|
||||
default: 'pnpm exec nx run frontend:serve',
|
||||
production: 'pnpm exec nx run frontend:serve-static',
|
||||
},
|
||||
ciWebServerCommand: 'pnpm exec nx run frontend:serve-static',
|
||||
ciBaseUrl: 'http://localhost:4200',
|
||||
}),
|
||||
baseUrl: 'http://localhost:4200',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import cypress from 'eslint-plugin-cypress/flat';
|
||||
import baseConfig from '../../eslint.config.mjs';
|
||||
|
||||
export default [
|
||||
cypress.configs['recommended'],
|
||||
...baseConfig,
|
||||
{
|
||||
// Override or add rules here
|
||||
rules: {},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "@library/frontend-e2e",
|
||||
"version": "0.0.1"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "frontend-e2e",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "application",
|
||||
"sourceRoot": "apps/frontend-e2e/src",
|
||||
"tags": [],
|
||||
"implicitDependencies": ["frontend"],
|
||||
"// targets": "to see all targets run: nx show project frontend-e2e --web",
|
||||
"targets": {}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
*/
|
||||
|
||||
import { getGreeting } from "../support/app.po";
|
||||
|
||||
describe("frontend-e2e", () => {
|
||||
beforeEach(() => {
|
||||
cy.visit("/");
|
||||
});
|
||||
|
||||
it("should display welcome message", () => {
|
||||
// Custom command example, see `../support/commands.ts` file
|
||||
cy.login("my-email@something.com", "myPassword");
|
||||
|
||||
// Function helper example, see `../support/app.po.ts` file
|
||||
getGreeting().contains(/Welcome/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Using fixtures to represent data",
|
||||
"email": "hello@cypress.io",
|
||||
"body": "Fixtures are a great way to mock data for responses to routes"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
*/
|
||||
|
||||
export const getGreeting = (): Cypress.Chainable => cy.get("h1");
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
*/
|
||||
|
||||
/// <reference types="cypress" />
|
||||
|
||||
/**
|
||||
* This example commands.ts shows you how to
|
||||
* create various custom commands and overwrite
|
||||
* existing commands.
|
||||
*
|
||||
* For more comprehensive examples of custom
|
||||
* commands please read more here:
|
||||
* https://on.cypress.io/custom-commands
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace -- Required for Cypress type extensions
|
||||
declare namespace Cypress {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Subject is required for type definition
|
||||
interface Chainable<Subject> {
|
||||
login: (email: string, password: string) => void;
|
||||
}
|
||||
}
|
||||
|
||||
// -- This is a parent command --
|
||||
Cypress.Commands.add("login", (email, password) => {
|
||||
// eslint-disable-next-line no-console -- Example command for demonstration
|
||||
console.log("Custom command example: Login", email, password);
|
||||
});
|
||||
|
||||
// -- This is a child command --
|
||||
// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
|
||||
|
||||
// -- This is a dual command --
|
||||
// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
|
||||
|
||||
// -- This will overwrite an existing command --
|
||||
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
/**
|
||||
* This example support/e2e.ts is processed and
|
||||
* loaded automatically before your test files.
|
||||
*
|
||||
* This is a great place to put global configuration and
|
||||
* behavior that modifies Cypress.
|
||||
*
|
||||
* You can change the location of this file or turn off
|
||||
* automatically serving support files with the
|
||||
* 'supportFile' configuration option.
|
||||
*
|
||||
* You can read more here:
|
||||
* https://on.cypress.io/configuration
|
||||
*/
|
||||
|
||||
// Import commands.ts using ES2015 syntax:
|
||||
// eslint-disable-next-line import/no-unassigned-import -- Side effects import for Cypress commands
|
||||
import "./commands.ts";
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "node10",
|
||||
"allowJs": true,
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"types": ["cypress", "node"],
|
||||
"sourceMap": false,
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.js",
|
||||
"cypress.config.ts",
|
||||
"**/*.cy.ts",
|
||||
"**/*.cy.js",
|
||||
"**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import nx from '@nx/eslint-plugin';
|
||||
import baseConfig from '../../eslint.config.mjs';
|
||||
|
||||
export default [
|
||||
...baseConfig,
|
||||
...nx.configs['flat/angular'],
|
||||
...nx.configs['flat/angular-template'],
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
rules: {
|
||||
'@angular-eslint/directive-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'attribute',
|
||||
prefix: 'app',
|
||||
style: 'camelCase',
|
||||
},
|
||||
],
|
||||
'@angular-eslint/component-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'element',
|
||||
prefix: 'app',
|
||||
style: 'kebab-case',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.html'],
|
||||
// Override or add rules here
|
||||
rules: {},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "application",
|
||||
"prefix": "app",
|
||||
"sourceRoot": "apps/frontend/src",
|
||||
"tags": [],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@angular-devkit/build-angular:browser",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputPath": "dist/apps/frontend",
|
||||
"index": "apps/frontend/src/index.html",
|
||||
"main": "apps/frontend/src/main.ts",
|
||||
"tsConfig": "apps/frontend/tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "apps/frontend/public"
|
||||
}
|
||||
],
|
||||
"styles": ["apps/frontend/src/styles.scss"]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kb",
|
||||
"maximumError": "1mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "8kb",
|
||||
"maximumError": "12kb"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all",
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "apps/frontend/src/environments/environment.ts",
|
||||
"with": "apps/frontend/src/environments/environment.prod.ts"
|
||||
}
|
||||
]
|
||||
},
|
||||
"development": {
|
||||
"buildOptimizer": false,
|
||||
"optimization": false,
|
||||
"vendorChunk": true,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true,
|
||||
"namedChunks": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"continuous": true,
|
||||
"executor": "@angular-devkit/build-angular:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "frontend:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "frontend:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"executor": "@angular-devkit/build-angular:extract-i18n",
|
||||
"options": {
|
||||
"buildTarget": "frontend:build"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
},
|
||||
"serve-static": {
|
||||
"continuous": true,
|
||||
"executor": "@nx/web:file-server",
|
||||
"options": {
|
||||
"buildTarget": "frontend:build",
|
||||
"port": 4200,
|
||||
"spa": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
ApplicationConfig,
|
||||
provideBrowserGlobalErrorListeners,
|
||||
APP_INITIALIZER,
|
||||
} from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient, withInterceptors, HTTP_INTERCEPTORS } from '@angular/common/http';
|
||||
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 = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(appRoutes),
|
||||
provideHttpClient(),
|
||||
{
|
||||
provide: HTTP_INTERCEPTORS,
|
||||
useClass: AuthInterceptor,
|
||||
multi: true
|
||||
},
|
||||
{
|
||||
provide: APP_INITIALIZER,
|
||||
useFactory: initializeAuth,
|
||||
deps: [AuthService],
|
||||
multi: true
|
||||
}
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
<app-header></app-header>
|
||||
<main class="main-content">
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
<app-footer></app-footer>
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Route } from '@angular/router';
|
||||
|
||||
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: 'art',
|
||||
loadComponent: () => import('./components/art/art-gallery.component').then(m => m.ArtGalleryComponent)
|
||||
},
|
||||
{
|
||||
path: 'shows',
|
||||
loadComponent: () => import('./components/shows/shows-list.component').then(m => m.ShowsListComponent)
|
||||
},
|
||||
{
|
||||
path: 'manga',
|
||||
loadComponent: () => import('./components/manga/manga-list.component').then(m => m.MangaListComponent)
|
||||
},
|
||||
{
|
||||
path: 'admin/users',
|
||||
loadComponent: () => import('./components/admin/admin-users.component').then(m => m.AdminUsersComponent)
|
||||
},
|
||||
{
|
||||
path: 'admin/audit',
|
||||
loadComponent: () => import('./components/admin/admin-audit.component').then(m => m.AdminAuditComponent)
|
||||
},
|
||||
{
|
||||
path: 'admin/suggestions',
|
||||
loadComponent: () => import('./components/admin/admin-suggestions.component').then(m => m.AdminSuggestionsComponent)
|
||||
},
|
||||
{
|
||||
path: 'my-suggestions',
|
||||
loadComponent: () => import('./components/my-suggestions/my-suggestions.component').then(m => m.MySuggestionsComponent)
|
||||
},
|
||||
{
|
||||
path: 'my-likes',
|
||||
loadComponent: () => import('./components/my-likes/my-likes.component').then(m => m.MyLikesComponent)
|
||||
},
|
||||
{
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { App } from './app';
|
||||
import { NxWelcome } from './nx-welcome';
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App, NxWelcome],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should render title', async () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
await fixture.whenStable();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('h1')?.textContent).toContain(
|
||||
'Welcome frontend',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { HeaderComponent } from './components/header/header.component';
|
||||
import { FooterComponent } from './components/footer/footer.component';
|
||||
import { AnalyticsService } from './services/analytics.service';
|
||||
|
||||
@Component({
|
||||
imports: [RouterModule, HeaderComponent, FooterComponent],
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss',
|
||||
})
|
||||
export class App implements OnInit {
|
||||
protected title = 'Naomi\'s Library';
|
||||
private analytics = inject(AnalyticsService);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.analytics.initialise();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AuditLogService } from '../../services/audit.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { Router } from '@angular/router';
|
||||
import type { AuditLog, AuditAction, AuditCategory } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-audit',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<div class="audit-container">
|
||||
<h1>Audit Logs</h1>
|
||||
|
||||
@if (!authService.isAuthenticated() || !authService.user()?.isAdmin) {
|
||||
<div class="unauthorized">
|
||||
<p>You must be an admin to view this page.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="filters">
|
||||
<div class="filter-group">
|
||||
<label for="category-filter">Category:</label>
|
||||
<select
|
||||
id="category-filter"
|
||||
[(ngModel)]="selectedCategory"
|
||||
(change)="loadLogs()"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
<option value="AUTH">Authentication</option>
|
||||
<option value="CONTENT">Content</option>
|
||||
<option value="ADMIN">Administration</option>
|
||||
<option value="SECURITY">Security</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label for="action-filter">Action:</label>
|
||||
<select
|
||||
id="action-filter"
|
||||
[(ngModel)]="selectedAction"
|
||||
(change)="loadLogs()"
|
||||
>
|
||||
<option value="">All Actions</option>
|
||||
<option value="LOGIN">Login</option>
|
||||
<option value="LOGOUT">Logout</option>
|
||||
<option value="LOGIN_FAILED">Login Failed</option>
|
||||
<option value="COMMENT_CREATE">Comment Created</option>
|
||||
<option value="COMMENT_DELETE">Comment Deleted</option>
|
||||
<option value="ENTRY_CREATE">Entry Created</option>
|
||||
<option value="ENTRY_UPDATE">Entry Updated</option>
|
||||
<option value="ENTRY_DELETE">Entry Deleted</option>
|
||||
<option value="USER_BAN">User Banned</option>
|
||||
<option value="USER_UNBAN">User Unbanned</option>
|
||||
<option value="RATE_LIMIT_EXCEEDED">Rate Limit Exceeded</option>
|
||||
<option value="CSRF_VALIDATION_FAILED">CSRF Failed</option>
|
||||
<option value="UNAUTHORIZED_ACCESS">Unauthorized Access</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label for="success-filter">Status:</label>
|
||||
<select
|
||||
id="success-filter"
|
||||
[(ngModel)]="selectedSuccess"
|
||||
(change)="loadLogs()"
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="true">Success</option>
|
||||
<option value="false">Failed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button class="refresh-btn" (click)="loadLogs()">Refresh</button>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading">Loading audit logs...</div>
|
||||
} @else if (logs().length === 0) {
|
||||
<div class="no-logs">No audit logs found.</div>
|
||||
} @else {
|
||||
<div class="logs-table-container">
|
||||
<table class="logs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>User</th>
|
||||
<th>Category</th>
|
||||
<th>Action</th>
|
||||
<th>Target User</th>
|
||||
<th>Details</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (log of logs(); track log.id) {
|
||||
<tr [class.failed]="!log.success">
|
||||
<td class="time">{{ formatDate(log.createdAt) }}</td>
|
||||
<td class="user-cell">
|
||||
@if (log.user) {
|
||||
<div class="user-info">
|
||||
@if (log.user.avatar) {
|
||||
<img [src]="log.user.avatar" [alt]="log.user.username" class="user-avatar" />
|
||||
}
|
||||
<div class="user-details">
|
||||
<span class="username">{{ log.user.username }}</span>
|
||||
<span class="user-id">{{ log.userId }}</span>
|
||||
</div>
|
||||
</div>
|
||||
} @else if (log.userId) {
|
||||
<span class="user-id-only">{{ log.userId }}</span>
|
||||
} @else {
|
||||
<span class="no-user">-</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="category-badge"
|
||||
[style.background-color]="auditService.getCategoryColor(log.category)"
|
||||
>
|
||||
{{ auditService.getCategoryLabel(log.category) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ auditService.getActionLabel(log.action) }}</td>
|
||||
<td class="user-cell">
|
||||
@if (log.targetUser) {
|
||||
<div class="user-info">
|
||||
@if (log.targetUser.avatar) {
|
||||
<img [src]="log.targetUser.avatar" [alt]="log.targetUser.username" class="user-avatar" />
|
||||
}
|
||||
<div class="user-details">
|
||||
<span class="username">{{ log.targetUser.username }}</span>
|
||||
<span class="user-id">{{ log.targetUserId }}</span>
|
||||
</div>
|
||||
</div>
|
||||
} @else if (log.targetUserId) {
|
||||
<span class="user-id-only">{{ log.targetUserId }}</span>
|
||||
} @else {
|
||||
<span class="no-user">-</span>
|
||||
}
|
||||
</td>
|
||||
<td class="details" [class.expanded]="expandedRows()[log.id]" (click)="toggleRowExpand(log.id)">
|
||||
<span class="details-content">{{ log.details ?? '-' }}</span>
|
||||
@if (log.details && log.details.length > 50) {
|
||||
<span class="expand-hint">{{ expandedRows()[log.id] ? '(click to collapse)' : '(click to expand)' }}</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-badge" [class.success]="log.success" [class.failed]="!log.success">
|
||||
{{ log.success ? '✓' : '✗' }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pagination">
|
||||
<button
|
||||
[disabled]="currentPage() <= 1"
|
||||
(click)="goToPage(currentPage() - 1)"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span>Page {{ currentPage() }} of {{ totalPages() }}</span>
|
||||
<button
|
||||
[disabled]="currentPage() >= totalPages()"
|
||||
(click)="goToPage(currentPage() + 1)"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.audit-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #2d1b4e;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.unauthorized {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background: #fef2f2;
|
||||
border-radius: 8px;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-size: 0.85rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.filter-group select {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #8b5cf6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.refresh-btn:hover {
|
||||
background: #7c3aed;
|
||||
}
|
||||
|
||||
.loading, .no-logs {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.logs-table-container {
|
||||
overflow-x: auto;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.logs-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.logs-table th,
|
||||
.logs-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.logs-table th {
|
||||
background: #f9fafb;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.logs-table tr:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.logs-table tr.failed {
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.logs-table tr.failed:hover {
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.time {
|
||||
white-space: nowrap;
|
||||
font-size: 0.85rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.user-cell {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.user-id {
|
||||
font-size: 0.7rem;
|
||||
color: #9ca3af;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.user-id-only {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.no-user {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.category-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.details {
|
||||
max-width: 400px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.details .details-content {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.details.expanded .details-content {
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.details .expand-hint {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
color: #9ca3af;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.details:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
text-align: center;
|
||||
line-height: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-badge.success {
|
||||
background: #dcfce7;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.status-badge.failed {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #8b5cf6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pagination button:disabled {
|
||||
background: #d1d5db;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination button:not(:disabled):hover {
|
||||
background: #7c3aed;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class AdminAuditComponent implements OnInit {
|
||||
authService = inject(AuthService);
|
||||
auditService = inject(AuditLogService);
|
||||
private router = inject(Router);
|
||||
|
||||
logs = signal<AuditLog[]>([]);
|
||||
loading = signal(true);
|
||||
currentPage = signal(1);
|
||||
totalPages = signal(1);
|
||||
expandedRows = signal<Record<string, boolean>>({});
|
||||
|
||||
selectedCategory = '';
|
||||
selectedAction = '';
|
||||
selectedSuccess = '';
|
||||
|
||||
ngOnInit() {
|
||||
if (!this.authService.isAuthenticated() || !this.authService.user()?.isAdmin) {
|
||||
this.router.navigate(['/']);
|
||||
return;
|
||||
}
|
||||
this.loadLogs();
|
||||
}
|
||||
|
||||
async loadLogs() {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const filters: Record<string, unknown> = {
|
||||
page: this.currentPage(),
|
||||
limit: 50,
|
||||
};
|
||||
|
||||
if (this.selectedCategory) {
|
||||
filters['category'] = this.selectedCategory as AuditCategory;
|
||||
}
|
||||
if (this.selectedAction) {
|
||||
filters['action'] = this.selectedAction as AuditAction;
|
||||
}
|
||||
if (this.selectedSuccess !== '') {
|
||||
filters['success'] = this.selectedSuccess === 'true';
|
||||
}
|
||||
|
||||
const response = await this.auditService.getLogs(filters);
|
||||
this.logs.set(response.logs);
|
||||
this.totalPages.set(response.totalPages);
|
||||
} catch (error) {
|
||||
console.error('Failed to load audit logs:', error);
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
goToPage(page: number) {
|
||||
this.currentPage.set(page);
|
||||
this.loadLogs();
|
||||
}
|
||||
|
||||
formatDate(date: Date | string): string {
|
||||
const d = new Date(date);
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
toggleRowExpand(logId: string) {
|
||||
this.expandedRows.set({
|
||||
...this.expandedRows(),
|
||||
[logId]: !this.expandedRows()[logId]
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,914 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { SuggestionService } from '../../services/suggestion.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { PaginationComponent } from '../shared/pagination.component';
|
||||
import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-suggestions',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, PaginationComponent],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="header-section">
|
||||
<h2>Manage Suggestions</h2>
|
||||
<p class="subtitle">Review and respond to community suggestions</p>
|
||||
</div>
|
||||
|
||||
@if (!authService.isAdmin()) {
|
||||
<div class="not-authorized">
|
||||
<p>You don't have permission to view this page.</p>
|
||||
</div>
|
||||
} @else if (loading()) {
|
||||
<div class="loading">Loading suggestions...</div>
|
||||
} @else {
|
||||
<div class="filters">
|
||||
<button
|
||||
(click)="setFilter('all')"
|
||||
[class.active]="statusFilter() === 'all'"
|
||||
class="filter-btn"
|
||||
>
|
||||
All ({{ suggestions().length }})
|
||||
</button>
|
||||
<button
|
||||
(click)="setFilter(SuggestionStatus.UNREVIEWED)"
|
||||
[class.active]="statusFilter() === SuggestionStatus.UNREVIEWED"
|
||||
class="filter-btn pending"
|
||||
>
|
||||
Pending ({{ unreviewedCount() }})
|
||||
</button>
|
||||
<button
|
||||
(click)="setFilter(SuggestionStatus.ACCEPTED)"
|
||||
[class.active]="statusFilter() === SuggestionStatus.ACCEPTED"
|
||||
class="filter-btn accepted"
|
||||
>
|
||||
Accepted ({{ acceptedCount() }})
|
||||
</button>
|
||||
<button
|
||||
(click)="setFilter(SuggestionStatus.DECLINED)"
|
||||
[class.active]="statusFilter() === SuggestionStatus.DECLINED"
|
||||
class="filter-btn declined"
|
||||
>
|
||||
Declined ({{ declinedCount() }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (filteredSuggestions().length === 0) {
|
||||
<div class="empty-state">
|
||||
<p>No suggestions found.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<app-pagination
|
||||
[currentPage]="currentPage()"
|
||||
[pageSize]="pageSize()"
|
||||
[totalItems]="totalFilteredSuggestions()"
|
||||
(pageChange)="onPageChange($event)"
|
||||
(pageSizeChange)="onPageSizeChange($event)"
|
||||
></app-pagination>
|
||||
|
||||
<div class="suggestions-list">
|
||||
@for (suggestion of paginatedSuggestions(); track suggestion.id) {
|
||||
<div class="suggestion-card" [class]="'status-' + suggestion.status.toLowerCase()">
|
||||
<div class="suggestion-header">
|
||||
<div class="badges">
|
||||
<span class="entity-badge" [class]="'entity-' + suggestion.entityType.toLowerCase()">
|
||||
{{ getEntityIcon(suggestion.entityType) }} {{ suggestion.entityType }}
|
||||
</span>
|
||||
<span class="status-badge" [class]="'status-' + suggestion.status.toLowerCase()">
|
||||
{{ getStatusLabel(suggestion.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="user-info">
|
||||
@if (suggestion.user.avatar) {
|
||||
<img [src]="suggestion.user.avatar" [alt]="suggestion.user.username" class="user-avatar">
|
||||
}
|
||||
{{ suggestion.user.username }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 class="suggestion-title">{{ suggestion.title }}</h3>
|
||||
|
||||
<div class="suggestion-details">
|
||||
@if (suggestion.gameData) {
|
||||
@if (suggestion.gameData.platform) {
|
||||
<p><strong>Platform:</strong> {{ suggestion.gameData.platform }}</p>
|
||||
}
|
||||
@if (suggestion.gameData.notes) {
|
||||
<p><strong>Notes:</strong> {{ suggestion.gameData.notes }}</p>
|
||||
}
|
||||
@if (suggestion.gameData.coverImage) {
|
||||
<img [src]="suggestion.gameData.coverImage" alt="Cover" class="suggestion-image">
|
||||
}
|
||||
}
|
||||
@if (suggestion.bookData) {
|
||||
@if (suggestion.bookData.author) {
|
||||
<p><strong>Author:</strong> {{ suggestion.bookData.author }}</p>
|
||||
}
|
||||
@if (suggestion.bookData.isbn) {
|
||||
<p><strong>ISBN:</strong> {{ suggestion.bookData.isbn }}</p>
|
||||
}
|
||||
@if (suggestion.bookData.notes) {
|
||||
<p><strong>Notes:</strong> {{ suggestion.bookData.notes }}</p>
|
||||
}
|
||||
@if (suggestion.bookData.coverImage) {
|
||||
<img [src]="suggestion.bookData.coverImage" alt="Cover" class="suggestion-image">
|
||||
}
|
||||
}
|
||||
@if (suggestion.musicData) {
|
||||
@if (suggestion.musicData.artist) {
|
||||
<p><strong>Artist:</strong> {{ suggestion.musicData.artist }}</p>
|
||||
}
|
||||
@if (suggestion.musicData.type) {
|
||||
<p><strong>Type:</strong> {{ suggestion.musicData.type }}</p>
|
||||
}
|
||||
@if (suggestion.musicData.notes) {
|
||||
<p><strong>Notes:</strong> {{ suggestion.musicData.notes }}</p>
|
||||
}
|
||||
@if (suggestion.musicData.coverArt) {
|
||||
<img [src]="suggestion.musicData.coverArt" alt="Cover" class="suggestion-image">
|
||||
}
|
||||
}
|
||||
@if (suggestion.artData) {
|
||||
@if (suggestion.artData.artist) {
|
||||
<p><strong>Artist:</strong> {{ suggestion.artData.artist }}</p>
|
||||
}
|
||||
@if (suggestion.artData.description) {
|
||||
<p><strong>Description:</strong> {{ suggestion.artData.description }}</p>
|
||||
}
|
||||
@if (suggestion.artData.imageUrl) {
|
||||
<img [src]="suggestion.artData.imageUrl" alt="Artwork" class="suggestion-image">
|
||||
}
|
||||
}
|
||||
@if (suggestion.showData) {
|
||||
@if (suggestion.showData.type) {
|
||||
<p><strong>Type:</strong> {{ suggestion.showData.type }}</p>
|
||||
}
|
||||
@if (suggestion.showData.notes) {
|
||||
<p><strong>Notes:</strong> {{ suggestion.showData.notes }}</p>
|
||||
}
|
||||
@if (suggestion.showData.coverImage) {
|
||||
<img [src]="suggestion.showData.coverImage" alt="Cover" class="suggestion-image">
|
||||
}
|
||||
}
|
||||
@if (suggestion.mangaData) {
|
||||
@if (suggestion.mangaData.author) {
|
||||
<p><strong>Author:</strong> {{ suggestion.mangaData.author }}</p>
|
||||
}
|
||||
@if (suggestion.mangaData.notes) {
|
||||
<p><strong>Notes:</strong> {{ suggestion.mangaData.notes }}</p>
|
||||
}
|
||||
@if (suggestion.mangaData.coverImage) {
|
||||
<img [src]="suggestion.mangaData.coverImage" alt="Cover" class="suggestion-image">
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (suggestion.status === SuggestionStatus.DECLINED && suggestion.declineReason) {
|
||||
<div class="decline-reason">
|
||||
<strong>Decline reason:</strong> {{ suggestion.declineReason }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="suggestion-footer">
|
||||
<span class="date">Suggested on {{ formatDate(suggestion.createdAt) }}</span>
|
||||
|
||||
@if (suggestion.status === SuggestionStatus.UNREVIEWED) {
|
||||
<div class="actions">
|
||||
<button (click)="acceptSuggestion(suggestion)" class="btn btn-accept">
|
||||
Accept
|
||||
</button>
|
||||
<button (click)="openDeclineModal(suggestion)" class="btn btn-decline">
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<app-pagination
|
||||
[currentPage]="currentPage()"
|
||||
[pageSize]="pageSize()"
|
||||
[totalItems]="totalFilteredSuggestions()"
|
||||
(pageChange)="onPageChange($event)"
|
||||
(pageSizeChange)="onPageSizeChange($event)"
|
||||
></app-pagination>
|
||||
}
|
||||
}
|
||||
|
||||
@if (showDeclineModal()) {
|
||||
<div class="modal-overlay" (click)="closeDeclineModal()">
|
||||
<div class="modal" (click)="$event.stopPropagation()">
|
||||
<h3>Decline Suggestion</h3>
|
||||
<p>Are you sure you want to decline "{{ decliningsuggestion()?.title }}"?</p>
|
||||
<div class="form-group">
|
||||
<label for="decline-reason">Reason (optional):</label>
|
||||
<textarea
|
||||
id="decline-reason"
|
||||
[(ngModel)]="declineReason"
|
||||
name="declineReason"
|
||||
rows="3"
|
||||
placeholder="Let the user know why you're declining this suggestion..."
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button (click)="confirmDecline()" class="btn btn-decline">Decline</button>
|
||||
<button (click)="closeDeclineModal()" class="btn btn-secondary">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showEditModal()) {
|
||||
<div class="modal-overlay" (click)="closeEditModal()">
|
||||
<div class="modal edit-modal" (click)="$event.stopPropagation()">
|
||||
<h3>Review & Edit Before Accepting</h3>
|
||||
<p>Review and edit the details before adding to your collection.</p>
|
||||
|
||||
@if (editingSuggestion()) {
|
||||
<form (ngSubmit)="confirmAcceptWithEdits()">
|
||||
<div class="form-group">
|
||||
<label for="edit-title">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
id="edit-title"
|
||||
[(ngModel)]="editedData.title"
|
||||
name="title"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
@switch (editingSuggestion()!.entityType) {
|
||||
@case ('BOOK') {
|
||||
<div class="form-group">
|
||||
<label for="edit-author">Author</label>
|
||||
<input
|
||||
type="text"
|
||||
id="edit-author"
|
||||
[(ngModel)]="editedData.author"
|
||||
name="author"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-isbn">ISBN</label>
|
||||
<input
|
||||
type="text"
|
||||
id="edit-isbn"
|
||||
[(ngModel)]="editedData.isbn"
|
||||
name="isbn"
|
||||
>
|
||||
</div>
|
||||
}
|
||||
@case ('GAME') {
|
||||
<div class="form-group">
|
||||
<label for="edit-platform">Platform</label>
|
||||
<input
|
||||
type="text"
|
||||
id="edit-platform"
|
||||
[(ngModel)]="editedData.platform"
|
||||
name="platform"
|
||||
>
|
||||
</div>
|
||||
}
|
||||
@case ('MUSIC') {
|
||||
<div class="form-group">
|
||||
<label for="edit-artist">Artist</label>
|
||||
<input
|
||||
type="text"
|
||||
id="edit-artist"
|
||||
[(ngModel)]="editedData.artist"
|
||||
name="artist"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-type">Type</label>
|
||||
<select id="edit-type" [(ngModel)]="editedData.type" name="type" required>
|
||||
<option value="ALBUM">Album</option>
|
||||
<option value="SINGLE">Single</option>
|
||||
<option value="EP">EP</option>
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
@case ('ART') {
|
||||
<div class="form-group">
|
||||
<label for="edit-artist">Artist</label>
|
||||
<input
|
||||
type="text"
|
||||
id="edit-artist"
|
||||
[(ngModel)]="editedData.artist"
|
||||
name="artist"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-description">Description</label>
|
||||
<textarea
|
||||
id="edit-description"
|
||||
[(ngModel)]="editedData.description"
|
||||
name="description"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-imageUrl">Image URL</label>
|
||||
<input
|
||||
type="text"
|
||||
id="edit-imageUrl"
|
||||
[(ngModel)]="editedData.imageUrl"
|
||||
name="imageUrl"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
}
|
||||
@case ('SHOW') {
|
||||
<div class="form-group">
|
||||
<label for="edit-type">Type</label>
|
||||
<select id="edit-type" [(ngModel)]="editedData.type" name="type" required>
|
||||
<option value="TV_SERIES">TV Series</option>
|
||||
<option value="ANIME">Anime</option>
|
||||
<option value="FILM">Film</option>
|
||||
<option value="DOCUMENTARY">Documentary</option>
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
@case ('MANGA') {
|
||||
<div class="form-group">
|
||||
<label for="edit-author">Author</label>
|
||||
<input
|
||||
type="text"
|
||||
id="edit-author"
|
||||
[(ngModel)]="editedData.author"
|
||||
name="author"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-notes">Notes</label>
|
||||
<textarea
|
||||
id="edit-notes"
|
||||
[(ngModel)]="editedData.notes"
|
||||
name="notes"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
@if (editingSuggestion()!.entityType !== 'ART') {
|
||||
<div class="form-group">
|
||||
<label for="edit-coverImage">Cover Image URL</label>
|
||||
<input
|
||||
type="text"
|
||||
id="edit-coverImage"
|
||||
[(ngModel)]="editedData.coverImage"
|
||||
name="coverImage"
|
||||
>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="submit" class="btn btn-accept">Accept with Edits</button>
|
||||
<button type="button" (click)="closeEditModal()" class="btn btn-secondary">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.header-section h2 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.not-authorized,
|
||||
.loading,
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #666;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #e5e7eb;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background: #d1d5db;
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.filter-btn.active,
|
||||
.filter-btn.active.pending {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.filter-btn.active.accepted {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.filter-btn.active.declined {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.suggestions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.suggestion-card {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
transition: box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.suggestion-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.suggestion-card.status-accepted {
|
||||
border-left: 4px solid #10b981;
|
||||
}
|
||||
|
||||
.suggestion-card.status-declined {
|
||||
border-left: 4px solid #ef4444;
|
||||
}
|
||||
|
||||
.suggestion-card.status-unreviewed {
|
||||
border-left: 4px solid #f59e0b;
|
||||
}
|
||||
|
||||
.suggestion-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.entity-badge,
|
||||
.status-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.entity-badge {
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.entity-badge.entity-game { background: #fff1f1; color: #dc2626; }
|
||||
.entity-badge.entity-book { background: #fdf4ed; color: #8b6f47; }
|
||||
.entity-badge.entity-music { background: #eff6ff; color: #2563eb; }
|
||||
.entity-badge.entity-manga { background: #ecfdf5; color: #059669; }
|
||||
.entity-badge.entity-show { background: #fdf2f8; color: #db2777; }
|
||||
.entity-badge.entity-art { background: #fefce8; color: #ca8a04; }
|
||||
|
||||
.status-badge.status-unreviewed {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.status-badge.status-accepted {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.status-badge.status-declined {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.suggestion-title {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.suggestion-details {
|
||||
font-size: 0.9rem;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.suggestion-details p {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.suggestion-image {
|
||||
max-width: 150px;
|
||||
max-height: 200px;
|
||||
border-radius: 4px;
|
||||
margin-top: 0.5rem;
|
||||
border: 2px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.decline-reason {
|
||||
background: #fee2e2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem;
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.suggestion-footer {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-size: 0.8rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn-accept {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-accept:hover {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.btn-decline {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-decline:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #d1d5db;
|
||||
}
|
||||
|
||||
/* Modal styles */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.edit-modal {
|
||||
max-width: 650px;
|
||||
}
|
||||
|
||||
.edit-modal form {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.modal h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.modal p {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class AdminSuggestionsComponent implements OnInit {
|
||||
suggestionService = inject(SuggestionService);
|
||||
authService = inject(AuthService);
|
||||
|
||||
suggestions = signal<Suggestion[]>([]);
|
||||
loading = signal(true);
|
||||
statusFilter = signal<'all' | SuggestionStatus>('all');
|
||||
showDeclineModal = signal(false);
|
||||
decliningsuggestion = signal<Suggestion | null>(null);
|
||||
declineReason = '';
|
||||
|
||||
// Edit modal state
|
||||
showEditModal = signal(false);
|
||||
editingSuggestion = signal<Suggestion | null>(null);
|
||||
editedData: any = {};
|
||||
|
||||
// Pagination state
|
||||
currentPage = signal(1);
|
||||
pageSize = signal(25);
|
||||
|
||||
SuggestionStatus = SuggestionStatus;
|
||||
|
||||
unreviewedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.UNREVIEWED).length;
|
||||
acceptedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.ACCEPTED).length;
|
||||
declinedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.DECLINED).length;
|
||||
|
||||
filteredSuggestions = computed(() => {
|
||||
const filter = this.statusFilter();
|
||||
if (filter === 'all') {
|
||||
return this.suggestions();
|
||||
}
|
||||
return this.suggestions().filter(s => s.status === filter);
|
||||
});
|
||||
|
||||
paginatedSuggestions = computed(() => {
|
||||
const suggestions = this.filteredSuggestions();
|
||||
const start = (this.currentPage() - 1) * this.pageSize();
|
||||
const end = start + this.pageSize();
|
||||
return suggestions.slice(start, end);
|
||||
});
|
||||
|
||||
totalFilteredSuggestions = computed(() => this.filteredSuggestions().length);
|
||||
|
||||
ngOnInit() {
|
||||
if (this.authService.isAdmin()) {
|
||||
this.loadSuggestions();
|
||||
} else {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async loadSuggestions() {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const suggestions = await this.suggestionService.getAllSuggestions();
|
||||
this.suggestions.set(suggestions);
|
||||
} catch {
|
||||
// Handle error silently
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
setFilter(filter: 'all' | SuggestionStatus) {
|
||||
this.statusFilter.set(filter);
|
||||
this.currentPage.set(1); // Reset to first page when filter changes
|
||||
}
|
||||
|
||||
onPageChange(page: number) {
|
||||
this.currentPage.set(page);
|
||||
}
|
||||
|
||||
onPageSizeChange(pageSize: number) {
|
||||
this.pageSize.set(pageSize);
|
||||
// Calculate new current page to stay on approximately the same content
|
||||
const firstItemIndex = (this.currentPage() - 1) * this.pageSize();
|
||||
const newPage = Math.floor(firstItemIndex / pageSize) + 1;
|
||||
this.currentPage.set(newPage);
|
||||
}
|
||||
|
||||
getStatusLabel(status: SuggestionStatus): string {
|
||||
switch (status) {
|
||||
case SuggestionStatus.UNREVIEWED: return 'Pending';
|
||||
case SuggestionStatus.ACCEPTED: return 'Accepted';
|
||||
case SuggestionStatus.DECLINED: return 'Declined';
|
||||
}
|
||||
}
|
||||
|
||||
getEntityIcon(entityType: SuggestionEntity): string {
|
||||
switch (entityType) {
|
||||
case SuggestionEntity.GAME: return '🎮';
|
||||
case SuggestionEntity.BOOK: return '📚';
|
||||
case SuggestionEntity.MUSIC: return '🎵';
|
||||
case SuggestionEntity.MANGA: return '📖';
|
||||
case SuggestionEntity.SHOW: return '📺';
|
||||
case SuggestionEntity.ART: return '🎨';
|
||||
}
|
||||
}
|
||||
|
||||
formatDate(date: Date | string): string {
|
||||
return new Date(date).toLocaleDateString();
|
||||
}
|
||||
|
||||
async acceptSuggestion(suggestion: Suggestion) {
|
||||
this.editingSuggestion.set(suggestion);
|
||||
|
||||
// Pre-populate the edit form with suggestion data
|
||||
this.editedData = {
|
||||
title: suggestion.title,
|
||||
notes: '',
|
||||
coverImage: '',
|
||||
coverArt: ''
|
||||
};
|
||||
|
||||
// Add entity-specific data
|
||||
switch (suggestion.entityType) {
|
||||
case 'BOOK':
|
||||
const bookData = suggestion.bookData as any;
|
||||
this.editedData.author = bookData?.author || '';
|
||||
this.editedData.isbn = bookData?.isbn || '';
|
||||
this.editedData.notes = bookData?.notes || '';
|
||||
this.editedData.coverImage = bookData?.coverImage || '';
|
||||
break;
|
||||
case 'GAME':
|
||||
const gameData = suggestion.gameData as any;
|
||||
this.editedData.platform = gameData?.platform || '';
|
||||
this.editedData.notes = gameData?.notes || '';
|
||||
this.editedData.coverImage = gameData?.coverImage || '';
|
||||
break;
|
||||
case 'MUSIC':
|
||||
const musicData = suggestion.musicData as any;
|
||||
this.editedData.artist = musicData?.artist || '';
|
||||
this.editedData.type = musicData?.type || 'ALBUM';
|
||||
this.editedData.notes = musicData?.notes || '';
|
||||
this.editedData.coverArt = musicData?.coverArt || '';
|
||||
break;
|
||||
case 'ART':
|
||||
const artData = suggestion.artData as any;
|
||||
this.editedData.artist = artData?.artist || '';
|
||||
this.editedData.description = artData?.description || '';
|
||||
this.editedData.imageUrl = artData?.imageUrl || '';
|
||||
break;
|
||||
case 'SHOW':
|
||||
const showData = suggestion.showData as any;
|
||||
this.editedData.type = showData?.type || 'TV_SERIES';
|
||||
this.editedData.notes = showData?.notes || '';
|
||||
this.editedData.coverImage = showData?.coverImage || '';
|
||||
break;
|
||||
case 'MANGA':
|
||||
const mangaData = suggestion.mangaData as any;
|
||||
this.editedData.author = mangaData?.author || '';
|
||||
this.editedData.notes = mangaData?.notes || '';
|
||||
this.editedData.coverImage = mangaData?.coverImage || '';
|
||||
break;
|
||||
}
|
||||
|
||||
this.showEditModal.set(true);
|
||||
}
|
||||
|
||||
openDeclineModal(suggestion: Suggestion) {
|
||||
this.decliningsuggestion.set(suggestion);
|
||||
this.declineReason = '';
|
||||
this.showDeclineModal.set(true);
|
||||
}
|
||||
|
||||
closeDeclineModal() {
|
||||
this.showDeclineModal.set(false);
|
||||
this.decliningsuggestion.set(null);
|
||||
this.declineReason = '';
|
||||
}
|
||||
|
||||
closeEditModal() {
|
||||
this.showEditModal.set(false);
|
||||
this.editingSuggestion.set(null);
|
||||
this.editedData = {};
|
||||
}
|
||||
|
||||
async confirmAcceptWithEdits() {
|
||||
const suggestion = this.editingSuggestion();
|
||||
if (!suggestion) return;
|
||||
|
||||
try {
|
||||
await this.suggestionService.acceptSuggestionWithEdits(suggestion.id, this.editedData);
|
||||
alert(`"${this.editedData.title}" has been added to your collection with your edits!`);
|
||||
this.closeEditModal();
|
||||
this.loadSuggestions();
|
||||
} catch (error) {
|
||||
alert('Failed to accept suggestion. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
async confirmDecline() {
|
||||
const suggestion = this.decliningsuggestion();
|
||||
if (!suggestion) return;
|
||||
|
||||
try {
|
||||
await this.suggestionService.declineSuggestion(suggestion.id, {
|
||||
reason: this.declineReason || undefined
|
||||
});
|
||||
this.closeDeclineModal();
|
||||
this.loadSuggestions();
|
||||
} catch {
|
||||
alert('Failed to decline suggestion. Please try again.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { UserService } from '../../services/user.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { PaginationComponent } from '../shared/pagination.component';
|
||||
import { User } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-users',
|
||||
standalone: true,
|
||||
imports: [CommonModule, PaginationComponent],
|
||||
template: `
|
||||
<div class="admin-container">
|
||||
<h2>User Management</h2>
|
||||
|
||||
@if (loading()) {
|
||||
<p class="loading">Loading users...</p>
|
||||
} @else if (error()) {
|
||||
<p class="error">{{ error() }}</p>
|
||||
} @else {
|
||||
<app-pagination
|
||||
[currentPage]="currentPage()"
|
||||
[pageSize]="pageSize()"
|
||||
[totalItems]="totalUsers()"
|
||||
(pageChange)="onPageChange($event)"
|
||||
(pageSizeChange)="onPageSizeChange($event)"
|
||||
></app-pagination>
|
||||
|
||||
<div class="users-list">
|
||||
@for (user of paginatedUsers(); track user.id) {
|
||||
<div class="user-card" [class.banned]="user.isBanned">
|
||||
<div class="user-info">
|
||||
@if (user.avatar) {
|
||||
<img [src]="user.avatar" [alt]="user.username" class="avatar" />
|
||||
} @else {
|
||||
<div class="avatar-placeholder">{{ user.username.charAt(0).toUpperCase() }}</div>
|
||||
}
|
||||
<div class="user-details">
|
||||
<span class="username">{{ user.username }}</span>
|
||||
<span class="email">{{ user.email }}</span>
|
||||
@if (user.isAdmin) {
|
||||
<span class="admin-badge">Admin</span>
|
||||
}
|
||||
@if (user.isBanned) {
|
||||
<span class="banned-badge">Banned</span>
|
||||
}
|
||||
@if (user.inDiscord) {
|
||||
<span class="discord-badge">Discord</span>
|
||||
}
|
||||
@if (user.isVip) {
|
||||
<span class="vip-badge">VIP</span>
|
||||
}
|
||||
@if (user.isMod) {
|
||||
<span class="mod-badge">Mod</span>
|
||||
}
|
||||
@if (user.isStaff) {
|
||||
<span class="staff-badge">Staff</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-actions">
|
||||
@if (!user.isAdmin) {
|
||||
@if (user.isBanned) {
|
||||
<button (click)="unbanUser(user.id)" class="btn btn-unban">Unban</button>
|
||||
} @else {
|
||||
<button (click)="banUser(user.id)" class="btn btn-ban">Ban</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @empty {
|
||||
<p class="no-users">No users found.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<app-pagination
|
||||
[currentPage]="currentPage()"
|
||||
[pageSize]="pageSize()"
|
||||
[totalItems]="totalUsers()"
|
||||
(pageChange)="onPageChange($event)"
|
||||
(pageSizeChange)="onPageSizeChange($event)"
|
||||
></app-pagination>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.admin-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: var(--witch-purple);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.loading, .error, .no-users {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--witch-mauve);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--witch-rose);
|
||||
}
|
||||
|
||||
.users-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.user-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
background: var(--witch-moon);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px var(--witch-shadow);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.user-card.banned {
|
||||
opacity: 0.7;
|
||||
background: var(--witch-lavender);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: var(--witch-purple);
|
||||
color: var(--witch-moon);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-weight: 600;
|
||||
color: var(--witch-purple);
|
||||
}
|
||||
|
||||
.email {
|
||||
font-size: 0.85rem;
|
||||
color: var(--witch-mauve);
|
||||
}
|
||||
|
||||
.admin-badge {
|
||||
display: inline-block;
|
||||
background: var(--witch-rose);
|
||||
color: var(--witch-moon);
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.banned-badge {
|
||||
display: inline-block;
|
||||
background: var(--witch-plum);
|
||||
color: var(--witch-moon);
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.discord-badge {
|
||||
display: inline-block;
|
||||
background: #5865f2;
|
||||
color: var(--witch-moon);
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.vip-badge {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #ffd700, #ffaa00);
|
||||
color: #1a1a1a;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.mod-badge {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #00b894, #00cec9);
|
||||
color: var(--witch-moon);
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.staff-badge {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #e84393, #fd79a8);
|
||||
color: var(--witch-moon);
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.user-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.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-ban {
|
||||
background: var(--witch-rose);
|
||||
color: var(--witch-moon);
|
||||
}
|
||||
|
||||
.btn-ban:hover {
|
||||
background: var(--witch-plum);
|
||||
}
|
||||
|
||||
.btn-unban {
|
||||
background: var(--witch-purple);
|
||||
color: var(--witch-moon);
|
||||
}
|
||||
|
||||
.btn-unban:hover {
|
||||
background: var(--witch-mauve);
|
||||
color: var(--witch-purple);
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class AdminUsersComponent implements OnInit {
|
||||
private userService = inject(UserService);
|
||||
private authService = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
|
||||
users = signal<User[]>([]);
|
||||
loading = signal(true);
|
||||
error = signal<string | null>(null);
|
||||
|
||||
// Pagination state
|
||||
currentPage = signal(1);
|
||||
pageSize = signal(25);
|
||||
|
||||
paginatedUsers = computed(() => {
|
||||
const start = (this.currentPage() - 1) * this.pageSize();
|
||||
const end = start + this.pageSize();
|
||||
return this.users().slice(start, end);
|
||||
});
|
||||
|
||||
totalUsers = computed(() => this.users().length);
|
||||
|
||||
ngOnInit(): void {
|
||||
if (!this.authService.isAdmin()) {
|
||||
this.router.navigate(['/']);
|
||||
return;
|
||||
}
|
||||
|
||||
this.loadUsers();
|
||||
}
|
||||
|
||||
private loadUsers(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
this.userService.getAllUsers().subscribe({
|
||||
next: (users) => {
|
||||
this.users.set(users);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err.message ?? 'Failed to load users');
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
banUser(userId: string): void {
|
||||
this.userService.banUser(userId).subscribe({
|
||||
next: (updatedUser) => {
|
||||
this.users.update(users =>
|
||||
users.map(u => u.id === userId ? updatedUser : u)
|
||||
);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err.message ?? 'Failed to ban user');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
unbanUser(userId: string): void {
|
||||
this.userService.unbanUser(userId).subscribe({
|
||||
next: (updatedUser) => {
|
||||
this.users.update(users =>
|
||||
users.map(u => u.id === userId ? updatedUser : u)
|
||||
);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err.message ?? 'Failed to unban user');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onPageChange(page: number): void {
|
||||
this.currentPage.set(page);
|
||||
}
|
||||
|
||||
onPageSizeChange(pageSize: number): void {
|
||||
this.pageSize.set(pageSize);
|
||||
// Calculate new current page to stay on approximately the same content
|
||||
const firstItemIndex = (this.currentPage() - 1) * this.pageSize();
|
||||
const newPage = Math.floor(firstItemIndex / pageSize) + 1;
|
||||
this.currentPage.set(newPage);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-footer',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<footer class="footer">
|
||||
<div class="footer-content">
|
||||
<div class="cta-section">
|
||||
<div class="cta-card discord">
|
||||
<h3>Join the Community</h3>
|
||||
<p>Want to chat about what we're reading, playing, or listening to? Got recommendations? Come hang out!</p>
|
||||
<a href="https://chat.nhcarrigan.com" target="_blank" rel="noopener noreferrer" class="btn btn-discord">
|
||||
Join our Discord
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="cta-card donate">
|
||||
<h3>Support Naomi</h3>
|
||||
<p>Enjoying the vibes? Your support helps keep the servers running and the coffee flowing!</p>
|
||||
<a href="https://donate.nhcarrigan.com" target="_blank" rel="noopener noreferrer" class="btn btn-donate">
|
||||
Buy us a coffee
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-bottom">
|
||||
<p>© {{ currentYear }} NHCarrigan. Made with love.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
`,
|
||||
styles: [`
|
||||
.footer {
|
||||
background: linear-gradient(135deg, var(--witch-purple) 0%, var(--witch-plum) 100%);
|
||||
color: var(--witch-moon);
|
||||
padding: 3rem 2rem 1.5rem;
|
||||
margin-top: 4rem;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.cta-section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.cta-card {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
transition: transform 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.cta-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.cta-card h3 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.25rem;
|
||||
color: var(--witch-moon);
|
||||
}
|
||||
|
||||
.cta-card p {
|
||||
margin: 0 0 1.25rem 0;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
color: var(--witch-lavender);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.btn-discord {
|
||||
background: #5865F2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-discord:hover {
|
||||
background: #4752C4;
|
||||
}
|
||||
|
||||
.btn-donate {
|
||||
background: var(--witch-rose);
|
||||
color: var(--witch-moon);
|
||||
}
|
||||
|
||||
.btn-donate:hover {
|
||||
background: var(--witch-mauve);
|
||||
color: var(--witch-purple);
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
text-align: center;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.footer-bottom p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--witch-lavender);
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class FooterComponent {
|
||||
currentYear = new Date().getFullYear();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { ApiService } from '../../services/api.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>
|
||||
@if (version()) {
|
||||
<span class="version">v{{ version() }}</span>
|
||||
}
|
||||
</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>
|
||||
<li><a routerLink="/shows" routerLinkActive="active">Shows</a></li>
|
||||
<li><a routerLink="/manga" routerLinkActive="active">Manga</a></li>
|
||||
<li><a routerLink="/art" routerLinkActive="active">Art</a></li>
|
||||
</ul>
|
||||
|
||||
<div class="auth-section">
|
||||
@if (authService.user(); as user) {
|
||||
<span class="welcome">Welcome, {{ user.username }}!</span>
|
||||
@if (!user.isAdmin) {
|
||||
<a routerLink="/my-suggestions" class="user-link">My Suggestions</a>
|
||||
}
|
||||
<a routerLink="/my-likes" class="user-link">My Likes</a>
|
||||
@if (user.isAdmin) {
|
||||
<a routerLink="/admin/users" class="admin-badge">Users</a>
|
||||
<a routerLink="/admin/audit" class="admin-badge">Audit</a>
|
||||
<a routerLink="/admin/suggestions" class="admin-badge">Suggestions</a>
|
||||
}
|
||||
<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;
|
||||
}
|
||||
|
||||
.version {
|
||||
font-size: 0.7rem;
|
||||
color: var(--witch-lavender);
|
||||
opacity: 0.8;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.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;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.admin-badge:hover {
|
||||
background-color: var(--witch-plum);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.user-link {
|
||||
color: var(--witch-lavender);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.user-link:hover {
|
||||
background-color: var(--witch-plum);
|
||||
color: var(--witch-moon);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
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 implements OnInit {
|
||||
authService = inject(AuthService);
|
||||
private apiService = inject(ApiService);
|
||||
version = signal<string | null>(null);
|
||||
|
||||
ngOnInit() {
|
||||
this.apiService.get<{ version: string }>('/version').subscribe({
|
||||
next: (response) => this.version.set(response.version),
|
||||
error: () => this.version.set(null)
|
||||
});
|
||||
}
|
||||
|
||||
login() {
|
||||
this.authService.login();
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.authService.logout().subscribe();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* @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 { MangaService } from '../../services/manga.service';
|
||||
import { ShowsService } from '../../services/shows.service';
|
||||
import { ArtService } from '../../services/art.service';
|
||||
import { Game, GameStatus, Book, BookStatus, Music, MusicType, Manga, MangaStatus, Show, ShowStatus, ShowType, Art } 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, music, manga, shows, and art</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>
|
||||
|
||||
<a routerLink="/manga" class="stat-card manga-card">
|
||||
<div class="icon">📖</div>
|
||||
<div class="stat-info">
|
||||
<h3>Manga</h3>
|
||||
<div class="count">{{ mangaCount() }}</div>
|
||||
<p>{{ currentlyReadingMangaCount() }} currently reading</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a routerLink="/shows" class="stat-card shows-card">
|
||||
<div class="icon">📺</div>
|
||||
<div class="stat-info">
|
||||
<h3>Shows</h3>
|
||||
<div class="count">{{ showsCount() }}</div>
|
||||
<p>{{ animeCount() }} anime, {{ filmsCount() }} films</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a routerLink="/art" class="stat-card art-card">
|
||||
<div class="icon">🎨</div>
|
||||
<div class="stat-info">
|
||||
<h3>Art</h3>
|
||||
<div class="count">{{ artCount() }}</div>
|
||||
<p>commissioned pieces</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>
|
||||
}
|
||||
|
||||
@if (recentManga().length > 0) {
|
||||
<div class="recent-category">
|
||||
<h3>📖 Latest Manga</h3>
|
||||
<ul class="recent-list">
|
||||
@for (manga of recentManga(); track manga.id) {
|
||||
<li>
|
||||
<a routerLink="/manga">{{ manga.title }}</a>
|
||||
<span class="author">by {{ manga.author }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (recentShows().length > 0) {
|
||||
<div class="recent-category">
|
||||
<h3>📺 Latest Shows</h3>
|
||||
<ul class="recent-list">
|
||||
@for (show of recentShows(); track show.id) {
|
||||
<li>
|
||||
<a routerLink="/shows">{{ show.title }}</a>
|
||||
<span class="show-type">{{ formatShowType(show.type) }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (recentArt().length > 0) {
|
||||
<div class="recent-category">
|
||||
<h3>🎨 Latest Art</h3>
|
||||
<ul class="recent-list">
|
||||
@for (art of recentArt(); track art.id) {
|
||||
<li>
|
||||
<a routerLink="/art">{{ art.title }}</a>
|
||||
<span class="artist">by {{ art.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: #ff6b6b; }
|
||||
.books-card:hover { border-color: #8b6f47; }
|
||||
.music-card:hover { border-color: #74b9ff; }
|
||||
.manga-card:hover { border-color: #00b894; }
|
||||
.shows-card:hover { border-color: #e84393; }
|
||||
.art-card:hover { border-color: #fdcb6e; }
|
||||
|
||||
.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: #ff6b6b; }
|
||||
.books-card .count { color: #8b6f47; }
|
||||
.music-card .count { color: #74b9ff; }
|
||||
.manga-card .count { color: #00b894; }
|
||||
.shows-card .count { color: #e84393; }
|
||||
.art-card .count { color: #fdcb6e; }
|
||||
|
||||
.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,
|
||||
.show-type {
|
||||
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);
|
||||
mangaService = inject(MangaService);
|
||||
showsService = inject(ShowsService);
|
||||
artService = inject(ArtService);
|
||||
|
||||
games = signal<Game[]>([]);
|
||||
books = signal<Book[]>([]);
|
||||
music = signal<Music[]>([]);
|
||||
manga = signal<Manga[]>([]);
|
||||
shows = signal<Show[]>([]);
|
||||
art = signal<Art[]>([]);
|
||||
|
||||
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));
|
||||
this.mangaService.getAllManga().subscribe(manga => this.manga.set(manga));
|
||||
this.showsService.getAllShows().subscribe(shows => this.shows.set(shows));
|
||||
this.artService.getAllArt().subscribe(art => this.art.set(art));
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Manga stats
|
||||
mangaCount() {
|
||||
return this.manga().length;
|
||||
}
|
||||
|
||||
currentlyReadingMangaCount() {
|
||||
return this.manga().filter(m => m.status === MangaStatus.reading).length;
|
||||
}
|
||||
|
||||
recentManga() {
|
||||
return this.manga().slice(0, 5);
|
||||
}
|
||||
|
||||
// Shows stats
|
||||
showsCount() {
|
||||
return this.shows().length;
|
||||
}
|
||||
|
||||
animeCount() {
|
||||
return this.shows().filter(s => s.type === ShowType.anime).length;
|
||||
}
|
||||
|
||||
filmsCount() {
|
||||
return this.shows().filter(s => s.type === ShowType.film).length;
|
||||
}
|
||||
|
||||
recentShows() {
|
||||
return this.shows().slice(0, 5);
|
||||
}
|
||||
|
||||
formatShowType(type: ShowType): string {
|
||||
switch (type) {
|
||||
case ShowType.tvSeries:
|
||||
return 'TV Series';
|
||||
case ShowType.anime:
|
||||
return 'Anime';
|
||||
case ShowType.film:
|
||||
return 'Film';
|
||||
case ShowType.documentary:
|
||||
return 'Documentary';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
// Art stats
|
||||
artCount() {
|
||||
return this.art().length;
|
||||
}
|
||||
|
||||
recentArt() {
|
||||
return this.art().slice(0, 5);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,460 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { LikesService } from '../../services/likes.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { PaginationComponent } from '../shared/pagination.component';
|
||||
import { LikeButtonComponent } from '../shared/like-button.component';
|
||||
import { LikedItemDto, Like } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-my-likes',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, PaginationComponent, LikeButtonComponent],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="header-section">
|
||||
<h2>My Likes</h2>
|
||||
<p class="subtitle">All the items you've liked across the library</p>
|
||||
</div>
|
||||
|
||||
@if (!authService.isAuthenticated()) {
|
||||
<div class="not-authenticated">
|
||||
<p>Please log in to view your liked items.</p>
|
||||
</div>
|
||||
} @else if (loading()) {
|
||||
<div class="loading">Loading your liked items...</div>
|
||||
} @else if (likedItems().length === 0) {
|
||||
<div class="empty-state">
|
||||
<p>You haven't liked any items yet!</p>
|
||||
<p class="hint">Explore the library and click the heart button on items you enjoy.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="filters">
|
||||
<button
|
||||
(click)="setFilter('all')"
|
||||
[class.active]="typeFilter() === 'all'"
|
||||
class="filter-btn"
|
||||
>
|
||||
All ({{ likedItems().length }})
|
||||
</button>
|
||||
<button
|
||||
(click)="setFilter('book')"
|
||||
[class.active]="typeFilter() === 'book'"
|
||||
class="filter-btn"
|
||||
>
|
||||
Books ({{ bookCount() }})
|
||||
</button>
|
||||
<button
|
||||
(click)="setFilter('game')"
|
||||
[class.active]="typeFilter() === 'game'"
|
||||
class="filter-btn"
|
||||
>
|
||||
Games ({{ gameCount() }})
|
||||
</button>
|
||||
<button
|
||||
(click)="setFilter('show')"
|
||||
[class.active]="typeFilter() === 'show'"
|
||||
class="filter-btn"
|
||||
>
|
||||
Shows ({{ showCount() }})
|
||||
</button>
|
||||
<button
|
||||
(click)="setFilter('manga')"
|
||||
[class.active]="typeFilter() === 'manga'"
|
||||
class="filter-btn"
|
||||
>
|
||||
Manga ({{ mangaCount() }})
|
||||
</button>
|
||||
<button
|
||||
(click)="setFilter('music')"
|
||||
[class.active]="typeFilter() === 'music'"
|
||||
class="filter-btn"
|
||||
>
|
||||
Music ({{ musicCount() }})
|
||||
</button>
|
||||
<button
|
||||
(click)="setFilter('art')"
|
||||
[class.active]="typeFilter() === 'art'"
|
||||
class="filter-btn"
|
||||
>
|
||||
Art ({{ artCount() }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<app-pagination
|
||||
[currentPage]="currentPage()"
|
||||
[pageSize]="pageSize()"
|
||||
[totalItems]="totalFilteredItems()"
|
||||
(pageChange)="onPageChange($event)"
|
||||
(pageSizeChange)="onPageSizeChange($event)"
|
||||
></app-pagination>
|
||||
|
||||
<div class="liked-items-grid">
|
||||
@for (likedItem of paginatedItems(); track likedItem.like.id) {
|
||||
<div class="liked-item-card">
|
||||
<div class="item-type-badge">{{ getTypeBadgeLabel(likedItem.like.entityType) }}</div>
|
||||
|
||||
@if (getItemImage(likedItem)) {
|
||||
<img
|
||||
[src]="getItemImage(likedItem)"
|
||||
[alt]="getItemTitle(likedItem)"
|
||||
class="item-image"
|
||||
>
|
||||
} @else {
|
||||
<div class="item-image placeholder">{{ getTypePlaceholderEmoji(likedItem.like.entityType) }}</div>
|
||||
}
|
||||
|
||||
<div class="item-info">
|
||||
<h3>{{ getItemTitle(likedItem) }}</h3>
|
||||
<p class="item-subtitle">{{ getItemSubtitle(likedItem) }}</p>
|
||||
<p class="liked-date">Liked: {{ formatDate(likedItem.like.createdAt) }}</p>
|
||||
|
||||
<app-like-button
|
||||
[entityType]="likedItem.like.entityType"
|
||||
[entityId]="likedItem.like.entityId"
|
||||
></app-like-button>
|
||||
|
||||
<a
|
||||
[routerLink]="getItemLink(likedItem)"
|
||||
class="view-link"
|
||||
>
|
||||
View Details →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<app-pagination
|
||||
[currentPage]="currentPage()"
|
||||
[pageSize]="pageSize()"
|
||||
[totalItems]="totalFilteredItems()"
|
||||
(pageChange)="onPageChange($event)"
|
||||
(pageSizeChange)="onPageSizeChange($event)"
|
||||
></app-pagination>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.header-section h2 {
|
||||
color: var(--witch-purple);
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--witch-plum);
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.not-authenticated, .loading, .empty-state {
|
||||
background: var(--witch-lavender);
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
border-radius: 12px;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.empty-state .hint {
|
||||
color: var(--witch-plum);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 2px solid var(--witch-plum);
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 2px solid var(--witch-plum);
|
||||
background: transparent;
|
||||
color: var(--witch-purple);
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background: var(--witch-lavender);
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: var(--witch-rose);
|
||||
color: white;
|
||||
border-color: var(--witch-rose);
|
||||
}
|
||||
|
||||
.liked-items-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.liked-item-card {
|
||||
background: white;
|
||||
border: 2px solid var(--witch-plum);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.liked-item-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 16px var(--witch-shadow);
|
||||
}
|
||||
|
||||
.item-type-badge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: var(--witch-rose);
|
||||
color: white;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
text-transform: capitalize;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.item-image {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.item-image.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--witch-lavender);
|
||||
font-size: 4rem;
|
||||
}
|
||||
|
||||
.item-info {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.item-info h3 {
|
||||
color: var(--witch-purple);
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.item-subtitle {
|
||||
color: var(--witch-plum);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.liked-date {
|
||||
color: var(--witch-silver);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.view-link {
|
||||
display: inline-block;
|
||||
margin-top: 1rem;
|
||||
color: var(--witch-rose);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.view-link:hover {
|
||||
color: var(--witch-plum);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.liked-items-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class MyLikesComponent implements OnInit {
|
||||
private likesService = inject(LikesService);
|
||||
authService = inject(AuthService);
|
||||
|
||||
loading = signal(false);
|
||||
likedItems = signal<LikedItemDto[]>([]);
|
||||
typeFilter = signal<'all' | Like['entityType']>('all');
|
||||
currentPage = signal(1);
|
||||
pageSize = signal(12);
|
||||
|
||||
// Computed signals for filtering and pagination
|
||||
filteredItems = computed(() => {
|
||||
const filter = this.typeFilter();
|
||||
const items = this.likedItems();
|
||||
|
||||
if (filter === 'all') {
|
||||
return items;
|
||||
}
|
||||
|
||||
return items.filter(item => item.like.entityType === filter);
|
||||
});
|
||||
|
||||
totalFilteredItems = computed(() => this.filteredItems().length);
|
||||
|
||||
paginatedItems = computed(() => {
|
||||
const items = this.filteredItems();
|
||||
const page = this.currentPage();
|
||||
const size = this.pageSize();
|
||||
const start = (page - 1) * size;
|
||||
const end = start + size;
|
||||
|
||||
return items.slice(start, end);
|
||||
});
|
||||
|
||||
// Computed counts for each type
|
||||
bookCount = computed(() => this.likedItems().filter(item => item.like.entityType === 'book').length);
|
||||
gameCount = computed(() => this.likedItems().filter(item => item.like.entityType === 'game').length);
|
||||
showCount = computed(() => this.likedItems().filter(item => item.like.entityType === 'show').length);
|
||||
mangaCount = computed(() => this.likedItems().filter(item => item.like.entityType === 'manga').length);
|
||||
musicCount = computed(() => this.likedItems().filter(item => item.like.entityType === 'music').length);
|
||||
artCount = computed(() => this.likedItems().filter(item => item.like.entityType === 'art').length);
|
||||
|
||||
ngOnInit() {
|
||||
if (this.authService.isAuthenticated()) {
|
||||
this.loadLikedItems();
|
||||
}
|
||||
}
|
||||
|
||||
loadLikedItems() {
|
||||
this.loading.set(true);
|
||||
this.likesService.getUserLikedItems().subscribe({
|
||||
next: (items) => {
|
||||
this.likedItems.set(items);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setFilter(filter: 'all' | Like['entityType']) {
|
||||
this.typeFilter.set(filter);
|
||||
this.currentPage.set(1); // Reset to first page when filtering
|
||||
}
|
||||
|
||||
onPageChange(page: number) {
|
||||
this.currentPage.set(page);
|
||||
}
|
||||
|
||||
onPageSizeChange(size: number) {
|
||||
this.pageSize.set(size);
|
||||
this.currentPage.set(1); // Reset to first page when changing page size
|
||||
}
|
||||
|
||||
getItemTitle(likedItem: LikedItemDto): string {
|
||||
const item = likedItem.item;
|
||||
return item.title || item.name || 'Untitled';
|
||||
}
|
||||
|
||||
getItemSubtitle(likedItem: LikedItemDto): string {
|
||||
const item = likedItem.item;
|
||||
const type = likedItem.like.entityType;
|
||||
|
||||
switch (type) {
|
||||
case 'book':
|
||||
return `by ${item.author || 'Unknown author'}`;
|
||||
case 'game':
|
||||
return item.platform || 'Platform not specified';
|
||||
case 'show':
|
||||
return item.type === 'movie' ? 'Movie' : 'TV Show';
|
||||
case 'manga':
|
||||
return item.volumeCount ? `${item.volumeCount} volumes` : 'Ongoing';
|
||||
case 'music':
|
||||
return `${item.artist || 'Unknown artist'} - ${item.type || 'Album'}`;
|
||||
case 'art':
|
||||
return `by ${item.artist || 'Unknown artist'}`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
getItemImage(likedItem: LikedItemDto): string | null {
|
||||
const item = likedItem.item;
|
||||
return item.coverImage || item.imageUrl || null;
|
||||
}
|
||||
|
||||
getItemLink(likedItem: LikedItemDto): string[] {
|
||||
const type = likedItem.like.entityType;
|
||||
const id = likedItem.like.entityId;
|
||||
|
||||
switch (type) {
|
||||
case 'book':
|
||||
return ['/books'];
|
||||
case 'game':
|
||||
return ['/games'];
|
||||
case 'show':
|
||||
return ['/shows'];
|
||||
case 'manga':
|
||||
return ['/manga'];
|
||||
case 'music':
|
||||
return ['/music'];
|
||||
case 'art':
|
||||
return ['/art'];
|
||||
default:
|
||||
return ['/'];
|
||||
}
|
||||
}
|
||||
|
||||
getTypeBadgeLabel(type: Like['entityType']): string {
|
||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||
}
|
||||
|
||||
getTypePlaceholderEmoji(type: Like['entityType']): string {
|
||||
switch (type) {
|
||||
case 'book':
|
||||
return '📚';
|
||||
case 'game':
|
||||
return '🎮';
|
||||
case 'show':
|
||||
return '🎬';
|
||||
case 'manga':
|
||||
return '📖';
|
||||
case 'music':
|
||||
return '🎵';
|
||||
case 'art':
|
||||
return '🎨';
|
||||
default:
|
||||
return '❤️';
|
||||
}
|
||||
}
|
||||
|
||||
formatDate(date: Date | string): string {
|
||||
const d = new Date(date);
|
||||
return d.toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { SuggestionService } from '../../services/suggestion.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { PaginationComponent } from '../shared/pagination.component';
|
||||
import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-my-suggestions',
|
||||
standalone: true,
|
||||
imports: [CommonModule, PaginationComponent],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="header-section">
|
||||
<h2>My Suggestions</h2>
|
||||
<p class="subtitle">Track the status of items you've suggested to Naomi</p>
|
||||
</div>
|
||||
|
||||
@if (!authService.isAuthenticated()) {
|
||||
<div class="not-authenticated">
|
||||
<p>Please log in to view your suggestions.</p>
|
||||
</div>
|
||||
} @else if (loading()) {
|
||||
<div class="loading">Loading your suggestions...</div>
|
||||
} @else if (suggestions().length === 0) {
|
||||
<div class="empty-state">
|
||||
<p>You haven't made any suggestions yet!</p>
|
||||
<p class="hint">Visit any collection page and click "Suggest a..." to recommend something to Naomi.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="filters">
|
||||
<button
|
||||
(click)="setFilter('all')"
|
||||
[class.active]="statusFilter() === 'all'"
|
||||
class="filter-btn"
|
||||
>
|
||||
All ({{ suggestions().length }})
|
||||
</button>
|
||||
<button
|
||||
(click)="setFilter(SuggestionStatus.UNREVIEWED)"
|
||||
[class.active]="statusFilter() === SuggestionStatus.UNREVIEWED"
|
||||
class="filter-btn pending"
|
||||
>
|
||||
Pending ({{ unreviewedCount() }})
|
||||
</button>
|
||||
<button
|
||||
(click)="setFilter(SuggestionStatus.ACCEPTED)"
|
||||
[class.active]="statusFilter() === SuggestionStatus.ACCEPTED"
|
||||
class="filter-btn accepted"
|
||||
>
|
||||
Accepted ({{ acceptedCount() }})
|
||||
</button>
|
||||
<button
|
||||
(click)="setFilter(SuggestionStatus.DECLINED)"
|
||||
[class.active]="statusFilter() === SuggestionStatus.DECLINED"
|
||||
class="filter-btn declined"
|
||||
>
|
||||
Declined ({{ declinedCount() }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<app-pagination
|
||||
[currentPage]="currentPage()"
|
||||
[pageSize]="pageSize()"
|
||||
[totalItems]="totalFilteredSuggestions()"
|
||||
(pageChange)="onPageChange($event)"
|
||||
(pageSizeChange)="onPageSizeChange($event)"
|
||||
></app-pagination>
|
||||
|
||||
<div class="suggestions-list">
|
||||
@for (suggestion of paginatedSuggestions(); track suggestion.id) {
|
||||
<div class="suggestion-card" [class]="'status-' + suggestion.status.toLowerCase()">
|
||||
<div class="suggestion-header">
|
||||
<span class="entity-badge" [class]="'entity-' + suggestion.entityType.toLowerCase()">
|
||||
{{ getEntityIcon(suggestion.entityType) }} {{ suggestion.entityType }}
|
||||
</span>
|
||||
<span class="status-badge" [class]="'status-' + suggestion.status.toLowerCase()">
|
||||
{{ getStatusLabel(suggestion.status) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 class="suggestion-title">{{ suggestion.title }}</h3>
|
||||
|
||||
<div class="suggestion-details">
|
||||
@if (suggestion.gameData) {
|
||||
@if (suggestion.gameData.platform) {
|
||||
<p><strong>Platform:</strong> {{ suggestion.gameData.platform }}</p>
|
||||
}
|
||||
}
|
||||
@if (suggestion.bookData) {
|
||||
@if (suggestion.bookData.author) {
|
||||
<p><strong>Author:</strong> {{ suggestion.bookData.author }}</p>
|
||||
}
|
||||
}
|
||||
@if (suggestion.musicData) {
|
||||
@if (suggestion.musicData.artist) {
|
||||
<p><strong>Artist:</strong> {{ suggestion.musicData.artist }}</p>
|
||||
}
|
||||
}
|
||||
@if (suggestion.artData) {
|
||||
@if (suggestion.artData.artist) {
|
||||
<p><strong>Artist:</strong> {{ suggestion.artData.artist }}</p>
|
||||
}
|
||||
}
|
||||
@if (suggestion.showData) {
|
||||
@if (suggestion.showData.type) {
|
||||
<p><strong>Type:</strong> {{ suggestion.showData.type }}</p>
|
||||
}
|
||||
}
|
||||
@if (suggestion.mangaData) {
|
||||
@if (suggestion.mangaData.author) {
|
||||
<p><strong>Author:</strong> {{ suggestion.mangaData.author }}</p>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (suggestion.status === SuggestionStatus.DECLINED && suggestion.declineReason) {
|
||||
<div class="decline-reason">
|
||||
<strong>Reason:</strong> {{ suggestion.declineReason }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="suggestion-footer">
|
||||
<span class="date">Suggested on {{ formatDate(suggestion.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<app-pagination
|
||||
[currentPage]="currentPage()"
|
||||
[pageSize]="pageSize()"
|
||||
[totalItems]="totalFilteredSuggestions()"
|
||||
(pageChange)="onPageChange($event)"
|
||||
(pageSizeChange)="onPageSizeChange($event)"
|
||||
></app-pagination>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.header-section h2 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.not-authenticated,
|
||||
.loading,
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #666;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.9rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #e5e7eb;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background: #d1d5db;
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.filter-btn.active,
|
||||
.filter-btn.active.pending {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.filter-btn.active.accepted {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.filter-btn.active.declined {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.suggestions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.suggestion-card {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
transition: box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.suggestion-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.suggestion-card.status-accepted {
|
||||
border-left: 4px solid #10b981;
|
||||
}
|
||||
|
||||
.suggestion-card.status-declined {
|
||||
border-left: 4px solid #ef4444;
|
||||
}
|
||||
|
||||
.suggestion-card.status-unreviewed {
|
||||
border-left: 4px solid #f59e0b;
|
||||
}
|
||||
|
||||
.suggestion-header {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.entity-badge,
|
||||
.status-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.entity-badge {
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.entity-badge.entity-game { background: #fff1f1; color: #dc2626; }
|
||||
.entity-badge.entity-book { background: #fdf4ed; color: #8b6f47; }
|
||||
.entity-badge.entity-music { background: #eff6ff; color: #2563eb; }
|
||||
.entity-badge.entity-manga { background: #ecfdf5; color: #059669; }
|
||||
.entity-badge.entity-show { background: #fdf2f8; color: #db2777; }
|
||||
.entity-badge.entity-art { background: #fefce8; color: #ca8a04; }
|
||||
|
||||
.status-badge.status-unreviewed {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.status-badge.status-accepted {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.status-badge.status-declined {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.suggestion-title {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.suggestion-details {
|
||||
font-size: 0.9rem;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.suggestion-details p {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.decline-reason {
|
||||
background: #fee2e2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem;
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.suggestion-footer {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-size: 0.8rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class MySuggestionsComponent implements OnInit {
|
||||
suggestionService = inject(SuggestionService);
|
||||
authService = inject(AuthService);
|
||||
|
||||
suggestions = signal<Suggestion[]>([]);
|
||||
loading = signal(true);
|
||||
statusFilter = signal<'all' | SuggestionStatus>('all');
|
||||
|
||||
// Pagination state
|
||||
currentPage = signal(1);
|
||||
pageSize = signal(25);
|
||||
|
||||
SuggestionStatus = SuggestionStatus;
|
||||
|
||||
unreviewedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.UNREVIEWED).length;
|
||||
acceptedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.ACCEPTED).length;
|
||||
declinedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.DECLINED).length;
|
||||
|
||||
filteredSuggestions = computed(() => {
|
||||
const filter = this.statusFilter();
|
||||
if (filter === 'all') {
|
||||
return this.suggestions();
|
||||
}
|
||||
return this.suggestions().filter(s => s.status === filter);
|
||||
});
|
||||
|
||||
paginatedSuggestions = computed(() => {
|
||||
const suggestions = this.filteredSuggestions();
|
||||
const start = (this.currentPage() - 1) * this.pageSize();
|
||||
const end = start + this.pageSize();
|
||||
return suggestions.slice(start, end);
|
||||
});
|
||||
|
||||
totalFilteredSuggestions = computed(() => this.filteredSuggestions().length);
|
||||
|
||||
ngOnInit() {
|
||||
if (this.authService.isAuthenticated()) {
|
||||
this.loadSuggestions();
|
||||
} else {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async loadSuggestions() {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const suggestions = await this.suggestionService.getMySuggestions();
|
||||
this.suggestions.set(suggestions);
|
||||
} catch {
|
||||
// Handle error silently
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
setFilter(filter: 'all' | SuggestionStatus) {
|
||||
this.statusFilter.set(filter);
|
||||
this.currentPage.set(1); // Reset to first page when filter changes
|
||||
}
|
||||
|
||||
onPageChange(page: number) {
|
||||
this.currentPage.set(page);
|
||||
}
|
||||
|
||||
onPageSizeChange(pageSize: number) {
|
||||
this.pageSize.set(pageSize);
|
||||
// Calculate new current page to stay on approximately the same content
|
||||
const firstItemIndex = (this.currentPage() - 1) * this.pageSize();
|
||||
const newPage = Math.floor(firstItemIndex / pageSize) + 1;
|
||||
this.currentPage.set(newPage);
|
||||
}
|
||||
|
||||
getStatusLabel(status: SuggestionStatus): string {
|
||||
switch (status) {
|
||||
case SuggestionStatus.UNREVIEWED: return 'Pending Review';
|
||||
case SuggestionStatus.ACCEPTED: return 'Accepted';
|
||||
case SuggestionStatus.DECLINED: return 'Declined';
|
||||
}
|
||||
}
|
||||
|
||||
getEntityIcon(entityType: SuggestionEntity): string {
|
||||
switch (entityType) {
|
||||
case SuggestionEntity.GAME: return '🎮';
|
||||
case SuggestionEntity.BOOK: return '📚';
|
||||
case SuggestionEntity.MUSIC: return '🎵';
|
||||
case SuggestionEntity.MANGA: return '📖';
|
||||
case SuggestionEntity.SHOW: return '📺';
|
||||
case SuggestionEntity.ART: return '🎨';
|
||||
}
|
||||
}
|
||||
|
||||
formatDate(date: Date | string): string {
|
||||
return new Date(date).toLocaleDateString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Component, Input, inject, OnInit, signal, effect } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { LikesService } from '../../services/likes.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { Like } from '@library/shared-types';
|
||||
import { take } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-like-button',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="like-container">
|
||||
<button
|
||||
type="button"
|
||||
class="like-button"
|
||||
[class.liked]="liked()"
|
||||
[disabled]="loading() || !isAuthenticated()"
|
||||
(click)="toggleLike()"
|
||||
[title]="getTitle()"
|
||||
>
|
||||
<span class="heart-icon">{{ liked() ? '❤️' : '🤍' }}</span>
|
||||
<span class="like-count">{{ count() }}</span>
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.like-container {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.like-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 9999px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.like-button:hover:not(:disabled) {
|
||||
background: var(--hover-background);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.like-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.like-button.liked {
|
||||
border-color: var(--primary-color);
|
||||
background: var(--primary-light-background);
|
||||
}
|
||||
|
||||
.heart-icon {
|
||||
font-size: 1.1rem;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.like-button:hover:not(:disabled) .heart-icon {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.like-button:active:not(:disabled) .heart-icon {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.like-count {
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class LikeButtonComponent implements OnInit {
|
||||
@Input({ required: true }) entityType!: Like['entityType'];
|
||||
@Input({ required: true }) entityId!: string;
|
||||
|
||||
private likesService = inject(LikesService);
|
||||
private authService = inject(AuthService);
|
||||
|
||||
liked = signal(false);
|
||||
count = signal(0);
|
||||
loading = signal(false);
|
||||
isAuthenticated = signal(false);
|
||||
|
||||
ngOnInit() {
|
||||
// Set authentication state
|
||||
this.isAuthenticated.set(this.authService.isAuthenticated());
|
||||
|
||||
// Load initial state
|
||||
this.loadLikeState();
|
||||
}
|
||||
|
||||
private loadLikeState() {
|
||||
// Check cache first
|
||||
const cachedState = this.likesService.getCachedLikeState(this.entityType, this.entityId);
|
||||
if (cachedState) {
|
||||
this.liked.set(cachedState.liked);
|
||||
this.count.set(cachedState.count);
|
||||
}
|
||||
|
||||
// Always get count (public endpoint)
|
||||
this.likesService.getLikeCount(this.entityType, this.entityId)
|
||||
.pipe(take(1))
|
||||
.subscribe(count => {
|
||||
this.count.set(count);
|
||||
});
|
||||
|
||||
// Get user like status if authenticated
|
||||
if (this.isAuthenticated()) {
|
||||
this.likesService.getUserLikeStatus(this.entityType, this.entityId)
|
||||
.pipe(take(1))
|
||||
.subscribe(liked => {
|
||||
this.liked.set(liked);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toggleLike() {
|
||||
if (!this.isAuthenticated() || this.loading()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading.set(true);
|
||||
|
||||
// Optimistic update
|
||||
const newLiked = !this.liked();
|
||||
const newCount = newLiked ? this.count() + 1 : Math.max(0, this.count() - 1);
|
||||
this.liked.set(newLiked);
|
||||
this.count.set(newCount);
|
||||
|
||||
this.likesService.toggleLike(this.entityType, this.entityId)
|
||||
.pipe(take(1))
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
this.liked.set(response.liked);
|
||||
this.count.set(response.count);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
// Revert on error
|
||||
this.liked.set(!newLiked);
|
||||
this.count.set(newLiked ? Math.max(0, newCount - 1) : newCount + 1);
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getTitle(): string {
|
||||
if (!this.isAuthenticated()) {
|
||||
return 'Sign in to like';
|
||||
}
|
||||
return this.liked() ? 'Unlike' : 'Like';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Component, Input, Output, EventEmitter, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-pagination',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="pagination-container">
|
||||
<div class="pagination-info">
|
||||
Showing {{ startItem() }} - {{ endItem() }} of {{ totalItems }} items
|
||||
</div>
|
||||
|
||||
<div class="pagination-controls">
|
||||
<button
|
||||
(click)="onPageChange(1)"
|
||||
[disabled]="currentPage === 1"
|
||||
class="btn btn-sm pagination-btn"
|
||||
title="First page"
|
||||
>
|
||||
⟪
|
||||
</button>
|
||||
|
||||
<button
|
||||
(click)="onPageChange(currentPage - 1)"
|
||||
[disabled]="currentPage === 1"
|
||||
class="btn btn-sm pagination-btn"
|
||||
title="Previous page"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
|
||||
<div class="page-numbers">
|
||||
@for (page of pageNumbers(); track page) {
|
||||
@if (page === '...') {
|
||||
<span class="page-ellipsis">{{ page }}</span>
|
||||
} @else {
|
||||
<button
|
||||
(click)="onPageChange(+page)"
|
||||
[class.active]="currentPage === +page"
|
||||
class="btn btn-sm pagination-btn page-number"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<button
|
||||
(click)="onPageChange(currentPage + 1)"
|
||||
[disabled]="currentPage === totalPages()"
|
||||
class="btn btn-sm pagination-btn"
|
||||
title="Next page"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
|
||||
<button
|
||||
(click)="onPageChange(totalPages())"
|
||||
[disabled]="currentPage === totalPages()"
|
||||
class="btn btn-sm pagination-btn"
|
||||
title="Last page"
|
||||
>
|
||||
⟫
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="page-size-selector">
|
||||
<label for="page-size">Items per page:</label>
|
||||
<select
|
||||
id="page-size"
|
||||
[value]="pageSize"
|
||||
(change)="onPageSizeChange($event)"
|
||||
class="page-size-select"
|
||||
>
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.pagination-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
color: var(--witch-plum);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border: 1px solid var(--witch-lavender);
|
||||
background: var(--witch-moon);
|
||||
color: var(--witch-purple);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
min-width: 2.5rem;
|
||||
}
|
||||
|
||||
.pagination-btn:hover:not(:disabled) {
|
||||
background: var(--witch-lavender);
|
||||
border-color: var(--witch-mauve);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.pagination-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination-btn.active {
|
||||
background: #8b6f47;
|
||||
color: white;
|
||||
border-color: #8b6f47;
|
||||
}
|
||||
|
||||
.page-numbers {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.page-number {
|
||||
min-width: 2.5rem;
|
||||
}
|
||||
|
||||
.page-ellipsis {
|
||||
padding: 0 0.5rem;
|
||||
color: var(--witch-mauve);
|
||||
}
|
||||
|
||||
.page-size-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.page-size-selector label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--witch-plum);
|
||||
}
|
||||
|
||||
.page-size-select {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 2px solid var(--witch-lavender);
|
||||
border-radius: 4px;
|
||||
background: var(--witch-moon);
|
||||
color: var(--witch-purple);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.page-size-select:hover {
|
||||
border-color: var(--witch-mauve);
|
||||
}
|
||||
|
||||
.page-size-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--witch-rose);
|
||||
box-shadow: 0 0 0 3px rgba(168, 87, 126, 0.2);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pagination-container {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
order: 2;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
order: 1;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.page-size-selector {
|
||||
order: 3;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class PaginationComponent {
|
||||
@Input() currentPage = 1;
|
||||
@Input() pageSize = 25;
|
||||
@Input() totalItems = 0;
|
||||
@Output() pageChange = new EventEmitter<number>();
|
||||
@Output() pageSizeChange = new EventEmitter<number>();
|
||||
|
||||
totalPages = computed(() => Math.ceil(this.totalItems / this.pageSize));
|
||||
|
||||
startItem = computed(() => {
|
||||
if (this.totalItems === 0) return 0;
|
||||
return (this.currentPage - 1) * this.pageSize + 1;
|
||||
});
|
||||
|
||||
endItem = computed(() => {
|
||||
return Math.min(this.currentPage * this.pageSize, this.totalItems);
|
||||
});
|
||||
|
||||
pageNumbers = computed(() => {
|
||||
const total = this.totalPages();
|
||||
const current = this.currentPage;
|
||||
const pages: (number | string)[] = [];
|
||||
|
||||
if (total <= 7) {
|
||||
// Show all pages if 7 or fewer
|
||||
for (let i = 1; i <= total; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
// Always show first page
|
||||
pages.push(1);
|
||||
|
||||
if (current <= 3) {
|
||||
// Near the beginning
|
||||
for (let i = 2; i <= 5; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
pages.push('...');
|
||||
pages.push(total);
|
||||
} else if (current >= total - 2) {
|
||||
// Near the end
|
||||
pages.push('...');
|
||||
for (let i = total - 4; i <= total; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
// In the middle
|
||||
pages.push('...');
|
||||
pages.push(current - 1);
|
||||
pages.push(current);
|
||||
pages.push(current + 1);
|
||||
pages.push('...');
|
||||
pages.push(total);
|
||||
}
|
||||
}
|
||||
|
||||
return pages;
|
||||
});
|
||||
|
||||
onPageChange(page: number) {
|
||||
if (page >= 1 && page <= this.totalPages() && page !== this.currentPage) {
|
||||
this.pageChange.emit(page);
|
||||
}
|
||||
}
|
||||
|
||||
onPageSizeChange(event: Event) {
|
||||
const target = event.target as HTMLSelectElement;
|
||||
const newSize = parseInt(target.value, 10);
|
||||
this.pageSizeChange.emit(newSize);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,81 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {
|
||||
HttpRequest,
|
||||
HttpHandler,
|
||||
HttpEvent,
|
||||
HttpInterceptor,
|
||||
HttpErrorResponse,
|
||||
HttpClient
|
||||
} from '@angular/common/http';
|
||||
import { Observable, catchError, throwError, switchMap, BehaviorSubject, filter, take } from 'rxjs';
|
||||
import { Router } from '@angular/router';
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
@Injectable()
|
||||
export class AuthInterceptor implements HttpInterceptor {
|
||||
private isRefreshing = false;
|
||||
private refreshTokenSubject = new BehaviorSubject<boolean | null>(null);
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private http: HttpClient
|
||||
) {}
|
||||
|
||||
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||
// Clone the request to add withCredentials
|
||||
const authReq = request.clone({
|
||||
withCredentials: true
|
||||
});
|
||||
|
||||
return next.handle(authReq).pipe(
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
if (error.status === 401 && !request.url.includes('/auth/refresh') && !request.url.includes('/auth/logout')) {
|
||||
return this.handle401Error(authReq, next);
|
||||
}
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private handle401Error(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||
if (!this.isRefreshing) {
|
||||
this.isRefreshing = true;
|
||||
this.refreshTokenSubject.next(null);
|
||||
|
||||
return this.http.post(
|
||||
`${environment.apiUrl}/auth/refresh`,
|
||||
{},
|
||||
{ withCredentials: true }
|
||||
).pipe(
|
||||
switchMap(() => {
|
||||
this.isRefreshing = false;
|
||||
this.refreshTokenSubject.next(true);
|
||||
return next.handle(request);
|
||||
}),
|
||||
catchError((err) => {
|
||||
this.isRefreshing = false;
|
||||
this.refreshTokenSubject.next(false);
|
||||
this.router.navigate(['/']);
|
||||
return throwError(() => err);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return this.refreshTokenSubject.pipe(
|
||||
filter(result => result !== null),
|
||||
take(1),
|
||||
switchMap(success => {
|
||||
if (success) {
|
||||
return next.handle(request);
|
||||
}
|
||||
return throwError(() => new Error('Token refresh failed'));
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,951 @@
|
||||
import { Component, ViewEncapsulation } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-nx-welcome',
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<!--
|
||||
* * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
This is a starter component and can be deleted.
|
||||
* * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
Delete this file and get started with your project!
|
||||
* * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
-->
|
||||
<style>
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
'Helvetica Neue',
|
||||
Arial,
|
||||
'Noto Sans',
|
||||
sans-serif,
|
||||
'Apple Color Emoji',
|
||||
'Segoe UI Emoji',
|
||||
'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
line-height: 1.5;
|
||||
tab-size: 4;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
body {
|
||||
font-family: inherit;
|
||||
line-height: inherit;
|
||||
margin: 0;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
p,
|
||||
pre {
|
||||
margin: 0;
|
||||
}
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
box-sizing: border-box;
|
||||
border-width: 0;
|
||||
border-style: solid;
|
||||
border-color: currentColor;
|
||||
}
|
||||
h1,
|
||||
h2 {
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
pre {
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
'Liberation Mono', 'Courier New', monospace;
|
||||
}
|
||||
svg {
|
||||
display: block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
svg {
|
||||
shape-rendering: auto;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
pre {
|
||||
background-color: rgba(55, 65, 81, 1);
|
||||
border-radius: 0.25rem;
|
||||
color: rgba(229, 231, 235, 1);
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
'Liberation Mono', 'Courier New', monospace;
|
||||
overflow: auto;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
.shadow {
|
||||
box-shadow:
|
||||
0 0 #0000,
|
||||
0 0 #0000,
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.rounded {
|
||||
border-radius: 1.5rem;
|
||||
}
|
||||
.wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
.container {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 768px;
|
||||
padding-bottom: 3rem;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
color: rgba(55, 65, 81, 1);
|
||||
width: 100%;
|
||||
}
|
||||
#welcome {
|
||||
margin-top: 2.5rem;
|
||||
}
|
||||
#welcome h1 {
|
||||
font-size: 3rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1;
|
||||
}
|
||||
#welcome span {
|
||||
display: block;
|
||||
font-size: 1.875rem;
|
||||
font-weight: 300;
|
||||
line-height: 2.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
#hero {
|
||||
align-items: center;
|
||||
background-color: hsla(214, 62%, 21%, 1);
|
||||
border: none;
|
||||
box-sizing: border-box;
|
||||
color: rgba(55, 65, 81, 1);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
margin-top: 3.5rem;
|
||||
}
|
||||
#hero .text-container {
|
||||
color: rgba(255, 255, 255, 1);
|
||||
padding: 3rem 2rem;
|
||||
}
|
||||
#hero .text-container h2 {
|
||||
font-size: 1.5rem;
|
||||
line-height: 2rem;
|
||||
position: relative;
|
||||
}
|
||||
#hero .text-container h2 svg {
|
||||
color: hsla(162, 47%, 50%, 1);
|
||||
height: 2rem;
|
||||
left: -0.25rem;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 2rem;
|
||||
}
|
||||
#hero .text-container h2 span {
|
||||
margin-left: 2.5rem;
|
||||
}
|
||||
#hero .text-container a {
|
||||
background-color: rgba(255, 255, 255, 1);
|
||||
border-radius: 0.75rem;
|
||||
color: rgba(55, 65, 81, 1);
|
||||
display: inline-block;
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem 2rem;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
#hero .logo-container {
|
||||
display: none;
|
||||
justify-content: center;
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
#hero .logo-container svg {
|
||||
color: rgba(255, 255, 255, 1);
|
||||
width: 66.666667%;
|
||||
}
|
||||
#middle-content {
|
||||
align-items: flex-start;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
margin-top: 3.5rem;
|
||||
}
|
||||
#middle-content #middle-left-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
#learning-materials {
|
||||
padding: 2.5rem 2rem;
|
||||
}
|
||||
#learning-materials h2 {
|
||||
font-weight: 500;
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.75rem;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
.list-item-link {
|
||||
align-items: center;
|
||||
border-radius: 0.75rem;
|
||||
display: flex;
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
transition-property:
|
||||
background-color,
|
||||
border-color,
|
||||
color,
|
||||
fill,
|
||||
stroke,
|
||||
opacity,
|
||||
box-shadow,
|
||||
transform,
|
||||
filter,
|
||||
backdrop-filter,
|
||||
-webkit-backdrop-filter;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
width: 100%;
|
||||
}
|
||||
.list-item-link svg:first-child {
|
||||
margin-right: 1rem;
|
||||
height: 1.5rem;
|
||||
transition-property:
|
||||
background-color,
|
||||
border-color,
|
||||
color,
|
||||
fill,
|
||||
stroke,
|
||||
opacity,
|
||||
box-shadow,
|
||||
transform,
|
||||
filter,
|
||||
backdrop-filter,
|
||||
-webkit-backdrop-filter;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
width: 1.5rem;
|
||||
}
|
||||
.list-item-link > span {
|
||||
flex-grow: 1;
|
||||
font-weight: 400;
|
||||
transition-property:
|
||||
background-color,
|
||||
border-color,
|
||||
color,
|
||||
fill,
|
||||
stroke,
|
||||
opacity,
|
||||
box-shadow,
|
||||
transform,
|
||||
filter,
|
||||
backdrop-filter,
|
||||
-webkit-backdrop-filter;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.list-item-link > span > span {
|
||||
color: rgba(107, 114, 128, 1);
|
||||
display: block;
|
||||
flex-grow: 1;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 300;
|
||||
line-height: 1rem;
|
||||
transition-property:
|
||||
background-color,
|
||||
border-color,
|
||||
color,
|
||||
fill,
|
||||
stroke,
|
||||
opacity,
|
||||
box-shadow,
|
||||
transform,
|
||||
filter,
|
||||
backdrop-filter,
|
||||
-webkit-backdrop-filter;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.list-item-link svg:last-child {
|
||||
height: 1rem;
|
||||
transition-property: all;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
width: 1rem;
|
||||
}
|
||||
.list-item-link:hover {
|
||||
color: rgba(255, 255, 255, 1);
|
||||
background-color: hsla(162, 55%, 33%, 1);
|
||||
}
|
||||
|
||||
.list-item-link:hover > span > span {
|
||||
color: rgba(243, 244, 246, 1);
|
||||
}
|
||||
.list-item-link:hover svg:last-child {
|
||||
transform: translateX(0.25rem);
|
||||
}
|
||||
|
||||
.button-pill {
|
||||
padding: 1.5rem 2rem;
|
||||
margin-bottom: 2rem;
|
||||
transition-duration: 300ms;
|
||||
transition-property:
|
||||
background-color,
|
||||
border-color,
|
||||
color,
|
||||
fill,
|
||||
stroke,
|
||||
opacity,
|
||||
box-shadow,
|
||||
transform,
|
||||
filter,
|
||||
backdrop-filter,
|
||||
-webkit-backdrop-filter;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
.button-pill svg {
|
||||
transition-property:
|
||||
background-color,
|
||||
border-color,
|
||||
color,
|
||||
fill,
|
||||
stroke,
|
||||
opacity,
|
||||
box-shadow,
|
||||
transform,
|
||||
filter,
|
||||
backdrop-filter,
|
||||
-webkit-backdrop-filter;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
flex-shrink: 0;
|
||||
width: 3rem;
|
||||
}
|
||||
.button-pill > span {
|
||||
letter-spacing: -0.025em;
|
||||
font-weight: 400;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.75rem;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
.button-pill span span {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 300;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
.button-pill:hover svg,
|
||||
.button-pill:hover {
|
||||
color: rgba(255, 255, 255, 1) !important;
|
||||
}
|
||||
.nx-console:hover {
|
||||
background-color: rgba(0, 122, 204, 1);
|
||||
}
|
||||
.nx-console svg {
|
||||
color: rgba(0, 122, 204, 1);
|
||||
}
|
||||
.nx-console-jetbrains {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
.nx-console-jetbrains:hover {
|
||||
background-color: rgba(255, 49, 140, 1);
|
||||
}
|
||||
.nx-console-jetbrains svg {
|
||||
color: rgba(255, 49, 140, 1);
|
||||
}
|
||||
#nx-repo:hover {
|
||||
background-color: rgba(24, 23, 23, 1);
|
||||
}
|
||||
#nx-repo svg {
|
||||
color: rgba(24, 23, 23, 1);
|
||||
}
|
||||
#nx-cloud {
|
||||
margin-bottom: 2rem;
|
||||
margin-top: 2rem;
|
||||
padding: 2.5rem 2rem;
|
||||
}
|
||||
#nx-cloud > div {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
#nx-cloud > div svg {
|
||||
border-radius: 0.375rem;
|
||||
flex-shrink: 0;
|
||||
width: 3rem;
|
||||
}
|
||||
#nx-cloud > div h2 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.75rem;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
#nx-cloud > div h2 span {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 300;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
#nx-cloud p {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
#nx-cloud pre {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
#nx-cloud a {
|
||||
color: rgba(107, 114, 128, 1);
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
margin-top: 1.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
#nx-cloud a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
#commands {
|
||||
padding: 2.5rem 2rem;
|
||||
margin-top: 3.5rem;
|
||||
}
|
||||
#commands h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.75rem;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
#commands p {
|
||||
font-size: 1rem;
|
||||
font-weight: 300;
|
||||
line-height: 1.5rem;
|
||||
margin-top: 1rem;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
details {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
margin-top: 1rem;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
details pre > span {
|
||||
color: rgba(181, 181, 181, 1);
|
||||
}
|
||||
summary {
|
||||
border-radius: 0.5rem;
|
||||
display: flex;
|
||||
font-weight: 400;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition-property:
|
||||
background-color,
|
||||
border-color,
|
||||
color,
|
||||
fill,
|
||||
stroke,
|
||||
opacity,
|
||||
box-shadow,
|
||||
transform,
|
||||
filter,
|
||||
backdrop-filter,
|
||||
-webkit-backdrop-filter;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
summary:hover {
|
||||
background-color: rgba(243, 244, 246, 1);
|
||||
}
|
||||
summary svg {
|
||||
height: 1.5rem;
|
||||
margin-right: 1rem;
|
||||
width: 1.5rem;
|
||||
}
|
||||
#love {
|
||||
color: rgba(107, 114, 128, 1);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
margin-top: 3.5rem;
|
||||
opacity: 0.6;
|
||||
text-align: center;
|
||||
}
|
||||
#love svg {
|
||||
color: rgba(252, 165, 165, 1);
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
display: inline;
|
||||
margin-top: -0.25rem;
|
||||
}
|
||||
@media screen and (min-width: 768px) {
|
||||
#hero {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
#hero .logo-container {
|
||||
display: flex;
|
||||
}
|
||||
#middle-content {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 4rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<div class="wrapper">
|
||||
<div class="container">
|
||||
<!-- WELCOME -->
|
||||
<div id="welcome">
|
||||
<h1>
|
||||
<span> Hello there, </span>
|
||||
Welcome frontend 👋
|
||||
</h1>
|
||||
</div>
|
||||
<!-- HERO -->
|
||||
<div id="hero" class="rounded">
|
||||
<div class="text-container">
|
||||
<h2>
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z"
|
||||
/>
|
||||
</svg>
|
||||
<span>You're up and running</span>
|
||||
</h2>
|
||||
<a href="#commands"> What's next? </a>
|
||||
</div>
|
||||
<div class="logo-container">
|
||||
<svg
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M11.987 14.138l-3.132 4.923-5.193-8.427-.012 8.822H0V4.544h3.691l5.247 8.833.005-3.998 3.044 4.759zm.601-5.761c.024-.048 0-3.784.008-3.833h-3.65c.002.059-.005 3.776-.003 3.833h3.645zm5.634 4.134a2.061 2.061 0 0 0-1.969 1.336 1.963 1.963 0 0 1 2.343-.739c.396.161.917.422 1.33.283a2.1 2.1 0 0 0-1.704-.88zm3.39 1.061c-.375-.13-.8-.277-1.109-.681-.06-.08-.116-.17-.176-.265a2.143 2.143 0 0 0-.533-.642c-.294-.216-.68-.322-1.18-.322a2.482 2.482 0 0 0-2.294 1.536 2.325 2.325 0 0 1 4.002.388.75.75 0 0 0 .836.334c.493-.105.46.36 1.203.518v-.133c-.003-.446-.246-.55-.75-.733zm2.024 1.266a.723.723 0 0 0 .347-.638c-.01-2.957-2.41-5.487-5.37-5.487a5.364 5.364 0 0 0-4.487 2.418c-.01-.026-1.522-2.39-1.538-2.418H8.943l3.463 5.423-3.379 5.32h3.54l1.54-2.366 1.568 2.366h3.541l-3.21-5.052a.7.7 0 0 1-.084-.32 2.69 2.69 0 0 1 2.69-2.691h.001c1.488 0 1.736.89 2.057 1.308.634.826 1.9.464 1.9 1.541a.707.707 0 0 0 1.066.596zm.35.133c-.173.372-.56.338-.755.639-.176.271.114.412.114.412s.337.156.538-.311c.104-.231.14-.488.103-.74z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<!-- MIDDLE CONTENT -->
|
||||
<div id="middle-content">
|
||||
<div id="middle-left-content">
|
||||
<div id="learning-materials" class="rounded shadow">
|
||||
<h2>Learning materials</h2>
|
||||
<a
|
||||
href="https://nx.dev/getting-started/intro?utm_source=nx-project"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="list-item-link"
|
||||
>
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
Documentation
|
||||
<span> Everything is in there </span>
|
||||
</span>
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://nx.dev/blog?utm_source=nx-project"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="list-item-link"
|
||||
>
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
Blog
|
||||
<span> Changelog, features & events </span>
|
||||
</span>
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://www.youtube.com/@NxDevtools/videos?utm_source=nx-project&sub_confirmation=1"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="list-item-link"
|
||||
>
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>YouTube</title>
|
||||
<path
|
||||
d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
YouTube channel
|
||||
<span> Nx Show, talks & tutorials </span>
|
||||
</span>
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://nx.dev/getting-started/tutorials/angular-standalone-tutorial?utm_source=nx-project"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="list-item-link"
|
||||
>
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
Interactive tutorials
|
||||
<span> Create an app, step-by-step </span>
|
||||
</span>
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<a
|
||||
id="nx-repo"
|
||||
class="button-pill rounded shadow"
|
||||
href="https://github.com/nrwl/nx?utm_source=nx-project"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
Nx is open source
|
||||
<span> Love Nx? Give us a star! </span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div id="other-links">
|
||||
<a
|
||||
class="button-pill rounded shadow nx-console"
|
||||
href="https://marketplace.visualstudio.com/items?itemName=nrwl.angular-console&utm_source=nx-project"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Visual Studio Code</title>
|
||||
<path
|
||||
d="M23.15 2.587L18.21.21a1.494 1.494 0 0 0-1.705.29l-9.46 8.63-4.12-3.128a.999.999 0 0 0-1.276.057L.327 7.261A1 1 0 0 0 .326 8.74L3.899 12 .326 15.26a1 1 0 0 0 .001 1.479L1.65 17.94a.999.999 0 0 0 1.276.057l4.12-3.128 9.46 8.63a1.492 1.492 0 0 0 1.704.29l4.942-2.377A1.5 1.5 0 0 0 24 20.06V3.939a1.5 1.5 0 0 0-.85-1.352zm-5.146 14.861L10.826 12l7.178-5.448v10.896z"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
Install Nx Console for VSCode
|
||||
<span>The official VSCode extension for Nx.</span>
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
class="button-pill rounded shadow nx-console-jetbrains"
|
||||
href="https://plugins.jetbrains.com/plugin/21060-nx-console"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<svg
|
||||
height="48"
|
||||
width="48"
|
||||
viewBox="20 20 60 60"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="m22.5 22.5h60v60h-60z" />
|
||||
<g fill="#fff">
|
||||
<path d="m29.03 71.25h22.5v3.75h-22.5z" />
|
||||
<path
|
||||
d="m28.09 38 1.67-1.58a1.88 1.88 0 0 0 1.47.87c.64 0 1.06-.44 1.06-1.31v-5.98h2.58v6a3.48 3.48 0 0 1 -.87 2.6 3.56 3.56 0 0 1 -2.57.95 3.84 3.84 0 0 1 -3.34-1.55z"
|
||||
/>
|
||||
<path
|
||||
d="m36 30h7.53v2.19h-5v1.44h4.49v2h-4.42v1.49h5v2.21h-7.6z"
|
||||
/>
|
||||
<path d="m47.23 32.29h-2.8v-2.29h8.21v2.27h-2.81v7.1h-2.6z" />
|
||||
<path
|
||||
d="m29.13 43.08h4.42a3.53 3.53 0 0 1 2.55.83 2.09 2.09 0 0 1 .6 1.53 2.16 2.16 0 0 1 -1.44 2.09 2.27 2.27 0 0 1 1.86 2.29c0 1.61-1.31 2.59-3.55 2.59h-4.44zm5 2.89c0-.52-.42-.8-1.18-.8h-1.29v1.64h1.24c.79 0 1.25-.26 1.25-.81zm-.9 2.66h-1.57v1.73h1.62c.8 0 1.24-.31 1.24-.86 0-.5-.4-.87-1.27-.87z"
|
||||
/>
|
||||
<path
|
||||
d="m38 43.08h4.1a4.19 4.19 0 0 1 3 1 2.93 2.93 0 0 1 .9 2.19 3 3 0 0 1 -1.93 2.89l2.24 3.27h-3l-1.88-2.84h-.87v2.84h-2.56zm4 4.5c.87 0 1.39-.43 1.39-1.11 0-.75-.54-1.12-1.4-1.12h-1.44v2.26z"
|
||||
/>
|
||||
<path
|
||||
d="m49.59 43h2.5l4 9.44h-2.79l-.67-1.69h-3.63l-.67 1.69h-2.71zm2.27 5.73-1-2.65-1.06 2.65z"
|
||||
/>
|
||||
<path d="m56.46 43.05h2.6v9.37h-2.6z" />
|
||||
<path
|
||||
d="m60.06 43.05h2.42l3.37 5v-5h2.57v9.37h-2.26l-3.53-5.14v5.14h-2.57z"
|
||||
/>
|
||||
<path
|
||||
d="m68.86 51 1.45-1.73a4.84 4.84 0 0 0 3 1.13c.71 0 1.08-.24 1.08-.65 0-.4-.31-.6-1.59-.91-2-.46-3.53-1-3.53-2.93 0-1.74 1.37-3 3.62-3a5.89 5.89 0 0 1 3.86 1.25l-1.26 1.84a4.63 4.63 0 0 0 -2.62-.92c-.63 0-.94.25-.94.6 0 .42.32.61 1.63.91 2.14.46 3.44 1.16 3.44 2.91 0 1.91-1.51 3-3.79 3a6.58 6.58 0 0 1 -4.35-1.5z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<span>
|
||||
Install Nx Console for JetBrains
|
||||
<span
|
||||
>Available for WebStorm, Intellij IDEA Ultimate and
|
||||
more!</span
|
||||
>
|
||||
</span>
|
||||
</a>
|
||||
<div id="nx-cloud" class="rounded shadow">
|
||||
<div>
|
||||
<svg
|
||||
id="nx-cloud-logo"
|
||||
role="img"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke="currentColor"
|
||||
fill="transparent"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-width="2"
|
||||
d="M23 3.75V6.5c-3.036 0-5.5 2.464-5.5 5.5s-2.464 5.5-5.5 5.5-5.5 2.464-5.5 5.5H3.75C2.232 23 1 21.768 1 20.25V3.75C1 2.232 2.232 1 3.75 1h16.5C21.768 1 23 2.232 23 3.75Z"
|
||||
/>
|
||||
<path
|
||||
stroke-width="2"
|
||||
d="M23 6v14.1667C23 21.7307 21.7307 23 20.1667 23H6c0-3.128 2.53867-5.6667 5.6667-5.6667 3.128 0 5.6666-2.5386 5.6666-5.6666C17.3333 8.53867 19.872 6 23 6Z"
|
||||
/>
|
||||
</svg>
|
||||
<h2>
|
||||
Nx Cloud
|
||||
<span> Enable faster CI & better DX </span>
|
||||
</h2>
|
||||
</div>
|
||||
<p>
|
||||
You can activate distributed tasks executions and caching by
|
||||
running:
|
||||
</p>
|
||||
<pre>nx connect</pre>
|
||||
<a
|
||||
href="https://nx.dev/nx-cloud?utm_source=nx-project"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
What is Nx Cloud?
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- COMMANDS -->
|
||||
<div id="commands" class="rounded shadow">
|
||||
<h2>Next steps</h2>
|
||||
<p>Here are some things you can do with Nx:</p>
|
||||
<details>
|
||||
<summary>
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
Build, test and lint your app
|
||||
</summary>
|
||||
<pre><span># Build</span>
|
||||
nx build
|
||||
<span># Test</span>
|
||||
nx test
|
||||
<span># Lint</span>
|
||||
nx lint
|
||||
<span># Run them together!</span>
|
||||
nx run-many -t build test lint</pre>
|
||||
</details>
|
||||
<details>
|
||||
<summary>
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
View project details
|
||||
</summary>
|
||||
<pre>nx show project frontend</pre>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
View interactive project graph
|
||||
</summary>
|
||||
<pre>nx graph</pre>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
Add UI library
|
||||
</summary>
|
||||
<pre><span># Generate UI lib</span>
|
||||
nx g @nx/angular:lib ui
|
||||
<span># Add a component</span>
|
||||
nx g @nx/angular:component ui/src/lib/button</pre>
|
||||
</details>
|
||||
</div>
|
||||
<p id="love">
|
||||
Carefully crafted with
|
||||
<svg
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
|
||||
/>
|
||||
</svg>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [],
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
})
|
||||
export class NxWelcome {}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Router, NavigationEnd } from '@angular/router';
|
||||
import { filter } from 'rxjs/operators';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
plausible?: (event: string, options?: { props?: Record<string, string> }) => void;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AnalyticsService {
|
||||
private router = inject(Router);
|
||||
|
||||
initialise(): void {
|
||||
this.router.events.pipe(
|
||||
filter((event): event is NavigationEnd => event instanceof NavigationEnd)
|
||||
).subscribe((event) => {
|
||||
this.trackPageView(event.urlAfterRedirects);
|
||||
});
|
||||
}
|
||||
|
||||
private trackPageView(path: string): void {
|
||||
if (window.plausible) {
|
||||
window.plausible('pageview', {
|
||||
props: {
|
||||
domain: 'library.nhcarrigan.com',
|
||||
page: 'Naomi\'s Library',
|
||||
path: path || '/'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Injectable, signal, inject } from '@angular/core';
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Observable, switchMap, tap, of } from 'rxjs';
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
interface CsrfTokenResponse {
|
||||
csrfToken: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ApiService {
|
||||
private http = inject(HttpClient);
|
||||
private readonly apiUrl = environment.apiUrl;
|
||||
private csrfToken = signal<string | null>(null);
|
||||
|
||||
private getHeaders(): HttpHeaders {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
const token = this.csrfToken();
|
||||
if (token) {
|
||||
headers['X-CSRF-Token'] = token;
|
||||
}
|
||||
|
||||
return new HttpHeaders(headers);
|
||||
}
|
||||
|
||||
private ensureCsrfToken(): Observable<string> {
|
||||
const existingToken = this.csrfToken();
|
||||
if (existingToken) {
|
||||
return of(existingToken);
|
||||
}
|
||||
|
||||
return this.http.get<CsrfTokenResponse>(`${this.apiUrl}/auth/csrf-token`, {
|
||||
withCredentials: true
|
||||
}).pipe(
|
||||
tap(response => this.csrfToken.set(response.csrfToken)),
|
||||
switchMap(response => of(response.csrfToken))
|
||||
);
|
||||
}
|
||||
|
||||
get<T>(endpoint: string): Observable<T> {
|
||||
return this.http.get<T>(`${this.apiUrl}${endpoint}`, {
|
||||
headers: this.getHeaders(),
|
||||
withCredentials: true
|
||||
});
|
||||
}
|
||||
|
||||
post<T>(endpoint: string, body: unknown): Observable<T> {
|
||||
return this.ensureCsrfToken().pipe(
|
||||
switchMap(() => this.http.post<T>(`${this.apiUrl}${endpoint}`, body, {
|
||||
headers: this.getHeaders(),
|
||||
withCredentials: true
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
put<T>(endpoint: string, body: unknown): Observable<T> {
|
||||
return this.ensureCsrfToken().pipe(
|
||||
switchMap(() => this.http.put<T>(`${this.apiUrl}${endpoint}`, body, {
|
||||
headers: this.getHeaders(),
|
||||
withCredentials: true
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
delete<T>(endpoint: string): Observable<T> {
|
||||
return this.ensureCsrfToken().pipe(
|
||||
switchMap(() => {
|
||||
// Don't send Content-Type for DELETE requests as they have no body
|
||||
const headers: Record<string, string> = {};
|
||||
const token = this.csrfToken();
|
||||
if (token) {
|
||||
headers['X-CSRF-Token'] = token;
|
||||
}
|
||||
return this.http.delete<T>(`${this.apiUrl}${endpoint}`, {
|
||||
headers: new HttpHeaders(headers),
|
||||
withCredentials: true
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
clearCsrfToken(): void {
|
||||
this.csrfToken.set(null);
|
||||
}
|
||||
}
|
||||
@@ -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 { Art, CreateArtDto, UpdateArtDto } from '@library/shared-types';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ArtService {
|
||||
constructor(private api: ApiService) {}
|
||||
|
||||
getAllArt(): Observable<Art[]> {
|
||||
return this.api.get<Art[]>('/art');
|
||||
}
|
||||
|
||||
getArtById(id: string): Observable<Art | null> {
|
||||
return this.api.get<Art | null>(`/art/${id}`);
|
||||
}
|
||||
|
||||
createArt(art: CreateArtDto): Observable<Art> {
|
||||
return this.api.post<Art>('/art', art);
|
||||
}
|
||||
|
||||
updateArt(id: string, art: UpdateArtDto): Observable<Art> {
|
||||
return this.api.put<Art>(`/art/${id}`, art);
|
||||
}
|
||||
|
||||
deleteArt(id: string): Observable<{ success: boolean }> {
|
||||
return this.api.delete<{ success: boolean }>(`/art/${id}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { ApiService } from './api.service';
|
||||
import type { AuditLog, AuditLogFilters, AuditAction, AuditCategory } from '@library/shared-types';
|
||||
|
||||
interface AuditLogResponse {
|
||||
logs: AuditLog[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuditLogService {
|
||||
private api = inject(ApiService);
|
||||
|
||||
async getLogs(filters: AuditLogFilters = {}): Promise<AuditLogResponse> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters.action) params.set('action', filters.action);
|
||||
if (filters.category) params.set('category', filters.category);
|
||||
if (filters.userId) params.set('userId', filters.userId);
|
||||
if (filters.success !== undefined) params.set('success', String(filters.success));
|
||||
if (filters.startDate) params.set('startDate', filters.startDate.toISOString());
|
||||
if (filters.endDate) params.set('endDate', filters.endDate.toISOString());
|
||||
if (filters.page) params.set('page', String(filters.page));
|
||||
if (filters.limit) params.set('limit', String(filters.limit));
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = queryString ? `/audit?${queryString}` : '/audit';
|
||||
|
||||
return firstValueFrom(this.api.get<AuditLogResponse>(url));
|
||||
}
|
||||
|
||||
async getSecurityLogs(page = 1, limit = 50): Promise<AuditLogResponse> {
|
||||
return firstValueFrom(this.api.get<AuditLogResponse>(`/audit/security?page=${page}&limit=${limit}`));
|
||||
}
|
||||
|
||||
async getUserLogs(userId: string, page = 1, limit = 50): Promise<AuditLogResponse> {
|
||||
return firstValueFrom(this.api.get<AuditLogResponse>(`/audit/user/${userId}?page=${page}&limit=${limit}`));
|
||||
}
|
||||
|
||||
getActionLabel(action: AuditAction): string {
|
||||
const labels: Record<string, string> = {
|
||||
LOGIN: 'Login',
|
||||
LOGOUT: 'Logout',
|
||||
LOGIN_FAILED: 'Login Failed',
|
||||
COMMENT_CREATE: 'Comment Created',
|
||||
COMMENT_DELETE: 'Comment Deleted',
|
||||
ENTRY_CREATE: 'Entry Created',
|
||||
ENTRY_UPDATE: 'Entry Updated',
|
||||
ENTRY_DELETE: 'Entry Deleted',
|
||||
USER_BAN: 'User Banned',
|
||||
USER_UNBAN: 'User Unbanned',
|
||||
RATE_LIMIT_EXCEEDED: 'Rate Limit Exceeded',
|
||||
CSRF_VALIDATION_FAILED: 'CSRF Validation Failed',
|
||||
UNAUTHORIZED_ACCESS: 'Unauthorized Access',
|
||||
};
|
||||
return labels[action] ?? action;
|
||||
}
|
||||
|
||||
getCategoryLabel(category: AuditCategory): string {
|
||||
const labels: Record<string, string> = {
|
||||
AUTH: 'Authentication',
|
||||
CONTENT: 'Content',
|
||||
ADMIN: 'Administration',
|
||||
SECURITY: 'Security',
|
||||
};
|
||||
return labels[category] ?? category;
|
||||
}
|
||||
|
||||
getCategoryColor(category: AuditCategory): string {
|
||||
const colors: Record<string, string> = {
|
||||
AUTH: '#3b82f6', // blue
|
||||
CONTENT: '#10b981', // green
|
||||
ADMIN: '#8b5cf6', // purple
|
||||
SECURITY: '#ef4444', // red
|
||||
};
|
||||
return colors[category] ?? '#6b7280';
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user