Initial commit: Dimension47 project setup

- NestJS backend with JWT auth, Prisma ORM, Swagger docs
- Vite + React 19 frontend with TypeScript
- Tailwind CSS v4 with custom dark theme design system
- Auth module: Login, Register, Protected routes
- Campaigns module: CRUD, Member management
- Full Prisma schema for PF2e campaign management
- Docker Compose for PostgreSQL

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-18 16:24:18 +01:00
commit 090aae53d8
73 changed files with 19842 additions and 0 deletions

67
client/src/App.tsx Normal file
View File

@@ -0,0 +1,67 @@
import { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { LoginPage, RegisterPage, useAuthStore } from '@/features/auth';
import { CampaignsPage } from '@/features/campaigns';
import { ProtectedRoute } from '@/shared/components/protected-route';
import { Layout } from '@/shared/components/layout';
// Create a client
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 1,
},
},
});
function AppContent() {
const { checkAuth, isAuthenticated } = useAuthStore();
useEffect(() => {
checkAuth();
}, [checkAuth]);
return (
<Routes>
{/* Public Routes */}
<Route
path="/login"
element={
isAuthenticated ? <Navigate to="/" replace /> : <LoginPage />
}
/>
<Route
path="/register"
element={
isAuthenticated ? <Navigate to="/" replace /> : <RegisterPage />
}
/>
{/* Protected Routes */}
<Route element={<ProtectedRoute />}>
<Route element={<Layout />}>
<Route path="/" element={<CampaignsPage />} />
<Route path="/campaigns/:id" element={<div>Campaign Detail (TODO)</div>} />
<Route path="/library" element={<div>Library (TODO)</div>} />
</Route>
</Route>
{/* Fallback */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AppContent />
</BrowserRouter>
</QueryClientProvider>
);
}
export default App;

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,105 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { Button, Input, Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/shared/components/ui';
import { useAuthStore } from '../hooks/use-auth-store';
export function LoginPage() {
const navigate = useNavigate();
const { login, isLoading, error, clearError } = useAuthStore();
const [identifier, setIdentifier] = useState('');
const [password, setPassword] = useState('');
const [formError, setFormError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError('');
clearError();
if (!identifier.trim() || !password) {
setFormError('Bitte alle Felder ausf\u00fcllen');
return;
}
try {
await login(identifier.trim(), password);
navigate('/');
} catch (err) {
// Error is handled in the store
console.error('Login failed:', err);
}
};
return (
<div className="min-h-screen flex items-center justify-center px-4 py-12 bg-bg-primary">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 h-12 w-12 rounded-xl bg-primary-500/10 flex items-center justify-center">
<svg
className="h-6 w-6 text-primary-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1 1 21.75 8.25Z"
/>
</svg>
</div>
<CardTitle>Willkommen zur\u00fcck</CardTitle>
<CardDescription>
Melde dich an, um fortzufahren
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{(error || formError) && (
<div className="p-3 rounded-lg bg-error-500/10 border border-error-500/20 text-error-500 text-sm">
{error || formError}
</div>
)}
<Input
label="Benutzername oder E-Mail"
type="text"
placeholder="dein-username"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
autoComplete="username"
disabled={isLoading}
/>
<Input
label="Passwort"
type="password"
placeholder="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
disabled={isLoading}
/>
</CardContent>
<CardFooter className="flex-col gap-4">
<Button
type="submit"
className="w-full"
isLoading={isLoading}
>
Anmelden
</Button>
<p className="text-sm text-text-secondary text-center">
Noch kein Konto?{' '}
<Link
to="/register"
className="text-primary-500 hover:text-primary-400 font-medium"
>
Registrieren
</Link>
</p>
</CardFooter>
</form>
</Card>
</div>
);
}

View File

@@ -0,0 +1,134 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { Button, Input, Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/shared/components/ui';
import { useAuthStore } from '../hooks/use-auth-store';
export function RegisterPage() {
const navigate = useNavigate();
const { register, isLoading, error, clearError } = useAuthStore();
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [formError, setFormError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError('');
clearError();
if (!username.trim() || !email.trim() || !password) {
setFormError('Bitte alle Felder ausf\u00fcllen');
return;
}
if (password !== confirmPassword) {
setFormError('Passw\u00f6rter stimmen nicht \u00fcberein');
return;
}
if (password.length < 8) {
setFormError('Passwort muss mindestens 8 Zeichen lang sein');
return;
}
try {
await register(username.trim(), email.trim(), password);
navigate('/');
} catch (err) {
console.error('Registration failed:', err);
}
};
return (
<div className="min-h-screen flex items-center justify-center px-4 py-12 bg-bg-primary">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 h-12 w-12 rounded-xl bg-primary-500/10 flex items-center justify-center">
<svg
className="h-6 w-6 text-primary-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M18 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0ZM3 19.235v-.11a6.375 6.375 0 0 1 12.75 0v.109A12.318 12.318 0 0 1 9.374 21c-2.331 0-4.512-.645-6.374-1.766Z"
/>
</svg>
</div>
<CardTitle>Konto erstellen</CardTitle>
<CardDescription>
Registriere dich f\u00fcr Dimension47
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{(error || formError) && (
<div className="p-3 rounded-lg bg-error-500/10 border border-error-500/20 text-error-500 text-sm">
{error || formError}
</div>
)}
<Input
label="Benutzername"
type="text"
placeholder="dein-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
disabled={isLoading}
/>
<Input
label="E-Mail"
type="email"
placeholder="deine@email.de"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
disabled={isLoading}
/>
<Input
label="Passwort"
type="password"
placeholder="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
disabled={isLoading}
/>
<Input
label="Passwort best\u00e4tigen"
type="password"
placeholder="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
disabled={isLoading}
/>
</CardContent>
<CardFooter className="flex-col gap-4">
<Button
type="submit"
className="w-full"
isLoading={isLoading}
>
Registrieren
</Button>
<p className="text-sm text-text-secondary text-center">
Bereits ein Konto?{' '}
<Link
to="/login"
className="text-primary-500 hover:text-primary-400 font-medium"
>
Anmelden
</Link>
</p>
</CardFooter>
</form>
</Card>
</div>
);
}

View File

@@ -0,0 +1,103 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { User } from '@/shared/types';
import { api } from '@/shared/lib/api';
interface AuthState {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
// Actions
login: (identifier: string, password: string) => Promise<void>;
register: (username: string, email: string, password: string) => Promise<void>;
logout: () => void;
checkAuth: () => Promise<void>;
clearError: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
login: async (identifier: string, password: string) => {
set({ isLoading: true, error: null });
try {
const response = await api.login(identifier, password);
api.setToken(response.token);
set({
user: response.user,
isAuthenticated: true,
isLoading: false,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Login failed';
set({ error: message, isLoading: false });
throw error;
}
},
register: async (username: string, email: string, password: string) => {
set({ isLoading: true, error: null });
try {
const response = await api.register(username, email, password);
api.setToken(response.token);
set({
user: response.user,
isAuthenticated: true,
isLoading: false,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Registration failed';
set({ error: message, isLoading: false });
throw error;
}
},
logout: () => {
api.clearToken();
set({
user: null,
isAuthenticated: false,
error: null,
});
},
checkAuth: async () => {
const token = api.getToken();
if (!token) {
set({ isAuthenticated: false, user: null });
return;
}
set({ isLoading: true });
try {
const user = await api.getProfile();
set({
user,
isAuthenticated: true,
isLoading: false,
});
} catch {
api.clearToken();
set({
user: null,
isAuthenticated: false,
isLoading: false,
});
}
},
clearError: () => set({ error: null }),
}),
{
name: 'auth-storage',
partialize: (state) => ({ user: state.user, isAuthenticated: state.isAuthenticated }),
}
)
);

View File

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

View File

@@ -0,0 +1,128 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Plus, Users, Swords } from 'lucide-react';
import { Button, Card, CardHeader, CardTitle, CardDescription, CardContent, Spinner } from '@/shared/components/ui';
import { api } from '@/shared/lib/api';
import type { Campaign } from '@/shared/types';
import { CreateCampaignModal } from './create-campaign-modal';
export function CampaignsPage() {
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showCreateModal, setShowCreateModal] = useState(false);
const fetchCampaigns = async () => {
try {
const data = await api.getCampaigns();
setCampaigns(data);
} catch (error) {
console.error('Failed to fetch campaigns:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchCampaigns();
}, []);
const handleCampaignCreated = () => {
setShowCreateModal(false);
fetchCampaigns();
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Spinner size="lg" />
</div>
);
}
return (
<div className="space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Kampagnen</h1>
<p className="text-text-secondary mt-1">
Verwalte deine Pathfinder 2e Kampagnen
</p>
</div>
<Button onClick={() => setShowCreateModal(true)}>
<Plus className="h-4 w-4" />
Neue Kampagne
</Button>
</div>
{/* Campaigns Grid */}
{campaigns.length === 0 ? (
<Card className="p-12 text-center">
<div className="mx-auto h-12 w-12 rounded-xl bg-primary-500/10 flex items-center justify-center mb-4">
<Swords className="h-6 w-6 text-primary-500" />
</div>
<h3 className="text-lg font-medium text-text-primary mb-2">
Keine Kampagnen
</h3>
<p className="text-text-secondary mb-6">
Erstelle deine erste Kampagne, um loszulegen.
</p>
<Button onClick={() => setShowCreateModal(true)}>
<Plus className="h-4 w-4" />
Kampagne erstellen
</Button>
</Card>
) : (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{campaigns.map((campaign) => (
<Link key={campaign.id} to={`/campaigns/${campaign.id}`}>
<Card className="h-full hover:border-border-hover transition-colors cursor-pointer">
{campaign.imageUrl ? (
<div className="h-32 bg-bg-tertiary rounded-t-xl overflow-hidden">
<img
src={campaign.imageUrl}
alt={campaign.name}
className="w-full h-full object-cover"
/>
</div>
) : (
<div className="h-32 bg-gradient-to-br from-primary-500/20 to-secondary-800/20 rounded-t-xl flex items-center justify-center">
<Swords className="h-8 w-8 text-primary-500/50" />
</div>
)}
<CardHeader>
<CardTitle className="text-lg">{campaign.name}</CardTitle>
{campaign.description && (
<CardDescription className="line-clamp-2">
{campaign.description}
</CardDescription>
)}
</CardHeader>
<CardContent className="pt-0">
<div className="flex items-center gap-4 text-sm text-text-secondary">
<div className="flex items-center gap-1">
<Users className="h-4 w-4" />
<span>{campaign.members?.length || 0} Spieler</span>
</div>
<div className="flex items-center gap-1">
<Swords className="h-4 w-4" />
<span>{campaign._count?.characters || 0} Charaktere</span>
</div>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
)}
{/* Create Campaign Modal */}
{showCreateModal && (
<CreateCampaignModal
onClose={() => setShowCreateModal(false)}
onCreated={handleCampaignCreated}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,111 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import { Button, Input, Card, CardHeader, CardTitle, CardContent, CardFooter } from '@/shared/components/ui';
import { api } from '@/shared/lib/api';
interface CreateCampaignModalProps {
onClose: () => void;
onCreated: () => void;
}
export function CreateCampaignModal({ onClose, onCreated }: CreateCampaignModalProps) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!name.trim()) {
setError('Bitte einen Namen eingeben');
return;
}
setIsLoading(true);
try {
await api.createCampaign({
name: name.trim(),
description: description.trim() || undefined,
});
onCreated();
} catch (err) {
console.error('Failed to create campaign:', err);
setError('Kampagne konnte nicht erstellt werden');
} finally {
setIsLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<Card className="relative z-10 w-full max-w-md mx-4 animate-slide-up">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Neue Kampagne</CardTitle>
<button
onClick={onClose}
className="h-8 w-8 rounded-lg hover:bg-bg-tertiary flex items-center justify-center transition-colors"
>
<X className="h-4 w-4 text-text-secondary" />
</button>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{error && (
<div className="p-3 rounded-lg bg-error-500/10 border border-error-500/20 text-error-500 text-sm">
{error}
</div>
)}
<Input
label="Name"
type="text"
placeholder="z.B. Rise of the Runelords"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={isLoading}
autoFocus
/>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1.5">
Beschreibung (optional)
</label>
<textarea
className="flex min-h-[100px] w-full rounded-lg border border-border bg-bg-secondary px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-primary-500 focus:ring-2 focus:ring-primary-500/20 disabled:cursor-not-allowed disabled:opacity-50 transition-colors resize-none"
placeholder="Eine kurze Beschreibung der Kampagne..."
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={isLoading}
/>
</div>
</CardContent>
<CardFooter className="gap-3">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isLoading}
className="flex-1"
>
Abbrechen
</Button>
<Button
type="submit"
isLoading={isLoading}
className="flex-1"
>
Erstellen
</Button>
</CardFooter>
</form>
</Card>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { CampaignsPage } from './components/campaigns-page';
export { CreateCampaignModal } from './components/create-campaign-modal';

162
client/src/index.css Normal file
View File

@@ -0,0 +1,162 @@
@import "tailwindcss";
/* Dimension47 Design System */
@theme {
/* Primary Colors - Dimension47 Magenta */
--color-primary-50: #fdf2fc;
--color-primary-100: #fce8fa;
--color-primary-200: #f9cef4;
--color-primary-300: #f5a5ea;
--color-primary-400: #ed6dd9;
--color-primary-500: #c26dbc;
--color-primary-600: #9a4a94;
--color-primary-700: #7a3977;
--color-primary-800: #5e2e5a;
--color-primary-900: #4a274a;
--color-primary-950: #2d0f2c;
/* Secondary Colors - Dark Purple (47) */
--color-secondary-50: #f9f5f9;
--color-secondary-100: #f4eaf3;
--color-secondary-200: #e7d5e6;
--color-secondary-300: #d4b4d2;
--color-secondary-400: #b988b5;
--color-secondary-500: #9a6495;
--color-secondary-600: #7d4d79;
--color-secondary-700: #663d62;
--color-secondary-800: #542e52;
--color-secondary-900: #472945;
--color-secondary-950: #2a1429;
/* Background Colors (Dark Mode) */
--color-bg-primary: #0f0f12;
--color-bg-secondary: #1a1a1f;
--color-bg-tertiary: #242429;
--color-bg-elevated: #2a2a30;
/* Text Colors */
--color-text-primary: #f5f5f7;
--color-text-secondary: #a1a1a6;
--color-text-muted: #6b6b70;
--color-text-inverse: #0f0f12;
/* Border Colors */
--color-border: #2a2a2f;
--color-border-hover: #3a3a3f;
--color-border-focus: #c26dbc;
/* Semantic Colors */
--color-success-50: #f0fdf4;
--color-success-500: #22c55e;
--color-success-600: #16a34a;
--color-warning-50: #fffbeb;
--color-warning-500: #f59e0b;
--color-warning-600: #d97706;
--color-error-50: #fef2f2;
--color-error-500: #ef4444;
--color-error-600: #dc2626;
--color-info-50: #eff6ff;
--color-info-500: #3b82f6;
--color-info-600: #2563eb;
/* Fonts */
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
/* Border Radius */
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
--radius-2xl: 1rem;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.4);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5), 0 4px 6px -4px rgb(0 0 0 / 0.5);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.5), 0 8px 10px -6px rgb(0 0 0 / 0.5);
/* Animations */
--animate-fade-in: fade-in 0.2s ease-out;
--animate-slide-up: slide-up 0.3s ease-out;
--animate-slide-down: slide-down 0.3s ease-out;
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slide-down {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Base Styles */
html {
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: var(--font-sans);
min-height: 100vh;
margin: 0;
}
/* Focus Styles */
*:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--color-bg-primary), 0 0 0 4px var(--color-primary-500);
}
/* Selection */
::selection {
background-color: rgba(194, 109, 188, 0.3);
}
/* Scrollbar Styles */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background-color: var(--color-bg-secondary);
}
::-webkit-scrollbar-thumb {
background-color: var(--color-border);
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--color-border-hover);
}

10
client/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,82 @@
import { Outlet, Link, useNavigate } from 'react-router-dom';
import { useAuthStore } from '@/features/auth';
import { Button } from './ui';
export function Layout() {
const navigate = useNavigate();
const { user, logout } = useAuthStore();
const handleLogout = () => {
logout();
navigate('/login');
};
return (
<div className="min-h-screen bg-bg-primary">
{/* Header */}
<header className="sticky top-0 z-50 border-b border-border bg-bg-secondary/80 backdrop-blur-sm">
<div className="container mx-auto px-4">
<div className="flex h-16 items-center justify-between">
{/* Logo */}
<Link to="/" className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-primary-500 flex items-center justify-center">
<span className="text-white font-bold text-sm">D47</span>
</div>
<span className="font-semibold text-text-primary hidden sm:block">
Dimension47
</span>
</Link>
{/* Navigation */}
<nav className="hidden md:flex items-center gap-6">
<Link
to="/"
className="text-sm text-text-secondary hover:text-text-primary transition-colors"
>
Kampagnen
</Link>
<Link
to="/library"
className="text-sm text-text-secondary hover:text-text-primary transition-colors"
>
Bibliothek
</Link>
</nav>
{/* User Menu */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-full bg-primary-500/20 flex items-center justify-center">
<span className="text-sm font-medium text-primary-500">
{user?.username?.charAt(0).toUpperCase()}
</span>
</div>
<span className="text-sm text-text-secondary hidden sm:block">
{user?.username}
</span>
</div>
<Button variant="ghost" size="sm" onClick={handleLogout}>
Abmelden
</Button>
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="container mx-auto px-4 py-8">
<Outlet />
</main>
{/* Footer */}
<footer className="border-t border-border py-6 mt-auto">
<div className="container mx-auto px-4">
<div className="flex items-center justify-center gap-2 text-text-muted text-sm">
<span>Powered by</span>
<span className="font-medium text-text-secondary">Zeasy Software</span>
</div>
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { Navigate, Outlet } from 'react-router-dom';
import { useAuthStore } from '@/features/auth';
import { Spinner } from './ui';
export function ProtectedRoute() {
const { isAuthenticated, isLoading } = useAuthStore();
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-bg-primary">
<Spinner size="lg" />
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <Outlet />;
}

View File

@@ -0,0 +1,51 @@
import * as React from 'react';
import { cn } from '@/shared/lib/utils';
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
size?: 'default' | 'sm' | 'lg' | 'icon';
isLoading?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'default', size = 'default', isLoading, disabled, children, ...props }, ref) => {
const baseStyles = 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 focus-visible:ring-offset-bg-primary disabled:pointer-events-none disabled:opacity-50';
const variants = {
default: 'bg-primary-500 text-white hover:bg-primary-600 active:bg-primary-700',
destructive: 'bg-error-500 text-white hover:bg-error-600 active:bg-error-600',
outline: 'border border-border bg-transparent hover:bg-bg-tertiary hover:border-border-hover',
secondary: 'bg-secondary-800 text-text-primary hover:bg-secondary-700',
ghost: 'hover:bg-bg-tertiary',
link: 'text-primary-500 underline-offset-4 hover:underline',
};
const sizes = {
default: 'h-11 px-4 py-2',
sm: 'h-9 px-3 text-xs',
lg: 'h-12 px-8',
icon: 'h-11 w-11',
};
return (
<button
className={cn(baseStyles, variants[variant], sizes[size], className)}
ref={ref}
disabled={disabled || isLoading}
{...props}
>
{isLoading ? (
<svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : null}
{children}
</button>
);
}
);
Button.displayName = 'Button';
export { Button };

View File

@@ -0,0 +1,69 @@
import * as React from 'react';
import { cn } from '@/shared/lib/utils';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-xl border border-border bg-bg-secondary shadow-sm',
className
)}
{...props}
/>
)
);
Card.displayName = 'Card';
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
)
);
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-xl font-semibold leading-none tracking-tight text-text-primary', className)}
{...props}
/>
)
);
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-text-secondary', className)}
{...props}
/>
)
);
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)
);
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
)
);
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

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

View File

@@ -0,0 +1,47 @@
import * as React from 'react';
import { cn } from '@/shared/lib/utils';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, label, error, id, ...props }, ref) => {
const inputId = id || React.useId();
return (
<div className="w-full">
{label && (
<label
htmlFor={inputId}
className="block text-sm font-medium text-text-secondary mb-1.5"
>
{label}
</label>
)}
<input
type={type}
id={inputId}
className={cn(
'flex h-11 w-full rounded-lg border bg-bg-secondary px-3 py-2 text-sm text-text-primary placeholder:text-text-muted',
'border-border focus:border-primary-500 focus:ring-2 focus:ring-primary-500/20',
'disabled:cursor-not-allowed disabled:opacity-50',
'transition-colors',
error && 'border-error-500 focus:border-error-500 focus:ring-error-500/20',
className
)}
ref={ref}
{...props}
/>
{error && (
<p className="mt-1.5 text-sm text-error-500">{error}</p>
)}
</div>
);
}
);
Input.displayName = 'Input';
export { Input };

View File

@@ -0,0 +1,45 @@
import * as React from 'react';
import { cn } from '@/shared/lib/utils';
interface SpinnerProps extends React.SVGAttributes<SVGSVGElement> {
size?: 'sm' | 'default' | 'lg';
}
const Spinner = React.forwardRef<SVGSVGElement, SpinnerProps>(
({ className, size = 'default', ...props }, ref) => {
const sizes = {
sm: 'h-4 w-4',
default: 'h-6 w-6',
lg: 'h-8 w-8',
};
return (
<svg
ref={ref}
className={cn('animate-spin text-primary-500', sizes[size], className)}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
}
);
Spinner.displayName = 'Spinner';
export { Spinner };

View File

@@ -0,0 +1,130 @@
import axios, { type AxiosError, type AxiosInstance } from 'axios';
import type { ApiError } from '@/shared/types';
const API_URL = import.meta.env.VITE_API_URL || '/api';
class ApiClient {
private client: AxiosInstance;
private token: string | null = null;
constructor() {
this.client = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Load token from localStorage
this.token = localStorage.getItem('auth_token');
// Request interceptor to add auth header
this.client.interceptors.request.use((config) => {
if (this.token) {
config.headers.Authorization = `Bearer ${this.token}`;
}
return config;
});
// Response interceptor to handle errors
this.client.interceptors.response.use(
(response) => response,
(error: AxiosError<ApiError>) => {
if (error.response?.status === 401) {
this.clearToken();
window.location.href = '/login';
}
return Promise.reject(error);
}
);
}
setToken(token: string) {
this.token = token;
localStorage.setItem('auth_token', token);
}
clearToken() {
this.token = null;
localStorage.removeItem('auth_token');
}
getToken() {
return this.token;
}
// Auth endpoints
async login(identifier: string, password: string) {
const response = await this.client.post('/auth/login', { identifier, password });
return response.data;
}
async register(username: string, email: string, password: string) {
const response = await this.client.post('/auth/register', { username, email, password });
return response.data;
}
async getProfile() {
const response = await this.client.get('/auth/me');
return response.data;
}
async updateProfile(data: { avatarUrl?: string }) {
const response = await this.client.put('/auth/profile', data);
return response.data;
}
async searchUsers(query: string) {
const response = await this.client.get('/auth/users/search', { params: { q: query } });
return response.data;
}
// Campaign endpoints
async getCampaigns() {
const response = await this.client.get('/campaigns');
return response.data;
}
async getCampaign(id: string) {
const response = await this.client.get(`/campaigns/${id}`);
return response.data;
}
async createCampaign(data: { name: string; description?: string; imageUrl?: string }) {
const response = await this.client.post('/campaigns', data);
return response.data;
}
async updateCampaign(id: string, data: { name?: string; description?: string; imageUrl?: string }) {
const response = await this.client.put(`/campaigns/${id}`, data);
return response.data;
}
async deleteCampaign(id: string) {
const response = await this.client.delete(`/campaigns/${id}`);
return response.data;
}
async addCampaignMember(campaignId: string, userId: string) {
const response = await this.client.post(`/campaigns/${campaignId}/members`, { userId });
return response.data;
}
async removeCampaignMember(campaignId: string, userId: string) {
const response = await this.client.delete(`/campaigns/${campaignId}/members/${userId}`);
return response.data;
}
// Character endpoints (placeholder - to be expanded)
async getCharacters(campaignId: string) {
const response = await this.client.get(`/characters/${campaignId}`);
return response.data;
}
async getCharacter(campaignId: string, characterId: string) {
const response = await this.client.get(`/characters/${campaignId}/${characterId}`);
return response.data;
}
}
export const api = new ApiClient();

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,238 @@
// User & Auth Types
export type UserRole = 'ADMIN' | 'GM' | 'PLAYER';
export interface User {
id: string;
username: string;
email: string;
role: UserRole;
avatarUrl?: string;
createdAt?: string;
updatedAt?: string;
}
export interface AuthResponse {
user: User;
token: string;
}
// Campaign Types
export interface Campaign {
id: string;
name: string;
description?: string;
gmId: string;
imageUrl?: string;
createdAt: string;
updatedAt: string;
gm: Pick<User, 'id' | 'username' | 'avatarUrl'>;
members: CampaignMember[];
characters?: CharacterSummary[];
_count?: {
characters: number;
battleSessions?: number;
documents?: number;
};
}
export interface CampaignMember {
campaignId: string;
userId: string;
joinedAt: string;
user: Pick<User, 'id' | 'username' | 'email' | 'avatarUrl'>;
}
// Character Types
export type CharacterType = 'PC' | 'NPC';
export type AbilityType = 'STR' | 'DEX' | 'CON' | 'INT' | 'WIS' | 'CHA';
export type Proficiency = 'UNTRAINED' | 'TRAINED' | 'EXPERT' | 'MASTER' | 'LEGENDARY';
export interface CharacterSummary {
id: string;
name: string;
type: CharacterType;
level: number;
avatarUrl?: string;
hpCurrent: number;
hpMax: number;
owner?: Pick<User, 'id' | 'username'>;
}
export interface Character extends CharacterSummary {
campaignId: string;
ownerId?: string;
hpTemp: number;
ancestryId?: string;
heritageId?: string;
classId?: string;
backgroundId?: string;
experiencePoints: number;
pathbuilderData?: unknown;
createdAt: string;
updatedAt: string;
abilities: CharacterAbility[];
feats: CharacterFeat[];
skills: CharacterSkill[];
spells: CharacterSpell[];
items: CharacterItem[];
conditions: CharacterCondition[];
resources: CharacterResource[];
}
export interface CharacterAbility {
id: string;
characterId: string;
ability: AbilityType;
score: number;
}
export interface CharacterFeat {
id: string;
characterId: string;
featId?: string;
name: string;
nameGerman?: string;
level: number;
source: 'CLASS' | 'ANCESTRY' | 'GENERAL' | 'SKILL' | 'BONUS' | 'ARCHETYPE';
}
export interface CharacterSkill {
id: string;
characterId: string;
skillName: string;
proficiency: Proficiency;
}
export interface CharacterSpell {
id: string;
characterId: string;
spellId?: string;
name: string;
nameGerman?: string;
tradition: 'ARCANE' | 'DIVINE' | 'OCCULT' | 'PRIMAL';
spellLevel: number;
prepared: boolean;
}
export interface CharacterItem {
id: string;
characterId: string;
equipmentId?: string;
name: string;
nameGerman?: string;
quantity: number;
bulk: number;
equipped: boolean;
invested: boolean;
containerId?: string;
notes?: string;
}
export interface CharacterCondition {
id: string;
characterId: string;
name: string;
nameGerman?: string;
value?: number;
duration?: string;
source?: string;
}
export interface CharacterResource {
id: string;
characterId: string;
name: string;
current: number;
max: number;
}
// Battle Types
export interface BattleMap {
id: string;
campaignId: string;
name: string;
imageUrl: string;
gridSizeX: number;
gridSizeY: number;
gridOffsetX: number;
gridOffsetY: number;
createdAt: string;
}
export interface Combatant {
id: string;
campaignId: string;
name: string;
type: 'PC' | 'NPC' | 'MONSTER';
level: number;
hpMax: number;
ac: number;
fortitude: number;
reflex: number;
will: number;
perception: number;
speed: number;
avatarUrl?: string;
description?: string;
createdAt: string;
abilities: CombatantAbility[];
}
export interface CombatantAbility {
id: string;
combatantId: string;
name: string;
actionCost: number;
actionType: 'ACTION' | 'REACTION' | 'FREE';
description: string;
damage?: string;
traits: string[];
}
export interface BattleSession {
id: string;
campaignId: string;
mapId?: string;
name?: string;
isActive: boolean;
roundNumber: number;
createdAt: string;
map?: BattleMap;
tokens: BattleToken[];
}
export interface BattleToken {
id: string;
battleSessionId: string;
combatantId?: string;
characterId?: string;
name: string;
positionX: number;
positionY: number;
hpCurrent: number;
hpMax: number;
initiative?: number;
conditions: string[];
size: number;
}
// Document Types
export interface Document {
id: string;
campaignId: string;
title: string;
description?: string;
category?: string;
tags: string[];
filePath: string;
fileType: string;
uploadedBy: string;
createdAt: string;
}
// API Response Types
export interface ApiError {
statusCode: number;
message: string;
error?: string;
}