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:
67
client/src/App.tsx
Normal file
67
client/src/App.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useEffect } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { LoginPage, RegisterPage, useAuthStore } from '@/features/auth';
|
||||
import { CampaignsPage } from '@/features/campaigns';
|
||||
import { ProtectedRoute } from '@/shared/components/protected-route';
|
||||
import { Layout } from '@/shared/components/layout';
|
||||
|
||||
// Create a client
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function AppContent() {
|
||||
const { checkAuth, isAuthenticated } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, [checkAuth]);
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
{/* Public Routes */}
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
isAuthenticated ? <Navigate to="/" replace /> : <LoginPage />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/register"
|
||||
element={
|
||||
isAuthenticated ? <Navigate to="/" replace /> : <RegisterPage />
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Protected Routes */}
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<CampaignsPage />} />
|
||||
<Route path="/campaigns/:id" element={<div>Campaign Detail (TODO)</div>} />
|
||||
<Route path="/library" element={<div>Library (TODO)</div>} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Fallback */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<AppContent />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
1
client/src/assets/react.svg
Normal file
1
client/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
105
client/src/features/auth/components/login-page.tsx
Normal file
105
client/src/features/auth/components/login-page.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { Button, Input, Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/shared/components/ui';
|
||||
import { useAuthStore } from '../hooks/use-auth-store';
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { login, isLoading, error, clearError } = useAuthStore();
|
||||
const [identifier, setIdentifier] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [formError, setFormError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError('');
|
||||
clearError();
|
||||
|
||||
if (!identifier.trim() || !password) {
|
||||
setFormError('Bitte alle Felder ausf\u00fcllen');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await login(identifier.trim(), password);
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
// Error is handled in the store
|
||||
console.error('Login failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-4 py-12 bg-bg-primary">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 h-12 w-12 rounded-xl bg-primary-500/10 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-6 w-6 text-primary-500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1 1 21.75 8.25Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<CardTitle>Willkommen zur\u00fcck</CardTitle>
|
||||
<CardDescription>
|
||||
Melde dich an, um fortzufahren
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
{(error || formError) && (
|
||||
<div className="p-3 rounded-lg bg-error-500/10 border border-error-500/20 text-error-500 text-sm">
|
||||
{error || formError}
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
label="Benutzername oder E-Mail"
|
||||
type="text"
|
||||
placeholder="dein-username"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
autoComplete="username"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Input
|
||||
label="Passwort"
|
||||
type="password"
|
||||
placeholder="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter className="flex-col gap-4">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Anmelden
|
||||
</Button>
|
||||
<p className="text-sm text-text-secondary text-center">
|
||||
Noch kein Konto?{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
className="text-primary-500 hover:text-primary-400 font-medium"
|
||||
>
|
||||
Registrieren
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
client/src/features/auth/components/register-page.tsx
Normal file
134
client/src/features/auth/components/register-page.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { Button, Input, Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/shared/components/ui';
|
||||
import { useAuthStore } from '../hooks/use-auth-store';
|
||||
|
||||
export function RegisterPage() {
|
||||
const navigate = useNavigate();
|
||||
const { register, isLoading, error, clearError } = useAuthStore();
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [formError, setFormError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError('');
|
||||
clearError();
|
||||
|
||||
if (!username.trim() || !email.trim() || !password) {
|
||||
setFormError('Bitte alle Felder ausf\u00fcllen');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setFormError('Passw\u00f6rter stimmen nicht \u00fcberein');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
setFormError('Passwort muss mindestens 8 Zeichen lang sein');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await register(username.trim(), email.trim(), password);
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
console.error('Registration failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-4 py-12 bg-bg-primary">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 h-12 w-12 rounded-xl bg-primary-500/10 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-6 w-6 text-primary-500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M18 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0ZM3 19.235v-.11a6.375 6.375 0 0 1 12.75 0v.109A12.318 12.318 0 0 1 9.374 21c-2.331 0-4.512-.645-6.374-1.766Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<CardTitle>Konto erstellen</CardTitle>
|
||||
<CardDescription>
|
||||
Registriere dich f\u00fcr Dimension47
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
{(error || formError) && (
|
||||
<div className="p-3 rounded-lg bg-error-500/10 border border-error-500/20 text-error-500 text-sm">
|
||||
{error || formError}
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
label="Benutzername"
|
||||
type="text"
|
||||
placeholder="dein-username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Input
|
||||
label="E-Mail"
|
||||
type="email"
|
||||
placeholder="deine@email.de"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Input
|
||||
label="Passwort"
|
||||
type="password"
|
||||
placeholder="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Input
|
||||
label="Passwort best\u00e4tigen"
|
||||
type="password"
|
||||
placeholder="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter className="flex-col gap-4">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Registrieren
|
||||
</Button>
|
||||
<p className="text-sm text-text-secondary text-center">
|
||||
Bereits ein Konto?{' '}
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-primary-500 hover:text-primary-400 font-medium"
|
||||
>
|
||||
Anmelden
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
client/src/features/auth/hooks/use-auth-store.ts
Normal file
103
client/src/features/auth/hooks/use-auth-store.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { User } from '@/shared/types';
|
||||
import { api } from '@/shared/lib/api';
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
login: (identifier: string, password: string) => Promise<void>;
|
||||
register: (username: string, email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
checkAuth: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
login: async (identifier: string, password: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const response = await api.login(identifier, password);
|
||||
api.setToken(response.token);
|
||||
set({
|
||||
user: response.user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Login failed';
|
||||
set({ error: message, isLoading: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
register: async (username: string, email: string, password: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const response = await api.register(username, email, password);
|
||||
api.setToken(response.token);
|
||||
set({
|
||||
user: response.user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Registration failed';
|
||||
set({ error: message, isLoading: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
api.clearToken();
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
|
||||
checkAuth: async () => {
|
||||
const token = api.getToken();
|
||||
if (!token) {
|
||||
set({ isAuthenticated: false, user: null });
|
||||
return;
|
||||
}
|
||||
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const user = await api.getProfile();
|
||||
set({
|
||||
user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch {
|
||||
api.clearToken();
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
partialize: (state) => ({ user: state.user, isAuthenticated: state.isAuthenticated }),
|
||||
}
|
||||
)
|
||||
);
|
||||
3
client/src/features/auth/index.ts
Normal file
3
client/src/features/auth/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { LoginPage } from './components/login-page';
|
||||
export { RegisterPage } from './components/register-page';
|
||||
export { useAuthStore } from './hooks/use-auth-store';
|
||||
128
client/src/features/campaigns/components/campaigns-page.tsx
Normal file
128
client/src/features/campaigns/components/campaigns-page.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Plus, Users, Swords } from 'lucide-react';
|
||||
import { Button, Card, CardHeader, CardTitle, CardDescription, CardContent, Spinner } from '@/shared/components/ui';
|
||||
import { api } from '@/shared/lib/api';
|
||||
import type { Campaign } from '@/shared/types';
|
||||
import { CreateCampaignModal } from './create-campaign-modal';
|
||||
|
||||
export function CampaignsPage() {
|
||||
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
|
||||
const fetchCampaigns = async () => {
|
||||
try {
|
||||
const data = await api.getCampaigns();
|
||||
setCampaigns(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch campaigns:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCampaigns();
|
||||
}, []);
|
||||
|
||||
const handleCampaignCreated = () => {
|
||||
setShowCreateModal(false);
|
||||
fetchCampaigns();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">Kampagnen</h1>
|
||||
<p className="text-text-secondary mt-1">
|
||||
Verwalte deine Pathfinder 2e Kampagnen
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreateModal(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Neue Kampagne
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Campaigns Grid */}
|
||||
{campaigns.length === 0 ? (
|
||||
<Card className="p-12 text-center">
|
||||
<div className="mx-auto h-12 w-12 rounded-xl bg-primary-500/10 flex items-center justify-center mb-4">
|
||||
<Swords className="h-6 w-6 text-primary-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-text-primary mb-2">
|
||||
Keine Kampagnen
|
||||
</h3>
|
||||
<p className="text-text-secondary mb-6">
|
||||
Erstelle deine erste Kampagne, um loszulegen.
|
||||
</p>
|
||||
<Button onClick={() => setShowCreateModal(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Kampagne erstellen
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{campaigns.map((campaign) => (
|
||||
<Link key={campaign.id} to={`/campaigns/${campaign.id}`}>
|
||||
<Card className="h-full hover:border-border-hover transition-colors cursor-pointer">
|
||||
{campaign.imageUrl ? (
|
||||
<div className="h-32 bg-bg-tertiary rounded-t-xl overflow-hidden">
|
||||
<img
|
||||
src={campaign.imageUrl}
|
||||
alt={campaign.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-32 bg-gradient-to-br from-primary-500/20 to-secondary-800/20 rounded-t-xl flex items-center justify-center">
|
||||
<Swords className="h-8 w-8 text-primary-500/50" />
|
||||
</div>
|
||||
)}
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{campaign.name}</CardTitle>
|
||||
{campaign.description && (
|
||||
<CardDescription className="line-clamp-2">
|
||||
{campaign.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex items-center gap-4 text-sm text-text-secondary">
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4" />
|
||||
<span>{campaign.members?.length || 0} Spieler</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Swords className="h-4 w-4" />
|
||||
<span>{campaign._count?.characters || 0} Charaktere</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Campaign Modal */}
|
||||
{showCreateModal && (
|
||||
<CreateCampaignModal
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onCreated={handleCampaignCreated}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { Button, Input, Card, CardHeader, CardTitle, CardContent, CardFooter } from '@/shared/components/ui';
|
||||
import { api } from '@/shared/lib/api';
|
||||
|
||||
interface CreateCampaignModalProps {
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
export function CreateCampaignModal({ onClose, onCreated }: CreateCampaignModalProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!name.trim()) {
|
||||
setError('Bitte einen Namen eingeben');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await api.createCampaign({
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
});
|
||||
onCreated();
|
||||
} catch (err) {
|
||||
console.error('Failed to create campaign:', err);
|
||||
setError('Kampagne konnte nicht erstellt werden');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<Card className="relative z-10 w-full max-w-md mx-4 animate-slide-up">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Neue Kampagne</CardTitle>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="h-8 w-8 rounded-lg hover:bg-bg-tertiary flex items-center justify-center transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4 text-text-secondary" />
|
||||
</button>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-error-500/10 border border-error-500/20 text-error-500 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
label="Name"
|
||||
type="text"
|
||||
placeholder="z.B. Rise of the Runelords"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1.5">
|
||||
Beschreibung (optional)
|
||||
</label>
|
||||
<textarea
|
||||
className="flex min-h-[100px] w-full rounded-lg border border-border bg-bg-secondary px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-primary-500 focus:ring-2 focus:ring-primary-500/20 disabled:cursor-not-allowed disabled:opacity-50 transition-colors resize-none"
|
||||
placeholder="Eine kurze Beschreibung der Kampagne..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="flex-1"
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={isLoading}
|
||||
className="flex-1"
|
||||
>
|
||||
Erstellen
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
client/src/features/campaigns/index.ts
Normal file
2
client/src/features/campaigns/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { CampaignsPage } from './components/campaigns-page';
|
||||
export { CreateCampaignModal } from './components/create-campaign-modal';
|
||||
162
client/src/index.css
Normal file
162
client/src/index.css
Normal file
@@ -0,0 +1,162 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Dimension47 Design System */
|
||||
@theme {
|
||||
/* Primary Colors - Dimension47 Magenta */
|
||||
--color-primary-50: #fdf2fc;
|
||||
--color-primary-100: #fce8fa;
|
||||
--color-primary-200: #f9cef4;
|
||||
--color-primary-300: #f5a5ea;
|
||||
--color-primary-400: #ed6dd9;
|
||||
--color-primary-500: #c26dbc;
|
||||
--color-primary-600: #9a4a94;
|
||||
--color-primary-700: #7a3977;
|
||||
--color-primary-800: #5e2e5a;
|
||||
--color-primary-900: #4a274a;
|
||||
--color-primary-950: #2d0f2c;
|
||||
|
||||
/* Secondary Colors - Dark Purple (47) */
|
||||
--color-secondary-50: #f9f5f9;
|
||||
--color-secondary-100: #f4eaf3;
|
||||
--color-secondary-200: #e7d5e6;
|
||||
--color-secondary-300: #d4b4d2;
|
||||
--color-secondary-400: #b988b5;
|
||||
--color-secondary-500: #9a6495;
|
||||
--color-secondary-600: #7d4d79;
|
||||
--color-secondary-700: #663d62;
|
||||
--color-secondary-800: #542e52;
|
||||
--color-secondary-900: #472945;
|
||||
--color-secondary-950: #2a1429;
|
||||
|
||||
/* Background Colors (Dark Mode) */
|
||||
--color-bg-primary: #0f0f12;
|
||||
--color-bg-secondary: #1a1a1f;
|
||||
--color-bg-tertiary: #242429;
|
||||
--color-bg-elevated: #2a2a30;
|
||||
|
||||
/* Text Colors */
|
||||
--color-text-primary: #f5f5f7;
|
||||
--color-text-secondary: #a1a1a6;
|
||||
--color-text-muted: #6b6b70;
|
||||
--color-text-inverse: #0f0f12;
|
||||
|
||||
/* Border Colors */
|
||||
--color-border: #2a2a2f;
|
||||
--color-border-hover: #3a3a3f;
|
||||
--color-border-focus: #c26dbc;
|
||||
|
||||
/* Semantic Colors */
|
||||
--color-success-50: #f0fdf4;
|
||||
--color-success-500: #22c55e;
|
||||
--color-success-600: #16a34a;
|
||||
|
||||
--color-warning-50: #fffbeb;
|
||||
--color-warning-500: #f59e0b;
|
||||
--color-warning-600: #d97706;
|
||||
|
||||
--color-error-50: #fef2f2;
|
||||
--color-error-500: #ef4444;
|
||||
--color-error-600: #dc2626;
|
||||
|
||||
--color-info-50: #eff6ff;
|
||||
--color-info-500: #3b82f6;
|
||||
--color-info-600: #2563eb;
|
||||
|
||||
/* Fonts */
|
||||
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||
|
||||
/* Border Radius */
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-xl: 0.75rem;
|
||||
--radius-2xl: 1rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.4);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5), 0 4px 6px -4px rgb(0 0 0 / 0.5);
|
||||
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.5), 0 8px 10px -6px rgb(0 0 0 / 0.5);
|
||||
|
||||
/* Animations */
|
||||
--animate-fade-in: fade-in 0.2s ease-out;
|
||||
--animate-slide-up: slide-up 0.3s ease-out;
|
||||
--animate-slide-down: slide-down 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-down {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Base Styles */
|
||||
html {
|
||||
background-color: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Focus Styles */
|
||||
*:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--color-bg-primary), 0 0 0 4px var(--color-primary-500);
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
background-color: rgba(194, 109, 188, 0.3);
|
||||
}
|
||||
|
||||
/* Scrollbar Styles */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--color-border);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--color-border-hover);
|
||||
}
|
||||
10
client/src/main.tsx
Normal file
10
client/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
82
client/src/shared/components/layout.tsx
Normal file
82
client/src/shared/components/layout.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Outlet, Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/features/auth';
|
||||
import { Button } from './ui';
|
||||
|
||||
export function Layout() {
|
||||
const navigate = useNavigate();
|
||||
const { user, logout } = useAuthStore();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-primary">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 border-b border-border bg-bg-secondary/80 backdrop-blur-sm">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex h-16 items-center justify-between">
|
||||
{/* Logo */}
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-lg bg-primary-500 flex items-center justify-center">
|
||||
<span className="text-white font-bold text-sm">D47</span>
|
||||
</div>
|
||||
<span className="font-semibold text-text-primary hidden sm:block">
|
||||
Dimension47
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="hidden md:flex items-center gap-6">
|
||||
<Link
|
||||
to="/"
|
||||
className="text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
Kampagnen
|
||||
</Link>
|
||||
<Link
|
||||
to="/library"
|
||||
className="text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
Bibliothek
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* User Menu */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-full bg-primary-500/20 flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-primary-500">
|
||||
{user?.username?.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-text-secondary hidden sm:block">
|
||||
{user?.username}
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={handleLogout}>
|
||||
Abmelden
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-border py-6 mt-auto">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-center gap-2 text-text-muted text-sm">
|
||||
<span>Powered by</span>
|
||||
<span className="font-medium text-text-secondary">Zeasy Software</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
client/src/shared/components/protected-route.tsx
Normal file
21
client/src/shared/components/protected-route.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/features/auth';
|
||||
import { Spinner } from './ui';
|
||||
|
||||
export function ProtectedRoute() {
|
||||
const { isAuthenticated, isLoading } = useAuthStore();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-bg-primary">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
51
client/src/shared/components/ui/button.tsx
Normal file
51
client/src/shared/components/ui/button.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon';
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = 'default', size = 'default', isLoading, disabled, children, ...props }, ref) => {
|
||||
const baseStyles = 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 focus-visible:ring-offset-bg-primary disabled:pointer-events-none disabled:opacity-50';
|
||||
|
||||
const variants = {
|
||||
default: 'bg-primary-500 text-white hover:bg-primary-600 active:bg-primary-700',
|
||||
destructive: 'bg-error-500 text-white hover:bg-error-600 active:bg-error-600',
|
||||
outline: 'border border-border bg-transparent hover:bg-bg-tertiary hover:border-border-hover',
|
||||
secondary: 'bg-secondary-800 text-text-primary hover:bg-secondary-700',
|
||||
ghost: 'hover:bg-bg-tertiary',
|
||||
link: 'text-primary-500 underline-offset-4 hover:underline',
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
default: 'h-11 px-4 py-2',
|
||||
sm: 'h-9 px-3 text-xs',
|
||||
lg: 'h-12 px-8',
|
||||
icon: 'h-11 w-11',
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(baseStyles, variants[variant], sizes[size], className)}
|
||||
ref={ref}
|
||||
disabled={disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
) : null}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button };
|
||||
69
client/src/shared/components/ui/card.tsx
Normal file
69
client/src/shared/components/ui/card.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-xl border border-border bg-bg-secondary shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('text-xl font-semibold leading-none tracking-tight text-text-primary', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-text-secondary', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center p-6 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
4
client/src/shared/components/ui/index.ts
Normal file
4
client/src/shared/components/ui/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './button';
|
||||
export * from './input';
|
||||
export * from './card';
|
||||
export * from './spinner';
|
||||
47
client/src/shared/components/ui/input.tsx
Normal file
47
client/src/shared/components/ui/input.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, label, error, id, ...props }, ref) => {
|
||||
const inputId = id || React.useId();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-sm font-medium text-text-secondary mb-1.5"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
type={type}
|
||||
id={inputId}
|
||||
className={cn(
|
||||
'flex h-11 w-full rounded-lg border bg-bg-secondary px-3 py-2 text-sm text-text-primary placeholder:text-text-muted',
|
||||
'border-border focus:border-primary-500 focus:ring-2 focus:ring-primary-500/20',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'transition-colors',
|
||||
error && 'border-error-500 focus:border-error-500 focus:ring-error-500/20',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-1.5 text-sm text-error-500">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
45
client/src/shared/components/ui/spinner.tsx
Normal file
45
client/src/shared/components/ui/spinner.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
|
||||
interface SpinnerProps extends React.SVGAttributes<SVGSVGElement> {
|
||||
size?: 'sm' | 'default' | 'lg';
|
||||
}
|
||||
|
||||
const Spinner = React.forwardRef<SVGSVGElement, SpinnerProps>(
|
||||
({ className, size = 'default', ...props }, ref) => {
|
||||
const sizes = {
|
||||
sm: 'h-4 w-4',
|
||||
default: 'h-6 w-6',
|
||||
lg: 'h-8 w-8',
|
||||
};
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={ref}
|
||||
className={cn('animate-spin text-primary-500', sizes[size], className)}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Spinner.displayName = 'Spinner';
|
||||
|
||||
export { Spinner };
|
||||
130
client/src/shared/lib/api.ts
Normal file
130
client/src/shared/lib/api.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import axios, { type AxiosError, type AxiosInstance } from 'axios';
|
||||
import type { ApiError } from '@/shared/types';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
class ApiClient {
|
||||
private client: AxiosInstance;
|
||||
private token: string | null = null;
|
||||
|
||||
constructor() {
|
||||
this.client = axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Load token from localStorage
|
||||
this.token = localStorage.getItem('auth_token');
|
||||
|
||||
// Request interceptor to add auth header
|
||||
this.client.interceptors.request.use((config) => {
|
||||
if (this.token) {
|
||||
config.headers.Authorization = `Bearer ${this.token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Response interceptor to handle errors
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError<ApiError>) => {
|
||||
if (error.response?.status === 401) {
|
||||
this.clearToken();
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
setToken(token: string) {
|
||||
this.token = token;
|
||||
localStorage.setItem('auth_token', token);
|
||||
}
|
||||
|
||||
clearToken() {
|
||||
this.token = null;
|
||||
localStorage.removeItem('auth_token');
|
||||
}
|
||||
|
||||
getToken() {
|
||||
return this.token;
|
||||
}
|
||||
|
||||
// Auth endpoints
|
||||
async login(identifier: string, password: string) {
|
||||
const response = await this.client.post('/auth/login', { identifier, password });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async register(username: string, email: string, password: string) {
|
||||
const response = await this.client.post('/auth/register', { username, email, password });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getProfile() {
|
||||
const response = await this.client.get('/auth/me');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateProfile(data: { avatarUrl?: string }) {
|
||||
const response = await this.client.put('/auth/profile', data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async searchUsers(query: string) {
|
||||
const response = await this.client.get('/auth/users/search', { params: { q: query } });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Campaign endpoints
|
||||
async getCampaigns() {
|
||||
const response = await this.client.get('/campaigns');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getCampaign(id: string) {
|
||||
const response = await this.client.get(`/campaigns/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async createCampaign(data: { name: string; description?: string; imageUrl?: string }) {
|
||||
const response = await this.client.post('/campaigns', data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateCampaign(id: string, data: { name?: string; description?: string; imageUrl?: string }) {
|
||||
const response = await this.client.put(`/campaigns/${id}`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deleteCampaign(id: string) {
|
||||
const response = await this.client.delete(`/campaigns/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async addCampaignMember(campaignId: string, userId: string) {
|
||||
const response = await this.client.post(`/campaigns/${campaignId}/members`, { userId });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async removeCampaignMember(campaignId: string, userId: string) {
|
||||
const response = await this.client.delete(`/campaigns/${campaignId}/members/${userId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Character endpoints (placeholder - to be expanded)
|
||||
async getCharacters(campaignId: string) {
|
||||
const response = await this.client.get(`/characters/${campaignId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getCharacter(campaignId: string, characterId: string) {
|
||||
const response = await this.client.get(`/characters/${campaignId}/${characterId}`);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient();
|
||||
6
client/src/shared/lib/utils.ts
Normal file
6
client/src/shared/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
238
client/src/shared/types/index.ts
Normal file
238
client/src/shared/types/index.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
// User & Auth Types
|
||||
export type UserRole = 'ADMIN' | 'GM' | 'PLAYER';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
avatarUrl?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
user: User;
|
||||
token: string;
|
||||
}
|
||||
|
||||
// Campaign Types
|
||||
export interface Campaign {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
gmId: string;
|
||||
imageUrl?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
gm: Pick<User, 'id' | 'username' | 'avatarUrl'>;
|
||||
members: CampaignMember[];
|
||||
characters?: CharacterSummary[];
|
||||
_count?: {
|
||||
characters: number;
|
||||
battleSessions?: number;
|
||||
documents?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CampaignMember {
|
||||
campaignId: string;
|
||||
userId: string;
|
||||
joinedAt: string;
|
||||
user: Pick<User, 'id' | 'username' | 'email' | 'avatarUrl'>;
|
||||
}
|
||||
|
||||
// Character Types
|
||||
export type CharacterType = 'PC' | 'NPC';
|
||||
export type AbilityType = 'STR' | 'DEX' | 'CON' | 'INT' | 'WIS' | 'CHA';
|
||||
export type Proficiency = 'UNTRAINED' | 'TRAINED' | 'EXPERT' | 'MASTER' | 'LEGENDARY';
|
||||
|
||||
export interface CharacterSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
type: CharacterType;
|
||||
level: number;
|
||||
avatarUrl?: string;
|
||||
hpCurrent: number;
|
||||
hpMax: number;
|
||||
owner?: Pick<User, 'id' | 'username'>;
|
||||
}
|
||||
|
||||
export interface Character extends CharacterSummary {
|
||||
campaignId: string;
|
||||
ownerId?: string;
|
||||
hpTemp: number;
|
||||
ancestryId?: string;
|
||||
heritageId?: string;
|
||||
classId?: string;
|
||||
backgroundId?: string;
|
||||
experiencePoints: number;
|
||||
pathbuilderData?: unknown;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
abilities: CharacterAbility[];
|
||||
feats: CharacterFeat[];
|
||||
skills: CharacterSkill[];
|
||||
spells: CharacterSpell[];
|
||||
items: CharacterItem[];
|
||||
conditions: CharacterCondition[];
|
||||
resources: CharacterResource[];
|
||||
}
|
||||
|
||||
export interface CharacterAbility {
|
||||
id: string;
|
||||
characterId: string;
|
||||
ability: AbilityType;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface CharacterFeat {
|
||||
id: string;
|
||||
characterId: string;
|
||||
featId?: string;
|
||||
name: string;
|
||||
nameGerman?: string;
|
||||
level: number;
|
||||
source: 'CLASS' | 'ANCESTRY' | 'GENERAL' | 'SKILL' | 'BONUS' | 'ARCHETYPE';
|
||||
}
|
||||
|
||||
export interface CharacterSkill {
|
||||
id: string;
|
||||
characterId: string;
|
||||
skillName: string;
|
||||
proficiency: Proficiency;
|
||||
}
|
||||
|
||||
export interface CharacterSpell {
|
||||
id: string;
|
||||
characterId: string;
|
||||
spellId?: string;
|
||||
name: string;
|
||||
nameGerman?: string;
|
||||
tradition: 'ARCANE' | 'DIVINE' | 'OCCULT' | 'PRIMAL';
|
||||
spellLevel: number;
|
||||
prepared: boolean;
|
||||
}
|
||||
|
||||
export interface CharacterItem {
|
||||
id: string;
|
||||
characterId: string;
|
||||
equipmentId?: string;
|
||||
name: string;
|
||||
nameGerman?: string;
|
||||
quantity: number;
|
||||
bulk: number;
|
||||
equipped: boolean;
|
||||
invested: boolean;
|
||||
containerId?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface CharacterCondition {
|
||||
id: string;
|
||||
characterId: string;
|
||||
name: string;
|
||||
nameGerman?: string;
|
||||
value?: number;
|
||||
duration?: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface CharacterResource {
|
||||
id: string;
|
||||
characterId: string;
|
||||
name: string;
|
||||
current: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
// Battle Types
|
||||
export interface BattleMap {
|
||||
id: string;
|
||||
campaignId: string;
|
||||
name: string;
|
||||
imageUrl: string;
|
||||
gridSizeX: number;
|
||||
gridSizeY: number;
|
||||
gridOffsetX: number;
|
||||
gridOffsetY: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Combatant {
|
||||
id: string;
|
||||
campaignId: string;
|
||||
name: string;
|
||||
type: 'PC' | 'NPC' | 'MONSTER';
|
||||
level: number;
|
||||
hpMax: number;
|
||||
ac: number;
|
||||
fortitude: number;
|
||||
reflex: number;
|
||||
will: number;
|
||||
perception: number;
|
||||
speed: number;
|
||||
avatarUrl?: string;
|
||||
description?: string;
|
||||
createdAt: string;
|
||||
abilities: CombatantAbility[];
|
||||
}
|
||||
|
||||
export interface CombatantAbility {
|
||||
id: string;
|
||||
combatantId: string;
|
||||
name: string;
|
||||
actionCost: number;
|
||||
actionType: 'ACTION' | 'REACTION' | 'FREE';
|
||||
description: string;
|
||||
damage?: string;
|
||||
traits: string[];
|
||||
}
|
||||
|
||||
export interface BattleSession {
|
||||
id: string;
|
||||
campaignId: string;
|
||||
mapId?: string;
|
||||
name?: string;
|
||||
isActive: boolean;
|
||||
roundNumber: number;
|
||||
createdAt: string;
|
||||
map?: BattleMap;
|
||||
tokens: BattleToken[];
|
||||
}
|
||||
|
||||
export interface BattleToken {
|
||||
id: string;
|
||||
battleSessionId: string;
|
||||
combatantId?: string;
|
||||
characterId?: string;
|
||||
name: string;
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
hpCurrent: number;
|
||||
hpMax: number;
|
||||
initiative?: number;
|
||||
conditions: string[];
|
||||
size: number;
|
||||
}
|
||||
|
||||
// Document Types
|
||||
export interface Document {
|
||||
id: string;
|
||||
campaignId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
tags: string[];
|
||||
filePath: string;
|
||||
fileType: string;
|
||||
uploadedBy: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// API Response Types
|
||||
export interface ApiError {
|
||||
statusCode: number;
|
||||
message: string;
|
||||
error?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user