feat: Charaktere-Modul mit Pathbuilder Import
Backend: - Characters-Modul (CRUD, HP-Tracking, Conditions) - Pathbuilder 2e JSON Import Service - Claude API Integration für automatische Übersetzungen - Translations-Modul mit Datenbank-Caching - Prisma Schema erweitert (Character, Abilities, Skills, Feats, Items, Resources) Frontend: - Kampagnen-Detailseite mit Mitglieder- und Charakterverwaltung - Charakter erstellen Modal - Pathbuilder Import Modal (Datei-Upload + JSON-Paste) - Logo-Integration (Dimension 47 + Zeasy) - Cinzel Font für Branding Weitere Änderungen: - Auth 401 Redirect Fix für Login-Seite - PROGRESS.md mit Projektfortschritt Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
8
.claude/settings.local.json
Normal file
8
.claude/settings.local.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(npm install:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
140
PROGRESS.md
Normal file
140
PROGRESS.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# Dimension 47 - Projektfortschritt
|
||||
|
||||
## Abgeschlossen
|
||||
|
||||
### Backend (NestJS + Prisma 7)
|
||||
|
||||
- **Auth-Modul**
|
||||
- JWT-basierte Authentifizierung
|
||||
- Login/Register Endpoints
|
||||
- Benutzerprofile mit Avatar
|
||||
- Benutzersuche
|
||||
- Globale Auth Guards
|
||||
|
||||
- **Kampagnen-Modul**
|
||||
- CRUD-Operationen für Kampagnen
|
||||
- Mitgliederverwaltung (hinzufügen/entfernen)
|
||||
- GM-Berechtigungen
|
||||
|
||||
- **Charaktere-Modul**
|
||||
- CRUD-Operationen für Charaktere (PC/NPC)
|
||||
- HP-Tracking (aktuell, max, temporär)
|
||||
- Zustände (Conditions) verwalten
|
||||
- Pathbuilder 2e Import mit automatischer Übersetzung
|
||||
- Abilities, Skills, Feats, Items, Resources Import
|
||||
|
||||
- **Übersetzungs-Modul**
|
||||
- Claude API Integration für Deutsch-Übersetzungen
|
||||
- Datenbank-Caching für Übersetzungen
|
||||
- Batch-Übersetzung für Performance
|
||||
|
||||
- **Prisma Schema**
|
||||
- User, Campaign, CampaignMember
|
||||
- Character mit allen Relationen
|
||||
- CharacterAbility, CharacterSkill, CharacterFeat
|
||||
- CharacterItem, CharacterResource, CharacterCondition
|
||||
- Translation (Cache)
|
||||
|
||||
### Frontend (React + Vite + TypeScript)
|
||||
|
||||
- **Auth**
|
||||
- Login-Seite mit Logo
|
||||
- Registrierung
|
||||
- Auth-Store (Zustand)
|
||||
- Protected Routes
|
||||
|
||||
- **Layout**
|
||||
- Navbar mit Logo + "Dimension 47" (Cinzel Font)
|
||||
- Footer mit Zeasy Logo (verlinkt)
|
||||
- Responsive Design
|
||||
- Dark Theme
|
||||
|
||||
- **Kampagnen**
|
||||
- Dashboard mit Kampagnen-Übersicht
|
||||
- Kampagnen-Detailseite
|
||||
- Kampagne erstellen/bearbeiten/löschen
|
||||
- Mitglieder verwalten
|
||||
|
||||
- **Charaktere**
|
||||
- Charakter erstellen (manuell)
|
||||
- Pathbuilder JSON Import (Datei-Upload + Paste)
|
||||
- Charakter-Vorschau vor Import
|
||||
- Charakterliste in Kampagne
|
||||
|
||||
- **UI-Komponenten**
|
||||
- Button, Card, Input, Spinner
|
||||
- Modal-System
|
||||
- Einheitliches Design-System
|
||||
|
||||
---
|
||||
|
||||
## In Arbeit / Geplant
|
||||
|
||||
### Hohe Priorität
|
||||
|
||||
- [ ] **Charakterbogen-Ansicht**
|
||||
- Vollständige Anzeige aller importierten Daten
|
||||
- Abilities, Skills, Feats, Items anzeigen
|
||||
- HP bearbeiten
|
||||
- Zustände hinzufügen/entfernen
|
||||
|
||||
- [ ] **Kampfbildschirm (Battle Tracker)**
|
||||
- Initiative-Tracking
|
||||
- Runden-Management
|
||||
- HP-Änderungen im Kampf
|
||||
- Zustände im Kampf verwalten
|
||||
|
||||
### Mittlere Priorität
|
||||
|
||||
- [ ] **Zauber-System**
|
||||
- Zauberslots verwalten
|
||||
- Zauber casten/vorbereiten
|
||||
- Fokuspunkte tracking
|
||||
|
||||
- [ ] **Inventar-Management**
|
||||
- Items hinzufügen/entfernen
|
||||
- Bulk-Berechnung
|
||||
- Geld verwalten
|
||||
|
||||
- [ ] **Würfel-System**
|
||||
- Würfelwürfe mit Modifikatoren
|
||||
- Skill-Checks
|
||||
- Angriffswürfe
|
||||
|
||||
### Niedrige Priorität
|
||||
|
||||
- [ ] **Dokumente**
|
||||
- Kampagnen-Dokumente hochladen
|
||||
- Notizen teilen
|
||||
|
||||
- [ ] **Notizen-System**
|
||||
- Persönliche Notizen
|
||||
- Geteilte Kampagnen-Notizen
|
||||
|
||||
- [ ] **WebSocket Integration**
|
||||
- Echtzeit-Updates für alle Spieler
|
||||
- Live HP/Zustands-Änderungen
|
||||
|
||||
- [ ] **Encounter Builder**
|
||||
- Monster/NPCs aus Datenbank
|
||||
- Encounter-Schwierigkeit berechnen
|
||||
|
||||
---
|
||||
|
||||
## Technische Schulden
|
||||
|
||||
- [ ] Unit Tests hinzufügen
|
||||
- [ ] E2E Tests hinzufügen
|
||||
- [ ] API-Dokumentation (Swagger)
|
||||
- [ ] Error Boundary im Frontend
|
||||
- [ ] Logging verbessern
|
||||
|
||||
---
|
||||
|
||||
## Letzte Änderungen
|
||||
|
||||
**2025-01-18**
|
||||
- Pathbuilder Import implementiert
|
||||
- Claude API für Übersetzungen integriert
|
||||
- Import-Modal im Frontend erstellt
|
||||
- Logo-Integration abgeschlossen
|
||||
1
client/Logos/Logo_Horizontal.svg
Normal file
1
client/Logos/Logo_Horizontal.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 18 KiB |
1
client/Logos/Logo_Vertikal.svg
Normal file
1
client/Logos/Logo_Vertikal.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 18 KiB |
1
client/Logos/Logo_ohne_Text.svg
Normal file
1
client/Logos/Logo_ohne_Text.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.5 KiB |
2
client/Logos/Zeasy/logo.svg
Normal file
2
client/Logos/Zeasy/logo.svg
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="793.44598" height="791.01794" version="1.1" viewBox="0 0 793.44598 791.01794" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><linearGradient id="b" x1="488.39264" x2="284.4079" y1="-170.60948" y2="590.67194" gradientUnits="userSpaceOnUse"><stop stop-color="#6e2ad8" offset=".21782178"/><stop stop-color="#8d19c9" offset=".84158415"/></linearGradient><linearGradient id="a" x1="541.3728" x2="220.80882" y1="83.038246" y2="803.0368" gradientUnits="userSpaceOnUse"><stop stop-color="#a608c2" offset="0"/><stop stop-color="#fb4ced" offset="1"/></linearGradient></defs><g transform="translate(12.977401,68.974609)" stroke-width="1.97972"><path d="m72.022599-68.974609c-46.944204 0-85 38.055796-85 85 1e-6 46.944203 38.055797 84.999999 85 84.999999 28.907141-2.4e-4 55.832701-14.691726 71.476561-38.999999h501.30553c17.80803 0 33.56211 10.526042 40.37695 26.978515 6.81484 16.452474 3.11757 35.032824-9.47461 47.625004l-260.4082 260.4082h-50.79883l-92 92h161.85352c12.19994-2.8e-4 23.90021-4.84647 32.52734-13.47266l273.88086-273.88086c38.71717-38.71716 50.36959-97.3003 29.41601-147.88672-20.95357-50.58641-70.6187-83.771484-125.37304-83.771484h-501.30553c-15.64386-24.308273-42.56942-38.999759-71.476561-39zm0 45.5c21.815247 1e-6 39.500001 17.684753 39.500001 39.5 0 21.815247-17.684754 39.499999-39.500001 39.5-21.815248 0-39.5-17.684752-39.5-39.5 0-21.815248 17.684752-39.5 39.5-39.5z" fill="url(#b)"/><path d="m695.46858 722.04333c46.94421 0 85-38.0558 85-85s-38.05579-85-85-85c-28.90713 2.4e-4 -55.83269 14.69173-71.47656 39h-501.30554c-17.80803 0-33.562109-10.52604-40.376949-26.97852-6.81484-16.45247-3.11757-35.03282 9.47461-47.625l260.4082-260.4082h50.79883l92-92h-161.85352c-12.19994 2.8e-4 -23.90021 4.84647-32.52734 13.47266l-273.88086 273.88086c-38.71717 38.71716-50.36961 97.3003-29.41601 147.88672 20.95357 50.58641 70.6187 83.77148 125.37304 83.77148h501.30554c15.64387 24.30827 42.56943 38.99976 71.47656 39zm0-45.5c-21.81524 0-39.5-17.68475-39.5-39.5s17.68476-39.5 39.5-39.5c21.81525 0 39.5 17.68475 39.5 39.5s-17.68475 39.5-39.5 39.5z" fill="url(#a)"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
2
client/Logos/Zeasy/logo_text_horizontal.svg
Normal file
2
client/Logos/Zeasy/logo_text_horizontal.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.6 KiB |
2
client/Logos/Zeasy/logo_text_vertical.svg
Normal file
2
client/Logos/Zeasy/logo_text_vertical.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.6 KiB |
@@ -8,7 +8,7 @@
|
||||
<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">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<title>Dimension47</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
13
client/package-lock.json
generated
13
client/package-lock.json
generated
@@ -68,7 +68,6 @@
|
||||
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/generator": "^7.28.6",
|
||||
@@ -1747,7 +1746,6 @@
|
||||
"integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
@@ -1758,7 +1756,6 @@
|
||||
"integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -1818,7 +1815,6 @@
|
||||
"integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.53.0",
|
||||
"@typescript-eslint/types": "8.53.0",
|
||||
@@ -2070,7 +2066,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2193,7 +2188,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -2575,7 +2569,6 @@
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -3763,7 +3756,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -3831,7 +3823,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -3841,7 +3832,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -4147,7 +4137,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -4234,7 +4223,6 @@
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -4385,7 +4373,6 @@
|
||||
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ 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 { CampaignsPage, CampaignDetailPage } from '@/features/campaigns';
|
||||
import { CharacterSheetPage } from '@/features/characters';
|
||||
import { ProtectedRoute } from '@/shared/components/protected-route';
|
||||
import { Layout } from '@/shared/components/layout';
|
||||
|
||||
@@ -43,7 +44,8 @@ function AppContent() {
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<CampaignsPage />} />
|
||||
<Route path="/campaigns/:id" element={<div>Campaign Detail (TODO)</div>} />
|
||||
<Route path="/campaigns/:id" element={<CampaignDetailPage />} />
|
||||
<Route path="/campaigns/:id/characters/:characterId" element={<CharacterSheetPage />} />
|
||||
<Route path="/library" element={<div>Library (TODO)</div>} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
import LogoVertical from '../../../../Logos/Logo_Vertikal.svg';
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -30,26 +31,17 @@ export function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-4 py-12 bg-bg-primary">
|
||||
<div className="min-h-screen flex flex-col items-center justify-center px-4 py-12 bg-bg-primary">
|
||||
{/* Logo */}
|
||||
<img
|
||||
src={LogoVertical}
|
||||
alt="Dimension 47"
|
||||
className="h-48 mb-10"
|
||||
/>
|
||||
|
||||
<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>
|
||||
<CardTitle>Willkommen zurück</CardTitle>
|
||||
<CardDescription>
|
||||
Melde dich an, um fortzufahren
|
||||
</CardDescription>
|
||||
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
import LogoVertical from '../../../../Logos/Logo_Vertikal.svg';
|
||||
|
||||
export function RegisterPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -41,25 +42,16 @@ export function RegisterPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-4 py-12 bg-bg-primary">
|
||||
<div className="min-h-screen flex flex-col items-center justify-center px-4 py-12 bg-bg-primary">
|
||||
{/* Logo */}
|
||||
<img
|
||||
src={LogoVertical}
|
||||
alt="Dimension 47"
|
||||
className="h-48 mb-10"
|
||||
/>
|
||||
|
||||
<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
|
||||
|
||||
111
client/src/features/campaigns/components/add-member-modal.tsx
Normal file
111
client/src/features/campaigns/components/add-member-modal.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useState } from 'react';
|
||||
import { X, Search, UserPlus } from 'lucide-react';
|
||||
import { Button, Input, Spinner } from '@/shared/components/ui';
|
||||
import { api } from '@/shared/lib/api';
|
||||
import type { User } from '@/shared/types';
|
||||
|
||||
interface AddMemberModalProps {
|
||||
campaignId: string;
|
||||
onClose: () => void;
|
||||
onMemberAdded: () => void;
|
||||
}
|
||||
|
||||
export function AddMemberModal({ campaignId, onClose, onMemberAdded }: AddMemberModalProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<User[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isAdding, setIsAdding] = useState<string | null>(null);
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (searchQuery.length < 2) return;
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const results = await api.searchUsers(searchQuery);
|
||||
setSearchResults(results);
|
||||
} catch (error) {
|
||||
console.error('Failed to search users:', error);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMember = async (userId: string) => {
|
||||
setIsAdding(userId);
|
||||
try {
|
||||
await api.addCampaignMember(campaignId, userId);
|
||||
onMemberAdded();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to add member:', error);
|
||||
setIsAdding(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative bg-bg-primary border border-border rounded-xl p-6 w-full max-w-md mx-4 shadow-xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Mitglied hinzufügen</h2>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Benutzername oder E-Mail suchen..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
<Button onClick={handleSearch} disabled={searchQuery.length < 2 || isSearching}>
|
||||
{isSearching ? <Spinner size="sm" /> : <Search className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{searchResults.length > 0 && (
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{searchResults.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-bg-secondary"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-full bg-primary-500/20 flex items-center justify-center">
|
||||
<span className="text-primary-500 text-sm font-medium">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-text-primary text-sm">{user.username}</p>
|
||||
<p className="text-xs text-text-secondary">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleAddMember(user.id)}
|
||||
disabled={isAdding === user.id}
|
||||
>
|
||||
{isAdding === user.id ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<UserPlus className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchQuery.length >= 2 && searchResults.length === 0 && !isSearching && (
|
||||
<p className="text-center text-text-secondary py-4">
|
||||
Keine Benutzer gefunden
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Settings,
|
||||
Users,
|
||||
Swords,
|
||||
UserPlus,
|
||||
Trash2,
|
||||
Crown,
|
||||
Shield,
|
||||
Heart,
|
||||
FileJson
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
Spinner
|
||||
} from '@/shared/components/ui';
|
||||
import { api } from '@/shared/lib/api';
|
||||
import { useAuthStore } from '@/features/auth';
|
||||
import type { Campaign, CharacterSummary } from '@/shared/types';
|
||||
import { AddMemberModal } from './add-member-modal';
|
||||
import { EditCampaignModal } from './edit-campaign-modal';
|
||||
import { CreateCharacterModal, ImportCharacterModal } from '@/features/characters';
|
||||
|
||||
export function CampaignDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuthStore();
|
||||
|
||||
const [campaign, setCampaign] = useState<Campaign | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showAddMember, setShowAddMember] = useState(false);
|
||||
const [showEditCampaign, setShowEditCampaign] = useState(false);
|
||||
const [showCreateCharacter, setShowCreateCharacter] = useState(false);
|
||||
const [showImportCharacter, setShowImportCharacter] = useState(false);
|
||||
|
||||
const isGM = campaign?.gmId === user?.id;
|
||||
const isAdmin = user?.role === 'ADMIN';
|
||||
const canManage = isGM || isAdmin;
|
||||
|
||||
const fetchCampaign = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const data = await api.getCampaign(id);
|
||||
setCampaign(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch campaign:', error);
|
||||
navigate('/');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCampaign();
|
||||
}, [id]);
|
||||
|
||||
const handleRemoveMember = async (userId: string) => {
|
||||
if (!id || !confirm('Mitglied wirklich entfernen?')) return;
|
||||
try {
|
||||
await api.removeCampaignMember(id, userId);
|
||||
fetchCampaign();
|
||||
} catch (error) {
|
||||
console.error('Failed to remove member:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCampaign = async () => {
|
||||
if (!id || !confirm('Kampagne wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.')) return;
|
||||
try {
|
||||
await api.deleteCampaign(id);
|
||||
navigate('/');
|
||||
} catch (error) {
|
||||
console.error('Failed to delete campaign:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!campaign) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-text-secondary">Kampagne nicht gefunden</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate('/')}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">{campaign.name}</h1>
|
||||
{campaign.description && (
|
||||
<p className="text-text-secondary mt-1">{campaign.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2 text-sm text-text-secondary">
|
||||
<Crown className="h-4 w-4 text-primary-500" />
|
||||
<span>GM: {campaign.gm.username}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setShowEditCampaign(true)}>
|
||||
<Settings className="h-4 w-4" />
|
||||
Bearbeiten
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={handleDeleteCampaign}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Campaign Image */}
|
||||
{campaign.imageUrl && (
|
||||
<div className="h-48 rounded-xl overflow-hidden bg-bg-tertiary">
|
||||
<img
|
||||
src={campaign.imageUrl}
|
||||
alt={campaign.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Members Section */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Mitglieder ({campaign.members.length})
|
||||
</CardTitle>
|
||||
{canManage && (
|
||||
<Button size="sm" onClick={() => setShowAddMember(true)}>
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Hinzufügen
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{campaign.members.map((member) => (
|
||||
<div
|
||||
key={member.userId}
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-bg-secondary"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-full bg-primary-500/20 flex items-center justify-center">
|
||||
{member.user.avatarUrl ? (
|
||||
<img
|
||||
src={member.user.avatarUrl}
|
||||
alt={member.user.username}
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-primary-500 font-medium">
|
||||
{member.user.username.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-text-primary flex items-center gap-2">
|
||||
{member.user.username}
|
||||
{member.userId === campaign.gmId && (
|
||||
<Crown className="h-4 w-4 text-primary-500" />
|
||||
)}
|
||||
</p>
|
||||
<p className="text-sm text-text-secondary">{member.user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
{canManage && member.userId !== campaign.gmId && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveMember(member.userId)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-text-secondary hover:text-red-500" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Characters Section */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Swords className="h-5 w-5" />
|
||||
Charaktere ({campaign.characters?.length || 0})
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setShowImportCharacter(true)}>
|
||||
<FileJson className="h-4 w-4" />
|
||||
Import
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setShowCreateCharacter(true)}>
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Neuer Charakter
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!campaign.characters?.length ? (
|
||||
<div className="text-center py-8">
|
||||
<Swords className="h-8 w-8 text-text-secondary mx-auto mb-2" />
|
||||
<p className="text-text-secondary">Noch keine Charaktere</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{campaign.characters.map((character: CharacterSummary) => (
|
||||
<div
|
||||
key={character.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-bg-secondary hover:bg-bg-tertiary cursor-pointer transition-colors"
|
||||
onClick={() => navigate(`/campaigns/${id}/characters/${character.id}`)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-full bg-primary-500/20 flex items-center justify-center">
|
||||
{character.avatarUrl ? (
|
||||
<img
|
||||
src={character.avatarUrl}
|
||||
alt={character.name}
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Shield className="h-5 w-5 text-primary-500" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-text-primary">{character.name}</p>
|
||||
<p className="text-sm text-text-secondary">
|
||||
Level {character.level} {character.type}
|
||||
{character.owner && ` • ${character.owner.username}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Heart className="h-4 w-4 text-red-500" />
|
||||
<span className={character.hpCurrent < character.hpMax / 2 ? 'text-red-500' : 'text-text-secondary'}>
|
||||
{character.hpCurrent}/{character.hpMax}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Card className="p-4 hover:border-border-hover cursor-pointer transition-colors" onClick={() => navigate(`/campaigns/${id}/battle`)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-red-500/20 flex items-center justify-center">
|
||||
<Swords className="h-5 w-5 text-red-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-text-primary">Kampfbildschirm</p>
|
||||
<p className="text-sm text-text-secondary">Kämpfe verwalten</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4 hover:border-border-hover cursor-pointer transition-colors opacity-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
|
||||
<Users className="h-5 w-5 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-text-primary">Dokumente</p>
|
||||
<p className="text-sm text-text-secondary">Bald verfügbar</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4 hover:border-border-hover cursor-pointer transition-colors opacity-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-green-500/20 flex items-center justify-center">
|
||||
<Users className="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-text-primary">Notizen</p>
|
||||
<p className="text-sm text-text-secondary">Bald verfügbar</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
{showAddMember && (
|
||||
<AddMemberModal
|
||||
campaignId={campaign.id}
|
||||
onClose={() => setShowAddMember(false)}
|
||||
onMemberAdded={fetchCampaign}
|
||||
/>
|
||||
)}
|
||||
{showEditCampaign && (
|
||||
<EditCampaignModal
|
||||
campaign={campaign}
|
||||
onClose={() => setShowEditCampaign(false)}
|
||||
onUpdated={fetchCampaign}
|
||||
/>
|
||||
)}
|
||||
{showCreateCharacter && (
|
||||
<CreateCharacterModal
|
||||
campaignId={campaign.id}
|
||||
onClose={() => setShowCreateCharacter(false)}
|
||||
onCreated={fetchCampaign}
|
||||
/>
|
||||
)}
|
||||
{showImportCharacter && (
|
||||
<ImportCharacterModal
|
||||
campaignId={campaign.id}
|
||||
onClose={() => setShowImportCharacter(false)}
|
||||
onImported={fetchCampaign}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
client/src/features/campaigns/components/edit-campaign-modal.tsx
Normal file
109
client/src/features/campaigns/components/edit-campaign-modal.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { Button, Input, Spinner } from '@/shared/components/ui';
|
||||
import { api } from '@/shared/lib/api';
|
||||
import type { Campaign } from '@/shared/types';
|
||||
|
||||
interface EditCampaignModalProps {
|
||||
campaign: Campaign;
|
||||
onClose: () => void;
|
||||
onUpdated: () => void;
|
||||
}
|
||||
|
||||
export function EditCampaignModal({ campaign, onClose, onUpdated }: EditCampaignModalProps) {
|
||||
const [name, setName] = useState(campaign.name);
|
||||
const [description, setDescription] = useState(campaign.description || '');
|
||||
const [imageUrl, setImageUrl] = useState(campaign.imageUrl || '');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) {
|
||||
setError('Name ist erforderlich');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await api.updateCampaign(campaign.id, {
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
imageUrl: imageUrl.trim() || undefined,
|
||||
});
|
||||
onUpdated();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError('Fehler beim Aktualisieren der Kampagne');
|
||||
console.error('Failed to update campaign:', err);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative bg-bg-primary border border-border rounded-xl p-6 w-full max-w-md mx-4 shadow-xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Kampagne bearbeiten</h2>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">
|
||||
Name *
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Name der Kampagne"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">
|
||||
Beschreibung
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Optionale Beschreibung"
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 rounded-lg bg-bg-secondary border border-border text-text-primary placeholder:text-text-secondary focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">
|
||||
Bild-URL
|
||||
</label>
|
||||
<Input
|
||||
value={imageUrl}
|
||||
onChange={(e) => setImageUrl(e.target.value)}
|
||||
placeholder="https://example.com/image.jpg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? <Spinner size="sm" /> : 'Speichern'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1,5 @@
|
||||
export { CampaignsPage } from './components/campaigns-page';
|
||||
export { CampaignDetailPage } from './components/campaign-detail-page';
|
||||
export { CreateCampaignModal } from './components/create-campaign-modal';
|
||||
export { AddMemberModal } from './components/add-member-modal';
|
||||
export { EditCampaignModal } from './components/edit-campaign-modal';
|
||||
|
||||
@@ -0,0 +1,718 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Heart,
|
||||
Shield,
|
||||
Zap,
|
||||
Swords,
|
||||
BookOpen,
|
||||
Package,
|
||||
AlertCircle,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Plus,
|
||||
Minus,
|
||||
Sparkles,
|
||||
FlaskConical,
|
||||
User,
|
||||
Star,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
Spinner,
|
||||
Input,
|
||||
} from '@/shared/components/ui';
|
||||
import { api } from '@/shared/lib/api';
|
||||
import { useAuthStore } from '@/features/auth';
|
||||
import type { Character } from '@/shared/types';
|
||||
|
||||
type TabType = 'status' | 'inventory' | 'feats' | 'spells' | 'alchemy' | 'actions';
|
||||
|
||||
const TABS: { id: TabType; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: 'status', label: 'Status', icon: <User className="h-4 w-4" /> },
|
||||
{ id: 'inventory', label: 'Inventar', icon: <Package className="h-4 w-4" /> },
|
||||
{ id: 'feats', label: 'Talente', icon: <Star className="h-4 w-4" /> },
|
||||
{ id: 'spells', label: 'Zauber', icon: <Sparkles className="h-4 w-4" /> },
|
||||
{ id: 'alchemy', label: 'Alchemie', icon: <FlaskConical className="h-4 w-4" /> },
|
||||
{ id: 'actions', label: 'Aktionen', icon: <Swords className="h-4 w-4" /> },
|
||||
];
|
||||
|
||||
const ABILITY_NAMES: Record<string, string> = {
|
||||
STR: 'Stärke',
|
||||
DEX: 'Geschicklichkeit',
|
||||
CON: 'Konstitution',
|
||||
INT: 'Intelligenz',
|
||||
WIS: 'Weisheit',
|
||||
CHA: 'Charisma',
|
||||
};
|
||||
|
||||
const PROFICIENCY_NAMES: Record<string, string> = {
|
||||
UNTRAINED: 'Ungeübt',
|
||||
TRAINED: 'Geübt',
|
||||
EXPERT: 'Experte',
|
||||
MASTER: 'Meister',
|
||||
LEGENDARY: 'Legendär',
|
||||
};
|
||||
|
||||
const PROFICIENCY_COLORS: Record<string, string> = {
|
||||
UNTRAINED: 'text-text-secondary',
|
||||
TRAINED: 'text-blue-500',
|
||||
EXPERT: 'text-purple-500',
|
||||
MASTER: 'text-orange-500',
|
||||
LEGENDARY: 'text-red-500',
|
||||
};
|
||||
|
||||
export function CharacterSheetPage() {
|
||||
const { id: campaignId, characterId } = useParams<{ id: string; characterId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuthStore();
|
||||
|
||||
const [character, setCharacter] = useState<Character | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<TabType>('status');
|
||||
const [hpEdit, setHpEdit] = useState<number | null>(null);
|
||||
|
||||
const isOwner = character?.ownerId === user?.id;
|
||||
|
||||
const fetchCharacter = async () => {
|
||||
if (!campaignId || !characterId) return;
|
||||
try {
|
||||
const data = await api.getCharacter(campaignId, characterId);
|
||||
setCharacter(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch character:', error);
|
||||
navigate(`/campaigns/${campaignId}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCharacter();
|
||||
}, [campaignId, characterId]);
|
||||
|
||||
const handleHpChange = async (delta: number) => {
|
||||
if (!character || !campaignId) return;
|
||||
const newHp = Math.max(0, Math.min(character.hpMax, character.hpCurrent + delta));
|
||||
try {
|
||||
await api.updateCharacterHp(campaignId, character.id, newHp);
|
||||
setCharacter({ ...character, hpCurrent: newHp });
|
||||
} catch (error) {
|
||||
console.error('Failed to update HP:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHpSet = async () => {
|
||||
if (hpEdit === null || !character || !campaignId) return;
|
||||
const newHp = Math.max(0, Math.min(character.hpMax, hpEdit));
|
||||
try {
|
||||
await api.updateCharacterHp(campaignId, character.id, newHp);
|
||||
setCharacter({ ...character, hpCurrent: newHp });
|
||||
setHpEdit(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to update HP:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!character || !campaignId || !confirm('Charakter wirklich löschen?')) return;
|
||||
try {
|
||||
await api.deleteCharacter(campaignId, character.id);
|
||||
navigate(`/campaigns/${campaignId}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete character:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveCondition = async (conditionId: string) => {
|
||||
if (!character || !campaignId) return;
|
||||
try {
|
||||
await api.removeCharacterCondition(campaignId, character.id, conditionId);
|
||||
setCharacter({
|
||||
...character,
|
||||
conditions: character.conditions.filter((c) => c.id !== conditionId),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to remove condition:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getAbilityModifier = (score: number) => {
|
||||
const mod = Math.floor((score - 10) / 2);
|
||||
return mod >= 0 ? `+${mod}` : `${mod}`;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!character) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-text-secondary">Charakter nicht gefunden</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hpPercentage = (character.hpCurrent / character.hpMax) * 100;
|
||||
|
||||
// Tab Content Renderers
|
||||
const renderStatusTab = () => (
|
||||
<div className="space-y-6">
|
||||
{/* HP Bar */}
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Heart className="h-6 w-6 text-red-500" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium text-text-primary">Trefferpunkte</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{hpEdit !== null ? (
|
||||
<>
|
||||
<Input
|
||||
type="number"
|
||||
value={hpEdit}
|
||||
onChange={(e) => setHpEdit(parseInt(e.target.value) || 0)}
|
||||
className="w-20 h-8 text-center"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleHpSet()}
|
||||
/>
|
||||
<Button size="sm" onClick={handleHpSet}>OK</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setHpEdit(null)}>X</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button size="icon" variant="ghost" onClick={() => handleHpChange(-1)}>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<span
|
||||
className={`font-bold cursor-pointer ${hpPercentage < 25 ? 'text-red-500' : hpPercentage < 50 ? 'text-yellow-500' : 'text-text-primary'}`}
|
||||
onClick={() => setHpEdit(character.hpCurrent)}
|
||||
>
|
||||
{character.hpCurrent} / {character.hpMax}
|
||||
</span>
|
||||
<Button size="icon" variant="ghost" onClick={() => handleHpChange(1)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{character.hpTemp > 0 && (
|
||||
<span className="text-sm text-blue-500">(+{character.hpTemp} temp)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-3 bg-bg-tertiary rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all ${hpPercentage < 25 ? 'bg-red-500' : hpPercentage < 50 ? 'bg-yellow-500' : 'bg-green-500'}`}
|
||||
style={{ width: `${hpPercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Abilities */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Zap className="h-5 w-5" />
|
||||
Attribute
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3">
|
||||
{character.abilities.map((ability) => (
|
||||
<div
|
||||
key={ability.ability}
|
||||
className="text-center p-3 rounded-lg bg-bg-secondary"
|
||||
>
|
||||
<p className="text-xs text-text-secondary mb-1">
|
||||
{ABILITY_NAMES[ability.ability]}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-text-primary">
|
||||
{getAbilityModifier(ability.score)}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">{ability.score}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Conditions */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
Zustände ({character.conditions.length})
|
||||
</CardTitle>
|
||||
<Button size="sm" variant="outline">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{character.conditions.length === 0 ? (
|
||||
<p className="text-center text-text-secondary py-4">Keine aktiven Zustände</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{character.conditions.map((condition) => (
|
||||
<div
|
||||
key={condition.id}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-red-500/20 text-red-400"
|
||||
>
|
||||
<span className="text-sm font-medium">
|
||||
{condition.nameGerman || condition.name}
|
||||
{condition.value && ` ${condition.value}`}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleRemoveCondition(condition.id)}
|
||||
className="hover:text-red-300"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Skills */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BookOpen className="h-5 w-5" />
|
||||
Fertigkeiten ({character.skills.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-1 sm:grid-cols-2">
|
||||
{character.skills.map((skill) => (
|
||||
<div
|
||||
key={skill.skillName}
|
||||
className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-bg-secondary"
|
||||
>
|
||||
<span className="text-text-primary">{skill.skillName}</span>
|
||||
<span className={`text-sm font-medium ${PROFICIENCY_COLORS[skill.proficiency]}`}>
|
||||
{PROFICIENCY_NAMES[skill.proficiency]}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderInventoryTab = () => (
|
||||
<div className="space-y-6">
|
||||
{/* Equipped Items */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Ausrüstung
|
||||
</CardTitle>
|
||||
<Button size="sm" variant="outline">
|
||||
<Plus className="h-4 w-4" />
|
||||
Hinzufügen
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{character.items.filter(i => i.equipped).length === 0 ? (
|
||||
<p className="text-center text-text-secondary py-4">Keine ausgerüsteten Gegenstände</p>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{character.items.filter(i => i.equipped).map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="p-3 rounded-lg bg-bg-secondary border border-primary-500/50"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-text-primary">
|
||||
{item.nameGerman || item.name}
|
||||
</span>
|
||||
<span className="text-xs bg-primary-500/20 text-primary-500 px-2 py-0.5 rounded">
|
||||
Ausgerüstet
|
||||
</span>
|
||||
</div>
|
||||
{item.notes && (
|
||||
<p className="text-xs text-text-secondary mt-1">{item.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* All Items */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Package className="h-5 w-5" />
|
||||
Inventar ({character.items.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{character.items.length === 0 ? (
|
||||
<p className="text-center text-text-secondary py-4">Keine Gegenstände</p>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{character.items.filter(i => !i.equipped).map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="p-3 rounded-lg bg-bg-secondary"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-text-primary">
|
||||
{item.nameGerman || item.name}
|
||||
{item.quantity > 1 && ` (×${item.quantity})`}
|
||||
</span>
|
||||
</div>
|
||||
{item.notes && (
|
||||
<p className="text-xs text-text-secondary mt-1">{item.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderFeatsTab = () => (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Star className="h-5 w-5" />
|
||||
Talente ({character.feats.length})
|
||||
</CardTitle>
|
||||
<Button size="sm" variant="outline">
|
||||
<Plus className="h-4 w-4" />
|
||||
Hinzufügen
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{character.feats.length === 0 ? (
|
||||
<p className="text-center text-text-secondary py-4">Keine Talente</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{character.feats.map((feat) => (
|
||||
<div
|
||||
key={feat.id}
|
||||
className="p-3 rounded-lg bg-bg-secondary hover:bg-bg-tertiary transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-medium text-text-primary">
|
||||
{feat.nameGerman || feat.name}
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-text-secondary">
|
||||
Level {feat.level}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-bg-tertiary text-text-secondary">
|
||||
{feat.source}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSpellsTab = () => (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5" />
|
||||
Zauber ({character.spells.length})
|
||||
</CardTitle>
|
||||
<Button size="sm" variant="outline">
|
||||
<Plus className="h-4 w-4" />
|
||||
Hinzufügen
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{character.spells.length === 0 ? (
|
||||
<p className="text-center text-text-secondary py-4">Keine Zauber</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Group by spell level */}
|
||||
{[...new Set(character.spells.map(s => s.spellLevel))].sort().map(level => (
|
||||
<div key={level}>
|
||||
<h4 className="text-sm font-medium text-text-secondary mb-2">
|
||||
{level === 0 ? 'Zaubertricks' : `Grad ${level}`}
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{character.spells.filter(s => s.spellLevel === level).map((spell) => (
|
||||
<div
|
||||
key={spell.id}
|
||||
className="flex items-center justify-between p-2 rounded-lg bg-bg-secondary hover:bg-bg-tertiary"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${spell.prepared ? 'bg-green-500' : 'bg-text-secondary'}`} />
|
||||
<span className="text-text-primary">
|
||||
{spell.nameGerman || spell.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-text-secondary">
|
||||
{spell.tradition}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderAlchemyTab = () => (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FlaskConical className="h-5 w-5" />
|
||||
Alchemie-Ressourcen
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{character.resources.filter(r => r.name.toLowerCase().includes('vial') || r.name.toLowerCase().includes('alchemy')).length === 0 ? (
|
||||
<p className="text-center text-text-secondary py-4">
|
||||
Keine Alchemie-Ressourcen verfügbar
|
||||
</p>
|
||||
) : (
|
||||
character.resources.filter(r => r.name.toLowerCase().includes('vial') || r.name.toLowerCase().includes('alchemy')).map((resource) => (
|
||||
<div key={resource.id} className="flex items-center justify-between p-3 rounded-lg bg-bg-secondary">
|
||||
<span className="font-medium text-text-primary">{resource.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="icon" variant="ghost" className="h-8 w-8">
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="font-bold text-text-primary min-w-[60px] text-center">
|
||||
{resource.current} / {resource.max}
|
||||
</span>
|
||||
<Button size="icon" variant="ghost" className="h-8 w-8">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Alchemical Items from Inventory */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Alchemistische Gegenstände</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{character.items.filter(i => i.name.toLowerCase().includes('bomb') || i.name.toLowerCase().includes('elixir') || i.name.toLowerCase().includes('mutagen')).length === 0 ? (
|
||||
<p className="text-center text-text-secondary py-4">
|
||||
Keine alchemistischen Gegenstände
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{character.items.filter(i => i.name.toLowerCase().includes('bomb') || i.name.toLowerCase().includes('elixir') || i.name.toLowerCase().includes('mutagen')).map((item) => (
|
||||
<div key={item.id} className="p-3 rounded-lg bg-bg-secondary">
|
||||
<span className="font-medium text-text-primary">
|
||||
{item.nameGerman || item.name}
|
||||
{item.quantity > 1 && ` (×${item.quantity})`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderActionsTab = () => {
|
||||
const basicActions = [
|
||||
{ name: 'Angriff', actions: 1, type: 'Aktion' },
|
||||
{ name: 'Schritt', actions: 1, type: 'Aktion' },
|
||||
{ name: 'Bewegung', actions: 1, type: 'Aktion' },
|
||||
{ name: 'Interagieren', actions: 1, type: 'Aktion' },
|
||||
{ name: 'Schild heben', actions: 1, type: 'Aktion' },
|
||||
{ name: 'Aufstehen', actions: 1, type: 'Aktion' },
|
||||
{ name: 'Springen', actions: 1, type: 'Aktion' },
|
||||
{ name: 'Fallenlassen', actions: 0, type: 'Freie Aktion' },
|
||||
{ name: 'Verzögern', actions: 0, type: 'Freie Aktion' },
|
||||
{ name: 'Gelegenheitsangriff', actions: -1, type: 'Reaktion' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Swords className="h-5 w-5" />
|
||||
Grundaktionen
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{basicActions.map((action) => (
|
||||
<div
|
||||
key={action.name}
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-bg-secondary hover:bg-bg-tertiary cursor-pointer"
|
||||
>
|
||||
<span className="font-medium text-text-primary">{action.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{action.actions === -1 ? (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-yellow-500/20 text-yellow-500">
|
||||
Reaktion
|
||||
</span>
|
||||
) : action.actions === 0 ? (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-green-500/20 text-green-500">
|
||||
Frei
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex gap-0.5">
|
||||
{[...Array(action.actions)].map((_, i) => (
|
||||
<div key={i} className="w-4 h-4 rounded-full bg-primary-500" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Character-specific actions from feats */}
|
||||
{character.feats.filter(f => f.source === 'CLASS').length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Klassenaktionen</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{character.feats.filter(f => f.source === 'CLASS').map((feat) => (
|
||||
<div
|
||||
key={feat.id}
|
||||
className="p-3 rounded-lg bg-bg-secondary hover:bg-bg-tertiary cursor-pointer"
|
||||
>
|
||||
<span className="font-medium text-text-primary">
|
||||
{feat.nameGerman || feat.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'status':
|
||||
return renderStatusTab();
|
||||
case 'inventory':
|
||||
return renderInventoryTab();
|
||||
case 'feats':
|
||||
return renderFeatsTab();
|
||||
case 'spells':
|
||||
return renderSpellsTab();
|
||||
case 'alchemy':
|
||||
return renderAlchemyTab();
|
||||
case 'actions':
|
||||
return renderActionsTab();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(`/campaigns/${campaignId}`)}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-14 w-14 rounded-full bg-primary-500/20 flex items-center justify-center">
|
||||
{character.avatarUrl ? (
|
||||
<img
|
||||
src={character.avatarUrl}
|
||||
alt={character.name}
|
||||
className="h-14 w-14 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Shield className="h-7 w-7 text-primary-500" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-text-primary">{character.name}</h1>
|
||||
<p className="text-sm text-text-secondary">
|
||||
Level {character.level} {character.type}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isOwner && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={handleDelete}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex gap-1 overflow-x-auto pb-2 -mx-4 px-4 sm:mx-0 sm:px-0">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-bg-secondary text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="min-h-[400px]">
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { Button, Input, Spinner } from '@/shared/components/ui';
|
||||
import { api } from '@/shared/lib/api';
|
||||
|
||||
interface CreateCharacterModalProps {
|
||||
campaignId: string;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
export function CreateCharacterModal({ campaignId, onClose, onCreated }: CreateCharacterModalProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [type, setType] = useState<'PC' | 'NPC'>('PC');
|
||||
const [level, setLevel] = useState(1);
|
||||
const [hpMax, setHpMax] = useState(20);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) {
|
||||
setError('Name ist erforderlich');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await api.createCharacter(campaignId, {
|
||||
name: name.trim(),
|
||||
type,
|
||||
level,
|
||||
hpCurrent: hpMax,
|
||||
hpMax,
|
||||
});
|
||||
onCreated();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError('Fehler beim Erstellen des Charakters');
|
||||
console.error('Failed to create character:', err);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative bg-bg-primary border border-border rounded-xl p-6 w-full max-w-md mx-4 shadow-xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Neuer Charakter</h2>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">
|
||||
Name *
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Name des Charakters"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">
|
||||
Typ
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={type === 'PC' ? 'default' : 'outline'}
|
||||
onClick={() => setType('PC')}
|
||||
className="flex-1"
|
||||
>
|
||||
Spielercharakter (PC)
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={type === 'NPC' ? 'default' : 'outline'}
|
||||
onClick={() => setType('NPC')}
|
||||
className="flex-1"
|
||||
>
|
||||
NPC
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">
|
||||
Level
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={level}
|
||||
onChange={(e) => setLevel(parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">
|
||||
Max HP
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={hpMax}
|
||||
onChange={(e) => setHpMax(parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? <Spinner size="sm" /> : 'Erstellen'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { X, Upload, FileJson, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
import { Button, Spinner } from '@/shared/components/ui';
|
||||
import { api } from '@/shared/lib/api';
|
||||
|
||||
interface ImportCharacterModalProps {
|
||||
campaignId: string;
|
||||
onClose: () => void;
|
||||
onImported: () => void;
|
||||
}
|
||||
|
||||
interface PathbuilderJson {
|
||||
success: boolean;
|
||||
build: {
|
||||
name: string;
|
||||
class: string;
|
||||
level: number;
|
||||
ancestry: string;
|
||||
heritage: string;
|
||||
background: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function ImportCharacterModal({ campaignId, onClose, onImported }: ImportCharacterModalProps) {
|
||||
const [jsonText, setJsonText] = useState('');
|
||||
const [parsedData, setParsedData] = useState<PathbuilderJson | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [importSuccess, setImportSuccess] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const validateAndParseJson = (text: string) => {
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
|
||||
// Validate Pathbuilder structure
|
||||
if (!data.build) {
|
||||
throw new Error('Kein "build" Objekt gefunden. Ist das eine Pathbuilder Export-Datei?');
|
||||
}
|
||||
|
||||
if (!data.build.name || !data.build.class || !data.build.level) {
|
||||
throw new Error('Fehlende Pflichtfelder (name, class, level)');
|
||||
}
|
||||
|
||||
setParsedData(data);
|
||||
setError('');
|
||||
return data;
|
||||
} catch (err) {
|
||||
if (err instanceof SyntaxError) {
|
||||
setError('Ungültiges JSON Format');
|
||||
} else if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
}
|
||||
setParsedData(null);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextChange = (text: string) => {
|
||||
setJsonText(text);
|
||||
if (text.trim()) {
|
||||
validateAndParseJson(text);
|
||||
} else {
|
||||
setParsedData(null);
|
||||
setError('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (file.size > 1024 * 1024) {
|
||||
setError('Datei ist zu groß (max. 1MB)');
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const text = event.target?.result as string;
|
||||
setJsonText(text);
|
||||
validateAndParseJson(text);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
setError('Fehler beim Lesen der Datei');
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!parsedData) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await api.importCharacterFromPathbuilder(campaignId, parsedData);
|
||||
setImportSuccess(true);
|
||||
setTimeout(() => {
|
||||
onImported();
|
||||
onClose();
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
console.error('Import failed:', err);
|
||||
setError('Import fehlgeschlagen. Bitte versuche es erneut.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative bg-bg-primary border border-border rounded-xl p-6 w-full max-w-2xl mx-4 shadow-xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold text-text-primary flex items-center gap-2">
|
||||
<FileJson className="h-5 w-5 text-primary-500" />
|
||||
Pathbuilder Import
|
||||
</h2>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{importSuccess ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<CheckCircle className="h-16 w-16 text-green-500 mb-4" />
|
||||
<h3 className="text-xl font-semibold text-text-primary mb-2">
|
||||
Import erfolgreich!
|
||||
</h3>
|
||||
<p className="text-text-secondary">
|
||||
{parsedData?.build.name} wurde importiert.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* File Upload */}
|
||||
<div className="mb-4">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
JSON-Datei hochladen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-4">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-border" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-bg-primary text-text-secondary">oder</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* JSON Text Area */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">
|
||||
JSON einfügen
|
||||
</label>
|
||||
<textarea
|
||||
value={jsonText}
|
||||
onChange={(e) => handleTextChange(e.target.value)}
|
||||
placeholder='{"success": true, "build": {...}}'
|
||||
rows={8}
|
||||
className="w-full px-3 py-2 bg-bg-secondary border border-border rounded-lg text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-500 text-sm flex items-start gap-2">
|
||||
<AlertCircle className="h-5 w-5 flex-shrink-0 mt-0.5" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{parsedData && (
|
||||
<div className="mb-6 p-4 rounded-lg bg-bg-secondary border border-border">
|
||||
<h3 className="text-sm font-medium text-text-primary mb-3">Vorschau</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<span className="text-text-secondary">Name:</span>{' '}
|
||||
<span className="text-text-primary font-medium">{parsedData.build.name}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-secondary">Level:</span>{' '}
|
||||
<span className="text-text-primary font-medium">{parsedData.build.level}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-secondary">Klasse:</span>{' '}
|
||||
<span className="text-text-primary font-medium">{parsedData.build.class}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-secondary">Abstammung:</span>{' '}
|
||||
<span className="text-text-primary font-medium">{parsedData.build.ancestry}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-secondary">Erbe:</span>{' '}
|
||||
<span className="text-text-primary font-medium">{parsedData.build.heritage}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-secondary">Hintergrund:</span>{' '}
|
||||
<span className="text-text-primary font-medium">{parsedData.build.background}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={!parsedData || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Spinner size="sm" />
|
||||
<span className="ml-2">Importiere...</span>
|
||||
</>
|
||||
) : (
|
||||
'Importieren'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
client/src/features/characters/index.ts
Normal file
3
client/src/features/characters/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { CreateCharacterModal } from './components/create-character-modal';
|
||||
export { ImportCharacterModal } from './components/import-character-modal';
|
||||
export { CharacterSheetPage } from './components/character-sheet-page';
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Outlet, Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/features/auth';
|
||||
import { Button } from './ui';
|
||||
import LogoIcon from '../../../Logos/Logo_ohne_Text.svg';
|
||||
import ZeasyLogo from '../../../Logos/Zeasy/logo_text_horizontal.svg';
|
||||
|
||||
export function Layout() {
|
||||
const navigate = useNavigate();
|
||||
@@ -18,12 +20,17 @@ export function Layout() {
|
||||
<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
|
||||
<Link to="/" className="flex items-center gap-3">
|
||||
<img
|
||||
src={LogoIcon}
|
||||
alt="Dimension 47"
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
<span
|
||||
className="text-xl font-semibold text-text-primary tracking-wide hidden sm:block"
|
||||
style={{ fontFamily: 'Cinzel, serif' }}
|
||||
>
|
||||
Dimension 47
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
@@ -73,7 +80,17 @@ export function Layout() {
|
||||
<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>
|
||||
<a
|
||||
href="https://zeasy.software/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img
|
||||
src={ZeasyLogo}
|
||||
alt="Zeasy Software"
|
||||
className="h-5 opacity-70 hover:opacity-100 transition-opacity"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -30,7 +30,11 @@ class ApiClient {
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError<ApiError>) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Don't redirect on 401 for auth endpoints (login, register)
|
||||
const isAuthEndpoint = error.config?.url?.startsWith('/auth/login') ||
|
||||
error.config?.url?.startsWith('/auth/register');
|
||||
|
||||
if (error.response?.status === 401 && !isAuthEndpoint) {
|
||||
this.clearToken();
|
||||
window.location.href = '/login';
|
||||
}
|
||||
@@ -115,14 +119,80 @@ class ApiClient {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Character endpoints (placeholder - to be expanded)
|
||||
// Character endpoints
|
||||
async getCharacters(campaignId: string) {
|
||||
const response = await this.client.get(`/characters/${campaignId}`);
|
||||
const response = await this.client.get(`/campaigns/${campaignId}/characters`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getCharacter(campaignId: string, characterId: string) {
|
||||
const response = await this.client.get(`/characters/${campaignId}/${characterId}`);
|
||||
const response = await this.client.get(`/campaigns/${campaignId}/characters/${characterId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async createCharacter(campaignId: string, data: {
|
||||
name: string;
|
||||
type?: 'PC' | 'NPC';
|
||||
level?: number;
|
||||
avatarUrl?: string;
|
||||
hpCurrent: number;
|
||||
hpMax: number;
|
||||
hpTemp?: number;
|
||||
ancestryId?: string;
|
||||
heritageId?: string;
|
||||
classId?: string;
|
||||
backgroundId?: string;
|
||||
experiencePoints?: number;
|
||||
pathbuilderData?: unknown;
|
||||
}) {
|
||||
const response = await this.client.post(`/campaigns/${campaignId}/characters`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async importCharacterFromPathbuilder(campaignId: string, pathbuilderJson: unknown) {
|
||||
const response = await this.client.post(`/campaigns/${campaignId}/characters/import`, {
|
||||
pathbuilderJson,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateCharacter(campaignId: string, characterId: string, data: Partial<{
|
||||
name: string;
|
||||
type: 'PC' | 'NPC';
|
||||
level: number;
|
||||
avatarUrl: string;
|
||||
hpCurrent: number;
|
||||
hpMax: number;
|
||||
hpTemp: number;
|
||||
}>) {
|
||||
const response = await this.client.put(`/campaigns/${campaignId}/characters/${characterId}`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deleteCharacter(campaignId: string, characterId: string) {
|
||||
const response = await this.client.delete(`/campaigns/${campaignId}/characters/${characterId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateCharacterHp(campaignId: string, characterId: string, hpCurrent: number, hpTemp?: number) {
|
||||
const response = await this.client.patch(`/campaigns/${campaignId}/characters/${characterId}/hp`, { hpCurrent, hpTemp });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Conditions
|
||||
async addCharacterCondition(campaignId: string, characterId: string, data: {
|
||||
name: string;
|
||||
nameGerman?: string;
|
||||
value?: number;
|
||||
duration?: string;
|
||||
source?: string;
|
||||
}) {
|
||||
const response = await this.client.post(`/campaigns/${campaignId}/characters/${characterId}/conditions`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async removeCharacterCondition(campaignId: string, characterId: string, conditionId: string) {
|
||||
const response = await this.client.delete(`/campaigns/${campaignId}/characters/${characterId}/conditions/${conditionId}`);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
268
server/package-lock.json
generated
268
server/package-lock.json
generated
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "0.0.1",
|
||||
"name": "dimension47-server",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "server",
|
||||
"version": "0.0.1",
|
||||
"name": "dimension47-server",
|
||||
"version": "1.0.0",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.71.2",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
@@ -18,6 +19,7 @@
|
||||
"@nestjs/platform-socket.io": "^11.1.12",
|
||||
"@nestjs/swagger": "^11.2.5",
|
||||
"@nestjs/websockets": "^11.1.12",
|
||||
"@prisma/adapter-pg": "^7.2.0",
|
||||
"@prisma/client": "^7.2.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
@@ -205,6 +207,26 @@
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.71.2.tgz",
|
||||
"integrity": "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"anthropic-ai-sdk": "bin/cli"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
|
||||
@@ -236,7 +258,6 @@
|
||||
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/generator": "^7.28.6",
|
||||
@@ -667,6 +688,15 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
||||
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
@@ -809,8 +839,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.2.tgz",
|
||||
"integrity": "sha512-zfWWa+V2ViDCY/cmUfRqeWY1yLto+EpxjXnZzenB1TyxsTiXaTWeZFIZw6mac52BsuQm0RjCnisjBtdBaXOI6w==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@electric-sql/pglite-socket": {
|
||||
"version": "0.0.6",
|
||||
@@ -2250,7 +2279,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.12.tgz",
|
||||
"integrity": "sha512-v6U3O01YohHO+IE3EIFXuRuu3VJILWzyMmSYZXpyBbnp0hk0mFyHxK2w3dF4I5WnbwiRbWlEXdeXFvPQ7qaZzw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"file-type": "21.3.0",
|
||||
"iterare": "1.2.1",
|
||||
@@ -2310,7 +2338,6 @@
|
||||
"integrity": "sha512-97DzTYMf5RtGAVvX1cjwpKRiCUpkeQ9CCzSAenqkAhOmNVVFaApbhuw+xrDt13rsCa2hHVOYPrV4dBgOYMJjsA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@nuxt/opencollective": "0.4.1",
|
||||
"fast-safe-stringify": "2.1.1",
|
||||
@@ -2394,7 +2421,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.12.tgz",
|
||||
"integrity": "sha512-GYK/vHI0SGz5m8mxr7v3Urx8b9t78Cf/dj5aJMZlGd9/1D9OI1hAl00BaphjEXINUJ/BQLxIlF2zUjrYsd6enQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cors": "2.8.5",
|
||||
"express": "5.2.1",
|
||||
@@ -2416,7 +2442,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.12.tgz",
|
||||
"integrity": "sha512-1itTTYsAZecrq2NbJOkch32y8buLwN7UpcNRdJrhlS+ovJ5GxLx3RyJ3KylwBhbYnO5AeYyL1U/i4W5mg/4qDA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"socket.io": "4.8.3",
|
||||
"tslib": "2.8.1"
|
||||
@@ -2595,7 +2620,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.12.tgz",
|
||||
"integrity": "sha512-ulSOYcgosx1TqY425cRC5oXtAu1R10+OSmVfgyR9ueR25k4luekURt8dzAZxhxSCI0OsDj9WKCFLTkEuAwg0wg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"iterare": "1.2.1",
|
||||
"object-hash": "3.0.0",
|
||||
@@ -2677,6 +2701,17 @@
|
||||
"url": "https://opencollective.com/pkgr"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/adapter-pg": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.2.0.tgz",
|
||||
"integrity": "sha512-euIdQ13cRB2wZ3jPsnDnFhINquo1PYFPCg6yVL8b2rp3EdinQHsX9EDdCtRr489D5uhphcRk463OdQAFlsCr0w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/driver-adapter-utils": "7.2.0",
|
||||
"pg": "^8.16.3",
|
||||
"postgres-array": "3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/client": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.2.0.tgz",
|
||||
@@ -2724,7 +2759,6 @@
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz",
|
||||
"integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/dev": {
|
||||
@@ -2753,6 +2787,15 @@
|
||||
"zeptomatch": "2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/driver-adapter-utils": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.2.0.tgz",
|
||||
"integrity": "sha512-gzrUcbI9VmHS24Uf+0+7DNzdIw7keglJsD5m/MHxQOU68OhGVzlphQRobLiDMn8CHNA2XN8uugwKjudVtnfMVQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "7.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/engines": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.2.0.tgz",
|
||||
@@ -3049,7 +3092,6 @@
|
||||
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "*",
|
||||
"@types/json-schema": "*"
|
||||
@@ -3188,7 +3230,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
|
||||
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -3370,7 +3411,6 @@
|
||||
"integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.53.0",
|
||||
"@typescript-eslint/types": "8.53.0",
|
||||
@@ -4052,7 +4092,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -4102,7 +4141,6 @@
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -4545,7 +4583,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -4807,7 +4844,6 @@
|
||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
@@ -4865,15 +4901,13 @@
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
|
||||
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/class-validator": {
|
||||
"version": "0.14.3",
|
||||
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz",
|
||||
"integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/validator": "^13.15.3",
|
||||
"libphonenumber-js": "^1.11.1",
|
||||
@@ -5227,7 +5261,8 @@
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
@@ -5691,7 +5726,6 @@
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -5752,7 +5786,6 @@
|
||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
@@ -5985,7 +6018,6 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -6723,7 +6755,6 @@
|
||||
"integrity": "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@@ -7110,7 +7141,6 @@
|
||||
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jest/core": "30.2.0",
|
||||
"@jest/types": "30.2.0",
|
||||
@@ -7904,6 +7934,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-to-ts": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"ts-algebra": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
@@ -8896,7 +8939,6 @@
|
||||
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
|
||||
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"passport-strategy": "1.x.x",
|
||||
"pause": "0.0.1",
|
||||
@@ -9024,6 +9066,104 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.17.1.tgz",
|
||||
"integrity": "sha512-EIR+jXdYNSMOrpRp7g6WgQr7SaZNZfS7IzZIO0oTNEeibq956JxeD15t3Jk3zZH0KH8DmOIx38qJfQenoE8bXQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.10.0",
|
||||
"pg-pool": "^3.11.0",
|
||||
"pg-protocol": "^1.11.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
|
||||
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.10.0.tgz",
|
||||
"integrity": "sha512-ur/eoPKzDx2IjPaYyXS6Y8NSblxM7X64deV2ObV57vhjsWiwLvUD6meukAzogiOsu60GO8m/3Cb6FdJsWNjwXg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.11.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz",
|
||||
"integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz",
|
||||
"integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-types/node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -9159,6 +9299,45 @@
|
||||
"url": "https://github.com/sponsors/porsager"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz",
|
||||
"integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -9175,7 +9354,6 @@
|
||||
"integrity": "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -9234,7 +9412,6 @@
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@prisma/config": "7.2.0",
|
||||
"@prisma/dev": "0.17.0",
|
||||
@@ -9445,8 +9622,7 @@
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/regexp-to-ast": {
|
||||
"version": "0.5.0",
|
||||
@@ -9583,7 +9759,6 @@
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
@@ -9619,7 +9794,8 @@
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/schema-utils": {
|
||||
"version": "3.3.0",
|
||||
@@ -9952,6 +10128,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
@@ -10319,7 +10504,6 @@
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -10551,6 +10735,12 @@
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-algebra": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
|
||||
@@ -10657,7 +10847,6 @@
|
||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
"@tsconfig/node10": "^1.0.7",
|
||||
@@ -10805,7 +10994,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -11087,7 +11275,6 @@
|
||||
"integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/eslint-scope": "^3.7.7",
|
||||
"@types/estree": "^1.0.8",
|
||||
@@ -11157,7 +11344,6 @@
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.71.2",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
@@ -32,6 +33,7 @@
|
||||
"@nestjs/platform-socket.io": "^11.1.12",
|
||||
"@nestjs/swagger": "^11.2.5",
|
||||
"@nestjs/websockets": "^11.1.12",
|
||||
"@prisma/adapter-pg": "^7.2.0",
|
||||
"@prisma/client": "^7.2.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
|
||||
543
server/prisma/migrations/20260118162916_init/migration.sql
Normal file
543
server/prisma/migrations/20260118162916_init/migration.sql
Normal file
@@ -0,0 +1,543 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'GM', 'PLAYER');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CharacterType" AS ENUM ('PC', 'NPC');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AbilityType" AS ENUM ('STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Proficiency" AS ENUM ('UNTRAINED', 'TRAINED', 'EXPERT', 'MASTER', 'LEGENDARY');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "FeatSource" AS ENUM ('CLASS', 'ANCESTRY', 'GENERAL', 'SKILL', 'BONUS', 'ARCHETYPE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SpellTradition" AS ENUM ('ARCANE', 'DIVINE', 'OCCULT', 'PRIMAL');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CombatantType" AS ENUM ('PC', 'NPC', 'MONSTER');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ActionType" AS ENUM ('ACTION', 'REACTION', 'FREE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "HighlightColor" AS ENUM ('YELLOW', 'GREEN', 'BLUE', 'PINK');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TranslationType" AS ENUM ('FEAT', 'EQUIPMENT', 'SPELL', 'TRAIT', 'ANCESTRY', 'HERITAGE', 'CLASS', 'BACKGROUND', 'CONDITION', 'ACTION');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TranslationQuality" AS ENUM ('HIGH', 'MEDIUM', 'LOW');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"username" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"role" "UserRole" NOT NULL DEFAULT 'PLAYER',
|
||||
"avatarUrl" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Campaign" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"gmId" TEXT NOT NULL,
|
||||
"imageUrl" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Campaign_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CampaignMember" (
|
||||
"campaignId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "CampaignMember_pkey" PRIMARY KEY ("campaignId","userId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Character" (
|
||||
"id" TEXT NOT NULL,
|
||||
"campaignId" TEXT NOT NULL,
|
||||
"ownerId" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"type" "CharacterType" NOT NULL DEFAULT 'PC',
|
||||
"level" INTEGER NOT NULL DEFAULT 1,
|
||||
"avatarUrl" TEXT,
|
||||
"hpCurrent" INTEGER NOT NULL,
|
||||
"hpMax" INTEGER NOT NULL,
|
||||
"hpTemp" INTEGER NOT NULL DEFAULT 0,
|
||||
"ancestryId" TEXT,
|
||||
"heritageId" TEXT,
|
||||
"classId" TEXT,
|
||||
"backgroundId" TEXT,
|
||||
"experiencePoints" INTEGER NOT NULL DEFAULT 0,
|
||||
"pathbuilderData" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Character_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CharacterAbility" (
|
||||
"id" TEXT NOT NULL,
|
||||
"characterId" TEXT NOT NULL,
|
||||
"ability" "AbilityType" NOT NULL,
|
||||
"score" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "CharacterAbility_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CharacterFeat" (
|
||||
"id" TEXT NOT NULL,
|
||||
"characterId" TEXT NOT NULL,
|
||||
"featId" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"nameGerman" TEXT,
|
||||
"level" INTEGER NOT NULL,
|
||||
"source" "FeatSource" NOT NULL,
|
||||
|
||||
CONSTRAINT "CharacterFeat_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CharacterSkill" (
|
||||
"id" TEXT NOT NULL,
|
||||
"characterId" TEXT NOT NULL,
|
||||
"skillName" TEXT NOT NULL,
|
||||
"proficiency" "Proficiency" NOT NULL DEFAULT 'UNTRAINED',
|
||||
|
||||
CONSTRAINT "CharacterSkill_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CharacterSpell" (
|
||||
"id" TEXT NOT NULL,
|
||||
"characterId" TEXT NOT NULL,
|
||||
"spellId" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"nameGerman" TEXT,
|
||||
"tradition" "SpellTradition" NOT NULL,
|
||||
"spellLevel" INTEGER NOT NULL,
|
||||
"prepared" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "CharacterSpell_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CharacterItem" (
|
||||
"id" TEXT NOT NULL,
|
||||
"characterId" TEXT NOT NULL,
|
||||
"equipmentId" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"nameGerman" TEXT,
|
||||
"quantity" INTEGER NOT NULL DEFAULT 1,
|
||||
"bulk" DECIMAL(65,30) NOT NULL DEFAULT 0,
|
||||
"equipped" BOOLEAN NOT NULL DEFAULT false,
|
||||
"invested" BOOLEAN NOT NULL DEFAULT false,
|
||||
"containerId" TEXT,
|
||||
"notes" TEXT,
|
||||
|
||||
CONSTRAINT "CharacterItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CharacterCondition" (
|
||||
"id" TEXT NOT NULL,
|
||||
"characterId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"nameGerman" TEXT,
|
||||
"value" INTEGER,
|
||||
"duration" TEXT,
|
||||
"source" TEXT,
|
||||
|
||||
CONSTRAINT "CharacterCondition_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CharacterResource" (
|
||||
"id" TEXT NOT NULL,
|
||||
"characterId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"current" INTEGER NOT NULL,
|
||||
"max" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "CharacterResource_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "BattleMap" (
|
||||
"id" TEXT NOT NULL,
|
||||
"campaignId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"imageUrl" TEXT NOT NULL,
|
||||
"gridSizeX" INTEGER NOT NULL DEFAULT 20,
|
||||
"gridSizeY" INTEGER NOT NULL DEFAULT 20,
|
||||
"gridOffsetX" INTEGER NOT NULL DEFAULT 0,
|
||||
"gridOffsetY" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "BattleMap_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Combatant" (
|
||||
"id" TEXT NOT NULL,
|
||||
"campaignId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"type" "CombatantType" NOT NULL,
|
||||
"level" INTEGER NOT NULL,
|
||||
"hpMax" INTEGER NOT NULL,
|
||||
"ac" INTEGER NOT NULL,
|
||||
"fortitude" INTEGER NOT NULL,
|
||||
"reflex" INTEGER NOT NULL,
|
||||
"will" INTEGER NOT NULL,
|
||||
"perception" INTEGER NOT NULL,
|
||||
"speed" INTEGER NOT NULL DEFAULT 25,
|
||||
"avatarUrl" TEXT,
|
||||
"description" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Combatant_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CombatantAbility" (
|
||||
"id" TEXT NOT NULL,
|
||||
"combatantId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"actionCost" INTEGER NOT NULL,
|
||||
"actionType" "ActionType" NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"damage" TEXT,
|
||||
"traits" TEXT[],
|
||||
|
||||
CONSTRAINT "CombatantAbility_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "BattleSession" (
|
||||
"id" TEXT NOT NULL,
|
||||
"campaignId" TEXT NOT NULL,
|
||||
"mapId" TEXT,
|
||||
"name" TEXT,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT false,
|
||||
"roundNumber" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "BattleSession_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "BattleToken" (
|
||||
"id" TEXT NOT NULL,
|
||||
"battleSessionId" TEXT NOT NULL,
|
||||
"combatantId" TEXT,
|
||||
"characterId" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"positionX" DOUBLE PRECISION NOT NULL,
|
||||
"positionY" DOUBLE PRECISION NOT NULL,
|
||||
"hpCurrent" INTEGER NOT NULL,
|
||||
"hpMax" INTEGER NOT NULL,
|
||||
"initiative" INTEGER,
|
||||
"conditions" TEXT[],
|
||||
"size" INTEGER NOT NULL DEFAULT 1,
|
||||
|
||||
CONSTRAINT "BattleToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Document" (
|
||||
"id" TEXT NOT NULL,
|
||||
"campaignId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"category" TEXT,
|
||||
"tags" TEXT[],
|
||||
"filePath" TEXT NOT NULL,
|
||||
"fileType" TEXT NOT NULL,
|
||||
"uploadedBy" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Document_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "DocumentAccess" (
|
||||
"id" TEXT NOT NULL,
|
||||
"documentId" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"characterId" TEXT,
|
||||
|
||||
CONSTRAINT "DocumentAccess_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Highlight" (
|
||||
"id" TEXT NOT NULL,
|
||||
"documentId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"selectionText" TEXT NOT NULL,
|
||||
"startOffset" INTEGER NOT NULL,
|
||||
"endOffset" INTEGER NOT NULL,
|
||||
"color" "HighlightColor" NOT NULL,
|
||||
"note" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Highlight_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Note" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"campaignId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"isShared" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Note_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "NoteShare" (
|
||||
"noteId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "NoteShare_pkey" PRIMARY KEY ("noteId","userId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Feat" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"traits" TEXT[],
|
||||
"summary" TEXT,
|
||||
"actions" TEXT,
|
||||
"url" TEXT,
|
||||
"level" INTEGER,
|
||||
"sourceBook" TEXT,
|
||||
|
||||
CONSTRAINT "Feat_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Equipment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"traits" TEXT[],
|
||||
"itemCategory" TEXT NOT NULL,
|
||||
"itemSubcategory" TEXT,
|
||||
"bulk" TEXT,
|
||||
"url" TEXT,
|
||||
"summary" TEXT,
|
||||
"activation" TEXT,
|
||||
"hands" TEXT,
|
||||
"damage" TEXT,
|
||||
"range" TEXT,
|
||||
"weaponCategory" TEXT,
|
||||
"price" INTEGER,
|
||||
"level" INTEGER,
|
||||
|
||||
CONSTRAINT "Equipment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Spell" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"level" INTEGER NOT NULL,
|
||||
"actions" TEXT,
|
||||
"traditions" TEXT[],
|
||||
"traits" TEXT[],
|
||||
"range" TEXT,
|
||||
"targets" TEXT,
|
||||
"duration" TEXT,
|
||||
"description" TEXT,
|
||||
"url" TEXT,
|
||||
|
||||
CONSTRAINT "Spell_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Trait" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"url" TEXT,
|
||||
|
||||
CONSTRAINT "Trait_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Translation" (
|
||||
"id" TEXT NOT NULL,
|
||||
"type" "TranslationType" NOT NULL,
|
||||
"englishName" TEXT NOT NULL,
|
||||
"germanName" TEXT NOT NULL,
|
||||
"germanSummary" TEXT,
|
||||
"germanDescription" TEXT,
|
||||
"quality" "TranslationQuality" NOT NULL DEFAULT 'MEDIUM',
|
||||
"translatedBy" TEXT NOT NULL DEFAULT 'claude-api',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Translation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CharacterAbility_characterId_ability_key" ON "CharacterAbility"("characterId", "ability");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CharacterSkill_characterId_skillName_key" ON "CharacterSkill"("characterId", "skillName");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CharacterResource_characterId_name_key" ON "CharacterResource"("characterId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "DocumentAccess_documentId_userId_characterId_key" ON "DocumentAccess"("documentId", "userId", "characterId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Feat_name_key" ON "Feat"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Equipment_name_key" ON "Equipment"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Spell_name_key" ON "Spell"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Trait_name_key" ON "Trait"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Translation_type_idx" ON "Translation"("type");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Translation_englishName_idx" ON "Translation"("englishName");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Translation_type_englishName_key" ON "Translation"("type", "englishName");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Campaign" ADD CONSTRAINT "Campaign_gmId_fkey" FOREIGN KEY ("gmId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CampaignMember" ADD CONSTRAINT "CampaignMember_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "Campaign"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CampaignMember" ADD CONSTRAINT "CampaignMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Character" ADD CONSTRAINT "Character_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "Campaign"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Character" ADD CONSTRAINT "Character_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CharacterAbility" ADD CONSTRAINT "CharacterAbility_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CharacterFeat" ADD CONSTRAINT "CharacterFeat_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CharacterFeat" ADD CONSTRAINT "CharacterFeat_featId_fkey" FOREIGN KEY ("featId") REFERENCES "Feat"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CharacterSkill" ADD CONSTRAINT "CharacterSkill_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CharacterSpell" ADD CONSTRAINT "CharacterSpell_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CharacterSpell" ADD CONSTRAINT "CharacterSpell_spellId_fkey" FOREIGN KEY ("spellId") REFERENCES "Spell"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CharacterItem" ADD CONSTRAINT "CharacterItem_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CharacterItem" ADD CONSTRAINT "CharacterItem_equipmentId_fkey" FOREIGN KEY ("equipmentId") REFERENCES "Equipment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CharacterCondition" ADD CONSTRAINT "CharacterCondition_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CharacterResource" ADD CONSTRAINT "CharacterResource_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BattleMap" ADD CONSTRAINT "BattleMap_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "Campaign"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Combatant" ADD CONSTRAINT "Combatant_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "Campaign"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CombatantAbility" ADD CONSTRAINT "CombatantAbility_combatantId_fkey" FOREIGN KEY ("combatantId") REFERENCES "Combatant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BattleSession" ADD CONSTRAINT "BattleSession_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "Campaign"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BattleSession" ADD CONSTRAINT "BattleSession_mapId_fkey" FOREIGN KEY ("mapId") REFERENCES "BattleMap"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BattleToken" ADD CONSTRAINT "BattleToken_battleSessionId_fkey" FOREIGN KEY ("battleSessionId") REFERENCES "BattleSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BattleToken" ADD CONSTRAINT "BattleToken_combatantId_fkey" FOREIGN KEY ("combatantId") REFERENCES "Combatant"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BattleToken" ADD CONSTRAINT "BattleToken_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Document" ADD CONSTRAINT "Document_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "Campaign"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Document" ADD CONSTRAINT "Document_uploadedBy_fkey" FOREIGN KEY ("uploadedBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DocumentAccess" ADD CONSTRAINT "DocumentAccess_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DocumentAccess" ADD CONSTRAINT "DocumentAccess_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DocumentAccess" ADD CONSTRAINT "DocumentAccess_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Highlight" ADD CONSTRAINT "Highlight_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Highlight" ADD CONSTRAINT "Highlight_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Note" ADD CONSTRAINT "Note_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Note" ADD CONSTRAINT "Note_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "Campaign"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "NoteShare" ADD CONSTRAINT "NoteShare_noteId_fkey" FOREIGN KEY ("noteId") REFERENCES "Note"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "NoteShare" ADD CONSTRAINT "NoteShare_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
3
server/prisma/migrations/migration_lock.toml
Normal file
3
server/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -2,8 +2,9 @@
|
||||
// Prisma Schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
output = "../src/generated/prisma"
|
||||
provider = "prisma-client"
|
||||
output = "../src/generated/prisma"
|
||||
moduleFormat = "cjs"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
|
||||
28
server/prisma/seed.ts
Normal file
28
server/prisma/seed.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import 'dotenv/config';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { PrismaClient } from '../src/generated/prisma/client.js';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
|
||||
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
async function main() {
|
||||
const passwordHash = await bcrypt.hash('admin123', 10);
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email: 'admin@dimension47.local' },
|
||||
update: {},
|
||||
create: {
|
||||
username: 'admin',
|
||||
email: 'admin@dimension47.local',
|
||||
passwordHash,
|
||||
role: 'ADMIN',
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Admin user created:', user.username, user.email);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(console.error)
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -4,10 +4,13 @@ import { APP_GUARD } from '@nestjs/core';
|
||||
|
||||
// Core Modules
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { ClaudeModule } from './modules/claude/claude.module';
|
||||
|
||||
// Feature Modules
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { CampaignsModule } from './modules/campaigns/campaigns.module';
|
||||
import { CharactersModule } from './modules/characters/characters.module';
|
||||
import { TranslationsModule } from './modules/translations/translations.module';
|
||||
|
||||
// Guards
|
||||
import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard';
|
||||
@@ -23,10 +26,13 @@ import { RolesGuard } from './modules/auth/guards/roles.guard';
|
||||
|
||||
// Core
|
||||
PrismaModule,
|
||||
ClaudeModule,
|
||||
|
||||
// Features
|
||||
AuthModule,
|
||||
CampaignsModule,
|
||||
CharactersModule,
|
||||
TranslationsModule,
|
||||
],
|
||||
providers: [
|
||||
// Global JWT Auth Guard
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { UserRole } from '../../generated/prisma';
|
||||
import { UserRole } from '../../generated/prisma/client.js';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
|
||||
|
||||
@@ -24,9 +24,25 @@ async function bootstrap() {
|
||||
);
|
||||
|
||||
// CORS
|
||||
const corsOrigins = configService.get<string>('CORS_ORIGINS', 'http://localhost:3000,http://localhost:5173');
|
||||
const nodeEnv = configService.get<string>('NODE_ENV', 'development');
|
||||
app.enableCors({
|
||||
origin: corsOrigins.split(','),
|
||||
origin: (origin, callback) => {
|
||||
// Allow requests with no origin (mobile apps, curl, etc.)
|
||||
if (!origin) return callback(null, true);
|
||||
|
||||
// In development, allow all localhost origins
|
||||
if (nodeEnv === 'development' && /^https?:\/\/localhost(:\d+)?$/.test(origin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
// Check against configured origins
|
||||
const corsOrigins = configService.get<string>('CORS_ORIGINS', 'http://localhost:3000,http://localhost:5173');
|
||||
if (corsOrigins.split(',').includes(origin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
callback(new Error('Not allowed by CORS'));
|
||||
},
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ 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';
|
||||
import { UserRole } from '../../generated/prisma/client.js';
|
||||
|
||||
@ApiTags('Auth')
|
||||
@Controller('auth')
|
||||
|
||||
@@ -8,7 +8,7 @@ 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';
|
||||
import { UserRole } from '../../generated/prisma/client.js';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { UserRole } from '../../../generated/prisma';
|
||||
import { UserRole } from '../../../generated/prisma/client.js';
|
||||
import { ROLES_KEY } from '../../../common/decorators/roles.decorator';
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { CampaignsService } from './campaigns.service';
|
||||
import { CreateCampaignDto, UpdateCampaignDto, AddMemberDto } from './dto';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { UserRole } from '../../generated/prisma';
|
||||
import { UserRole } from '../../generated/prisma/client.js';
|
||||
|
||||
@ApiTags('Campaigns')
|
||||
@ApiBearerAuth()
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { CreateCampaignDto, UpdateCampaignDto, AddMemberDto } from './dto';
|
||||
import { UserRole } from '../../generated/prisma';
|
||||
import { UserRole } from '../../generated/prisma/client.js';
|
||||
|
||||
@Injectable()
|
||||
export class CampaignsService {
|
||||
|
||||
292
server/src/modules/characters/characters.controller.ts
Normal file
292
server/src/modules/characters/characters.controller.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
} from '@nestjs/swagger';
|
||||
import { CharactersService } from './characters.service';
|
||||
import { PathbuilderImportService } from './pathbuilder-import.service';
|
||||
import {
|
||||
CreateCharacterDto,
|
||||
UpdateCharacterDto,
|
||||
CreateAbilityDto,
|
||||
CreateSkillDto,
|
||||
CreateFeatDto,
|
||||
CreateSpellDto,
|
||||
CreateItemDto,
|
||||
CreateConditionDto,
|
||||
CreateResourceDto,
|
||||
PathbuilderImportDto,
|
||||
} from './dto';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@ApiTags('Characters')
|
||||
@ApiBearerAuth()
|
||||
@Controller('campaigns/:campaignId/characters')
|
||||
export class CharactersController {
|
||||
constructor(
|
||||
private readonly charactersService: CharactersService,
|
||||
private readonly pathbuilderImportService: PathbuilderImportService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a new character' })
|
||||
@ApiResponse({ status: 201, description: 'Character created successfully' })
|
||||
async create(
|
||||
@Param('campaignId') campaignId: string,
|
||||
@Body() dto: CreateCharacterDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
) {
|
||||
return this.charactersService.create(campaignId, dto, userId);
|
||||
}
|
||||
|
||||
@Post('import')
|
||||
@ApiOperation({ summary: 'Import character from Pathbuilder JSON' })
|
||||
@ApiResponse({ status: 201, description: 'Character imported successfully' })
|
||||
async importFromPathbuilder(
|
||||
@Param('campaignId') campaignId: string,
|
||||
@Body() dto: PathbuilderImportDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
) {
|
||||
return this.pathbuilderImportService.importCharacter(
|
||||
campaignId,
|
||||
userId,
|
||||
dto.pathbuilderJson,
|
||||
);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get all characters in a campaign' })
|
||||
@ApiResponse({ status: 200, description: 'List of characters' })
|
||||
async findAll(
|
||||
@Param('campaignId') campaignId: string,
|
||||
@CurrentUser('id') userId: string,
|
||||
) {
|
||||
return this.charactersService.findAllByCampaign(campaignId, userId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get character by ID' })
|
||||
@ApiResponse({ status: 200, description: 'Character details' })
|
||||
@ApiResponse({ status: 404, description: 'Character not found' })
|
||||
async findOne(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser('id') userId: string,
|
||||
) {
|
||||
return this.charactersService.findOne(id, userId);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: 'Update character' })
|
||||
@ApiResponse({ status: 200, description: 'Character updated' })
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateCharacterDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
) {
|
||||
return this.charactersService.update(id, dto, userId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete character' })
|
||||
@ApiResponse({ status: 200, description: 'Character deleted' })
|
||||
async remove(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser('id') userId: string,
|
||||
) {
|
||||
return this.charactersService.remove(id, userId);
|
||||
}
|
||||
|
||||
// HP Management
|
||||
@Patch(':id/hp')
|
||||
@ApiOperation({ summary: 'Update character HP' })
|
||||
async updateHp(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { hpCurrent: number; hpTemp?: number },
|
||||
@CurrentUser('id') userId: string,
|
||||
) {
|
||||
return this.charactersService.updateHp(id, body.hpCurrent, body.hpTemp, userId);
|
||||
}
|
||||
|
||||
// Abilities
|
||||
@Put(':id/abilities')
|
||||
@ApiOperation({ summary: 'Set character abilities' })
|
||||
async setAbilities(
|
||||
@Param('id') id: string,
|
||||
@Body() abilities: CreateAbilityDto[],
|
||||
@CurrentUser('id') userId: string,
|
||||
) {
|
||||
return this.charactersService.setAbilities(id, abilities, userId);
|
||||
}
|
||||
|
||||
// Skills
|
||||
@Put(':id/skills')
|
||||
@ApiOperation({ summary: 'Set character skills' })
|
||||
async setSkills(
|
||||
@Param('id') id: string,
|
||||
@Body() skills: CreateSkillDto[],
|
||||
@CurrentUser('id') userId: string,
|
||||
) {
|
||||
return this.charactersService.setSkills(id, skills, userId);
|
||||
}
|
||||
|
||||
@Patch(':id/skills/:skillName')
|
||||
@ApiOperation({ summary: 'Update single skill proficiency' })
|
||||
async updateSkill(
|
||||
@Param('id') id: string,
|
||||
@Param('skillName') skillName: string,
|
||||
@Body() body: { proficiency: string },
|
||||
@CurrentUser('id') userId: string,
|
||||
) {
|
||||
return this.charactersService.updateSkill(id, skillName, body.proficiency, userId);
|
||||
}
|
||||
|
||||
// Feats
|
||||
@Post(':id/feats')
|
||||
@ApiOperation({ summary: 'Add feat to character' })
|
||||
async addFeat(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: CreateFeatDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
) {
|
||||
return this.charactersService.addFeat(id, dto, userId);
|
||||
}
|
||||
|
||||
@Delete(':id/feats/:featId')
|
||||
@ApiOperation({ summary: 'Remove feat from character' })
|
||||
async removeFeat(
|
||||
@Param('id') id: string,
|
||||
@Param('featId') featId: string,
|
||||
@CurrentUser('id') userId: string,
|
||||
) {
|
||||
return this.charactersService.removeFeat(id, featId, userId);
|
||||
}
|
||||
|
||||
// Spells
|
||||
@Post(':id/spells')
|
||||
@ApiOperation({ summary: 'Add spell to character' })
|
||||
async addSpell(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: CreateSpellDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
) {
|
||||
return this.charactersService.addSpell(id, dto, userId);
|
||||
}
|
||||
|
||||
@Patch(':id/spells/:spellId')
|
||||
@ApiOperation({ summary: 'Update spell (prepared status)' })
|
||||
async updateSpell(
|
||||
@Param('id') id: string,
|
||||
@Param('spellId') spellId: string,
|
||||
@Body() body: { prepared: boolean },
|
||||
@CurrentUser('id') userId: string,
|
||||
) {
|
||||
return this.charactersService.updateSpell(id, spellId, body.prepared, userId);
|
||||
}
|
||||
|
||||
@Delete(':id/spells/:spellId')
|
||||
@ApiOperation({ summary: 'Remove spell from character' })
|
||||
async removeSpell(
|
||||
@Param('id') id: string,
|
||||
@Param('spellId') spellId: string,
|
||||
@CurrentUser('id') userId: string,
|
||||
) {
|
||||
return this.charactersService.removeSpell(id, spellId, userId);
|
||||
}
|
||||
|
||||
// Items
|
||||
@Post(':id/items')
|
||||
@ApiOperation({ summary: 'Add item to character' })
|
||||
async addItem(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: CreateItemDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
) {
|
||||
return this.charactersService.addItem(id, dto, userId);
|
||||
}
|
||||
|
||||
@Patch(':id/items/:itemId')
|
||||
@ApiOperation({ summary: 'Update item' })
|
||||
async updateItem(
|
||||
@Param('id') id: string,
|
||||
@Param('itemId') itemId: string,
|
||||
@Body() body: Partial<CreateItemDto>,
|
||||
@CurrentUser('id') userId: string,
|
||||
) {
|
||||
return this.charactersService.updateItem(id, itemId, body, userId);
|
||||
}
|
||||
|
||||
@Delete(':id/items/:itemId')
|
||||
@ApiOperation({ summary: 'Remove item from character' })
|
||||
async removeItem(
|
||||
@Param('id') id: string,
|
||||
@Param('itemId') itemId: string,
|
||||
@CurrentUser('id') userId: string,
|
||||
) {
|
||||
return this.charactersService.removeItem(id, itemId, userId);
|
||||
}
|
||||
|
||||
// Conditions
|
||||
@Post(':id/conditions')
|
||||
@ApiOperation({ summary: 'Add condition to character' })
|
||||
async addCondition(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: CreateConditionDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
) {
|
||||
return this.charactersService.addCondition(id, dto, userId);
|
||||
}
|
||||
|
||||
@Patch(':id/conditions/:conditionId')
|
||||
@ApiOperation({ summary: 'Update condition value' })
|
||||
async updateCondition(
|
||||
@Param('id') id: string,
|
||||
@Param('conditionId') conditionId: string,
|
||||
@Body() body: { value: number },
|
||||
@CurrentUser('id') userId: string,
|
||||
) {
|
||||
return this.charactersService.updateCondition(id, conditionId, body.value, userId);
|
||||
}
|
||||
|
||||
@Delete(':id/conditions/:conditionId')
|
||||
@ApiOperation({ summary: 'Remove condition from character' })
|
||||
async removeCondition(
|
||||
@Param('id') id: string,
|
||||
@Param('conditionId') conditionId: string,
|
||||
@CurrentUser('id') userId: string,
|
||||
) {
|
||||
return this.charactersService.removeCondition(id, conditionId, userId);
|
||||
}
|
||||
|
||||
// Resources
|
||||
@Put(':id/resources')
|
||||
@ApiOperation({ summary: 'Set character resources' })
|
||||
async setResources(
|
||||
@Param('id') id: string,
|
||||
@Body() resources: CreateResourceDto[],
|
||||
@CurrentUser('id') userId: string,
|
||||
) {
|
||||
return this.charactersService.setResources(id, resources, userId);
|
||||
}
|
||||
|
||||
@Patch(':id/resources/:resourceName')
|
||||
@ApiOperation({ summary: 'Update resource current value' })
|
||||
async updateResource(
|
||||
@Param('id') id: string,
|
||||
@Param('resourceName') resourceName: string,
|
||||
@Body() body: { current: number },
|
||||
@CurrentUser('id') userId: string,
|
||||
) {
|
||||
return this.charactersService.updateResource(id, resourceName, body.current, userId);
|
||||
}
|
||||
}
|
||||
13
server/src/modules/characters/characters.module.ts
Normal file
13
server/src/modules/characters/characters.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CharactersController } from './characters.controller';
|
||||
import { CharactersService } from './characters.service';
|
||||
import { PathbuilderImportService } from './pathbuilder-import.service';
|
||||
import { TranslationsModule } from '../translations/translations.module';
|
||||
|
||||
@Module({
|
||||
imports: [TranslationsModule],
|
||||
controllers: [CharactersController],
|
||||
providers: [CharactersService, PathbuilderImportService],
|
||||
exports: [CharactersService, PathbuilderImportService],
|
||||
})
|
||||
export class CharactersModule {}
|
||||
311
server/src/modules/characters/characters.service.ts
Normal file
311
server/src/modules/characters/characters.service.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import {
|
||||
CreateCharacterDto,
|
||||
UpdateCharacterDto,
|
||||
CreateAbilityDto,
|
||||
CreateSkillDto,
|
||||
CreateFeatDto,
|
||||
CreateSpellDto,
|
||||
CreateItemDto,
|
||||
CreateConditionDto,
|
||||
CreateResourceDto,
|
||||
} from './dto';
|
||||
|
||||
@Injectable()
|
||||
export class CharactersService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
// Check if user has access to campaign
|
||||
private async checkCampaignAccess(campaignId: string, userId: string) {
|
||||
const campaign = await this.prisma.campaign.findUnique({
|
||||
where: { id: campaignId },
|
||||
include: { members: true },
|
||||
});
|
||||
|
||||
if (!campaign) {
|
||||
throw new NotFoundException('Campaign not found');
|
||||
}
|
||||
|
||||
const hasAccess =
|
||||
campaign.gmId === userId ||
|
||||
campaign.members.some((m) => m.userId === userId);
|
||||
|
||||
if (!hasAccess) {
|
||||
throw new ForbiddenException('No access to this campaign');
|
||||
}
|
||||
|
||||
return campaign;
|
||||
}
|
||||
|
||||
// Check if user can edit character
|
||||
private async checkCharacterAccess(characterId: string, userId: string, requireOwnership = false) {
|
||||
const character = await this.prisma.character.findUnique({
|
||||
where: { id: characterId },
|
||||
include: { campaign: { include: { members: true } } },
|
||||
});
|
||||
|
||||
if (!character) {
|
||||
throw new NotFoundException('Character not found');
|
||||
}
|
||||
|
||||
const isGM = character.campaign.gmId === userId;
|
||||
const isOwner = character.ownerId === userId;
|
||||
|
||||
if (requireOwnership && !isOwner && !isGM) {
|
||||
throw new ForbiddenException('Only the owner or GM can modify this character');
|
||||
}
|
||||
|
||||
const isMember = character.campaign.members.some((m) => m.userId === userId);
|
||||
if (!isGM && !isMember) {
|
||||
throw new ForbiddenException('No access to this character');
|
||||
}
|
||||
|
||||
return character;
|
||||
}
|
||||
|
||||
async create(campaignId: string, dto: CreateCharacterDto, userId: string) {
|
||||
await this.checkCampaignAccess(campaignId, userId);
|
||||
|
||||
return this.prisma.character.create({
|
||||
data: {
|
||||
...dto,
|
||||
campaignId,
|
||||
ownerId: userId,
|
||||
pathbuilderData: dto.pathbuilderData as any,
|
||||
},
|
||||
include: {
|
||||
owner: { select: { id: true, username: true } },
|
||||
abilities: true,
|
||||
skills: true,
|
||||
feats: true,
|
||||
spells: true,
|
||||
items: true,
|
||||
conditions: true,
|
||||
resources: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findAllByCampaign(campaignId: string, userId: string) {
|
||||
await this.checkCampaignAccess(campaignId, userId);
|
||||
|
||||
return this.prisma.character.findMany({
|
||||
where: { campaignId },
|
||||
include: {
|
||||
owner: { select: { id: true, username: true } },
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string, userId: string) {
|
||||
const character = await this.checkCharacterAccess(id, userId);
|
||||
|
||||
return this.prisma.character.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
owner: { select: { id: true, username: true, avatarUrl: true } },
|
||||
abilities: true,
|
||||
skills: { orderBy: { skillName: 'asc' } },
|
||||
feats: { orderBy: [{ level: 'asc' }, { name: 'asc' }] },
|
||||
spells: { orderBy: [{ spellLevel: 'asc' }, { name: 'asc' }] },
|
||||
items: { orderBy: { name: 'asc' } },
|
||||
conditions: true,
|
||||
resources: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateCharacterDto, userId: string) {
|
||||
await this.checkCharacterAccess(id, userId, true);
|
||||
|
||||
return this.prisma.character.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...dto,
|
||||
pathbuilderData: dto.pathbuilderData as any,
|
||||
},
|
||||
include: {
|
||||
owner: { select: { id: true, username: true } },
|
||||
abilities: true,
|
||||
skills: true,
|
||||
feats: true,
|
||||
spells: true,
|
||||
items: true,
|
||||
conditions: true,
|
||||
resources: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: string, userId: string) {
|
||||
await this.checkCharacterAccess(id, userId, true);
|
||||
|
||||
await this.prisma.character.delete({ where: { id } });
|
||||
return { message: 'Character deleted successfully' };
|
||||
}
|
||||
|
||||
// HP Management
|
||||
async updateHp(id: string, hpCurrent: number, hpTemp?: number, userId?: string) {
|
||||
if (userId) {
|
||||
await this.checkCharacterAccess(id, userId, true);
|
||||
}
|
||||
|
||||
return this.prisma.character.update({
|
||||
where: { id },
|
||||
data: {
|
||||
hpCurrent: Math.max(0, hpCurrent),
|
||||
...(hpTemp !== undefined && { hpTemp: Math.max(0, hpTemp) }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Abilities
|
||||
async setAbilities(characterId: string, abilities: CreateAbilityDto[], userId: string) {
|
||||
await this.checkCharacterAccess(characterId, userId, true);
|
||||
|
||||
// Delete existing and create new
|
||||
await this.prisma.characterAbility.deleteMany({ where: { characterId } });
|
||||
|
||||
return this.prisma.characterAbility.createMany({
|
||||
data: abilities.map((a) => ({ ...a, characterId })),
|
||||
});
|
||||
}
|
||||
|
||||
// Skills
|
||||
async setSkills(characterId: string, skills: CreateSkillDto[], userId: string) {
|
||||
await this.checkCharacterAccess(characterId, userId, true);
|
||||
|
||||
await this.prisma.characterSkill.deleteMany({ where: { characterId } });
|
||||
|
||||
return this.prisma.characterSkill.createMany({
|
||||
data: skills.map((s) => ({ ...s, characterId })),
|
||||
});
|
||||
}
|
||||
|
||||
async updateSkill(characterId: string, skillName: string, proficiency: string, userId: string) {
|
||||
await this.checkCharacterAccess(characterId, userId, true);
|
||||
|
||||
return this.prisma.characterSkill.upsert({
|
||||
where: { characterId_skillName: { characterId, skillName } },
|
||||
update: { proficiency: proficiency as any },
|
||||
create: { characterId, skillName, proficiency: proficiency as any },
|
||||
});
|
||||
}
|
||||
|
||||
// Feats
|
||||
async addFeat(characterId: string, dto: CreateFeatDto, userId: string) {
|
||||
await this.checkCharacterAccess(characterId, userId, true);
|
||||
|
||||
return this.prisma.characterFeat.create({
|
||||
data: { ...dto, characterId },
|
||||
});
|
||||
}
|
||||
|
||||
async removeFeat(characterId: string, featId: string, userId: string) {
|
||||
await this.checkCharacterAccess(characterId, userId, true);
|
||||
|
||||
await this.prisma.characterFeat.delete({ where: { id: featId } });
|
||||
return { message: 'Feat removed' };
|
||||
}
|
||||
|
||||
// Spells
|
||||
async addSpell(characterId: string, dto: CreateSpellDto, userId: string) {
|
||||
await this.checkCharacterAccess(characterId, userId, true);
|
||||
|
||||
return this.prisma.characterSpell.create({
|
||||
data: { ...dto, characterId },
|
||||
});
|
||||
}
|
||||
|
||||
async updateSpell(characterId: string, spellId: string, prepared: boolean, userId: string) {
|
||||
await this.checkCharacterAccess(characterId, userId, true);
|
||||
|
||||
return this.prisma.characterSpell.update({
|
||||
where: { id: spellId },
|
||||
data: { prepared },
|
||||
});
|
||||
}
|
||||
|
||||
async removeSpell(characterId: string, spellId: string, userId: string) {
|
||||
await this.checkCharacterAccess(characterId, userId, true);
|
||||
|
||||
await this.prisma.characterSpell.delete({ where: { id: spellId } });
|
||||
return { message: 'Spell removed' };
|
||||
}
|
||||
|
||||
// Items
|
||||
async addItem(characterId: string, dto: CreateItemDto, userId: string) {
|
||||
await this.checkCharacterAccess(characterId, userId, true);
|
||||
|
||||
return this.prisma.characterItem.create({
|
||||
data: { ...dto, characterId, bulk: dto.bulk as any },
|
||||
});
|
||||
}
|
||||
|
||||
async updateItem(characterId: string, itemId: string, data: Partial<CreateItemDto>, userId: string) {
|
||||
await this.checkCharacterAccess(characterId, userId, true);
|
||||
|
||||
return this.prisma.characterItem.update({
|
||||
where: { id: itemId },
|
||||
data: { ...data, bulk: data.bulk as any },
|
||||
});
|
||||
}
|
||||
|
||||
async removeItem(characterId: string, itemId: string, userId: string) {
|
||||
await this.checkCharacterAccess(characterId, userId, true);
|
||||
|
||||
await this.prisma.characterItem.delete({ where: { id: itemId } });
|
||||
return { message: 'Item removed' };
|
||||
}
|
||||
|
||||
// Conditions
|
||||
async addCondition(characterId: string, dto: CreateConditionDto, userId: string) {
|
||||
await this.checkCharacterAccess(characterId, userId, true);
|
||||
|
||||
return this.prisma.characterCondition.create({
|
||||
data: { ...dto, characterId },
|
||||
});
|
||||
}
|
||||
|
||||
async updateCondition(characterId: string, conditionId: string, value: number, userId: string) {
|
||||
await this.checkCharacterAccess(characterId, userId, true);
|
||||
|
||||
return this.prisma.characterCondition.update({
|
||||
where: { id: conditionId },
|
||||
data: { value },
|
||||
});
|
||||
}
|
||||
|
||||
async removeCondition(characterId: string, conditionId: string, userId: string) {
|
||||
await this.checkCharacterAccess(characterId, userId, true);
|
||||
|
||||
await this.prisma.characterCondition.delete({ where: { id: conditionId } });
|
||||
return { message: 'Condition removed' };
|
||||
}
|
||||
|
||||
// Resources
|
||||
async setResources(characterId: string, resources: CreateResourceDto[], userId: string) {
|
||||
await this.checkCharacterAccess(characterId, userId, true);
|
||||
|
||||
await this.prisma.characterResource.deleteMany({ where: { characterId } });
|
||||
|
||||
return this.prisma.characterResource.createMany({
|
||||
data: resources.map((r) => ({ ...r, characterId })),
|
||||
});
|
||||
}
|
||||
|
||||
async updateResource(characterId: string, resourceName: string, current: number, userId: string) {
|
||||
await this.checkCharacterAccess(characterId, userId, true);
|
||||
|
||||
return this.prisma.characterResource.update({
|
||||
where: { characterId_name: { characterId, name: resourceName } },
|
||||
data: { current: Math.max(0, current) },
|
||||
});
|
||||
}
|
||||
}
|
||||
236
server/src/modules/characters/dto/create-character.dto.ts
Normal file
236
server/src/modules/characters/dto/create-character.dto.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsString, IsOptional, IsEnum, IsInt, Min, Max, IsArray, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { CharacterType, AbilityType, Proficiency, FeatSource, SpellTradition } from '../../../generated/prisma/client.js';
|
||||
|
||||
export class CreateCharacterDto {
|
||||
@ApiProperty({ description: 'Character name' })
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['PC', 'NPC'], default: 'PC' })
|
||||
@IsOptional()
|
||||
@IsEnum(CharacterType)
|
||||
type?: CharacterType = CharacterType.PC;
|
||||
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(20)
|
||||
level?: number = 1;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
avatarUrl?: string;
|
||||
|
||||
@ApiProperty({ description: 'Current HP' })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
hpCurrent: number;
|
||||
|
||||
@ApiProperty({ description: 'Maximum HP' })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
hpMax: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
hpTemp?: number = 0;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ancestryId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
heritageId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
classId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
backgroundId?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
experiencePoints?: number = 0;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Raw Pathbuilder JSON data' })
|
||||
@IsOptional()
|
||||
pathbuilderData?: unknown;
|
||||
}
|
||||
|
||||
export class CreateAbilityDto {
|
||||
@ApiProperty({ enum: ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'] })
|
||||
@IsEnum(AbilityType)
|
||||
ability: AbilityType;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(30)
|
||||
score: number;
|
||||
}
|
||||
|
||||
export class CreateSkillDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
skillName: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['UNTRAINED', 'TRAINED', 'EXPERT', 'MASTER', 'LEGENDARY'], default: 'UNTRAINED' })
|
||||
@IsOptional()
|
||||
@IsEnum(Proficiency)
|
||||
proficiency?: Proficiency = Proficiency.UNTRAINED;
|
||||
}
|
||||
|
||||
export class CreateFeatDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
featId?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nameGerman?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
level: number;
|
||||
|
||||
@ApiProperty({ enum: ['CLASS', 'ANCESTRY', 'GENERAL', 'SKILL', 'BONUS', 'ARCHETYPE'] })
|
||||
@IsEnum(FeatSource)
|
||||
source: FeatSource;
|
||||
}
|
||||
|
||||
export class CreateSpellDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
spellId?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nameGerman?: string;
|
||||
|
||||
@ApiProperty({ enum: ['ARCANE', 'DIVINE', 'OCCULT', 'PRIMAL'] })
|
||||
@IsEnum(SpellTradition)
|
||||
tradition: SpellTradition;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(10)
|
||||
spellLevel: number;
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
prepared?: boolean = false;
|
||||
}
|
||||
|
||||
export class CreateItemDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
equipmentId?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nameGerman?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
quantity?: number = 1;
|
||||
|
||||
@ApiPropertyOptional({ default: 0 })
|
||||
@IsOptional()
|
||||
bulk?: number = 0;
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
equipped?: boolean = false;
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
invested?: boolean = false;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
containerId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class CreateConditionDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nameGerman?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
value?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
duration?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export class CreateResourceDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
current: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
max: number;
|
||||
}
|
||||
3
server/src/modules/characters/dto/index.ts
Normal file
3
server/src/modules/characters/dto/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './create-character.dto';
|
||||
export * from './update-character.dto';
|
||||
export * from './pathbuilder-import.dto';
|
||||
152
server/src/modules/characters/dto/pathbuilder-import.dto.ts
Normal file
152
server/src/modules/characters/dto/pathbuilder-import.dto.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsObject, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
// Pathbuilder JSON structure
|
||||
export interface PathbuilderBuild {
|
||||
name: string;
|
||||
class: string;
|
||||
dualClass?: string | null;
|
||||
level: number;
|
||||
xp: number;
|
||||
ancestry: string;
|
||||
heritage: string;
|
||||
background: string;
|
||||
alignment: string;
|
||||
gender: string;
|
||||
age: string;
|
||||
deity: string;
|
||||
size: number;
|
||||
sizeName: string;
|
||||
keyability: string;
|
||||
languages: string[];
|
||||
abilities: {
|
||||
str: number;
|
||||
dex: number;
|
||||
con: number;
|
||||
int: number;
|
||||
wis: number;
|
||||
cha: number;
|
||||
breakdown?: unknown;
|
||||
};
|
||||
attributes: {
|
||||
ancestryhp: number;
|
||||
classhp: number;
|
||||
bonushp: number;
|
||||
bonushpPerLevel: number;
|
||||
speed: number;
|
||||
speedBonus: number;
|
||||
};
|
||||
proficiencies: {
|
||||
classDC: number;
|
||||
perception: number;
|
||||
fortitude: number;
|
||||
reflex: number;
|
||||
will: number;
|
||||
heavy: number;
|
||||
medium: number;
|
||||
light: number;
|
||||
unarmored: number;
|
||||
advanced: number;
|
||||
martial: number;
|
||||
simple: number;
|
||||
unarmed: number;
|
||||
castingArcane: number;
|
||||
castingDivine: number;
|
||||
castingOccult: number;
|
||||
castingPrimal: number;
|
||||
acrobatics: number;
|
||||
arcana: number;
|
||||
athletics: number;
|
||||
crafting: number;
|
||||
deception: number;
|
||||
diplomacy: number;
|
||||
intimidation: number;
|
||||
medicine: number;
|
||||
nature: number;
|
||||
occultism: number;
|
||||
performance: number;
|
||||
religion: number;
|
||||
society: number;
|
||||
stealth: number;
|
||||
survival: number;
|
||||
thievery: number;
|
||||
};
|
||||
feats: Array<[string, unknown, string, number, ...unknown[]]>;
|
||||
specials: string[];
|
||||
lores: Array<[string, number]>;
|
||||
equipment: Array<[string, number, string?]>;
|
||||
weapons: Array<{
|
||||
name: string;
|
||||
qty: number;
|
||||
prof: string;
|
||||
die: string;
|
||||
pot: number;
|
||||
str: string;
|
||||
mat: string | null;
|
||||
display: string;
|
||||
runes: string[];
|
||||
damageType: string;
|
||||
attack: number;
|
||||
damageBonus: number;
|
||||
extraDamage: string[];
|
||||
increasedDice: boolean;
|
||||
isInventor: boolean;
|
||||
}>;
|
||||
armor: Array<{
|
||||
name: string;
|
||||
qty: number;
|
||||
prof: string;
|
||||
pot: number;
|
||||
res: string;
|
||||
mat: string | null;
|
||||
display: string;
|
||||
worn: boolean;
|
||||
runes: string[];
|
||||
}>;
|
||||
money: {
|
||||
cp: number;
|
||||
sp: number;
|
||||
gp: number;
|
||||
pp: number;
|
||||
};
|
||||
spellCasters: Array<{
|
||||
name: string;
|
||||
magicTradition: string;
|
||||
spellcastingType: string;
|
||||
ability: string;
|
||||
proficiency: number;
|
||||
focusPoints: number;
|
||||
spells: Array<{
|
||||
spellLevel: number;
|
||||
list: string[];
|
||||
}>;
|
||||
perDay: number[];
|
||||
}>;
|
||||
focusPoints: number;
|
||||
focus: unknown;
|
||||
formula: Array<{
|
||||
type: string;
|
||||
known: string[];
|
||||
}>;
|
||||
acTotal: {
|
||||
acProfBonus: number;
|
||||
acAbilityBonus: number;
|
||||
acItemBonus: number;
|
||||
acTotal: number;
|
||||
shieldBonus: number | null;
|
||||
};
|
||||
pets: unknown[];
|
||||
familiars: unknown[];
|
||||
}
|
||||
|
||||
export interface PathbuilderJson {
|
||||
success: boolean;
|
||||
build: PathbuilderBuild;
|
||||
}
|
||||
|
||||
export class PathbuilderImportDto {
|
||||
@ApiProperty({ description: 'Raw Pathbuilder export JSON' })
|
||||
@IsObject()
|
||||
pathbuilderJson: PathbuilderJson;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateCharacterDto } from './create-character.dto';
|
||||
|
||||
export class UpdateCharacterDto extends PartialType(CreateCharacterDto) {}
|
||||
407
server/src/modules/characters/pathbuilder-import.service.ts
Normal file
407
server/src/modules/characters/pathbuilder-import.service.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TranslationsService } from '../translations/translations.service';
|
||||
import {
|
||||
AbilityType,
|
||||
Proficiency,
|
||||
FeatSource,
|
||||
TranslationType,
|
||||
CharacterType,
|
||||
} from '../../generated/prisma/client.js';
|
||||
import { PathbuilderJson, PathbuilderBuild } from './dto/pathbuilder-import.dto';
|
||||
|
||||
// Skill name mappings (English -> German)
|
||||
const SKILL_TRANSLATIONS: Record<string, string> = {
|
||||
acrobatics: 'Akrobatik',
|
||||
arcana: 'Arkane Künste',
|
||||
athletics: 'Athletik',
|
||||
crafting: 'Handwerk',
|
||||
deception: 'Täuschung',
|
||||
diplomacy: 'Diplomatie',
|
||||
intimidation: 'Einschüchtern',
|
||||
medicine: 'Medizin',
|
||||
nature: 'Naturkunde',
|
||||
occultism: 'Okkultismus',
|
||||
performance: 'Darbietung',
|
||||
religion: 'Religionskunde',
|
||||
society: 'Gesellschaftskunde',
|
||||
stealth: 'Heimlichkeit',
|
||||
survival: 'Überleben',
|
||||
thievery: 'Diebeskunst',
|
||||
};
|
||||
|
||||
// Proficiency value mappings (Pathbuilder uses 0, 2, 4, 6, 8)
|
||||
function proficiencyFromValue(value: number): Proficiency {
|
||||
switch (value) {
|
||||
case 8: return Proficiency.LEGENDARY;
|
||||
case 6: return Proficiency.MASTER;
|
||||
case 4: return Proficiency.EXPERT;
|
||||
case 2: return Proficiency.TRAINED;
|
||||
default: return Proficiency.UNTRAINED;
|
||||
}
|
||||
}
|
||||
|
||||
// Feat source mapping
|
||||
function featSourceFromType(type: string): FeatSource {
|
||||
const normalized = type.toLowerCase();
|
||||
if (normalized.includes('class')) return FeatSource.CLASS;
|
||||
if (normalized.includes('ancestry') || normalized.includes('human')) return FeatSource.ANCESTRY;
|
||||
if (normalized.includes('skill')) return FeatSource.SKILL;
|
||||
if (normalized.includes('general')) return FeatSource.GENERAL;
|
||||
if (normalized.includes('archetype')) return FeatSource.ARCHETYPE;
|
||||
return FeatSource.BONUS;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PathbuilderImportService {
|
||||
private readonly logger = new Logger(PathbuilderImportService.name);
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private translationsService: TranslationsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Import a character from Pathbuilder JSON
|
||||
*/
|
||||
async importCharacter(
|
||||
campaignId: string,
|
||||
ownerId: string,
|
||||
pathbuilderJson: PathbuilderJson,
|
||||
) {
|
||||
const build = pathbuilderJson.build;
|
||||
this.logger.log(`Importing character: ${build.name} (Level ${build.level} ${build.class})`);
|
||||
|
||||
// Calculate HP
|
||||
const conModifier = Math.floor((build.abilities.con - 10) / 2);
|
||||
const hpMax = build.attributes.ancestryhp +
|
||||
build.attributes.classhp +
|
||||
build.attributes.bonushp +
|
||||
(conModifier * build.level) +
|
||||
(build.attributes.bonushpPerLevel * build.level);
|
||||
|
||||
// Translate basics
|
||||
const [classTranslation, ancestryTranslation, heritageTranslation, backgroundTranslation] =
|
||||
await Promise.all([
|
||||
this.translationsService.getTranslation(TranslationType.CLASS, build.class),
|
||||
this.translationsService.getTranslation(TranslationType.ANCESTRY, build.ancestry),
|
||||
this.translationsService.getTranslation(TranslationType.HERITAGE, build.heritage),
|
||||
this.translationsService.getTranslation(TranslationType.BACKGROUND, build.background),
|
||||
]);
|
||||
|
||||
// Create character
|
||||
const character = await this.prisma.character.create({
|
||||
data: {
|
||||
campaignId,
|
||||
ownerId,
|
||||
name: build.name,
|
||||
type: CharacterType.PC,
|
||||
level: build.level,
|
||||
hpCurrent: hpMax,
|
||||
hpMax,
|
||||
hpTemp: 0,
|
||||
ancestryId: build.ancestry,
|
||||
heritageId: build.heritage,
|
||||
classId: build.class,
|
||||
backgroundId: build.background,
|
||||
experiencePoints: build.xp || 0,
|
||||
pathbuilderData: JSON.parse(JSON.stringify({
|
||||
...pathbuilderJson,
|
||||
translations: {
|
||||
class: classTranslation,
|
||||
ancestry: ancestryTranslation,
|
||||
heritage: heritageTranslation,
|
||||
background: backgroundTranslation,
|
||||
},
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Character created with ID: ${character.id}`);
|
||||
|
||||
// Import all related data in parallel
|
||||
await Promise.all([
|
||||
this.importAbilities(character.id, build),
|
||||
this.importSkills(character.id, build),
|
||||
this.importFeats(character.id, build),
|
||||
this.importItems(character.id, build),
|
||||
this.importResources(character.id, build),
|
||||
]);
|
||||
|
||||
// Fetch complete character with relations
|
||||
return this.prisma.character.findUnique({
|
||||
where: { id: character.id },
|
||||
include: {
|
||||
owner: { select: { id: true, username: true } },
|
||||
abilities: true,
|
||||
skills: { orderBy: { skillName: 'asc' } },
|
||||
feats: { orderBy: [{ level: 'asc' }, { name: 'asc' }] },
|
||||
items: { orderBy: { name: 'asc' } },
|
||||
resources: true,
|
||||
conditions: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Import ability scores
|
||||
*/
|
||||
private async importAbilities(characterId: string, build: PathbuilderBuild) {
|
||||
const abilities = [
|
||||
{ ability: AbilityType.STR, score: build.abilities.str },
|
||||
{ ability: AbilityType.DEX, score: build.abilities.dex },
|
||||
{ ability: AbilityType.CON, score: build.abilities.con },
|
||||
{ ability: AbilityType.INT, score: build.abilities.int },
|
||||
{ ability: AbilityType.WIS, score: build.abilities.wis },
|
||||
{ ability: AbilityType.CHA, score: build.abilities.cha },
|
||||
];
|
||||
|
||||
await this.prisma.characterAbility.createMany({
|
||||
data: abilities.map(a => ({ ...a, characterId })),
|
||||
});
|
||||
|
||||
this.logger.debug(`Imported ${abilities.length} abilities`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import skills with proficiencies
|
||||
*/
|
||||
private async importSkills(characterId: string, build: PathbuilderBuild) {
|
||||
const prof = build.proficiencies;
|
||||
const skills = [
|
||||
{ skillName: 'Acrobatics', skillNameGerman: SKILL_TRANSLATIONS.acrobatics, proficiency: proficiencyFromValue(prof.acrobatics) },
|
||||
{ skillName: 'Arcana', skillNameGerman: SKILL_TRANSLATIONS.arcana, proficiency: proficiencyFromValue(prof.arcana) },
|
||||
{ skillName: 'Athletics', skillNameGerman: SKILL_TRANSLATIONS.athletics, proficiency: proficiencyFromValue(prof.athletics) },
|
||||
{ skillName: 'Crafting', skillNameGerman: SKILL_TRANSLATIONS.crafting, proficiency: proficiencyFromValue(prof.crafting) },
|
||||
{ skillName: 'Deception', skillNameGerman: SKILL_TRANSLATIONS.deception, proficiency: proficiencyFromValue(prof.deception) },
|
||||
{ skillName: 'Diplomacy', skillNameGerman: SKILL_TRANSLATIONS.diplomacy, proficiency: proficiencyFromValue(prof.diplomacy) },
|
||||
{ skillName: 'Intimidation', skillNameGerman: SKILL_TRANSLATIONS.intimidation, proficiency: proficiencyFromValue(prof.intimidation) },
|
||||
{ skillName: 'Medicine', skillNameGerman: SKILL_TRANSLATIONS.medicine, proficiency: proficiencyFromValue(prof.medicine) },
|
||||
{ skillName: 'Nature', skillNameGerman: SKILL_TRANSLATIONS.nature, proficiency: proficiencyFromValue(prof.nature) },
|
||||
{ skillName: 'Occultism', skillNameGerman: SKILL_TRANSLATIONS.occultism, proficiency: proficiencyFromValue(prof.occultism) },
|
||||
{ skillName: 'Performance', skillNameGerman: SKILL_TRANSLATIONS.performance, proficiency: proficiencyFromValue(prof.performance) },
|
||||
{ skillName: 'Religion', skillNameGerman: SKILL_TRANSLATIONS.religion, proficiency: proficiencyFromValue(prof.religion) },
|
||||
{ skillName: 'Society', skillNameGerman: SKILL_TRANSLATIONS.society, proficiency: proficiencyFromValue(prof.society) },
|
||||
{ skillName: 'Stealth', skillNameGerman: SKILL_TRANSLATIONS.stealth, proficiency: proficiencyFromValue(prof.stealth) },
|
||||
{ skillName: 'Survival', skillNameGerman: SKILL_TRANSLATIONS.survival, proficiency: proficiencyFromValue(prof.survival) },
|
||||
{ skillName: 'Thievery', skillNameGerman: SKILL_TRANSLATIONS.thievery, proficiency: proficiencyFromValue(prof.thievery) },
|
||||
// Saves
|
||||
{ skillName: 'Perception', skillNameGerman: 'Wahrnehmung', proficiency: proficiencyFromValue(prof.perception) },
|
||||
{ skillName: 'Fortitude', skillNameGerman: 'Zähigkeit', proficiency: proficiencyFromValue(prof.fortitude) },
|
||||
{ skillName: 'Reflex', skillNameGerman: 'Reflex', proficiency: proficiencyFromValue(prof.reflex) },
|
||||
{ skillName: 'Will', skillNameGerman: 'Wille', proficiency: proficiencyFromValue(prof.will) },
|
||||
];
|
||||
|
||||
// Add lore skills
|
||||
for (const [loreName, profValue] of build.lores) {
|
||||
skills.push({
|
||||
skillName: `Lore: ${loreName}`,
|
||||
skillNameGerman: `Wissen: ${loreName}`,
|
||||
proficiency: proficiencyFromValue(profValue),
|
||||
});
|
||||
}
|
||||
|
||||
await this.prisma.characterSkill.createMany({
|
||||
data: skills.map(s => ({
|
||||
characterId,
|
||||
skillName: s.skillName,
|
||||
proficiency: s.proficiency,
|
||||
})),
|
||||
});
|
||||
|
||||
this.logger.debug(`Imported ${skills.length} skills`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import feats with translations
|
||||
*/
|
||||
private async importFeats(characterId: string, build: PathbuilderBuild) {
|
||||
if (!build.feats || build.feats.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract feat names for batch translation
|
||||
const featNames = build.feats.map(f => ({ englishName: f[0] as string }));
|
||||
const translations = await this.translationsService.getTranslationsBatch(
|
||||
TranslationType.FEAT,
|
||||
featNames,
|
||||
);
|
||||
|
||||
const feats = build.feats.map(feat => {
|
||||
const name = feat[0] as string;
|
||||
const featType = feat[2] as string;
|
||||
const level = feat[3] as number;
|
||||
const translation = translations.get(name);
|
||||
|
||||
return {
|
||||
characterId,
|
||||
name,
|
||||
nameGerman: translation?.germanName || name,
|
||||
level,
|
||||
source: featSourceFromType(featType),
|
||||
};
|
||||
});
|
||||
|
||||
await this.prisma.characterFeat.createMany({ data: feats });
|
||||
this.logger.debug(`Imported ${feats.length} feats`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import items (weapons, armor, equipment)
|
||||
*/
|
||||
private async importItems(characterId: string, build: PathbuilderBuild) {
|
||||
const items: Array<{
|
||||
characterId: string;
|
||||
name: string;
|
||||
nameGerman?: string;
|
||||
quantity: number;
|
||||
bulk: number;
|
||||
equipped: boolean;
|
||||
invested: boolean;
|
||||
notes?: string;
|
||||
}> = [];
|
||||
|
||||
// Collect all item names for batch translation
|
||||
const itemNames: Array<{ englishName: string }> = [];
|
||||
|
||||
// Weapons
|
||||
for (const weapon of build.weapons || []) {
|
||||
itemNames.push({ englishName: weapon.name });
|
||||
items.push({
|
||||
characterId,
|
||||
name: weapon.name,
|
||||
quantity: weapon.qty || 1,
|
||||
bulk: 1, // Default bulk for weapons
|
||||
equipped: true,
|
||||
invested: false,
|
||||
notes: `${weapon.die} ${weapon.damageType}${weapon.extraDamage?.length ? ' + ' + weapon.extraDamage.join(', ') : ''}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Armor
|
||||
for (const armor of build.armor || []) {
|
||||
itemNames.push({ englishName: armor.name });
|
||||
items.push({
|
||||
characterId,
|
||||
name: armor.name,
|
||||
quantity: armor.qty || 1,
|
||||
bulk: 1, // Default bulk for armor
|
||||
equipped: armor.worn,
|
||||
invested: false,
|
||||
});
|
||||
}
|
||||
|
||||
// General equipment
|
||||
for (const equip of build.equipment || []) {
|
||||
const name = equip[0] as string;
|
||||
const qty = equip[1] as number;
|
||||
const invested = equip[2] === 'Invested';
|
||||
|
||||
itemNames.push({ englishName: name });
|
||||
items.push({
|
||||
characterId,
|
||||
name,
|
||||
quantity: qty,
|
||||
bulk: 0,
|
||||
equipped: false,
|
||||
invested,
|
||||
});
|
||||
}
|
||||
|
||||
// Add money as a note item
|
||||
if (build.money) {
|
||||
const moneyNote: string[] = [];
|
||||
if (build.money.pp > 0) moneyNote.push(`${build.money.pp} PP`);
|
||||
if (build.money.gp > 0) moneyNote.push(`${build.money.gp} GP`);
|
||||
if (build.money.sp > 0) moneyNote.push(`${build.money.sp} SP`);
|
||||
if (build.money.cp > 0) moneyNote.push(`${build.money.cp} CP`);
|
||||
|
||||
if (moneyNote.length > 0) {
|
||||
items.push({
|
||||
characterId,
|
||||
name: 'Münzbeutel',
|
||||
quantity: 1,
|
||||
bulk: 0,
|
||||
equipped: false,
|
||||
invested: false,
|
||||
notes: moneyNote.join(', '),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Batch translate items
|
||||
if (itemNames.length > 0) {
|
||||
const translations = await this.translationsService.getTranslationsBatch(
|
||||
TranslationType.EQUIPMENT,
|
||||
itemNames,
|
||||
);
|
||||
|
||||
// Apply translations
|
||||
for (const item of items) {
|
||||
if (item.name !== 'Münzbeutel') {
|
||||
const translation = translations.get(item.name);
|
||||
if (translation) {
|
||||
item.nameGerman = translation.germanName;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length > 0) {
|
||||
await this.prisma.characterItem.createMany({
|
||||
data: items.map(i => ({
|
||||
...i,
|
||||
bulk: i.bulk,
|
||||
})),
|
||||
});
|
||||
this.logger.debug(`Imported ${items.length} items`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import resources (focus points, spell slots, etc.)
|
||||
*/
|
||||
private async importResources(characterId: string, build: PathbuilderBuild) {
|
||||
const resources: Array<{ characterId: string; name: string; current: number; max: number }> = [];
|
||||
|
||||
// Focus Points
|
||||
if (build.focusPoints > 0) {
|
||||
resources.push({
|
||||
characterId,
|
||||
name: 'Fokuspunkte',
|
||||
current: build.focusPoints,
|
||||
max: build.focusPoints,
|
||||
});
|
||||
}
|
||||
|
||||
// Spell slots from spellcasters
|
||||
for (const caster of build.spellCasters || []) {
|
||||
if (caster.perDay) {
|
||||
for (let level = 0; level < caster.perDay.length; level++) {
|
||||
const slots = caster.perDay[level];
|
||||
if (slots > 0) {
|
||||
resources.push({
|
||||
characterId,
|
||||
name: level === 0 ? 'Zaubertricks' : `Zauberplätze Grad ${level}`,
|
||||
current: slots,
|
||||
max: slots,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hero Points (always starts at 1)
|
||||
resources.push({
|
||||
characterId,
|
||||
name: 'Heldenpunkte',
|
||||
current: 1,
|
||||
max: 3,
|
||||
});
|
||||
|
||||
if (resources.length > 0) {
|
||||
await this.prisma.characterResource.createMany({ data: resources });
|
||||
this.logger.debug(`Imported ${resources.length} resources`);
|
||||
}
|
||||
}
|
||||
}
|
||||
9
server/src/modules/claude/claude.module.ts
Normal file
9
server/src/modules/claude/claude.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { ClaudeService } from './claude.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [ClaudeService],
|
||||
exports: [ClaudeService],
|
||||
})
|
||||
export class ClaudeModule {}
|
||||
138
server/src/modules/claude/claude.service.ts
Normal file
138
server/src/modules/claude/claude.service.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
|
||||
export interface TranslationRequest {
|
||||
type: 'FEAT' | 'ITEM' | 'SPELL' | 'ACTION' | 'SKILL' | 'CLASS' | 'ANCESTRY' | 'HERITAGE' | 'BACKGROUND' | 'CONDITION' | 'TRAIT';
|
||||
englishName: string;
|
||||
englishDescription?: string;
|
||||
context?: string;
|
||||
}
|
||||
|
||||
export interface TranslationResponse {
|
||||
englishName: string;
|
||||
germanName: string;
|
||||
germanDescription?: string;
|
||||
translationQuality: 'HIGH' | 'MEDIUM' | 'LOW';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ClaudeService {
|
||||
private readonly logger = new Logger(ClaudeService.name);
|
||||
private client: Anthropic;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
const apiKey = this.configService.get<string>('ANTHROPIC_API_KEY');
|
||||
if (apiKey) {
|
||||
this.client = new Anthropic({ apiKey });
|
||||
this.logger.log('Claude API initialized');
|
||||
} else {
|
||||
this.logger.warn('ANTHROPIC_API_KEY not set - translations will be disabled');
|
||||
}
|
||||
}
|
||||
|
||||
async translateBatch(items: TranslationRequest[]): Promise<TranslationResponse[]> {
|
||||
if (!this.client) {
|
||||
this.logger.warn('Claude API not available, returning untranslated items');
|
||||
return items.map(item => ({
|
||||
englishName: item.englishName,
|
||||
germanName: item.englishName,
|
||||
germanDescription: item.englishDescription,
|
||||
translationQuality: 'LOW' as const,
|
||||
}));
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const itemsList = items.map((item, i) =>
|
||||
`${i + 1}. Type: ${item.type}, Name: "${item.englishName}"${item.englishDescription ? `, Description: "${item.englishDescription}"` : ''}${item.context ? `, Context: ${item.context}` : ''}`
|
||||
).join('\n');
|
||||
|
||||
const prompt = `Du bist ein Übersetzer für Pathfinder 2e Spielinhalte von Englisch nach Deutsch.
|
||||
|
||||
WICHTIGE ÜBERSETZUNGSREGELN:
|
||||
- "Feat" = "Talent"
|
||||
- "Action" = "Aktion"
|
||||
- "Spell" = "Zauber"
|
||||
- "Weapon" = "Waffe"
|
||||
- "Armor" = "Rüstung"
|
||||
- "Shield" = "Schild"
|
||||
- "Item" = "Gegenstand"
|
||||
- "Skill" = "Fertigkeit"
|
||||
- "Class" = "Klasse"
|
||||
- "Ancestry" = "Abstammung"
|
||||
- "Heritage" = "Erbe"
|
||||
- "Background" = "Hintergrund"
|
||||
- "Condition" = "Zustand"
|
||||
- "Trait" = "Merkmal"
|
||||
|
||||
Behalte Pathfinder-spezifische Begriffe bei (z.B. "Versatile", "Finesse" bleiben auf Englisch als Spielmechanik-Begriffe).
|
||||
Übersetze Eigennamen nicht (z.B. "Alchemist's Fire" → "Alchemistenfeuer", aber "Bane" bleibt "Bane").
|
||||
|
||||
Übersetze folgende ${items.length} Einträge:
|
||||
|
||||
${itemsList}
|
||||
|
||||
Antworte NUR mit einem JSON-Array in diesem Format:
|
||||
[
|
||||
{
|
||||
"englishName": "Original English Name",
|
||||
"germanName": "Deutscher Name",
|
||||
"germanDescription": "Deutsche Beschreibung (falls vorhanden)",
|
||||
"confidence": 0.9
|
||||
}
|
||||
]
|
||||
|
||||
Gib confidence zwischen 0.0 und 1.0 an basierend auf der Übersetzungsqualität.`;
|
||||
|
||||
const response = await this.client.messages.create({
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
max_tokens: 4000,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
});
|
||||
|
||||
const content = response.content[0];
|
||||
if (content.type !== 'text') {
|
||||
throw new Error('Unexpected response type from Claude');
|
||||
}
|
||||
|
||||
// Extract JSON from response
|
||||
const jsonMatch = content.text.match(/\[[\s\S]*\]/);
|
||||
if (!jsonMatch) {
|
||||
this.logger.error('Could not extract JSON from Claude response');
|
||||
throw new Error('Invalid JSON response from Claude');
|
||||
}
|
||||
|
||||
const translations = JSON.parse(jsonMatch[0]) as Array<{
|
||||
englishName: string;
|
||||
germanName: string;
|
||||
germanDescription?: string;
|
||||
confidence: number;
|
||||
}>;
|
||||
|
||||
return translations.map(t => ({
|
||||
englishName: t.englishName,
|
||||
germanName: t.germanName,
|
||||
germanDescription: t.germanDescription,
|
||||
translationQuality: t.confidence >= 0.8 ? 'HIGH' : t.confidence >= 0.6 ? 'MEDIUM' : 'LOW',
|
||||
}));
|
||||
} catch (error) {
|
||||
this.logger.error('Claude translation failed:', error);
|
||||
// Return untranslated items as fallback
|
||||
return items.map(item => ({
|
||||
englishName: item.englishName,
|
||||
germanName: item.englishName,
|
||||
germanDescription: item.englishDescription,
|
||||
translationQuality: 'LOW' as const,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async translateSingle(item: TranslationRequest): Promise<TranslationResponse> {
|
||||
const results = await this.translateBatch([item]);
|
||||
return results[0];
|
||||
}
|
||||
}
|
||||
2
server/src/modules/claude/index.ts
Normal file
2
server/src/modules/claude/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './claude.service';
|
||||
export * from './claude.module';
|
||||
3
server/src/modules/translations/index.ts
Normal file
3
server/src/modules/translations/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './translations.service';
|
||||
export * from './translations.controller';
|
||||
export * from './translations.module';
|
||||
57
server/src/modules/translations/translations.controller.ts
Normal file
57
server/src/modules/translations/translations.controller.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { TranslationsService } from './translations.service';
|
||||
import { TranslationType } from '../../generated/prisma/client.js';
|
||||
|
||||
class TranslateRequestDto {
|
||||
type: TranslationType;
|
||||
englishName: string;
|
||||
englishDescription?: string;
|
||||
}
|
||||
|
||||
class TranslateBatchRequestDto {
|
||||
type: TranslationType;
|
||||
items: Array<{ englishName: string; englishDescription?: string }>;
|
||||
}
|
||||
|
||||
@ApiTags('translations')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('translations')
|
||||
export class TranslationsController {
|
||||
constructor(private translationsService: TranslationsService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Translate a single item' })
|
||||
async translate(@Body() dto: TranslateRequestDto) {
|
||||
return this.translationsService.getTranslation(
|
||||
dto.type,
|
||||
dto.englishName,
|
||||
dto.englishDescription,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('batch')
|
||||
@ApiOperation({ summary: 'Translate multiple items' })
|
||||
async translateBatch(@Body() dto: TranslateBatchRequestDto) {
|
||||
const result = await this.translationsService.getTranslationsBatch(
|
||||
dto.type,
|
||||
dto.items,
|
||||
);
|
||||
return Object.fromEntries(result);
|
||||
}
|
||||
|
||||
@Get(':type')
|
||||
@ApiOperation({ summary: 'Get all cached translations of a type' })
|
||||
async getAllByType(@Param('type') type: TranslationType) {
|
||||
return this.translationsService.getAllByType(type);
|
||||
}
|
||||
}
|
||||
10
server/src/modules/translations/translations.module.ts
Normal file
10
server/src/modules/translations/translations.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TranslationsService } from './translations.service';
|
||||
import { TranslationsController } from './translations.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [TranslationsController],
|
||||
providers: [TranslationsService],
|
||||
exports: [TranslationsService],
|
||||
})
|
||||
export class TranslationsModule {}
|
||||
159
server/src/modules/translations/translations.service.ts
Normal file
159
server/src/modules/translations/translations.service.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { ClaudeService, TranslationRequest, TranslationResponse } from '../claude/claude.service';
|
||||
import { TranslationType, TranslationQuality } from '../../generated/prisma/client.js';
|
||||
|
||||
@Injectable()
|
||||
export class TranslationsService {
|
||||
private readonly logger = new Logger(TranslationsService.name);
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private claudeService: ClaudeService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get a translation from cache or translate via Claude
|
||||
*/
|
||||
async getTranslation(
|
||||
type: TranslationType,
|
||||
englishName: string,
|
||||
englishDescription?: string,
|
||||
): Promise<TranslationResponse> {
|
||||
// Check cache first
|
||||
const cached = await this.prisma.translation.findUnique({
|
||||
where: { type_englishName: { type, englishName } },
|
||||
});
|
||||
|
||||
if (cached && !this.isIncomplete(cached.germanDescription)) {
|
||||
this.logger.debug(`Cache hit for ${type}: ${englishName}`);
|
||||
return {
|
||||
englishName: cached.englishName,
|
||||
germanName: cached.germanName,
|
||||
germanDescription: cached.germanDescription || undefined,
|
||||
translationQuality: cached.quality,
|
||||
};
|
||||
}
|
||||
|
||||
// Translate via Claude
|
||||
this.logger.debug(`Cache miss for ${type}: ${englishName}, calling Claude`);
|
||||
const translation = await this.claudeService.translateSingle({
|
||||
type: type as TranslationRequest['type'],
|
||||
englishName,
|
||||
englishDescription,
|
||||
});
|
||||
|
||||
// Cache the result
|
||||
await this.upsertTranslation(type, translation);
|
||||
|
||||
return translation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple translations, using cache where possible
|
||||
*/
|
||||
async getTranslationsBatch(
|
||||
type: TranslationType,
|
||||
items: Array<{ englishName: string; englishDescription?: string }>,
|
||||
): Promise<Map<string, TranslationResponse>> {
|
||||
const result = new Map<string, TranslationResponse>();
|
||||
|
||||
if (items.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check cache for all items
|
||||
const englishNames = items.map(i => i.englishName);
|
||||
const cached = await this.prisma.translation.findMany({
|
||||
where: {
|
||||
type,
|
||||
englishName: { in: englishNames },
|
||||
},
|
||||
});
|
||||
|
||||
const cachedMap = new Map(cached.map(c => [c.englishName, c]));
|
||||
const toTranslate: TranslationRequest[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const cachedItem = cachedMap.get(item.englishName);
|
||||
if (cachedItem && !this.isIncomplete(cachedItem.germanDescription)) {
|
||||
result.set(item.englishName, {
|
||||
englishName: cachedItem.englishName,
|
||||
germanName: cachedItem.germanName,
|
||||
germanDescription: cachedItem.germanDescription || undefined,
|
||||
translationQuality: cachedItem.quality,
|
||||
});
|
||||
} else {
|
||||
toTranslate.push({
|
||||
type: type as TranslationRequest['type'],
|
||||
englishName: item.englishName,
|
||||
englishDescription: item.englishDescription,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`${type}: ${cached.length} cached, ${toTranslate.length} to translate`);
|
||||
|
||||
// Translate missing items in batches of 20
|
||||
if (toTranslate.length > 0) {
|
||||
const batchSize = 20;
|
||||
for (let i = 0; i < toTranslate.length; i += batchSize) {
|
||||
const batch = toTranslate.slice(i, i + batchSize);
|
||||
const translations = await this.claudeService.translateBatch(batch);
|
||||
|
||||
for (const translation of translations) {
|
||||
result.set(translation.englishName, translation);
|
||||
await this.upsertTranslation(type, translation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store or update a translation in the cache
|
||||
*/
|
||||
async upsertTranslation(
|
||||
type: TranslationType,
|
||||
translation: TranslationResponse,
|
||||
): Promise<void> {
|
||||
await this.prisma.translation.upsert({
|
||||
where: {
|
||||
type_englishName: { type, englishName: translation.englishName },
|
||||
},
|
||||
update: {
|
||||
germanName: translation.germanName,
|
||||
germanDescription: translation.germanDescription,
|
||||
quality: translation.translationQuality as TranslationQuality,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
type,
|
||||
englishName: translation.englishName,
|
||||
germanName: translation.germanName,
|
||||
germanDescription: translation.germanDescription,
|
||||
quality: translation.translationQuality as TranslationQuality,
|
||||
translatedBy: 'claude-api',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cached translations of a type
|
||||
*/
|
||||
async getAllByType(type: TranslationType) {
|
||||
return this.prisma.translation.findMany({
|
||||
where: { type },
|
||||
orderBy: { englishName: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a translation is incomplete (truncated)
|
||||
*/
|
||||
private isIncomplete(description?: string | null): boolean {
|
||||
if (!description) return false;
|
||||
return description.trim().endsWith('…') || description.trim().endsWith('...');
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
import 'dotenv/config';
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '../generated/prisma';
|
||||
import { PrismaClient } from '../generated/prisma/client.js';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
constructor() {
|
||||
const adapter = new PrismaPg({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
});
|
||||
|
||||
super({
|
||||
adapter,
|
||||
log: process.env.NODE_ENV === 'development'
|
||||
? ['query', 'info', 'warn', 'error']
|
||||
: ['error'],
|
||||
|
||||
Reference in New Issue
Block a user