Initial commit: Dimension47 project setup
- NestJS backend with JWT auth, Prisma ORM, Swagger docs - Vite + React 19 frontend with TypeScript - Tailwind CSS v4 with custom dark theme design system - Auth module: Login, Register, Protected routes - Campaigns module: CRUD, Member management - Full Prisma schema for PF2e campaign management - Docker Compose for PostgreSQL Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
48
.gitignore
vendored
Normal file
48
.gitignore
vendored
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.next/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
|
# Prisma
|
||||||
|
server/src/generated/
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
uploads/
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.tgz
|
||||||
|
.cache
|
||||||
154
README.md
Normal file
154
README.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# Dimension47
|
||||||
|
|
||||||
|
TTRPG Campaign Management Platform for Pathfinder 2e.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- **Vite** - Build tool
|
||||||
|
- **React 19 + TypeScript** - UI Framework
|
||||||
|
- **Tailwind CSS v4** - Styling
|
||||||
|
- **TanStack Query** - Server state management
|
||||||
|
- **Zustand** - Client state management
|
||||||
|
- **React Router** - Routing
|
||||||
|
- **Framer Motion** - Animations
|
||||||
|
- **Lucide Icons** - Icon library
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- **NestJS** - API Framework
|
||||||
|
- **Prisma ORM** - Database access
|
||||||
|
- **PostgreSQL** - Database
|
||||||
|
- **JWT** - Authentication
|
||||||
|
- **Swagger** - API Documentation
|
||||||
|
- **Socket.io** - WebSocket for real-time features
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Node.js 20+
|
||||||
|
- PostgreSQL 16+ (or Docker)
|
||||||
|
- npm
|
||||||
|
|
||||||
|
### 1. Start Database
|
||||||
|
|
||||||
|
Using Docker:
|
||||||
|
```bash
|
||||||
|
docker-compose up -d postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
Or install PostgreSQL locally and create a database named `dimension47`.
|
||||||
|
|
||||||
|
### 2. Setup Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Configure environment
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your database credentials
|
||||||
|
|
||||||
|
# Generate Prisma client
|
||||||
|
npx prisma generate
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
npx prisma db push
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
npm run start:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The API will be available at `http://localhost:5000`
|
||||||
|
API Documentation at `http://localhost:5000/api/docs`
|
||||||
|
|
||||||
|
### 3. Setup Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The frontend will be available at `http://localhost:5173`
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
dimension47/
|
||||||
|
├── client/ # Frontend (Vite + React)
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── app/ # App-wide config
|
||||||
|
│ │ ├── features/ # Feature modules
|
||||||
|
│ │ │ ├── auth/ # Authentication
|
||||||
|
│ │ │ ├── campaigns/ # Campaign management
|
||||||
|
│ │ │ ├── characters/ # Character management
|
||||||
|
│ │ │ ├── battle/ # Battle screen
|
||||||
|
│ │ │ └── documents/ # Document viewer
|
||||||
|
│ │ └── shared/ # Shared components & utils
|
||||||
|
│ │ ├── components/ui/ # UI Components
|
||||||
|
│ │ ├── hooks/ # Custom hooks
|
||||||
|
│ │ ├── lib/ # Utilities
|
||||||
|
│ │ └── types/ # TypeScript types
|
||||||
|
│ └── package.json
|
||||||
|
│
|
||||||
|
├── server/ # Backend (NestJS)
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── modules/ # Feature modules
|
||||||
|
│ │ │ ├── auth/ # Authentication
|
||||||
|
│ │ │ ├── campaigns/ # Campaigns API
|
||||||
|
│ │ │ ├── characters/ # Characters API
|
||||||
|
│ │ │ ├── battle/ # Battle WebSocket
|
||||||
|
│ │ │ └── documents/ # Documents API
|
||||||
|
│ │ ├── prisma/ # Prisma service
|
||||||
|
│ │ └── common/ # Shared decorators, guards
|
||||||
|
│ ├── prisma/
|
||||||
|
│ │ └── schema.prisma # Database schema
|
||||||
|
│ └── package.json
|
||||||
|
│
|
||||||
|
├── docker-compose.yml # Docker services
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Implemented
|
||||||
|
- [x] User authentication (JWT)
|
||||||
|
- [x] User registration
|
||||||
|
- [x] Campaign management (CRUD)
|
||||||
|
- [x] Campaign member management
|
||||||
|
- [x] Dark mode design system
|
||||||
|
- [x] Responsive UI
|
||||||
|
|
||||||
|
### Planned
|
||||||
|
- [ ] Character management
|
||||||
|
- [ ] Pathbuilder 2e import
|
||||||
|
- [ ] Battle screen with WebSocket
|
||||||
|
- [ ] Document viewer with highlights
|
||||||
|
- [ ] Mobile app (Flutter)
|
||||||
|
|
||||||
|
## Design System
|
||||||
|
|
||||||
|
The design uses a dark theme with Dimension47's signature magenta as the primary color:
|
||||||
|
|
||||||
|
- **Primary**: `#c26dbc` (Magenta)
|
||||||
|
- **Secondary**: `#542e52` (Dark Purple)
|
||||||
|
- **Background**: `#0f0f12` (Near Black)
|
||||||
|
- **Text**: `#f5f5f7` (White)
|
||||||
|
|
||||||
|
## API Documentation
|
||||||
|
|
||||||
|
When the backend is running, visit `http://localhost:5000/api/docs` for the Swagger documentation.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Private project - All rights reserved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Powered by Zeasy Software**
|
||||||
24
client/.gitignore
vendored
Normal file
24
client/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
73
client/README.md
Normal file
73
client/README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
23
client/eslint.config.js
Normal file
23
client/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
18
client/index.html
Normal file
18
client/index.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Dimension47 - TTRPG Campaign Management Platform" />
|
||||||
|
<meta name="theme-color" content="#c26dbc" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<title>Dimension47</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4436
client/package-lock.json
generated
Normal file
4436
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
client/package.json
Normal file
41
client/package.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "client",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.90.19",
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^12.26.2",
|
||||||
|
"lucide-react": "^0.562.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.12.0",
|
||||||
|
"socket.io-client": "^4.8.3",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"zustand": "^5.0.10"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@types/node": "^24.10.9",
|
||||||
|
"@types/react": "^19.2.5",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.46.4",
|
||||||
|
"vite": "^7.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
client/public/vite.svg
Normal file
1
client/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
67
client/src/App.tsx
Normal file
67
client/src/App.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { LoginPage, RegisterPage, useAuthStore } from '@/features/auth';
|
||||||
|
import { CampaignsPage } from '@/features/campaigns';
|
||||||
|
import { ProtectedRoute } from '@/shared/components/protected-route';
|
||||||
|
import { Layout } from '@/shared/components/layout';
|
||||||
|
|
||||||
|
// Create a client
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function AppContent() {
|
||||||
|
const { checkAuth, isAuthenticated } = useAuthStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkAuth();
|
||||||
|
}, [checkAuth]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
{/* Public Routes */}
|
||||||
|
<Route
|
||||||
|
path="/login"
|
||||||
|
element={
|
||||||
|
isAuthenticated ? <Navigate to="/" replace /> : <LoginPage />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/register"
|
||||||
|
element={
|
||||||
|
isAuthenticated ? <Navigate to="/" replace /> : <RegisterPage />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Protected Routes */}
|
||||||
|
<Route element={<ProtectedRoute />}>
|
||||||
|
<Route element={<Layout />}>
|
||||||
|
<Route path="/" element={<CampaignsPage />} />
|
||||||
|
<Route path="/campaigns/:id" element={<div>Campaign Detail (TODO)</div>} />
|
||||||
|
<Route path="/library" element={<div>Library (TODO)</div>} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* Fallback */}
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AppContent />
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
1
client/src/assets/react.svg
Normal file
1
client/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
105
client/src/features/auth/components/login-page.tsx
Normal file
105
client/src/features/auth/components/login-page.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { Button, Input, Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/shared/components/ui';
|
||||||
|
import { useAuthStore } from '../hooks/use-auth-store';
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { login, isLoading, error, clearError } = useAuthStore();
|
||||||
|
const [identifier, setIdentifier] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [formError, setFormError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setFormError('');
|
||||||
|
clearError();
|
||||||
|
|
||||||
|
if (!identifier.trim() || !password) {
|
||||||
|
setFormError('Bitte alle Felder ausf\u00fcllen');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(identifier.trim(), password);
|
||||||
|
navigate('/');
|
||||||
|
} catch (err) {
|
||||||
|
// Error is handled in the store
|
||||||
|
console.error('Login failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center px-4 py-12 bg-bg-primary">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-4 h-12 w-12 rounded-xl bg-primary-500/10 flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
className="h-6 w-6 text-primary-500"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1 1 21.75 8.25Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<CardTitle>Willkommen zur\u00fcck</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Melde dich an, um fortzufahren
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{(error || formError) && (
|
||||||
|
<div className="p-3 rounded-lg bg-error-500/10 border border-error-500/20 text-error-500 text-sm">
|
||||||
|
{error || formError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
label="Benutzername oder E-Mail"
|
||||||
|
type="text"
|
||||||
|
placeholder="dein-username"
|
||||||
|
value={identifier}
|
||||||
|
onChange={(e) => setIdentifier(e.target.value)}
|
||||||
|
autoComplete="username"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Passwort"
|
||||||
|
type="password"
|
||||||
|
placeholder="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex-col gap-4">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
isLoading={isLoading}
|
||||||
|
>
|
||||||
|
Anmelden
|
||||||
|
</Button>
|
||||||
|
<p className="text-sm text-text-secondary text-center">
|
||||||
|
Noch kein Konto?{' '}
|
||||||
|
<Link
|
||||||
|
to="/register"
|
||||||
|
className="text-primary-500 hover:text-primary-400 font-medium"
|
||||||
|
>
|
||||||
|
Registrieren
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
client/src/features/auth/components/register-page.tsx
Normal file
134
client/src/features/auth/components/register-page.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { Button, Input, Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/shared/components/ui';
|
||||||
|
import { useAuthStore } from '../hooks/use-auth-store';
|
||||||
|
|
||||||
|
export function RegisterPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { register, isLoading, error, clearError } = useAuthStore();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [formError, setFormError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setFormError('');
|
||||||
|
clearError();
|
||||||
|
|
||||||
|
if (!username.trim() || !email.trim() || !password) {
|
||||||
|
setFormError('Bitte alle Felder ausf\u00fcllen');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setFormError('Passw\u00f6rter stimmen nicht \u00fcberein');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
setFormError('Passwort muss mindestens 8 Zeichen lang sein');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await register(username.trim(), email.trim(), password);
|
||||||
|
navigate('/');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Registration failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center px-4 py-12 bg-bg-primary">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-4 h-12 w-12 rounded-xl bg-primary-500/10 flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
className="h-6 w-6 text-primary-500"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M18 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0ZM3 19.235v-.11a6.375 6.375 0 0 1 12.75 0v.109A12.318 12.318 0 0 1 9.374 21c-2.331 0-4.512-.645-6.374-1.766Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<CardTitle>Konto erstellen</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Registriere dich f\u00fcr Dimension47
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{(error || formError) && (
|
||||||
|
<div className="p-3 rounded-lg bg-error-500/10 border border-error-500/20 text-error-500 text-sm">
|
||||||
|
{error || formError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
label="Benutzername"
|
||||||
|
type="text"
|
||||||
|
placeholder="dein-username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
autoComplete="username"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="E-Mail"
|
||||||
|
type="email"
|
||||||
|
placeholder="deine@email.de"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
autoComplete="email"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Passwort"
|
||||||
|
type="password"
|
||||||
|
placeholder="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Passwort best\u00e4tigen"
|
||||||
|
type="password"
|
||||||
|
placeholder="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex-col gap-4">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
isLoading={isLoading}
|
||||||
|
>
|
||||||
|
Registrieren
|
||||||
|
</Button>
|
||||||
|
<p className="text-sm text-text-secondary text-center">
|
||||||
|
Bereits ein Konto?{' '}
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="text-primary-500 hover:text-primary-400 font-medium"
|
||||||
|
>
|
||||||
|
Anmelden
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
client/src/features/auth/hooks/use-auth-store.ts
Normal file
103
client/src/features/auth/hooks/use-auth-store.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import type { User } from '@/shared/types';
|
||||||
|
import { api } from '@/shared/lib/api';
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: User | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
login: (identifier: string, password: string) => Promise<void>;
|
||||||
|
register: (username: string, email: string, password: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
checkAuth: () => Promise<void>;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
login: async (identifier: string, password: string) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const response = await api.login(identifier, password);
|
||||||
|
api.setToken(response.token);
|
||||||
|
set({
|
||||||
|
user: response.user,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Login failed';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
register: async (username: string, email: string, password: string) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const response = await api.register(username, email, password);
|
||||||
|
api.setToken(response.token);
|
||||||
|
set({
|
||||||
|
user: response.user,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Registration failed';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: () => {
|
||||||
|
api.clearToken();
|
||||||
|
set({
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
checkAuth: async () => {
|
||||||
|
const token = api.getToken();
|
||||||
|
if (!token) {
|
||||||
|
set({ isAuthenticated: false, user: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
const user = await api.getProfile();
|
||||||
|
set({
|
||||||
|
user,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
api.clearToken();
|
||||||
|
set({
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'auth-storage',
|
||||||
|
partialize: (state) => ({ user: state.user, isAuthenticated: state.isAuthenticated }),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
3
client/src/features/auth/index.ts
Normal file
3
client/src/features/auth/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { LoginPage } from './components/login-page';
|
||||||
|
export { RegisterPage } from './components/register-page';
|
||||||
|
export { useAuthStore } from './hooks/use-auth-store';
|
||||||
128
client/src/features/campaigns/components/campaigns-page.tsx
Normal file
128
client/src/features/campaigns/components/campaigns-page.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Plus, Users, Swords } from 'lucide-react';
|
||||||
|
import { Button, Card, CardHeader, CardTitle, CardDescription, CardContent, Spinner } from '@/shared/components/ui';
|
||||||
|
import { api } from '@/shared/lib/api';
|
||||||
|
import type { Campaign } from '@/shared/types';
|
||||||
|
import { CreateCampaignModal } from './create-campaign-modal';
|
||||||
|
|
||||||
|
export function CampaignsPage() {
|
||||||
|
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
|
||||||
|
const fetchCampaigns = async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.getCampaigns();
|
||||||
|
setCampaigns(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch campaigns:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCampaigns();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCampaignCreated = () => {
|
||||||
|
setShowCreateModal(false);
|
||||||
|
fetchCampaigns();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Spinner size="lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-text-primary">Kampagnen</h1>
|
||||||
|
<p className="text-text-secondary mt-1">
|
||||||
|
Verwalte deine Pathfinder 2e Kampagnen
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setShowCreateModal(true)}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Neue Kampagne
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Campaigns Grid */}
|
||||||
|
{campaigns.length === 0 ? (
|
||||||
|
<Card className="p-12 text-center">
|
||||||
|
<div className="mx-auto h-12 w-12 rounded-xl bg-primary-500/10 flex items-center justify-center mb-4">
|
||||||
|
<Swords className="h-6 w-6 text-primary-500" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-text-primary mb-2">
|
||||||
|
Keine Kampagnen
|
||||||
|
</h3>
|
||||||
|
<p className="text-text-secondary mb-6">
|
||||||
|
Erstelle deine erste Kampagne, um loszulegen.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => setShowCreateModal(true)}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Kampagne erstellen
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{campaigns.map((campaign) => (
|
||||||
|
<Link key={campaign.id} to={`/campaigns/${campaign.id}`}>
|
||||||
|
<Card className="h-full hover:border-border-hover transition-colors cursor-pointer">
|
||||||
|
{campaign.imageUrl ? (
|
||||||
|
<div className="h-32 bg-bg-tertiary rounded-t-xl overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={campaign.imageUrl}
|
||||||
|
alt={campaign.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-32 bg-gradient-to-br from-primary-500/20 to-secondary-800/20 rounded-t-xl flex items-center justify-center">
|
||||||
|
<Swords className="h-8 w-8 text-primary-500/50" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">{campaign.name}</CardTitle>
|
||||||
|
{campaign.description && (
|
||||||
|
<CardDescription className="line-clamp-2">
|
||||||
|
{campaign.description}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="flex items-center gap-4 text-sm text-text-secondary">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
<span>{campaign.members?.length || 0} Spieler</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Swords className="h-4 w-4" />
|
||||||
|
<span>{campaign._count?.characters || 0} Charaktere</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Campaign Modal */}
|
||||||
|
{showCreateModal && (
|
||||||
|
<CreateCampaignModal
|
||||||
|
onClose={() => setShowCreateModal(false)}
|
||||||
|
onCreated={handleCampaignCreated}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { Button, Input, Card, CardHeader, CardTitle, CardContent, CardFooter } from '@/shared/components/ui';
|
||||||
|
import { api } from '@/shared/lib/api';
|
||||||
|
|
||||||
|
interface CreateCampaignModalProps {
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateCampaignModal({ onClose, onCreated }: CreateCampaignModalProps) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError('Bitte einen Namen eingeben');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await api.createCampaign({
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
});
|
||||||
|
onCreated();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create campaign:', err);
|
||||||
|
setError('Kampagne konnte nicht erstellt werden');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<Card className="relative z-10 w-full max-w-md mx-4 animate-slide-up">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle>Neue Kampagne</CardTitle>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-8 w-8 rounded-lg hover:bg-bg-tertiary flex items-center justify-center transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4 text-text-secondary" />
|
||||||
|
</button>
|
||||||
|
</CardHeader>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 rounded-lg bg-error-500/10 border border-error-500/20 text-error-500 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
label="Name"
|
||||||
|
type="text"
|
||||||
|
placeholder="z.B. Rise of the Runelords"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-secondary mb-1.5">
|
||||||
|
Beschreibung (optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="flex min-h-[100px] w-full rounded-lg border border-border bg-bg-secondary px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-primary-500 focus:ring-2 focus:ring-primary-500/20 disabled:cursor-not-allowed disabled:opacity-50 transition-colors resize-none"
|
||||||
|
placeholder="Eine kurze Beschreibung der Kampagne..."
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
isLoading={isLoading}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Erstellen
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
client/src/features/campaigns/index.ts
Normal file
2
client/src/features/campaigns/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { CampaignsPage } from './components/campaigns-page';
|
||||||
|
export { CreateCampaignModal } from './components/create-campaign-modal';
|
||||||
162
client/src/index.css
Normal file
162
client/src/index.css
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* Dimension47 Design System */
|
||||||
|
@theme {
|
||||||
|
/* Primary Colors - Dimension47 Magenta */
|
||||||
|
--color-primary-50: #fdf2fc;
|
||||||
|
--color-primary-100: #fce8fa;
|
||||||
|
--color-primary-200: #f9cef4;
|
||||||
|
--color-primary-300: #f5a5ea;
|
||||||
|
--color-primary-400: #ed6dd9;
|
||||||
|
--color-primary-500: #c26dbc;
|
||||||
|
--color-primary-600: #9a4a94;
|
||||||
|
--color-primary-700: #7a3977;
|
||||||
|
--color-primary-800: #5e2e5a;
|
||||||
|
--color-primary-900: #4a274a;
|
||||||
|
--color-primary-950: #2d0f2c;
|
||||||
|
|
||||||
|
/* Secondary Colors - Dark Purple (47) */
|
||||||
|
--color-secondary-50: #f9f5f9;
|
||||||
|
--color-secondary-100: #f4eaf3;
|
||||||
|
--color-secondary-200: #e7d5e6;
|
||||||
|
--color-secondary-300: #d4b4d2;
|
||||||
|
--color-secondary-400: #b988b5;
|
||||||
|
--color-secondary-500: #9a6495;
|
||||||
|
--color-secondary-600: #7d4d79;
|
||||||
|
--color-secondary-700: #663d62;
|
||||||
|
--color-secondary-800: #542e52;
|
||||||
|
--color-secondary-900: #472945;
|
||||||
|
--color-secondary-950: #2a1429;
|
||||||
|
|
||||||
|
/* Background Colors (Dark Mode) */
|
||||||
|
--color-bg-primary: #0f0f12;
|
||||||
|
--color-bg-secondary: #1a1a1f;
|
||||||
|
--color-bg-tertiary: #242429;
|
||||||
|
--color-bg-elevated: #2a2a30;
|
||||||
|
|
||||||
|
/* Text Colors */
|
||||||
|
--color-text-primary: #f5f5f7;
|
||||||
|
--color-text-secondary: #a1a1a6;
|
||||||
|
--color-text-muted: #6b6b70;
|
||||||
|
--color-text-inverse: #0f0f12;
|
||||||
|
|
||||||
|
/* Border Colors */
|
||||||
|
--color-border: #2a2a2f;
|
||||||
|
--color-border-hover: #3a3a3f;
|
||||||
|
--color-border-focus: #c26dbc;
|
||||||
|
|
||||||
|
/* Semantic Colors */
|
||||||
|
--color-success-50: #f0fdf4;
|
||||||
|
--color-success-500: #22c55e;
|
||||||
|
--color-success-600: #16a34a;
|
||||||
|
|
||||||
|
--color-warning-50: #fffbeb;
|
||||||
|
--color-warning-500: #f59e0b;
|
||||||
|
--color-warning-600: #d97706;
|
||||||
|
|
||||||
|
--color-error-50: #fef2f2;
|
||||||
|
--color-error-500: #ef4444;
|
||||||
|
--color-error-600: #dc2626;
|
||||||
|
|
||||||
|
--color-info-50: #eff6ff;
|
||||||
|
--color-info-500: #3b82f6;
|
||||||
|
--color-info-600: #2563eb;
|
||||||
|
|
||||||
|
/* Fonts */
|
||||||
|
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||||
|
|
||||||
|
/* Border Radius */
|
||||||
|
--radius-sm: 0.25rem;
|
||||||
|
--radius-md: 0.375rem;
|
||||||
|
--radius-lg: 0.5rem;
|
||||||
|
--radius-xl: 0.75rem;
|
||||||
|
--radius-2xl: 1rem;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.4);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5), 0 4px 6px -4px rgb(0 0 0 / 0.5);
|
||||||
|
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.5), 0 8px 10px -6px rgb(0 0 0 / 0.5);
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
--animate-fade-in: fade-in 0.2s ease-out;
|
||||||
|
--animate-slide-up: slide-up 0.3s ease-out;
|
||||||
|
--animate-slide-down: slide-down 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-down {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base Styles */
|
||||||
|
html {
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus Styles */
|
||||||
|
*:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px var(--color-bg-primary), 0 0 0 4px var(--color-primary-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection */
|
||||||
|
::selection {
|
||||||
|
background-color: rgba(194, 109, 188, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar Styles */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--color-border);
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: var(--color-border-hover);
|
||||||
|
}
|
||||||
10
client/src/main.tsx
Normal file
10
client/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
82
client/src/shared/components/layout.tsx
Normal file
82
client/src/shared/components/layout.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { Outlet, Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '@/features/auth';
|
||||||
|
import { Button } from './ui';
|
||||||
|
|
||||||
|
export function Layout() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user, logout } = useAuthStore();
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-bg-primary">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="sticky top-0 z-50 border-b border-border bg-bg-secondary/80 backdrop-blur-sm">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<div className="flex h-16 items-center justify-between">
|
||||||
|
{/* Logo */}
|
||||||
|
<Link to="/" className="flex items-center gap-2">
|
||||||
|
<div className="h-8 w-8 rounded-lg bg-primary-500 flex items-center justify-center">
|
||||||
|
<span className="text-white font-bold text-sm">D47</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-text-primary hidden sm:block">
|
||||||
|
Dimension47
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="hidden md:flex items-center gap-6">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||||
|
>
|
||||||
|
Kampagnen
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/library"
|
||||||
|
className="text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||||
|
>
|
||||||
|
Bibliothek
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User Menu */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-8 w-8 rounded-full bg-primary-500/20 flex items-center justify-center">
|
||||||
|
<span className="text-sm font-medium text-primary-500">
|
||||||
|
{user?.username?.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-text-secondary hidden sm:block">
|
||||||
|
{user?.username}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleLogout}>
|
||||||
|
Abmelden
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="container mx-auto px-4 py-8">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="border-t border-border py-6 mt-auto">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<div className="flex items-center justify-center gap-2 text-text-muted text-sm">
|
||||||
|
<span>Powered by</span>
|
||||||
|
<span className="font-medium text-text-secondary">Zeasy Software</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
client/src/shared/components/protected-route.tsx
Normal file
21
client/src/shared/components/protected-route.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Navigate, Outlet } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '@/features/auth';
|
||||||
|
import { Spinner } from './ui';
|
||||||
|
|
||||||
|
export function ProtectedRoute() {
|
||||||
|
const { isAuthenticated, isLoading } = useAuthStore();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-bg-primary">
|
||||||
|
<Spinner size="lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
51
client/src/shared/components/ui/button.tsx
Normal file
51
client/src/shared/components/ui/button.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/shared/lib/utils';
|
||||||
|
|
||||||
|
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
|
||||||
|
size?: 'default' | 'sm' | 'lg' | 'icon';
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant = 'default', size = 'default', isLoading, disabled, children, ...props }, ref) => {
|
||||||
|
const baseStyles = 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 focus-visible:ring-offset-bg-primary disabled:pointer-events-none disabled:opacity-50';
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
default: 'bg-primary-500 text-white hover:bg-primary-600 active:bg-primary-700',
|
||||||
|
destructive: 'bg-error-500 text-white hover:bg-error-600 active:bg-error-600',
|
||||||
|
outline: 'border border-border bg-transparent hover:bg-bg-tertiary hover:border-border-hover',
|
||||||
|
secondary: 'bg-secondary-800 text-text-primary hover:bg-secondary-700',
|
||||||
|
ghost: 'hover:bg-bg-tertiary',
|
||||||
|
link: 'text-primary-500 underline-offset-4 hover:underline',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
default: 'h-11 px-4 py-2',
|
||||||
|
sm: 'h-9 px-3 text-xs',
|
||||||
|
lg: 'h-12 px-8',
|
||||||
|
icon: 'h-11 w-11',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(baseStyles, variants[variant], sizes[size], className)}
|
||||||
|
ref={ref}
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export { Button };
|
||||||
69
client/src/shared/components/ui/card.tsx
Normal file
69
client/src/shared/components/ui/card.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/shared/lib/utils';
|
||||||
|
|
||||||
|
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'rounded-xl border border-border bg-bg-secondary shadow-sm',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Card.displayName = 'Card';
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardHeader.displayName = 'CardHeader';
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-xl font-semibold leading-none tracking-tight text-text-primary', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardTitle.displayName = 'CardTitle';
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-text-secondary', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardDescription.displayName = 'CardDescription';
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardContent.displayName = 'CardContent';
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex items-center p-6 pt-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardFooter.displayName = 'CardFooter';
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||||
4
client/src/shared/components/ui/index.ts
Normal file
4
client/src/shared/components/ui/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './button';
|
||||||
|
export * from './input';
|
||||||
|
export * from './card';
|
||||||
|
export * from './spinner';
|
||||||
47
client/src/shared/components/ui/input.tsx
Normal file
47
client/src/shared/components/ui/input.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/shared/lib/utils';
|
||||||
|
|
||||||
|
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, label, error, id, ...props }, ref) => {
|
||||||
|
const inputId = id || React.useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{label && (
|
||||||
|
<label
|
||||||
|
htmlFor={inputId}
|
||||||
|
className="block text-sm font-medium text-text-secondary mb-1.5"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
id={inputId}
|
||||||
|
className={cn(
|
||||||
|
'flex h-11 w-full rounded-lg border bg-bg-secondary px-3 py-2 text-sm text-text-primary placeholder:text-text-muted',
|
||||||
|
'border-border focus:border-primary-500 focus:ring-2 focus:ring-primary-500/20',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
'transition-colors',
|
||||||
|
error && 'border-error-500 focus:border-error-500 focus:ring-error-500/20',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p className="mt-1.5 text-sm text-error-500">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
|
export { Input };
|
||||||
45
client/src/shared/components/ui/spinner.tsx
Normal file
45
client/src/shared/components/ui/spinner.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/shared/lib/utils';
|
||||||
|
|
||||||
|
interface SpinnerProps extends React.SVGAttributes<SVGSVGElement> {
|
||||||
|
size?: 'sm' | 'default' | 'lg';
|
||||||
|
}
|
||||||
|
|
||||||
|
const Spinner = React.forwardRef<SVGSVGElement, SpinnerProps>(
|
||||||
|
({ className, size = 'default', ...props }, ref) => {
|
||||||
|
const sizes = {
|
||||||
|
sm: 'h-4 w-4',
|
||||||
|
default: 'h-6 w-6',
|
||||||
|
lg: 'h-8 w-8',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
ref={ref}
|
||||||
|
className={cn('animate-spin text-primary-500', sizes[size], className)}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Spinner.displayName = 'Spinner';
|
||||||
|
|
||||||
|
export { Spinner };
|
||||||
130
client/src/shared/lib/api.ts
Normal file
130
client/src/shared/lib/api.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import axios, { type AxiosError, type AxiosInstance } from 'axios';
|
||||||
|
import type { ApiError } from '@/shared/types';
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
|
class ApiClient {
|
||||||
|
private client: AxiosInstance;
|
||||||
|
private token: string | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.client = axios.create({
|
||||||
|
baseURL: API_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load token from localStorage
|
||||||
|
this.token = localStorage.getItem('auth_token');
|
||||||
|
|
||||||
|
// Request interceptor to add auth header
|
||||||
|
this.client.interceptors.request.use((config) => {
|
||||||
|
if (this.token) {
|
||||||
|
config.headers.Authorization = `Bearer ${this.token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Response interceptor to handle errors
|
||||||
|
this.client.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error: AxiosError<ApiError>) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
this.clearToken();
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setToken(token: string) {
|
||||||
|
this.token = token;
|
||||||
|
localStorage.setItem('auth_token', token);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearToken() {
|
||||||
|
this.token = null;
|
||||||
|
localStorage.removeItem('auth_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
getToken() {
|
||||||
|
return this.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth endpoints
|
||||||
|
async login(identifier: string, password: string) {
|
||||||
|
const response = await this.client.post('/auth/login', { identifier, password });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async register(username: string, email: string, password: string) {
|
||||||
|
const response = await this.client.post('/auth/register', { username, email, password });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProfile() {
|
||||||
|
const response = await this.client.get('/auth/me');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProfile(data: { avatarUrl?: string }) {
|
||||||
|
const response = await this.client.put('/auth/profile', data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchUsers(query: string) {
|
||||||
|
const response = await this.client.get('/auth/users/search', { params: { q: query } });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Campaign endpoints
|
||||||
|
async getCampaigns() {
|
||||||
|
const response = await this.client.get('/campaigns');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCampaign(id: string) {
|
||||||
|
const response = await this.client.get(`/campaigns/${id}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCampaign(data: { name: string; description?: string; imageUrl?: string }) {
|
||||||
|
const response = await this.client.post('/campaigns', data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCampaign(id: string, data: { name?: string; description?: string; imageUrl?: string }) {
|
||||||
|
const response = await this.client.put(`/campaigns/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCampaign(id: string) {
|
||||||
|
const response = await this.client.delete(`/campaigns/${id}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addCampaignMember(campaignId: string, userId: string) {
|
||||||
|
const response = await this.client.post(`/campaigns/${campaignId}/members`, { userId });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeCampaignMember(campaignId: string, userId: string) {
|
||||||
|
const response = await this.client.delete(`/campaigns/${campaignId}/members/${userId}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Character endpoints (placeholder - to be expanded)
|
||||||
|
async getCharacters(campaignId: string) {
|
||||||
|
const response = await this.client.get(`/characters/${campaignId}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCharacter(campaignId: string, characterId: string) {
|
||||||
|
const response = await this.client.get(`/characters/${campaignId}/${characterId}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = new ApiClient();
|
||||||
6
client/src/shared/lib/utils.ts
Normal file
6
client/src/shared/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
238
client/src/shared/types/index.ts
Normal file
238
client/src/shared/types/index.ts
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
// User & Auth Types
|
||||||
|
export type UserRole = 'ADMIN' | 'GM' | 'PLAYER';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
role: UserRole;
|
||||||
|
avatarUrl?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
user: User;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Campaign Types
|
||||||
|
export interface Campaign {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
gmId: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
gm: Pick<User, 'id' | 'username' | 'avatarUrl'>;
|
||||||
|
members: CampaignMember[];
|
||||||
|
characters?: CharacterSummary[];
|
||||||
|
_count?: {
|
||||||
|
characters: number;
|
||||||
|
battleSessions?: number;
|
||||||
|
documents?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CampaignMember {
|
||||||
|
campaignId: string;
|
||||||
|
userId: string;
|
||||||
|
joinedAt: string;
|
||||||
|
user: Pick<User, 'id' | 'username' | 'email' | 'avatarUrl'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Character Types
|
||||||
|
export type CharacterType = 'PC' | 'NPC';
|
||||||
|
export type AbilityType = 'STR' | 'DEX' | 'CON' | 'INT' | 'WIS' | 'CHA';
|
||||||
|
export type Proficiency = 'UNTRAINED' | 'TRAINED' | 'EXPERT' | 'MASTER' | 'LEGENDARY';
|
||||||
|
|
||||||
|
export interface CharacterSummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: CharacterType;
|
||||||
|
level: number;
|
||||||
|
avatarUrl?: string;
|
||||||
|
hpCurrent: number;
|
||||||
|
hpMax: number;
|
||||||
|
owner?: Pick<User, 'id' | 'username'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Character extends CharacterSummary {
|
||||||
|
campaignId: string;
|
||||||
|
ownerId?: string;
|
||||||
|
hpTemp: number;
|
||||||
|
ancestryId?: string;
|
||||||
|
heritageId?: string;
|
||||||
|
classId?: string;
|
||||||
|
backgroundId?: string;
|
||||||
|
experiencePoints: number;
|
||||||
|
pathbuilderData?: unknown;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
abilities: CharacterAbility[];
|
||||||
|
feats: CharacterFeat[];
|
||||||
|
skills: CharacterSkill[];
|
||||||
|
spells: CharacterSpell[];
|
||||||
|
items: CharacterItem[];
|
||||||
|
conditions: CharacterCondition[];
|
||||||
|
resources: CharacterResource[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterAbility {
|
||||||
|
id: string;
|
||||||
|
characterId: string;
|
||||||
|
ability: AbilityType;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterFeat {
|
||||||
|
id: string;
|
||||||
|
characterId: string;
|
||||||
|
featId?: string;
|
||||||
|
name: string;
|
||||||
|
nameGerman?: string;
|
||||||
|
level: number;
|
||||||
|
source: 'CLASS' | 'ANCESTRY' | 'GENERAL' | 'SKILL' | 'BONUS' | 'ARCHETYPE';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterSkill {
|
||||||
|
id: string;
|
||||||
|
characterId: string;
|
||||||
|
skillName: string;
|
||||||
|
proficiency: Proficiency;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterSpell {
|
||||||
|
id: string;
|
||||||
|
characterId: string;
|
||||||
|
spellId?: string;
|
||||||
|
name: string;
|
||||||
|
nameGerman?: string;
|
||||||
|
tradition: 'ARCANE' | 'DIVINE' | 'OCCULT' | 'PRIMAL';
|
||||||
|
spellLevel: number;
|
||||||
|
prepared: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterItem {
|
||||||
|
id: string;
|
||||||
|
characterId: string;
|
||||||
|
equipmentId?: string;
|
||||||
|
name: string;
|
||||||
|
nameGerman?: string;
|
||||||
|
quantity: number;
|
||||||
|
bulk: number;
|
||||||
|
equipped: boolean;
|
||||||
|
invested: boolean;
|
||||||
|
containerId?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterCondition {
|
||||||
|
id: string;
|
||||||
|
characterId: string;
|
||||||
|
name: string;
|
||||||
|
nameGerman?: string;
|
||||||
|
value?: number;
|
||||||
|
duration?: string;
|
||||||
|
source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterResource {
|
||||||
|
id: string;
|
||||||
|
characterId: string;
|
||||||
|
name: string;
|
||||||
|
current: number;
|
||||||
|
max: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Battle Types
|
||||||
|
export interface BattleMap {
|
||||||
|
id: string;
|
||||||
|
campaignId: string;
|
||||||
|
name: string;
|
||||||
|
imageUrl: string;
|
||||||
|
gridSizeX: number;
|
||||||
|
gridSizeY: number;
|
||||||
|
gridOffsetX: number;
|
||||||
|
gridOffsetY: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Combatant {
|
||||||
|
id: string;
|
||||||
|
campaignId: string;
|
||||||
|
name: string;
|
||||||
|
type: 'PC' | 'NPC' | 'MONSTER';
|
||||||
|
level: number;
|
||||||
|
hpMax: number;
|
||||||
|
ac: number;
|
||||||
|
fortitude: number;
|
||||||
|
reflex: number;
|
||||||
|
will: number;
|
||||||
|
perception: number;
|
||||||
|
speed: number;
|
||||||
|
avatarUrl?: string;
|
||||||
|
description?: string;
|
||||||
|
createdAt: string;
|
||||||
|
abilities: CombatantAbility[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CombatantAbility {
|
||||||
|
id: string;
|
||||||
|
combatantId: string;
|
||||||
|
name: string;
|
||||||
|
actionCost: number;
|
||||||
|
actionType: 'ACTION' | 'REACTION' | 'FREE';
|
||||||
|
description: string;
|
||||||
|
damage?: string;
|
||||||
|
traits: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BattleSession {
|
||||||
|
id: string;
|
||||||
|
campaignId: string;
|
||||||
|
mapId?: string;
|
||||||
|
name?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
roundNumber: number;
|
||||||
|
createdAt: string;
|
||||||
|
map?: BattleMap;
|
||||||
|
tokens: BattleToken[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BattleToken {
|
||||||
|
id: string;
|
||||||
|
battleSessionId: string;
|
||||||
|
combatantId?: string;
|
||||||
|
characterId?: string;
|
||||||
|
name: string;
|
||||||
|
positionX: number;
|
||||||
|
positionY: number;
|
||||||
|
hpCurrent: number;
|
||||||
|
hpMax: number;
|
||||||
|
initiative?: number;
|
||||||
|
conditions: string[];
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Document Types
|
||||||
|
export interface Document {
|
||||||
|
id: string;
|
||||||
|
campaignId: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
tags: string[];
|
||||||
|
filePath: string;
|
||||||
|
fileType: string;
|
||||||
|
uploadedBy: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Response Types
|
||||||
|
export interface ApiError {
|
||||||
|
statusCode: number;
|
||||||
|
message: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
34
client/tsconfig.app.json
Normal file
34
client/tsconfig.app.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Path aliases */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
client/tsconfig.json
Normal file
7
client/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
client/tsconfig.node.json
Normal file
26
client/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
23
client/vite.config.ts
Normal file
23
client/vite.config.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:5000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
31
docker-compose.yml
Normal file
31
docker-compose.yml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: dimension47-db
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: dimension47
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Optional: pgAdmin for database management
|
||||||
|
pgadmin:
|
||||||
|
image: dpage/pgadmin4
|
||||||
|
container_name: dimension47-pgadmin
|
||||||
|
environment:
|
||||||
|
PGADMIN_DEFAULT_EMAIL: admin@dimension47.local
|
||||||
|
PGADMIN_DEFAULT_PASSWORD: admin
|
||||||
|
ports:
|
||||||
|
- "5050:80"
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
22
server/.env.example
Normal file
22
server/.env.example
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Dimension47 Server Environment Variables
|
||||||
|
|
||||||
|
# Database (PostgreSQL)
|
||||||
|
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/dimension47?schema=public"
|
||||||
|
|
||||||
|
# JWT Authentication
|
||||||
|
JWT_SECRET="change-this-to-a-secure-random-string"
|
||||||
|
JWT_EXPIRES_IN="7d"
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
PORT=5000
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# CORS Origins (comma separated)
|
||||||
|
CORS_ORIGINS="http://localhost:3000,http://localhost:5173"
|
||||||
|
|
||||||
|
# Claude API (for translations)
|
||||||
|
CLAUDE_API_KEY=""
|
||||||
|
|
||||||
|
# File Upload
|
||||||
|
UPLOAD_DIR="./uploads"
|
||||||
|
MAX_FILE_SIZE=10485760
|
||||||
5
server/.gitignore
vendored
Normal file
5
server/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
# Keep environment variables out of version control
|
||||||
|
.env
|
||||||
|
|
||||||
|
/generated/prisma
|
||||||
4
server/.prettierrc
Normal file
4
server/.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
98
server/README.md
Normal file
98
server/README.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<p align="center">
|
||||||
|
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||||
|
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||||
|
|
||||||
|
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
||||||
|
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
||||||
|
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
||||||
|
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
||||||
|
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
||||||
|
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
||||||
|
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
||||||
|
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
|
||||||
|
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
||||||
|
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
|
||||||
|
</p>
|
||||||
|
<!--[](https://opencollective.com/nest#backer)
|
||||||
|
[](https://opencollective.com/nest#sponsor)-->
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||||
|
|
||||||
|
## Project setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Compile and run the project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# development
|
||||||
|
$ npm run start
|
||||||
|
|
||||||
|
# watch mode
|
||||||
|
$ npm run start:dev
|
||||||
|
|
||||||
|
# production mode
|
||||||
|
$ npm run start:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# unit tests
|
||||||
|
$ npm run test
|
||||||
|
|
||||||
|
# e2e tests
|
||||||
|
$ npm run test:e2e
|
||||||
|
|
||||||
|
# test coverage
|
||||||
|
$ npm run test:cov
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
|
||||||
|
|
||||||
|
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ npm install -g @nestjs/mau
|
||||||
|
$ mau deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
Check out a few resources that may come in handy when working with NestJS:
|
||||||
|
|
||||||
|
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
|
||||||
|
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
|
||||||
|
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
|
||||||
|
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
|
||||||
|
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
|
||||||
|
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
|
||||||
|
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
|
||||||
|
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||||
|
|
||||||
|
## Stay in touch
|
||||||
|
|
||||||
|
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
|
||||||
|
- Website - [https://nestjs.com](https://nestjs.com/)
|
||||||
|
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
|
||||||
35
server/eslint.config.mjs
Normal file
35
server/eslint.config.mjs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// @ts-check
|
||||||
|
import eslint from '@eslint/js';
|
||||||
|
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||||
|
import globals from 'globals';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ['eslint.config.mjs'],
|
||||||
|
},
|
||||||
|
eslint.configs.recommended,
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
eslintPluginPrettierRecommended,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
...globals.jest,
|
||||||
|
},
|
||||||
|
sourceType: 'commonjs',
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-floating-promises': 'warn',
|
||||||
|
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||||
|
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
8
server/nest-cli.json
Normal file
8
server/nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
11487
server/package-lock.json
generated
Normal file
11487
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
94
server/package.json
Normal file
94
server/package.json
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"name": "dimension47-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Dimension47 TTRPG Campaign Management API",
|
||||||
|
"author": "Zeasy Software",
|
||||||
|
"private": true,
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"db:studio": "prisma studio",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:cov": "jest --coverage",
|
||||||
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
|
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^11.0.1",
|
||||||
|
"@nestjs/config": "^4.0.2",
|
||||||
|
"@nestjs/core": "^11.0.1",
|
||||||
|
"@nestjs/jwt": "^11.0.2",
|
||||||
|
"@nestjs/passport": "^11.0.5",
|
||||||
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
|
"@nestjs/platform-socket.io": "^11.1.12",
|
||||||
|
"@nestjs/swagger": "^11.2.5",
|
||||||
|
"@nestjs/websockets": "^11.1.12",
|
||||||
|
"@prisma/client": "^7.2.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.3",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"multer": "^2.0.2",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"socket.io": "^4.8.3",
|
||||||
|
"swagger-ui-express": "^5.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
|
"@eslint/js": "^9.18.0",
|
||||||
|
"@nestjs/cli": "^11.0.0",
|
||||||
|
"@nestjs/schematics": "^11.0.0",
|
||||||
|
"@nestjs/testing": "^11.0.1",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/multer": "^2.0.0",
|
||||||
|
"@types/node": "^22.10.7",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
"@types/supertest": "^6.0.2",
|
||||||
|
"eslint": "^9.18.0",
|
||||||
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
"eslint-plugin-prettier": "^5.2.2",
|
||||||
|
"globals": "^16.0.0",
|
||||||
|
"jest": "^30.0.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"prisma": "^7.2.0",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
|
"ts-jest": "^29.2.5",
|
||||||
|
"ts-loader": "^9.5.2",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"typescript-eslint": "^8.20.0"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": [
|
||||||
|
"**/*.(t|j)s"
|
||||||
|
],
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node"
|
||||||
|
}
|
||||||
|
}
|
||||||
14
server/prisma.config.ts
Normal file
14
server/prisma.config.ts
Normal file
@@ -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"],
|
||||||
|
},
|
||||||
|
});
|
||||||
542
server/prisma/schema.prisma
Normal file
542
server/prisma/schema.prisma
Normal file
@@ -0,0 +1,542 @@
|
|||||||
|
// Dimension47 - TTRPG Campaign Management Platform
|
||||||
|
// Prisma Schema
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
output = "../src/generated/prisma"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// ENUMS
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
enum UserRole {
|
||||||
|
ADMIN
|
||||||
|
GM
|
||||||
|
PLAYER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CharacterType {
|
||||||
|
PC
|
||||||
|
NPC
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AbilityType {
|
||||||
|
STR
|
||||||
|
DEX
|
||||||
|
CON
|
||||||
|
INT
|
||||||
|
WIS
|
||||||
|
CHA
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Proficiency {
|
||||||
|
UNTRAINED
|
||||||
|
TRAINED
|
||||||
|
EXPERT
|
||||||
|
MASTER
|
||||||
|
LEGENDARY
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FeatSource {
|
||||||
|
CLASS
|
||||||
|
ANCESTRY
|
||||||
|
GENERAL
|
||||||
|
SKILL
|
||||||
|
BONUS
|
||||||
|
ARCHETYPE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SpellTradition {
|
||||||
|
ARCANE
|
||||||
|
DIVINE
|
||||||
|
OCCULT
|
||||||
|
PRIMAL
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CombatantType {
|
||||||
|
PC
|
||||||
|
NPC
|
||||||
|
MONSTER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ActionType {
|
||||||
|
ACTION
|
||||||
|
REACTION
|
||||||
|
FREE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum HighlightColor {
|
||||||
|
YELLOW
|
||||||
|
GREEN
|
||||||
|
BLUE
|
||||||
|
PINK
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TranslationType {
|
||||||
|
FEAT
|
||||||
|
EQUIPMENT
|
||||||
|
SPELL
|
||||||
|
TRAIT
|
||||||
|
ANCESTRY
|
||||||
|
HERITAGE
|
||||||
|
CLASS
|
||||||
|
BACKGROUND
|
||||||
|
CONDITION
|
||||||
|
ACTION
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TranslationQuality {
|
||||||
|
HIGH
|
||||||
|
MEDIUM
|
||||||
|
LOW
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// USER & AUTH
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
username String @unique
|
||||||
|
email String @unique
|
||||||
|
passwordHash String
|
||||||
|
role UserRole @default(PLAYER)
|
||||||
|
avatarUrl String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
gmCampaigns Campaign[] @relation("CampaignGM")
|
||||||
|
campaignMembers CampaignMember[]
|
||||||
|
characters Character[] @relation("CharacterOwner")
|
||||||
|
uploadedDocuments Document[]
|
||||||
|
documentAccess DocumentAccess[]
|
||||||
|
highlights Highlight[]
|
||||||
|
notes Note[]
|
||||||
|
noteShares NoteShare[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// CAMPAIGNS
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
model Campaign {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
gmId String
|
||||||
|
imageUrl String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
gm User @relation("CampaignGM", fields: [gmId], references: [id])
|
||||||
|
members CampaignMember[]
|
||||||
|
characters Character[]
|
||||||
|
battleMaps BattleMap[]
|
||||||
|
combatants Combatant[]
|
||||||
|
battleSessions BattleSession[]
|
||||||
|
documents Document[]
|
||||||
|
notes Note[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model CampaignMember {
|
||||||
|
campaignId String
|
||||||
|
userId String
|
||||||
|
joinedAt DateTime @default(now())
|
||||||
|
|
||||||
|
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([campaignId, userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// CHARACTERS (Normalisiert)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
model Character {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
campaignId String
|
||||||
|
ownerId String?
|
||||||
|
name String
|
||||||
|
type CharacterType @default(PC)
|
||||||
|
level Int @default(1)
|
||||||
|
avatarUrl String?
|
||||||
|
|
||||||
|
// Core Stats
|
||||||
|
hpCurrent Int
|
||||||
|
hpMax Int
|
||||||
|
hpTemp Int @default(0)
|
||||||
|
|
||||||
|
// Ancestry/Class/Background (References)
|
||||||
|
ancestryId String?
|
||||||
|
heritageId String?
|
||||||
|
classId String?
|
||||||
|
backgroundId String?
|
||||||
|
|
||||||
|
// Experience
|
||||||
|
experiencePoints Int @default(0)
|
||||||
|
|
||||||
|
// Pathbuilder Import Data (JSON blob for original import)
|
||||||
|
pathbuilderData Json?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||||
|
owner User? @relation("CharacterOwner", fields: [ownerId], references: [id])
|
||||||
|
abilities CharacterAbility[]
|
||||||
|
feats CharacterFeat[]
|
||||||
|
skills CharacterSkill[]
|
||||||
|
spells CharacterSpell[]
|
||||||
|
items CharacterItem[]
|
||||||
|
conditions CharacterCondition[]
|
||||||
|
resources CharacterResource[]
|
||||||
|
battleTokens BattleToken[]
|
||||||
|
documentAccess DocumentAccess[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model CharacterAbility {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
characterId String
|
||||||
|
ability AbilityType
|
||||||
|
score Int
|
||||||
|
|
||||||
|
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([characterId, ability])
|
||||||
|
}
|
||||||
|
|
||||||
|
model CharacterFeat {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
characterId String
|
||||||
|
featId String? // Reference to Feat table
|
||||||
|
name String // English name
|
||||||
|
nameGerman String? // German translation
|
||||||
|
level Int // Level obtained
|
||||||
|
source FeatSource
|
||||||
|
|
||||||
|
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||||
|
feat Feat? @relation(fields: [featId], references: [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
model CharacterSkill {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
characterId String
|
||||||
|
skillName String
|
||||||
|
proficiency Proficiency @default(UNTRAINED)
|
||||||
|
|
||||||
|
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([characterId, skillName])
|
||||||
|
}
|
||||||
|
|
||||||
|
model CharacterSpell {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
characterId String
|
||||||
|
spellId String?
|
||||||
|
name String
|
||||||
|
nameGerman String?
|
||||||
|
tradition SpellTradition
|
||||||
|
spellLevel Int
|
||||||
|
prepared Boolean @default(false)
|
||||||
|
|
||||||
|
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||||
|
spell Spell? @relation(fields: [spellId], references: [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
model CharacterItem {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
characterId String
|
||||||
|
equipmentId String? // Reference to Equipment library
|
||||||
|
name String
|
||||||
|
nameGerman String?
|
||||||
|
quantity Int @default(1)
|
||||||
|
bulk Decimal @default(0)
|
||||||
|
equipped Boolean @default(false)
|
||||||
|
invested Boolean @default(false)
|
||||||
|
containerId String? // For containers
|
||||||
|
notes String?
|
||||||
|
|
||||||
|
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||||
|
equipment Equipment? @relation(fields: [equipmentId], references: [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
model CharacterCondition {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
characterId String
|
||||||
|
name String
|
||||||
|
nameGerman String?
|
||||||
|
value Int? // For valued conditions like Frightened 2
|
||||||
|
duration String?
|
||||||
|
source String?
|
||||||
|
|
||||||
|
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model CharacterResource {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
characterId String
|
||||||
|
name String
|
||||||
|
current Int
|
||||||
|
max Int
|
||||||
|
|
||||||
|
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([characterId, name])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// BATTLE SYSTEM
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
model BattleMap {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
campaignId String
|
||||||
|
name String
|
||||||
|
imageUrl String
|
||||||
|
gridSizeX Int @default(20)
|
||||||
|
gridSizeY Int @default(20)
|
||||||
|
gridOffsetX Int @default(0)
|
||||||
|
gridOffsetY Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||||
|
battleSessions BattleSession[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Combatant {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
campaignId String
|
||||||
|
name String
|
||||||
|
type CombatantType
|
||||||
|
level Int
|
||||||
|
hpMax Int
|
||||||
|
ac Int
|
||||||
|
|
||||||
|
// Saves
|
||||||
|
fortitude Int
|
||||||
|
reflex Int
|
||||||
|
will Int
|
||||||
|
perception Int
|
||||||
|
|
||||||
|
// Speed
|
||||||
|
speed Int @default(25)
|
||||||
|
|
||||||
|
avatarUrl String?
|
||||||
|
description String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||||
|
abilities CombatantAbility[]
|
||||||
|
battleTokens BattleToken[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model CombatantAbility {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
combatantId String
|
||||||
|
name String
|
||||||
|
actionCost Int // 1, 2, 3, or 0 for free/reaction
|
||||||
|
actionType ActionType
|
||||||
|
description String
|
||||||
|
damage String? // e.g. "2d6+4 slashing"
|
||||||
|
traits String[]
|
||||||
|
|
||||||
|
combatant Combatant @relation(fields: [combatantId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model BattleSession {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
campaignId String
|
||||||
|
mapId String?
|
||||||
|
name String?
|
||||||
|
isActive Boolean @default(false)
|
||||||
|
roundNumber Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||||
|
map BattleMap? @relation(fields: [mapId], references: [id])
|
||||||
|
tokens BattleToken[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model BattleToken {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
battleSessionId String
|
||||||
|
combatantId String?
|
||||||
|
characterId String?
|
||||||
|
name String
|
||||||
|
positionX Float
|
||||||
|
positionY Float
|
||||||
|
hpCurrent Int
|
||||||
|
hpMax Int
|
||||||
|
initiative Int?
|
||||||
|
conditions String[]
|
||||||
|
size Int @default(1) // 1 = Medium, 2 = Large, etc.
|
||||||
|
|
||||||
|
battleSession BattleSession @relation(fields: [battleSessionId], references: [id], onDelete: Cascade)
|
||||||
|
combatant Combatant? @relation(fields: [combatantId], references: [id])
|
||||||
|
character Character? @relation(fields: [characterId], references: [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// DOCUMENTS SYSTEM
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
model Document {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
campaignId String
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
category String?
|
||||||
|
tags String[]
|
||||||
|
filePath String
|
||||||
|
fileType String // html, pdf
|
||||||
|
uploadedBy String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||||
|
uploader User @relation(fields: [uploadedBy], references: [id])
|
||||||
|
access DocumentAccess[]
|
||||||
|
highlights Highlight[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model DocumentAccess {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
documentId String
|
||||||
|
userId String? // NULL = all campaign members
|
||||||
|
characterId String? // Access per character
|
||||||
|
|
||||||
|
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||||
|
user User? @relation(fields: [userId], references: [id])
|
||||||
|
character Character? @relation(fields: [characterId], references: [id])
|
||||||
|
|
||||||
|
@@unique([documentId, userId, characterId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Highlight {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
documentId String
|
||||||
|
userId String
|
||||||
|
selectionText String
|
||||||
|
startOffset Int
|
||||||
|
endOffset Int
|
||||||
|
color HighlightColor
|
||||||
|
note String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Note {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
campaignId String
|
||||||
|
title String
|
||||||
|
content String // Markdown
|
||||||
|
isShared Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||||
|
shares NoteShare[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model NoteShare {
|
||||||
|
noteId String
|
||||||
|
userId String
|
||||||
|
|
||||||
|
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
|
@@id([noteId, userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// REFERENCE DATA (Pathfinder 2e)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
model Feat {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String @unique
|
||||||
|
traits String[]
|
||||||
|
summary String?
|
||||||
|
actions String?
|
||||||
|
url String?
|
||||||
|
level Int?
|
||||||
|
sourceBook String?
|
||||||
|
|
||||||
|
characterFeats CharacterFeat[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Equipment {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String @unique
|
||||||
|
traits String[]
|
||||||
|
itemCategory String
|
||||||
|
itemSubcategory String?
|
||||||
|
bulk String?
|
||||||
|
url String?
|
||||||
|
summary String?
|
||||||
|
activation String?
|
||||||
|
hands String?
|
||||||
|
damage String?
|
||||||
|
range String?
|
||||||
|
weaponCategory String?
|
||||||
|
price Int? // In CP
|
||||||
|
level Int?
|
||||||
|
|
||||||
|
characterItems CharacterItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Spell {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String @unique
|
||||||
|
level Int
|
||||||
|
actions String?
|
||||||
|
traditions String[]
|
||||||
|
traits String[]
|
||||||
|
range String?
|
||||||
|
targets String?
|
||||||
|
duration String?
|
||||||
|
description String?
|
||||||
|
url String?
|
||||||
|
|
||||||
|
characterSpells CharacterSpell[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Trait {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String @unique
|
||||||
|
description String?
|
||||||
|
url String?
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// TRANSLATION CACHE
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
model Translation {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
type TranslationType
|
||||||
|
englishName String
|
||||||
|
germanName String
|
||||||
|
germanSummary String?
|
||||||
|
germanDescription String?
|
||||||
|
quality TranslationQuality @default(MEDIUM)
|
||||||
|
translatedBy String @default("claude-api")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([type, englishName])
|
||||||
|
@@index([type])
|
||||||
|
@@index([englishName])
|
||||||
|
}
|
||||||
44
server/src/app.module.ts
Normal file
44
server/src/app.module.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
|
|
||||||
|
// Core Modules
|
||||||
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
|
|
||||||
|
// Feature Modules
|
||||||
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
|
import { CampaignsModule } from './modules/campaigns/campaigns.module';
|
||||||
|
|
||||||
|
// Guards
|
||||||
|
import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from './modules/auth/guards/roles.guard';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
// Configuration
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
envFilePath: '.env',
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Core
|
||||||
|
PrismaModule,
|
||||||
|
|
||||||
|
// Features
|
||||||
|
AuthModule,
|
||||||
|
CampaignsModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
// Global JWT Auth Guard
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: JwtAuthGuard,
|
||||||
|
},
|
||||||
|
// Global Roles Guard
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: RolesGuard,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
10
server/src/common/decorators/current-user.decorator.ts
Normal file
10
server/src/common/decorators/current-user.decorator.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const CurrentUser = createParamDecorator(
|
||||||
|
(data: string | undefined, ctx: ExecutionContext) => {
|
||||||
|
const request = ctx.switchToHttp().getRequest();
|
||||||
|
const user = request.user;
|
||||||
|
|
||||||
|
return data ? user?.[data] : user;
|
||||||
|
},
|
||||||
|
);
|
||||||
3
server/src/common/decorators/index.ts
Normal file
3
server/src/common/decorators/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './public.decorator';
|
||||||
|
export * from './roles.decorator';
|
||||||
|
export * from './current-user.decorator';
|
||||||
4
server/src/common/decorators/public.decorator.ts
Normal file
4
server/src/common/decorators/public.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const IS_PUBLIC_KEY = 'isPublic';
|
||||||
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||||
5
server/src/common/decorators/roles.decorator.ts
Normal file
5
server/src/common/decorators/roles.decorator.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import { UserRole } from '../../generated/prisma';
|
||||||
|
|
||||||
|
export const ROLES_KEY = 'roles';
|
||||||
|
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
58
server/src/main.ts
Normal file
58
server/src/main.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
const configService = app.get(ConfigService);
|
||||||
|
|
||||||
|
// Global prefix
|
||||||
|
app.setGlobalPrefix('api');
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
transformOptions: {
|
||||||
|
enableImplicitConversion: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// CORS
|
||||||
|
const corsOrigins = configService.get<string>('CORS_ORIGINS', 'http://localhost:3000,http://localhost:5173');
|
||||||
|
app.enableCors({
|
||||||
|
origin: corsOrigins.split(','),
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Swagger API Documentation
|
||||||
|
const config = new DocumentBuilder()
|
||||||
|
.setTitle('Dimension47 API')
|
||||||
|
.setDescription('TTRPG Campaign Management Platform API')
|
||||||
|
.setVersion('1.0')
|
||||||
|
.addBearerAuth()
|
||||||
|
.addTag('Auth', 'Authentication endpoints')
|
||||||
|
.addTag('Campaigns', 'Campaign management')
|
||||||
|
.addTag('Characters', 'Character management')
|
||||||
|
.addTag('Battle', 'Battle screen and combat')
|
||||||
|
.addTag('Documents', 'Document management')
|
||||||
|
.addTag('Library', 'Combatants and maps library')
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
|
SwaggerModule.setup('api/docs', app, document);
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
const port = configService.get<number>('PORT', 5000);
|
||||||
|
await app.listen(port);
|
||||||
|
|
||||||
|
console.log(`🚀 Dimension47 Server running on http://localhost:${port}`);
|
||||||
|
console.log(`📚 API Documentation: http://localhost:${port}/api/docs`);
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
79
server/src/modules/auth/auth.controller.ts
Normal file
79
server/src/modules/auth/auth.controller.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Get,
|
||||||
|
Put,
|
||||||
|
Body,
|
||||||
|
Query,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiBearerAuth,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { RegisterDto, LoginDto } from './dto';
|
||||||
|
import { Public } from '../../common/decorators/public.decorator';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { Roles } from '../../common/decorators/roles.decorator';
|
||||||
|
import { UserRole } from '../../generated/prisma';
|
||||||
|
|
||||||
|
@ApiTags('Auth')
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('register')
|
||||||
|
@ApiOperation({ summary: 'Register a new user' })
|
||||||
|
@ApiResponse({ status: 201, description: 'User registered successfully' })
|
||||||
|
@ApiResponse({ status: 409, description: 'Username or email already exists' })
|
||||||
|
async register(@Body() dto: RegisterDto) {
|
||||||
|
return this.authService.register(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('login')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Login with username/email and password' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Login successful' })
|
||||||
|
@ApiResponse({ status: 401, description: 'Invalid credentials' })
|
||||||
|
async login(@Body() dto: LoginDto) {
|
||||||
|
return this.authService.login(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('me')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Get current user profile' })
|
||||||
|
@ApiResponse({ status: 200, description: 'User profile' })
|
||||||
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
|
async getProfile(@CurrentUser('id') userId: string) {
|
||||||
|
return this.authService.getProfile(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('profile')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Update current user profile' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Profile updated' })
|
||||||
|
async updateProfile(
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
@Body() data: { avatarUrl?: string },
|
||||||
|
) {
|
||||||
|
return this.authService.updateProfile(userId, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('users/search')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Roles(UserRole.ADMIN, UserRole.GM)
|
||||||
|
@ApiOperation({ summary: 'Search for users (GM/Admin only)' })
|
||||||
|
@ApiResponse({ status: 200, description: 'List of matching users' })
|
||||||
|
async searchUsers(
|
||||||
|
@Query('q') query: string,
|
||||||
|
@CurrentUser('id') currentUserId: string,
|
||||||
|
) {
|
||||||
|
return this.authService.searchUsers(query || '', currentUserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
server/src/modules/auth/auth.module.ts
Normal file
29
server/src/modules/auth/auth.module.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { PassportModule } from '@nestjs/passport';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||||
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from './guards/roles.guard';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
secret: configService.get<string>('JWT_SECRET') || 'fallback-secret',
|
||||||
|
signOptions: {
|
||||||
|
expiresIn: '7d',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
inject: [ConfigService],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService, JwtStrategy, JwtAuthGuard, RolesGuard],
|
||||||
|
exports: [AuthService, JwtAuthGuard, RolesGuard],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
177
server/src/modules/auth/auth.service.ts
Normal file
177
server/src/modules/auth/auth.service.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
ConflictException,
|
||||||
|
UnauthorizedException,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { RegisterDto, LoginDto } from './dto';
|
||||||
|
import { UserRole } from '../../generated/prisma';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
private prisma: PrismaService,
|
||||||
|
private jwtService: JwtService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async register(dto: RegisterDto) {
|
||||||
|
// Check if username or email already exists
|
||||||
|
const existingUser = await this.prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ username: dto.username.toLowerCase() },
|
||||||
|
{ email: dto.email.toLowerCase() },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
if (existingUser.username.toLowerCase() === dto.username.toLowerCase()) {
|
||||||
|
throw new ConflictException('Username already taken');
|
||||||
|
}
|
||||||
|
throw new ConflictException('Email already registered');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const saltRounds = 10;
|
||||||
|
const passwordHash = await bcrypt.hash(dto.password, saltRounds);
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
const user = await this.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username: dto.username.toLowerCase(),
|
||||||
|
email: dto.email.toLowerCase(),
|
||||||
|
passwordHash,
|
||||||
|
role: UserRole.PLAYER,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate JWT
|
||||||
|
const token = this.generateToken(user.id, user.username, user.role);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(dto: LoginDto) {
|
||||||
|
// Find user by username or email
|
||||||
|
const user = await this.prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ username: dto.identifier.toLowerCase() },
|
||||||
|
{ email: dto.identifier.toLowerCase() },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
const isPasswordValid = await bcrypt.compare(dto.password, user.passwordHash);
|
||||||
|
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate JWT
|
||||||
|
const token = this.generateToken(user.id, user.username, user.role);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
},
|
||||||
|
token,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProfile(userId: string) {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProfile(userId: string, data: { avatarUrl?: string }) {
|
||||||
|
const user = await this.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchUsers(query: string, currentUserId: string) {
|
||||||
|
const users = await this.prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
{ id: { not: currentUserId } },
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ username: { contains: query, mode: 'insensitive' } },
|
||||||
|
{ email: { contains: query, mode: 'insensitive' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
},
|
||||||
|
take: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateToken(userId: string, username: string, role: UserRole): string {
|
||||||
|
const payload = {
|
||||||
|
sub: userId,
|
||||||
|
username,
|
||||||
|
role,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.jwtService.sign(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
2
server/src/modules/auth/dto/index.ts
Normal file
2
server/src/modules/auth/dto/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './register.dto';
|
||||||
|
export * from './login.dto';
|
||||||
14
server/src/modules/auth/dto/login.dto.ts
Normal file
14
server/src/modules/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { IsString, MinLength } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class LoginDto {
|
||||||
|
@ApiProperty({ example: 'johndoe', description: 'Username or email' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
identifier: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'SecurePass123!', description: 'Password' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
23
server/src/modules/auth/dto/register.dto.ts
Normal file
23
server/src/modules/auth/dto/register.dto.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { IsEmail, IsString, MinLength, MaxLength, Matches } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class RegisterDto {
|
||||||
|
@ApiProperty({ example: 'johndoe', description: 'Username (3-20 characters)' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(20)
|
||||||
|
@Matches(/^[a-zA-Z0-9_]+$/, {
|
||||||
|
message: 'Username can only contain letters, numbers, and underscores',
|
||||||
|
})
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'john@example.com', description: 'Email address' })
|
||||||
|
@IsEmail()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'SecurePass123!', description: 'Password (min 8 characters)' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
@MaxLength(100)
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
24
server/src/modules/auth/guards/jwt-auth.guard.ts
Normal file
24
server/src/modules/auth/guards/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Injectable, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { IS_PUBLIC_KEY } from '../../../common/decorators/public.decorator';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||||
|
constructor(private reflector: Reflector) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext) {
|
||||||
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (isPublic) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.canActivate(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
server/src/modules/auth/guards/roles.guard.ts
Normal file
23
server/src/modules/auth/guards/roles.guard.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { UserRole } from '../../../generated/prisma';
|
||||||
|
import { ROLES_KEY } from '../../../common/decorators/roles.decorator';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RolesGuard implements CanActivate {
|
||||||
|
constructor(private reflector: Reflector) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!requiredRoles) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = context.switchToHttp().getRequest();
|
||||||
|
return requiredRoles.some((role) => user.role === role);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
server/src/modules/auth/strategies/jwt.strategy.ts
Normal file
49
server/src/modules/auth/strategies/jwt.strategy.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
|
|
||||||
|
export interface JwtPayload {
|
||||||
|
sub: string;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
|
constructor(
|
||||||
|
configService: ConfigService,
|
||||||
|
private prisma: PrismaService,
|
||||||
|
) {
|
||||||
|
const secret = configService.get<string>('JWT_SECRET');
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error('JWT_SECRET is not defined in environment variables');
|
||||||
|
}
|
||||||
|
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey: secret,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(payload: JwtPayload) {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: payload.sub },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
101
server/src/modules/campaigns/campaigns.controller.ts
Normal file
101
server/src/modules/campaigns/campaigns.controller.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiBearerAuth,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { CampaignsService } from './campaigns.service';
|
||||||
|
import { CreateCampaignDto, UpdateCampaignDto, AddMemberDto } from './dto';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { UserRole } from '../../generated/prisma';
|
||||||
|
|
||||||
|
@ApiTags('Campaigns')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('campaigns')
|
||||||
|
export class CampaignsController {
|
||||||
|
constructor(private readonly campaignsService: CampaignsService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Create a new campaign' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Campaign created successfully' })
|
||||||
|
async create(
|
||||||
|
@Body() dto: CreateCampaignDto,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
) {
|
||||||
|
return this.campaignsService.create(dto, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Get all campaigns for current user' })
|
||||||
|
@ApiResponse({ status: 200, description: 'List of campaigns' })
|
||||||
|
async findAll(@CurrentUser('id') userId: string) {
|
||||||
|
return this.campaignsService.findAll(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get campaign by ID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Campaign details' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Campaign not found' })
|
||||||
|
async findOne(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
) {
|
||||||
|
return this.campaignsService.findOne(id, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
@ApiOperation({ summary: 'Update campaign' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Campaign updated' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Not authorized' })
|
||||||
|
async update(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateCampaignDto,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
) {
|
||||||
|
return this.campaignsService.update(id, dto, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@ApiOperation({ summary: 'Delete campaign' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Campaign deleted' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Not authorized' })
|
||||||
|
async remove(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
@CurrentUser('role') userRole: UserRole,
|
||||||
|
) {
|
||||||
|
return this.campaignsService.remove(id, userId, userRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/members')
|
||||||
|
@ApiOperation({ summary: 'Add member to campaign' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Member added' })
|
||||||
|
@ApiResponse({ status: 409, description: 'User already a member' })
|
||||||
|
async addMember(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: AddMemberDto,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
) {
|
||||||
|
return this.campaignsService.addMember(id, dto, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id/members/:userId')
|
||||||
|
@ApiOperation({ summary: 'Remove member from campaign' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Member removed' })
|
||||||
|
async removeMember(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Param('userId') memberUserId: string,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
) {
|
||||||
|
return this.campaignsService.removeMember(id, memberUserId, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
server/src/modules/campaigns/campaigns.module.ts
Normal file
10
server/src/modules/campaigns/campaigns.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CampaignsController } from './campaigns.controller';
|
||||||
|
import { CampaignsService } from './campaigns.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [CampaignsController],
|
||||||
|
providers: [CampaignsService],
|
||||||
|
exports: [CampaignsService],
|
||||||
|
})
|
||||||
|
export class CampaignsModule {}
|
||||||
289
server/src/modules/campaigns/campaigns.service.ts
Normal file
289
server/src/modules/campaigns/campaigns.service.ts
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
ForbiddenException,
|
||||||
|
ConflictException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { CreateCampaignDto, UpdateCampaignDto, AddMemberDto } from './dto';
|
||||||
|
import { UserRole } from '../../generated/prisma';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CampaignsService {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async create(dto: CreateCampaignDto, userId: string) {
|
||||||
|
// Create campaign with user as GM
|
||||||
|
const campaign = await this.prisma.campaign.create({
|
||||||
|
data: {
|
||||||
|
name: dto.name,
|
||||||
|
description: dto.description,
|
||||||
|
imageUrl: dto.imageUrl,
|
||||||
|
gmId: userId,
|
||||||
|
members: {
|
||||||
|
create: {
|
||||||
|
userId: userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
gm: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
characters: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return campaign;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(userId: string) {
|
||||||
|
// Get all campaigns where user is GM or member
|
||||||
|
const campaigns = await this.prisma.campaign.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ gmId: userId },
|
||||||
|
{ members: { some: { userId } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
gm: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
characters: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
updatedAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return campaigns;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string, userId: string) {
|
||||||
|
const campaign = await this.prisma.campaign.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
gm: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
characters: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
type: true,
|
||||||
|
level: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
hpCurrent: true,
|
||||||
|
hpMax: true,
|
||||||
|
owner: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
name: 'asc',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
characters: true,
|
||||||
|
battleSessions: true,
|
||||||
|
documents: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!campaign) {
|
||||||
|
throw new NotFoundException('Campaign not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has access
|
||||||
|
const hasAccess =
|
||||||
|
campaign.gmId === userId ||
|
||||||
|
campaign.members.some((m) => m.userId === userId);
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
throw new ForbiddenException('No access to this campaign');
|
||||||
|
}
|
||||||
|
|
||||||
|
return campaign;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateCampaignDto, userId: string) {
|
||||||
|
const campaign = await this.findOne(id, userId);
|
||||||
|
|
||||||
|
// Only GM can update campaign
|
||||||
|
if (campaign.gmId !== userId) {
|
||||||
|
throw new ForbiddenException('Only the GM can update the campaign');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.campaign.update({
|
||||||
|
where: { id },
|
||||||
|
data: dto,
|
||||||
|
include: {
|
||||||
|
gm: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string, userId: string, userRole: UserRole) {
|
||||||
|
const campaign = await this.findOne(id, userId);
|
||||||
|
|
||||||
|
// Only GM or Admin can delete campaign
|
||||||
|
if (campaign.gmId !== userId && userRole !== UserRole.ADMIN) {
|
||||||
|
throw new ForbiddenException('Only the GM or an Admin can delete the campaign');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.campaign.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { message: 'Campaign deleted successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async addMember(campaignId: string, dto: AddMemberDto, userId: string) {
|
||||||
|
const campaign = await this.findOne(campaignId, userId);
|
||||||
|
|
||||||
|
// Only GM can add members
|
||||||
|
if (campaign.gmId !== userId) {
|
||||||
|
throw new ForbiddenException('Only the GM can add members');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user exists
|
||||||
|
const userToAdd = await this.prisma.user.findUnique({
|
||||||
|
where: { id: dto.userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userToAdd) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is already a member
|
||||||
|
const existingMember = await this.prisma.campaignMember.findUnique({
|
||||||
|
where: {
|
||||||
|
campaignId_userId: {
|
||||||
|
campaignId,
|
||||||
|
userId: dto.userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingMember) {
|
||||||
|
throw new ConflictException('User is already a member of this campaign');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add member
|
||||||
|
await this.prisma.campaignMember.create({
|
||||||
|
data: {
|
||||||
|
campaignId,
|
||||||
|
userId: dto.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.findOne(campaignId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeMember(campaignId: string, memberUserId: string, userId: string) {
|
||||||
|
const campaign = await this.findOne(campaignId, userId);
|
||||||
|
|
||||||
|
// Only GM can remove members
|
||||||
|
if (campaign.gmId !== userId) {
|
||||||
|
throw new ForbiddenException('Only the GM can remove members');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cannot remove GM
|
||||||
|
if (memberUserId === campaign.gmId) {
|
||||||
|
throw new ForbiddenException('Cannot remove the GM from the campaign');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if member exists
|
||||||
|
const member = await this.prisma.campaignMember.findUnique({
|
||||||
|
where: {
|
||||||
|
campaignId_userId: {
|
||||||
|
campaignId,
|
||||||
|
userId: memberUserId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
throw new NotFoundException('Member not found in campaign');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.campaignMember.delete({
|
||||||
|
where: {
|
||||||
|
campaignId_userId: {
|
||||||
|
campaignId,
|
||||||
|
userId: memberUserId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.findOne(campaignId, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
server/src/modules/campaigns/dto/add-member.dto.ts
Normal file
9
server/src/modules/campaigns/dto/add-member.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { IsString, IsUUID } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class AddMemberDto {
|
||||||
|
@ApiProperty({ description: 'User ID to add as member' })
|
||||||
|
@IsString()
|
||||||
|
@IsUUID()
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
21
server/src/modules/campaigns/dto/create-campaign.dto.ts
Normal file
21
server/src/modules/campaigns/dto/create-campaign.dto.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { IsString, IsOptional, MinLength, MaxLength } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class CreateCampaignDto {
|
||||||
|
@ApiProperty({ example: 'Rise of the Runelords', description: 'Campaign name' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(100)
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Campaign description' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MaxLength(2000)
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Campaign image URL' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
imageUrl?: string;
|
||||||
|
}
|
||||||
3
server/src/modules/campaigns/dto/index.ts
Normal file
3
server/src/modules/campaigns/dto/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './create-campaign.dto';
|
||||||
|
export * from './update-campaign.dto';
|
||||||
|
export * from './add-member.dto';
|
||||||
4
server/src/modules/campaigns/dto/update-campaign.dto.ts
Normal file
4
server/src/modules/campaigns/dto/update-campaign.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateCampaignDto } from './create-campaign.dto';
|
||||||
|
|
||||||
|
export class UpdateCampaignDto extends PartialType(CreateCampaignDto) {}
|
||||||
9
server/src/prisma/prisma.module.ts
Normal file
9
server/src/prisma/prisma.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from './prisma.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [PrismaService],
|
||||||
|
exports: [PrismaService],
|
||||||
|
})
|
||||||
|
export class PrismaModule {}
|
||||||
22
server/src/prisma/prisma.service.ts
Normal file
22
server/src/prisma/prisma.service.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '../generated/prisma';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
log: process.env.NODE_ENV === 'development'
|
||||||
|
? ['query', 'info', 'warn', 'error']
|
||||||
|
: ['error'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.$connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
25
server/test/app.e2e-spec.ts
Normal file
25
server/test/app.e2e-spec.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { App } from 'supertest/types';
|
||||||
|
import { AppModule } from './../src/app.module';
|
||||||
|
|
||||||
|
describe('AppController (e2e)', () => {
|
||||||
|
let app: INestApplication<App>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleFixture.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/ (GET)', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/')
|
||||||
|
.expect(200)
|
||||||
|
.expect('Hello World!');
|
||||||
|
});
|
||||||
|
});
|
||||||
9
server/test/jest-e2e.json
Normal file
9
server/test/jest-e2e.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
|
"rootDir": ".",
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"testRegex": ".e2e-spec.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
}
|
||||||
|
}
|
||||||
4
server/tsconfig.build.json
Normal file
4
server/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
||||||
25
server/tsconfig.json
Normal file
25
server/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "nodenext",
|
||||||
|
"moduleResolution": "nodenext",
|
||||||
|
"resolvePackageJsonExports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2023",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"strictBindCallApply": false,
|
||||||
|
"noFallthroughCasesInSwitch": false
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user