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:
2026-01-18 16:24:18 +01:00
commit 090aae53d8
73 changed files with 19842 additions and 0 deletions

48
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

41
client/package.json Normal file
View 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
View 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
View 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;

View 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

View 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>
);
}

View 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>
);
}

View 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 }),
}
)
);

View File

@@ -0,0 +1,3 @@
export { LoginPage } from './components/login-page';
export { RegisterPage } from './components/register-page';
export { useAuthStore } from './hooks/use-auth-store';

View 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>
);
}

View File

@@ -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>
);
}

View 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
View 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
View 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>,
)

View 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>
);
}

View 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 />;
}

View 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 };

View 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 };

View File

@@ -0,0 +1,4 @@
export * from './button';
export * from './input';
export * from './card';
export * from './spinner';

View 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 };

View 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 };

View 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();

View 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));
}

View 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
View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
client/tsconfig.node.json Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
node_modules
# Keep environment variables out of version control
.env
/generated/prisma

4
server/.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

98
server/README.md Normal file
View 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>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](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
View 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
View 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

File diff suppressed because it is too large Load Diff

94
server/package.json Normal file
View 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
View 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
View 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
View 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 {}

View 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;
},
);

View File

@@ -0,0 +1,3 @@
export * from './public.decorator';
export * from './roles.decorator';
export * from './current-user.decorator';

View File

@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View 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
View 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();

View 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);
}
}

View 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 {}

View 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);
}
}

View File

@@ -0,0 +1,2 @@
export * from './register.dto';
export * from './login.dto';

View 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;
}

View 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;
}

View 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);
}
}

View 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);
}
}

View 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;
}
}

View 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);
}
}

View 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 {}

View 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);
}
}

View 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;
}

View 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;
}

View File

@@ -0,0 +1,3 @@
export * from './create-campaign.dto';
export * from './update-campaign.dto';
export * from './add-member.dto';

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateCampaignDto } from './create-campaign.dto';
export class UpdateCampaignDto extends PartialType(CreateCampaignDto) {}

View 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 {}

View 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();
}
}

View 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!');
});
});

View File

@@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

25
server/tsconfig.json Normal file
View 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
}
}