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:
Alexander Zielonka
2026-01-18 20:36:44 +01:00
parent 090aae53d8
commit 94335ecd12
53 changed files with 4581 additions and 114 deletions

View File

@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(npx tsc:*)",
"Bash(npm install:*)"
]
}
}

140
PROGRESS.md Normal file
View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 KiB

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.6 KiB

View File

@@ -8,7 +8,7 @@
<meta name="theme-color" content="#c26dbc" /> <meta name="theme-color" content="#c26dbc" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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> <title>Dimension47</title>
</head> </head>
<body> <body>

View File

@@ -68,7 +68,6 @@
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.28.6", "@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6", "@babel/generator": "^7.28.6",
@@ -1747,7 +1746,6 @@
"integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
@@ -1758,7 +1756,6 @@
"integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -1818,7 +1815,6 @@
"integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==", "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/scope-manager": "8.53.0",
"@typescript-eslint/types": "8.53.0", "@typescript-eslint/types": "8.53.0",
@@ -2070,7 +2066,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -2193,7 +2188,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -2575,7 +2569,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -3763,7 +3756,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -3831,7 +3823,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -3841,7 +3832,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -4147,7 +4137,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -4234,7 +4223,6 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -4385,7 +4373,6 @@
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View File

@@ -2,7 +2,8 @@ import { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { LoginPage, RegisterPage, useAuthStore } from '@/features/auth'; 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 { ProtectedRoute } from '@/shared/components/protected-route';
import { Layout } from '@/shared/components/layout'; import { Layout } from '@/shared/components/layout';
@@ -43,7 +44,8 @@ function AppContent() {
<Route element={<ProtectedRoute />}> <Route element={<ProtectedRoute />}>
<Route element={<Layout />}> <Route element={<Layout />}>
<Route path="/" element={<CampaignsPage />} /> <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 path="/library" element={<div>Library (TODO)</div>} />
</Route> </Route>
</Route> </Route>

View File

@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import { Button, Input, Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/shared/components/ui'; import { Button, Input, Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/shared/components/ui';
import { useAuthStore } from '../hooks/use-auth-store'; import { useAuthStore } from '../hooks/use-auth-store';
import LogoVertical from '../../../../Logos/Logo_Vertikal.svg';
export function LoginPage() { export function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -30,26 +31,17 @@ export function LoginPage() {
}; };
return ( 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"> <Card className="w-full max-w-md">
<CardHeader className="text-center"> <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"> <CardTitle>Willkommen zurück</CardTitle>
<svg
className="h-6 w-6 text-primary-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1 1 21.75 8.25Z"
/>
</svg>
</div>
<CardTitle>Willkommen zur\u00fcck</CardTitle>
<CardDescription> <CardDescription>
Melde dich an, um fortzufahren Melde dich an, um fortzufahren
</CardDescription> </CardDescription>

View File

@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import { Button, Input, Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/shared/components/ui'; import { Button, Input, Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/shared/components/ui';
import { useAuthStore } from '../hooks/use-auth-store'; import { useAuthStore } from '../hooks/use-auth-store';
import LogoVertical from '../../../../Logos/Logo_Vertikal.svg';
export function RegisterPage() { export function RegisterPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -41,25 +42,16 @@ export function RegisterPage() {
}; };
return ( 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"> <Card className="w-full max-w-md">
<CardHeader className="text-center"> <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> <CardTitle>Konto erstellen</CardTitle>
<CardDescription> <CardDescription>
Registriere dich f\u00fcr Dimension47 Registriere dich f\u00fcr Dimension47

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

View File

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

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

View File

@@ -1,2 +1,5 @@
export { CampaignsPage } from './components/campaigns-page'; export { CampaignsPage } from './components/campaigns-page';
export { CampaignDetailPage } from './components/campaign-detail-page';
export { CreateCampaignModal } from './components/create-campaign-modal'; export { CreateCampaignModal } from './components/create-campaign-modal';
export { AddMemberModal } from './components/add-member-modal';
export { EditCampaignModal } from './components/edit-campaign-modal';

View File

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

View File

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

View File

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

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

View File

@@ -1,6 +1,8 @@
import { Outlet, Link, useNavigate } from 'react-router-dom'; import { Outlet, Link, useNavigate } from 'react-router-dom';
import { useAuthStore } from '@/features/auth'; import { useAuthStore } from '@/features/auth';
import { Button } from './ui'; import { Button } from './ui';
import LogoIcon from '../../../Logos/Logo_ohne_Text.svg';
import ZeasyLogo from '../../../Logos/Zeasy/logo_text_horizontal.svg';
export function Layout() { export function Layout() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -18,12 +20,17 @@ export function Layout() {
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<div className="flex h-16 items-center justify-between"> <div className="flex h-16 items-center justify-between">
{/* Logo */} {/* Logo */}
<Link to="/" className="flex items-center gap-2"> <Link to="/" className="flex items-center gap-3">
<div className="h-8 w-8 rounded-lg bg-primary-500 flex items-center justify-center"> <img
<span className="text-white font-bold text-sm">D47</span> src={LogoIcon}
</div> alt="Dimension 47"
<span className="font-semibold text-text-primary hidden sm:block"> className="h-10 w-10"
Dimension47 />
<span
className="text-xl font-semibold text-text-primary tracking-wide hidden sm:block"
style={{ fontFamily: 'Cinzel, serif' }}
>
Dimension 47
</span> </span>
</Link> </Link>
@@ -73,7 +80,17 @@ export function Layout() {
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<div className="flex items-center justify-center gap-2 text-text-muted text-sm"> <div className="flex items-center justify-center gap-2 text-text-muted text-sm">
<span>Powered by</span> <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>
</div> </div>
</footer> </footer>

View File

@@ -30,7 +30,11 @@ class ApiClient {
this.client.interceptors.response.use( this.client.interceptors.response.use(
(response) => response, (response) => response,
(error: AxiosError<ApiError>) => { (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(); this.clearToken();
window.location.href = '/login'; window.location.href = '/login';
} }
@@ -115,14 +119,80 @@ class ApiClient {
return response.data; return response.data;
} }
// Character endpoints (placeholder - to be expanded) // Character endpoints
async getCharacters(campaignId: string) { async getCharacters(campaignId: string) {
const response = await this.client.get(`/characters/${campaignId}`); const response = await this.client.get(`/campaigns/${campaignId}/characters`);
return response.data; return response.data;
} }
async getCharacter(campaignId: string, characterId: string) { 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; return response.data;
} }
} }

268
server/package-lock.json generated
View File

@@ -1,14 +1,15 @@
{ {
"name": "server", "name": "dimension47-server",
"version": "0.0.1", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "server", "name": "dimension47-server",
"version": "0.0.1", "version": "1.0.0",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.71.2",
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
@@ -18,6 +19,7 @@
"@nestjs/platform-socket.io": "^11.1.12", "@nestjs/platform-socket.io": "^11.1.12",
"@nestjs/swagger": "^11.2.5", "@nestjs/swagger": "^11.2.5",
"@nestjs/websockets": "^11.1.12", "@nestjs/websockets": "^11.1.12",
"@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0", "@prisma/client": "^7.2.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
@@ -205,6 +207,26 @@
"tslib": "^2.1.0" "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": { "node_modules/@babel/code-frame": {
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
@@ -236,7 +258,6 @@
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.28.6", "@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6", "@babel/generator": "^7.28.6",
@@ -667,6 +688,15 @@
"@babel/core": "^7.0.0-0" "@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": { "node_modules/@babel/template": {
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "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", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.2.tgz",
"integrity": "sha512-zfWWa+V2ViDCY/cmUfRqeWY1yLto+EpxjXnZzenB1TyxsTiXaTWeZFIZw6mac52BsuQm0RjCnisjBtdBaXOI6w==", "integrity": "sha512-zfWWa+V2ViDCY/cmUfRqeWY1yLto+EpxjXnZzenB1TyxsTiXaTWeZFIZw6mac52BsuQm0RjCnisjBtdBaXOI6w==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0"
"peer": true
}, },
"node_modules/@electric-sql/pglite-socket": { "node_modules/@electric-sql/pglite-socket": {
"version": "0.0.6", "version": "0.0.6",
@@ -2250,7 +2279,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.12.tgz", "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.12.tgz",
"integrity": "sha512-v6U3O01YohHO+IE3EIFXuRuu3VJILWzyMmSYZXpyBbnp0hk0mFyHxK2w3dF4I5WnbwiRbWlEXdeXFvPQ7qaZzw==", "integrity": "sha512-v6U3O01YohHO+IE3EIFXuRuu3VJILWzyMmSYZXpyBbnp0hk0mFyHxK2w3dF4I5WnbwiRbWlEXdeXFvPQ7qaZzw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"file-type": "21.3.0", "file-type": "21.3.0",
"iterare": "1.2.1", "iterare": "1.2.1",
@@ -2310,7 +2338,6 @@
"integrity": "sha512-97DzTYMf5RtGAVvX1cjwpKRiCUpkeQ9CCzSAenqkAhOmNVVFaApbhuw+xrDt13rsCa2hHVOYPrV4dBgOYMJjsA==", "integrity": "sha512-97DzTYMf5RtGAVvX1cjwpKRiCUpkeQ9CCzSAenqkAhOmNVVFaApbhuw+xrDt13rsCa2hHVOYPrV4dBgOYMJjsA==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@nuxt/opencollective": "0.4.1", "@nuxt/opencollective": "0.4.1",
"fast-safe-stringify": "2.1.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", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.12.tgz",
"integrity": "sha512-GYK/vHI0SGz5m8mxr7v3Urx8b9t78Cf/dj5aJMZlGd9/1D9OI1hAl00BaphjEXINUJ/BQLxIlF2zUjrYsd6enQ==", "integrity": "sha512-GYK/vHI0SGz5m8mxr7v3Urx8b9t78Cf/dj5aJMZlGd9/1D9OI1hAl00BaphjEXINUJ/BQLxIlF2zUjrYsd6enQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cors": "2.8.5", "cors": "2.8.5",
"express": "5.2.1", "express": "5.2.1",
@@ -2416,7 +2442,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.12.tgz", "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.12.tgz",
"integrity": "sha512-1itTTYsAZecrq2NbJOkch32y8buLwN7UpcNRdJrhlS+ovJ5GxLx3RyJ3KylwBhbYnO5AeYyL1U/i4W5mg/4qDA==", "integrity": "sha512-1itTTYsAZecrq2NbJOkch32y8buLwN7UpcNRdJrhlS+ovJ5GxLx3RyJ3KylwBhbYnO5AeYyL1U/i4W5mg/4qDA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"socket.io": "4.8.3", "socket.io": "4.8.3",
"tslib": "2.8.1" "tslib": "2.8.1"
@@ -2595,7 +2620,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.12.tgz", "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.12.tgz",
"integrity": "sha512-ulSOYcgosx1TqY425cRC5oXtAu1R10+OSmVfgyR9ueR25k4luekURt8dzAZxhxSCI0OsDj9WKCFLTkEuAwg0wg==", "integrity": "sha512-ulSOYcgosx1TqY425cRC5oXtAu1R10+OSmVfgyR9ueR25k4luekURt8dzAZxhxSCI0OsDj9WKCFLTkEuAwg0wg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"iterare": "1.2.1", "iterare": "1.2.1",
"object-hash": "3.0.0", "object-hash": "3.0.0",
@@ -2677,6 +2701,17 @@
"url": "https://opencollective.com/pkgr" "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": { "node_modules/@prisma/client": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.2.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.2.0.tgz",
@@ -2724,7 +2759,6 @@
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz",
"integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==",
"devOptional": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/dev": { "node_modules/@prisma/dev": {
@@ -2753,6 +2787,15 @@
"zeptomatch": "2.0.2" "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": { "node_modules/@prisma/engines": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.2.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.2.0.tgz",
@@ -3049,7 +3092,6 @@
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/estree": "*", "@types/estree": "*",
"@types/json-schema": "*" "@types/json-schema": "*"
@@ -3188,7 +3230,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -3370,7 +3411,6 @@
"integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==", "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/scope-manager": "8.53.0",
"@typescript-eslint/types": "8.53.0", "@typescript-eslint/types": "8.53.0",
@@ -4052,7 +4092,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -4102,7 +4141,6 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.1", "fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0", "fast-json-stable-stringify": "^2.0.0",
@@ -4545,7 +4583,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -4807,7 +4844,6 @@
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"readdirp": "^4.0.1" "readdirp": "^4.0.1"
}, },
@@ -4865,15 +4901,13 @@
"version": "0.5.1", "version": "0.5.1",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/class-validator": { "node_modules/class-validator": {
"version": "0.14.3", "version": "0.14.3",
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz",
"integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/validator": "^13.15.3", "@types/validator": "^13.15.3",
"libphonenumber-js": "^1.11.1", "libphonenumber-js": "^1.11.1",
@@ -5227,7 +5261,8 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
@@ -5691,7 +5726,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -5752,7 +5786,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"eslint-config-prettier": "bin/cli.js" "eslint-config-prettier": "bin/cli.js"
}, },
@@ -5985,7 +6018,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"accepts": "^2.0.0", "accepts": "^2.0.0",
"body-parser": "^2.2.1", "body-parser": "^2.2.1",
@@ -6723,7 +6755,6 @@
"integrity": "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g==", "integrity": "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=16.9.0" "node": ">=16.9.0"
} }
@@ -7110,7 +7141,6 @@
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jest/core": "30.2.0", "@jest/core": "30.2.0",
"@jest/types": "30.2.0", "@jest/types": "30.2.0",
@@ -7904,6 +7934,19 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/json-schema-traverse": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "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", "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"passport-strategy": "1.x.x", "passport-strategy": "1.x.x",
"pause": "0.0.1", "pause": "0.0.1",
@@ -9024,6 +9066,104 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -9159,6 +9299,45 @@
"url": "https://github.com/sponsors/porsager" "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": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -9175,7 +9354,6 @@
"integrity": "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==", "integrity": "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"
}, },
@@ -9234,7 +9412,6 @@
"devOptional": true, "devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@prisma/config": "7.2.0", "@prisma/config": "7.2.0",
"@prisma/dev": "0.17.0", "@prisma/dev": "0.17.0",
@@ -9445,8 +9622,7 @@
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0", "license": "Apache-2.0"
"peer": true
}, },
"node_modules/regexp-to-ast": { "node_modules/regexp-to-ast": {
"version": "0.5.0", "version": "0.5.0",
@@ -9583,7 +9759,6 @@
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"tslib": "^2.1.0" "tslib": "^2.1.0"
} }
@@ -9619,7 +9794,8 @@
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/schema-utils": { "node_modules/schema-utils": {
"version": "3.3.0", "version": "3.3.0",
@@ -9952,6 +10128,15 @@
"node": ">=0.10.0" "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": { "node_modules/sprintf-js": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "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==", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@@ -10551,6 +10735,12 @@
"url": "https://github.com/sponsors/Borewit" "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": { "node_modules/ts-api-utils": {
"version": "2.4.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
@@ -10657,7 +10847,6 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@cspotcode/source-map-support": "^0.8.0", "@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7", "@tsconfig/node10": "^1.0.7",
@@ -10805,7 +10994,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -11087,7 +11275,6 @@
"integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/eslint-scope": "^3.7.7", "@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8", "@types/estree": "^1.0.8",
@@ -11157,7 +11344,6 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",

View File

@@ -23,6 +23,7 @@
"test:e2e": "jest --config ./test/jest-e2e.json" "test:e2e": "jest --config ./test/jest-e2e.json"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.71.2",
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
@@ -32,6 +33,7 @@
"@nestjs/platform-socket.io": "^11.1.12", "@nestjs/platform-socket.io": "^11.1.12",
"@nestjs/swagger": "^11.2.5", "@nestjs/swagger": "^11.2.5",
"@nestjs/websockets": "^11.1.12", "@nestjs/websockets": "^11.1.12",
"@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0", "@prisma/client": "^7.2.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",

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

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

View File

@@ -2,8 +2,9 @@
// Prisma Schema // Prisma Schema
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client"
output = "../src/generated/prisma" output = "../src/generated/prisma"
moduleFormat = "cjs"
} }
datasource db { datasource db {

28
server/prisma/seed.ts Normal file
View 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());

View File

@@ -4,10 +4,13 @@ import { APP_GUARD } from '@nestjs/core';
// Core Modules // Core Modules
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { ClaudeModule } from './modules/claude/claude.module';
// Feature Modules // Feature Modules
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from './modules/auth/auth.module';
import { CampaignsModule } from './modules/campaigns/campaigns.module'; import { CampaignsModule } from './modules/campaigns/campaigns.module';
import { CharactersModule } from './modules/characters/characters.module';
import { TranslationsModule } from './modules/translations/translations.module';
// Guards // Guards
import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard';
@@ -23,10 +26,13 @@ import { RolesGuard } from './modules/auth/guards/roles.guard';
// Core // Core
PrismaModule, PrismaModule,
ClaudeModule,
// Features // Features
AuthModule, AuthModule,
CampaignsModule, CampaignsModule,
CharactersModule,
TranslationsModule,
], ],
providers: [ providers: [
// Global JWT Auth Guard // Global JWT Auth Guard

View File

@@ -1,5 +1,5 @@
import { SetMetadata } from '@nestjs/common'; 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_KEY = 'roles';
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles); export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);

View File

@@ -24,9 +24,25 @@ async function bootstrap() {
); );
// CORS // CORS
const corsOrigins = configService.get<string>('CORS_ORIGINS', 'http://localhost:3000,http://localhost:5173'); const nodeEnv = configService.get<string>('NODE_ENV', 'development');
app.enableCors({ 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, credentials: true,
}); });

View File

@@ -19,7 +19,7 @@ import { RegisterDto, LoginDto } from './dto';
import { Public } from '../../common/decorators/public.decorator'; import { Public } from '../../common/decorators/public.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator'; import { Roles } from '../../common/decorators/roles.decorator';
import { UserRole } from '../../generated/prisma'; import { UserRole } from '../../generated/prisma/client.js';
@ApiTags('Auth') @ApiTags('Auth')
@Controller('auth') @Controller('auth')

View File

@@ -8,7 +8,7 @@ import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { RegisterDto, LoginDto } from './dto'; import { RegisterDto, LoginDto } from './dto';
import { UserRole } from '../../generated/prisma'; import { UserRole } from '../../generated/prisma/client.js';
@Injectable() @Injectable()
export class AuthService { export class AuthService {

View File

@@ -1,6 +1,6 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; 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'; import { ROLES_KEY } from '../../../common/decorators/roles.decorator';
@Injectable() @Injectable()

View File

@@ -16,7 +16,7 @@ import {
import { CampaignsService } from './campaigns.service'; import { CampaignsService } from './campaigns.service';
import { CreateCampaignDto, UpdateCampaignDto, AddMemberDto } from './dto'; import { CreateCampaignDto, UpdateCampaignDto, AddMemberDto } from './dto';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { UserRole } from '../../generated/prisma'; import { UserRole } from '../../generated/prisma/client.js';
@ApiTags('Campaigns') @ApiTags('Campaigns')
@ApiBearerAuth() @ApiBearerAuth()

View File

@@ -6,7 +6,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { CreateCampaignDto, UpdateCampaignDto, AddMemberDto } from './dto'; import { CreateCampaignDto, UpdateCampaignDto, AddMemberDto } from './dto';
import { UserRole } from '../../generated/prisma'; import { UserRole } from '../../generated/prisma/client.js';
@Injectable() @Injectable()
export class CampaignsService { export class CampaignsService {

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

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

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

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

View File

@@ -0,0 +1,3 @@
export * from './create-character.dto';
export * from './update-character.dto';
export * from './pathbuilder-import.dto';

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

View File

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

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

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

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

View File

@@ -0,0 +1,2 @@
export * from './claude.service';
export * from './claude.module';

View File

@@ -0,0 +1,3 @@
export * from './translations.service';
export * from './translations.controller';
export * from './translations.module';

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

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

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

View File

@@ -1,11 +1,17 @@
import 'dotenv/config'; import 'dotenv/config';
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; 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() @Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
constructor() { constructor() {
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL,
});
super({ super({
adapter,
log: process.env.NODE_ENV === 'development' log: process.env.NODE_ENV === 'development'
? ['query', 'info', 'warn', 'error'] ? ['query', 'info', 'warn', 'error']
: ['error'], : ['error'],