feat: implement complete Phase 2 frontend foundation with React 18

Major milestone: Frontend implementation complete for Shattered Void MMO

FRONTEND IMPLEMENTATION:
- React 18 + TypeScript + Vite development environment
- Tailwind CSS with custom dark theme for sci-fi aesthetic
- Zustand state management with authentication persistence
- Socket.io WebSocket client with auto-reconnection
- Protected routing with authentication guards
- Responsive design with mobile-first approach

AUTHENTICATION SYSTEM:
- Login/register forms with comprehensive validation
- JWT token management with localStorage persistence
- Password strength validation and user feedback
- Protected routes and authentication guards

CORE GAME INTERFACE:
- Colony management dashboard with real-time updates
- Resource display with live production tracking
- WebSocket integration for real-time game events
- Navigation with connection status indicator
- Toast notifications for user feedback

BACKEND ENHANCEMENTS:
- Complete Research System with technology tree (23 technologies)
- Fleet Management System with ship designs and movement
- Enhanced Authentication with email verification and password reset
- Complete game tick integration for all systems
- Advanced WebSocket events for real-time updates

ARCHITECTURE FEATURES:
- Type-safe TypeScript throughout
- Component-based architecture with reusable UI elements
- API client with request/response interceptors
- Error handling and loading states
- Performance optimized builds with code splitting

Phase 2 Status: Frontend foundation complete (Week 1-2 objectives met)
Ready for: Colony management, fleet operations, research interface
Next: Enhanced gameplay features and admin interface

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
MegaProxy 2025-08-02 18:36:06 +00:00
parent 8d9ef427be
commit d41d1e8125
130 changed files with 33588 additions and 14817 deletions

24
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

59
frontend/DEPLOYMENT.md Normal file
View file

@ -0,0 +1,59 @@
# Frontend Deployment Notes
## Node.js Version Compatibility
The current setup uses Vite 7.x and React Router 7.x which require Node.js >= 20.0.0. The current environment is running Node.js 18.19.1.
### Options to resolve:
1. **Upgrade Node.js** (Recommended)
```bash
# Update to Node.js 20 or later
nvm install 20
nvm use 20
```
2. **Downgrade dependencies** (Alternative)
```bash
npm install vite@^5.0.0 react-router-dom@^6.0.0
```
## Production Build
The build process works correctly despite version warnings:
- TypeScript compilation: ✅ No errors
- Bundle generation: ✅ Optimized chunks created
- CSS processing: ✅ Tailwind compiled successfully
## Development Server
Due to Node.js version compatibility, the dev server may not start. This is resolved by upgrading Node.js or using the production build for testing.
## Deployment Steps
1. Ensure Node.js >= 20.0.0
2. Install dependencies: `npm install`
3. Build: `npm run build`
4. Serve dist/ folder with any static file server
## Integration with Backend
The frontend is configured to connect to:
- API: `http://localhost:3000`
- WebSocket: `http://localhost:3000`
Update `.env.development` or `.env.production` as needed for different environments.
## Performance Optimizations
- Code splitting by vendor, router, and UI libraries
- Source maps for debugging
- Gzip compression ready
- Optimized dependency pre-bundling
## Security Considerations
- JWT tokens stored in localStorage (consider httpOnly cookies for production)
- CORS configured for local development
- Input validation on all forms
- Protected routes with authentication guards

69
frontend/README.md Normal file
View file

@ -0,0 +1,69 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
frontend/eslint.config.js Normal file
View file

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { globalIgnores } from 'eslint/config'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4509
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

45
frontend/package.json Normal file
View file

@ -0,0 +1,45 @@
{
"name": "shattered-void-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "Frontend for Shattered Void MMO - A post-collapse galaxy strategy game",
"scripts": {
"dev": "vite --port 5173 --host",
"build": "tsc -b && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint:fix": "eslint . --ext ts,tsx --fix",
"preview": "vite preview --port 4173",
"type-check": "tsc --noEmit",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\""
},
"dependencies": {
"@headlessui/react": "^2.2.7",
"@heroicons/react": "^2.2.0",
"@tailwindcss/postcss": "^4.1.11",
"autoprefixer": "^10.4.21",
"axios": "^1.11.0",
"postcss": "^8.5.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hot-toast": "^2.5.2",
"react-router-dom": "^7.7.1",
"socket.io-client": "^4.8.1",
"tailwindcss": "^4.1.11",
"zustand": "^5.0.7"
},
"devDependencies": {
"@eslint/js": "^9.30.1",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"eslint": "^9.30.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.35.1",
"vite": "^7.0.4"
}
}

View file

@ -0,0 +1,7 @@
import postcss from '@tailwindcss/postcss';
export default {
plugins: [
postcss(),
],
}

42
frontend/src/App.css Normal file
View file

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

121
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,121 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
// Layout components
import Layout from './components/layout/Layout';
import ProtectedRoute from './components/auth/ProtectedRoute';
// Auth components
import LoginForm from './components/auth/LoginForm';
import RegisterForm from './components/auth/RegisterForm';
// Page components
import Dashboard from './pages/Dashboard';
import Colonies from './pages/Colonies';
// Import styles
import './index.css';
const App: React.FC = () => {
return (
<Router>
<div className="App">
<Routes>
{/* Public routes (redirect to dashboard if authenticated) */}
<Route
path="/login"
element={
<ProtectedRoute requireAuth={false}>
<LoginForm />
</ProtectedRoute>
}
/>
<Route
path="/register"
element={
<ProtectedRoute requireAuth={false}>
<RegisterForm />
</ProtectedRoute>
}
/>
{/* Protected routes */}
<Route
path="/"
element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}
>
{/* Redirect root to dashboard */}
<Route index element={<Navigate to="/dashboard" replace />} />
{/* Main application routes */}
<Route path="dashboard" element={<Dashboard />} />
<Route path="colonies" element={<Colonies />} />
{/* Placeholder routes for future implementation */}
<Route
path="fleets"
element={
<div className="card text-center py-12">
<h1 className="text-2xl font-bold text-white mb-4">Fleet Management</h1>
<p className="text-dark-400">Coming soon...</p>
</div>
}
/>
<Route
path="research"
element={
<div className="card text-center py-12">
<h1 className="text-2xl font-bold text-white mb-4">Research Laboratory</h1>
<p className="text-dark-400">Coming soon...</p>
</div>
}
/>
<Route
path="galaxy"
element={
<div className="card text-center py-12">
<h1 className="text-2xl font-bold text-white mb-4">Galaxy Map</h1>
<p className="text-dark-400">Coming soon...</p>
</div>
}
/>
<Route
path="profile"
element={
<div className="card text-center py-12">
<h1 className="text-2xl font-bold text-white mb-4">Player Profile</h1>
<p className="text-dark-400">Coming soon...</p>
</div>
}
/>
</Route>
{/* Catch-all route for 404 */}
<Route
path="*"
element={
<div className="min-h-screen flex items-center justify-center bg-dark-900">
<div className="text-center">
<h1 className="text-4xl font-bold text-white mb-4">404</h1>
<p className="text-dark-400 mb-6">Page not found</p>
<a
href="/dashboard"
className="btn-primary"
>
Return to Dashboard
</a>
</div>
</div>
}
/>
</Routes>
</div>
</Router>
);
};
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 KiB

View file

@ -0,0 +1,174 @@
import React, { useState } from 'react';
import { Link, Navigate } from 'react-router-dom';
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
import { useAuthStore } from '../../store/authStore';
import type { LoginCredentials } from '../../types';
const LoginForm: React.FC = () => {
const [credentials, setCredentials] = useState<LoginCredentials>({
email: '',
password: '',
});
const [showPassword, setShowPassword] = useState(false);
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
const { login, isLoading, isAuthenticated } = useAuthStore();
// Redirect if already authenticated
if (isAuthenticated) {
return <Navigate to="/dashboard" replace />;
}
const validateForm = (): boolean => {
const errors: Record<string, string> = {};
if (!credentials.email) {
errors.email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(credentials.email)) {
errors.email = 'Please enter a valid email';
}
if (!credentials.password) {
errors.password = 'Password is required';
} else if (credentials.password.length < 6) {
errors.password = 'Password must be at least 6 characters';
}
setValidationErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
const success = await login(credentials);
if (success) {
// Navigation will be handled by the store/auth guard
}
};
const handleInputChange = (field: keyof LoginCredentials, value: string) => {
setCredentials(prev => ({ ...prev, [field]: value }));
// Clear validation error when user starts typing
if (validationErrors[field]) {
setValidationErrors(prev => ({ ...prev, [field]: '' }));
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-dark-900 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-white">
Sign in to Shattered Void
</h2>
<p className="mt-2 text-center text-sm text-dark-400">
Or{' '}
<Link
to="/register"
className="font-medium text-primary-600 hover:text-primary-500"
>
create a new account
</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-dark-300">
Email address
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
className={`input-field mt-1 ${
validationErrors.email ? 'border-red-500 focus:ring-red-500 focus:border-red-500' : ''
}`}
placeholder="Enter your email"
value={credentials.email}
onChange={(e) => handleInputChange('email', e.target.value)}
/>
{validationErrors.email && (
<p className="mt-1 text-sm text-red-500">{validationErrors.email}</p>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-dark-300">
Password
</label>
<div className="mt-1 relative">
<input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
required
className={`input-field pr-10 ${
validationErrors.password ? 'border-red-500 focus:ring-red-500 focus:border-red-500' : ''
}`}
placeholder="Enter your password"
value={credentials.password}
onChange={(e) => handleInputChange('password', e.target.value)}
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeSlashIcon className="h-5 w-5 text-dark-400" />
) : (
<EyeIcon className="h-5 w-5 text-dark-400" />
)}
</button>
</div>
{validationErrors.password && (
<p className="mt-1 text-sm text-red-500">{validationErrors.password}</p>
)}
</div>
</div>
<div className="flex items-center justify-between">
<div className="text-sm">
<Link
to="/forgot-password"
className="font-medium text-primary-600 hover:text-primary-500"
>
Forgot your password?
</Link>
</div>
</div>
<div>
<button
type="submit"
disabled={isLoading}
className="btn-primary w-full flex justify-center items-center disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<>
<div className="loading-spinner w-4 h-4 mr-2"></div>
Signing in...
</>
) : (
'Sign in'
)}
</button>
</div>
</form>
</div>
</div>
);
};
export default LoginForm;

View file

@ -0,0 +1,46 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../store/authStore';
interface ProtectedRouteProps {
children: React.ReactNode;
requireAuth?: boolean;
}
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
children,
requireAuth = true
}) => {
const { isAuthenticated, isLoading } = useAuthStore();
const location = useLocation();
// Show loading spinner while checking authentication
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-dark-900">
<div className="text-center">
<div className="loading-spinner w-8 h-8 mx-auto mb-4"></div>
<p className="text-dark-400">Loading...</p>
</div>
</div>
);
}
// If route requires authentication and user is not authenticated
if (requireAuth && !isAuthenticated) {
// Save the attempted location for redirecting after login
return <Navigate to="/login" state={{ from: location }} replace />;
}
// If route is for non-authenticated users (like login/register) and user is authenticated
if (!requireAuth && isAuthenticated) {
// Redirect to dashboard or the intended location
const from = location.state?.from?.pathname || '/dashboard';
return <Navigate to={from} replace />;
}
// Render the protected content
return <>{children}</>;
};
export default ProtectedRoute;

View file

@ -0,0 +1,293 @@
import React, { useState } from 'react';
import { Link, Navigate } from 'react-router-dom';
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
import { useAuthStore } from '../../store/authStore';
import type { RegisterCredentials } from '../../types';
const RegisterForm: React.FC = () => {
const [credentials, setCredentials] = useState<RegisterCredentials>({
username: '',
email: '',
password: '',
confirmPassword: '',
});
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
const { register, isLoading, isAuthenticated } = useAuthStore();
// Redirect if already authenticated
if (isAuthenticated) {
return <Navigate to="/dashboard" replace />;
}
const validateForm = (): boolean => {
const errors: Record<string, string> = {};
if (!credentials.username) {
errors.username = 'Username is required';
} else if (credentials.username.length < 3) {
errors.username = 'Username must be at least 3 characters';
} else if (credentials.username.length > 20) {
errors.username = 'Username must be less than 20 characters';
} else if (!/^[a-zA-Z0-9_]+$/.test(credentials.username)) {
errors.username = 'Username can only contain letters, numbers, and underscores';
}
if (!credentials.email) {
errors.email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(credentials.email)) {
errors.email = 'Please enter a valid email';
}
if (!credentials.password) {
errors.password = 'Password is required';
} else if (credentials.password.length < 8) {
errors.password = 'Password must be at least 8 characters';
} else if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(credentials.password)) {
errors.password = 'Password must contain at least one uppercase letter, one lowercase letter, and one number';
}
if (!credentials.confirmPassword) {
errors.confirmPassword = 'Please confirm your password';
} else if (credentials.password !== credentials.confirmPassword) {
errors.confirmPassword = 'Passwords do not match';
}
setValidationErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
const success = await register(credentials);
if (success) {
// Navigation will be handled by the store/auth guard
}
};
const handleInputChange = (field: keyof RegisterCredentials, value: string) => {
setCredentials(prev => ({ ...prev, [field]: value }));
// Clear validation error when user starts typing
if (validationErrors[field]) {
setValidationErrors(prev => ({ ...prev, [field]: '' }));
}
};
const getPasswordStrength = (password: string): { score: number; text: string; color: string } => {
let score = 0;
if (password.length >= 8) score++;
if (/[a-z]/.test(password)) score++;
if (/[A-Z]/.test(password)) score++;
if (/\d/.test(password)) score++;
if (/[^a-zA-Z\d]/.test(password)) score++;
const strength = {
0: { text: 'Very Weak', color: 'bg-red-500' },
1: { text: 'Weak', color: 'bg-red-400' },
2: { text: 'Fair', color: 'bg-yellow-500' },
3: { text: 'Good', color: 'bg-yellow-400' },
4: { text: 'Strong', color: 'bg-green-500' },
5: { text: 'Very Strong', color: 'bg-green-600' },
};
return { score, ...strength[Math.min(score, 5) as keyof typeof strength] };
};
const passwordStrength = getPasswordStrength(credentials.password);
return (
<div className="min-h-screen flex items-center justify-center bg-dark-900 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-white">
Join Shattered Void
</h2>
<p className="mt-2 text-center text-sm text-dark-400">
Or{' '}
<Link
to="/login"
className="font-medium text-primary-600 hover:text-primary-500"
>
sign in to your existing account
</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-dark-300">
Username
</label>
<input
id="username"
name="username"
type="text"
autoComplete="username"
required
className={`input-field mt-1 ${
validationErrors.username ? 'border-red-500 focus:ring-red-500 focus:border-red-500' : ''
}`}
placeholder="Choose a username"
value={credentials.username}
onChange={(e) => handleInputChange('username', e.target.value)}
/>
{validationErrors.username && (
<p className="mt-1 text-sm text-red-500">{validationErrors.username}</p>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-dark-300">
Email address
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
className={`input-field mt-1 ${
validationErrors.email ? 'border-red-500 focus:ring-red-500 focus:border-red-500' : ''
}`}
placeholder="Enter your email"
value={credentials.email}
onChange={(e) => handleInputChange('email', e.target.value)}
/>
{validationErrors.email && (
<p className="mt-1 text-sm text-red-500">{validationErrors.email}</p>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-dark-300">
Password
</label>
<div className="mt-1 relative">
<input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
autoComplete="new-password"
required
className={`input-field pr-10 ${
validationErrors.password ? 'border-red-500 focus:ring-red-500 focus:border-red-500' : ''
}`}
placeholder="Create a password"
value={credentials.password}
onChange={(e) => handleInputChange('password', e.target.value)}
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeSlashIcon className="h-5 w-5 text-dark-400" />
) : (
<EyeIcon className="h-5 w-5 text-dark-400" />
)}
</button>
</div>
{credentials.password && (
<div className="mt-2">
<div className="flex justify-between text-xs">
<span className="text-dark-400">Password strength:</span>
<span className={`${passwordStrength.score >= 3 ? 'text-green-400' : 'text-yellow-400'}`}>
{passwordStrength.text}
</span>
</div>
<div className="mt-1 h-1 bg-dark-700 rounded">
<div
className={`h-full rounded transition-all duration-300 ${passwordStrength.color}`}
style={{ width: `${(passwordStrength.score / 5) * 100}%` }}
/>
</div>
</div>
)}
{validationErrors.password && (
<p className="mt-1 text-sm text-red-500">{validationErrors.password}</p>
)}
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-dark-300">
Confirm Password
</label>
<div className="mt-1 relative">
<input
id="confirmPassword"
name="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
autoComplete="new-password"
required
className={`input-field pr-10 ${
validationErrors.confirmPassword ? 'border-red-500 focus:ring-red-500 focus:border-red-500' : ''
}`}
placeholder="Confirm your password"
value={credentials.confirmPassword}
onChange={(e) => handleInputChange('confirmPassword', e.target.value)}
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showConfirmPassword ? (
<EyeSlashIcon className="h-5 w-5 text-dark-400" />
) : (
<EyeIcon className="h-5 w-5 text-dark-400" />
)}
</button>
</div>
{validationErrors.confirmPassword && (
<p className="mt-1 text-sm text-red-500">{validationErrors.confirmPassword}</p>
)}
</div>
</div>
<div>
<button
type="submit"
disabled={isLoading}
className="btn-primary w-full flex justify-center items-center disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<>
<div className="loading-spinner w-4 h-4 mr-2"></div>
Creating account...
</>
) : (
'Create account'
)}
</button>
</div>
<div className="text-xs text-dark-400 text-center">
By creating an account, you agree to our{' '}
<Link to="/terms" className="text-primary-600 hover:text-primary-500">
Terms of Service
</Link>{' '}
and{' '}
<Link to="/privacy" className="text-primary-600 hover:text-primary-500">
Privacy Policy
</Link>
</div>
</form>
</div>
</div>
);
};
export default RegisterForm;

View file

@ -0,0 +1,76 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import Navigation from './Navigation';
import { useWebSocket } from '../../hooks/useWebSocket';
import { Toaster } from 'react-hot-toast';
const Layout: React.FC = () => {
// Initialize WebSocket connection for authenticated users
const { isConnected, isConnecting } = useWebSocket();
return (
<div className="min-h-screen bg-dark-900">
<Navigation />
{/* Connection status indicator */}
<div className="bg-dark-800 border-b border-dark-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-end py-1">
<div className="flex items-center space-x-2 text-xs">
<div
className={`w-2 h-2 rounded-full ${
isConnected
? 'bg-green-500'
: isConnecting
? 'bg-yellow-500 animate-pulse'
: 'bg-red-500'
}`}
/>
<span className="text-dark-400">
{isConnected
? 'Connected'
: isConnecting
? 'Connecting...'
: 'Disconnected'}
</span>
</div>
</div>
</div>
</div>
{/* Main content */}
<main className="flex-1">
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<Outlet />
</div>
</main>
{/* Toast notifications */}
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
style: {
background: '#1e293b',
color: '#f8fafc',
border: '1px solid #334155',
},
success: {
iconTheme: {
primary: '#22c55e',
secondary: '#f8fafc',
},
},
error: {
iconTheme: {
primary: '#ef4444',
secondary: '#f8fafc',
},
},
}}
/>
</div>
);
};
export default Layout;

View file

@ -0,0 +1,252 @@
import React, { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Disclosure } from '@headlessui/react';
import {
Bars3Icon,
XMarkIcon,
HomeIcon,
BuildingOfficeIcon,
RocketLaunchIcon,
BeakerIcon,
MapIcon,
BellIcon,
UserCircleIcon,
ArrowRightOnRectangleIcon,
} from '@heroicons/react/24/outline';
import { useAuthStore } from '../../store/authStore';
import { useGameStore } from '../../store/gameStore';
import type { NavItem } from '../../types';
const Navigation: React.FC = () => {
const location = useLocation();
const { user, logout } = useAuthStore();
const { totalResources } = useGameStore();
const [showUserMenu, setShowUserMenu] = useState(false);
const navigation: NavItem[] = [
{ name: 'Dashboard', href: '/dashboard', icon: HomeIcon },
{ name: 'Colonies', href: '/colonies', icon: BuildingOfficeIcon },
{ name: 'Fleets', href: '/fleets', icon: RocketLaunchIcon },
{ name: 'Research', href: '/research', icon: BeakerIcon },
{ name: 'Galaxy', href: '/galaxy', icon: MapIcon },
];
const isCurrentPath = (href: string) => {
return location.pathname === href || location.pathname.startsWith(href + '/');
};
const handleLogout = () => {
logout();
setShowUserMenu(false);
};
return (
<Disclosure as="nav" className="bg-dark-800 border-b border-dark-700">
{({ open }) => (
<>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex">
{/* Logo */}
<div className="flex-shrink-0 flex items-center">
<Link to="/dashboard" className="text-xl font-bold text-primary-500">
Shattered Void
</Link>
</div>
{/* Desktop navigation */}
<div className="hidden md:ml-6 md:flex md:space-x-8">
{navigation.map((item) => {
const Icon = item.icon;
const current = isCurrentPath(item.href);
return (
<Link
key={item.name}
to={item.href}
className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium transition-colors duration-200 ${
current
? 'border-primary-500 text-white'
: 'border-transparent text-dark-300 hover:border-dark-600 hover:text-white'
}`}
>
{Icon && <Icon className="w-4 h-4 mr-2" />}
{item.name}
</Link>
);
})}
</div>
</div>
{/* Resource display */}
{totalResources && (
<div className="hidden md:flex items-center space-x-4 text-sm">
<div className="resource-display">
<span className="text-dark-300">Scrap:</span>
<span className="text-yellow-400 font-mono">
{totalResources.scrap.toLocaleString()}
</span>
</div>
<div className="resource-display">
<span className="text-dark-300">Energy:</span>
<span className="text-blue-400 font-mono">
{totalResources.energy.toLocaleString()}
</span>
</div>
<div className="resource-display">
<span className="text-dark-300">Research:</span>
<span className="text-purple-400 font-mono">
{totalResources.research_points.toLocaleString()}
</span>
</div>
</div>
)}
{/* User menu */}
<div className="hidden md:ml-6 md:flex md:items-center">
<button
type="button"
className="bg-dark-700 p-1 rounded-full text-dark-400 hover:text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-dark-800 focus:ring-white"
>
<span className="sr-only">View notifications</span>
<BellIcon className="h-6 w-6" aria-hidden="true" />
</button>
{/* Profile dropdown */}
<div className="ml-3 relative">
<div>
<button
onClick={() => setShowUserMenu(!showUserMenu)}
className="max-w-xs bg-dark-700 rounded-full flex items-center text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-dark-800 focus:ring-white"
id="user-menu-button"
aria-expanded="false"
aria-haspopup="true"
>
<span className="sr-only">Open user menu</span>
<UserCircleIcon className="h-8 w-8 text-dark-300" />
<span className="ml-2 text-white">{user?.username}</span>
</button>
</div>
{showUserMenu && (
<div className="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-dark-700 ring-1 ring-black ring-opacity-5 focus:outline-none">
<Link
to="/profile"
className="flex items-center px-4 py-2 text-sm text-dark-300 hover:bg-dark-600 hover:text-white"
onClick={() => setShowUserMenu(false)}
>
<UserCircleIcon className="h-4 w-4 mr-2" />
Your Profile
</Link>
<button
onClick={handleLogout}
className="flex items-center w-full text-left px-4 py-2 text-sm text-dark-300 hover:bg-dark-600 hover:text-white"
>
<ArrowRightOnRectangleIcon className="h-4 w-4 mr-2" />
Sign out
</button>
</div>
)}
</div>
</div>
{/* Mobile menu button */}
<div className="-mr-2 flex items-center md:hidden">
<Disclosure.Button className="bg-dark-700 inline-flex items-center justify-center p-2 rounded-md text-dark-400 hover:text-white hover:bg-dark-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white">
<span className="sr-only">Open main menu</span>
{open ? (
<XMarkIcon className="block h-6 w-6" aria-hidden="true" />
) : (
<Bars3Icon className="block h-6 w-6" aria-hidden="true" />
)}
</Disclosure.Button>
</div>
</div>
</div>
{/* Mobile menu */}
<Disclosure.Panel className="md:hidden">
<div className="pt-2 pb-3 space-y-1">
{navigation.map((item) => {
const Icon = item.icon;
const current = isCurrentPath(item.href);
return (
<Link
key={item.name}
to={item.href}
className={`flex items-center pl-3 pr-4 py-2 border-l-4 text-base font-medium ${
current
? 'bg-primary-50 border-primary-500 text-primary-700'
: 'border-transparent text-dark-300 hover:bg-dark-700 hover:border-dark-600 hover:text-white'
}`}
>
{Icon && <Icon className="w-5 h-5 mr-3" />}
{item.name}
</Link>
);
})}
</div>
{/* Mobile resources */}
{totalResources && (
<div className="pt-4 pb-3 border-t border-dark-700">
<div className="px-4 space-y-2">
<div className="resource-display">
<span className="text-dark-300">Scrap:</span>
<span className="text-yellow-400 font-mono">
{totalResources.scrap.toLocaleString()}
</span>
</div>
<div className="resource-display">
<span className="text-dark-300">Energy:</span>
<span className="text-blue-400 font-mono">
{totalResources.energy.toLocaleString()}
</span>
</div>
<div className="resource-display">
<span className="text-dark-300">Research:</span>
<span className="text-purple-400 font-mono">
{totalResources.research_points.toLocaleString()}
</span>
</div>
</div>
</div>
)}
{/* Mobile user menu */}
<div className="pt-4 pb-3 border-t border-dark-700">
<div className="flex items-center px-4">
<div className="flex-shrink-0">
<UserCircleIcon className="h-8 w-8 text-dark-300" />
</div>
<div className="ml-3">
<div className="text-base font-medium text-white">{user?.username}</div>
<div className="text-sm font-medium text-dark-300">{user?.email}</div>
</div>
</div>
<div className="mt-3 space-y-1">
<Link
to="/profile"
className="flex items-center px-4 py-2 text-base font-medium text-dark-300 hover:text-white hover:bg-dark-700"
>
<UserCircleIcon className="h-5 w-5 mr-3" />
Your Profile
</Link>
<button
onClick={handleLogout}
className="flex items-center w-full text-left px-4 py-2 text-base font-medium text-dark-300 hover:text-white hover:bg-dark-700"
>
<ArrowRightOnRectangleIcon className="h-5 w-5 mr-3" />
Sign out
</button>
</div>
</div>
</Disclosure.Panel>
</>
)}
</Disclosure>
);
};
export default Navigation;

View file

@ -0,0 +1,231 @@
import { useEffect, useRef, useState } from 'react';
import { io, Socket } from 'socket.io-client';
import { useAuthStore } from '../store/authStore';
import { useGameStore } from '../store/gameStore';
import type { GameEvent } from '../types';
import toast from 'react-hot-toast';
interface UseWebSocketOptions {
autoConnect?: boolean;
reconnectionAttempts?: number;
reconnectionDelay?: number;
}
export const useWebSocket = (options: UseWebSocketOptions = {}) => {
const {
autoConnect = true,
reconnectionAttempts = 5,
reconnectionDelay = 1000,
} = options;
const socketRef = useRef<Socket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttemptsRef = useRef(0);
const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const { isAuthenticated, token } = useAuthStore();
const { updateColony, updateFleet, updateResearch } = useGameStore();
const connect = () => {
if (socketRef.current?.connected || isConnecting || !isAuthenticated || !token) {
return;
}
setIsConnecting(true);
const wsUrl = import.meta.env.VITE_WS_URL || 'http://localhost:3000';
socketRef.current = io(wsUrl, {
auth: {
token,
},
transports: ['websocket', 'polling'],
timeout: 10000,
reconnection: false, // We handle reconnection manually
});
const socket = socketRef.current;
socket.on('connect', () => {
console.log('WebSocket connected');
setIsConnected(true);
setIsConnecting(false);
reconnectAttemptsRef.current = 0;
// Clear any pending reconnection timeout
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
});
socket.on('disconnect', (reason) => {
console.log('WebSocket disconnected:', reason);
setIsConnected(false);
setIsConnecting(false);
// Only attempt reconnection if it wasn't a manual disconnect
if (reason !== 'io client disconnect' && isAuthenticated) {
scheduleReconnect();
}
});
socket.on('connect_error', (error) => {
console.error('WebSocket connection error:', error);
setIsConnected(false);
setIsConnecting(false);
if (isAuthenticated) {
scheduleReconnect();
}
});
// Game event handlers
socket.on('game_event', (event: GameEvent) => {
handleGameEvent(event);
});
socket.on('colony_update', (data) => {
updateColony(data.colony_id, data.updates);
});
socket.on('fleet_update', (data) => {
updateFleet(data.fleet_id, data.updates);
});
socket.on('research_complete', (data) => {
updateResearch(data.research_id, {
is_researching: false,
level: data.new_level
});
toast.success(`Research completed: ${data.technology_name}`);
});
socket.on('building_complete', (data) => {
updateColony(data.colony_id, {
buildings: data.buildings
});
toast.success(`Building completed: ${data.building_name}`);
});
socket.on('resource_update', (data) => {
updateColony(data.colony_id, {
resources: data.resources
});
});
// Error handling
socket.on('error', (error) => {
console.error('WebSocket error:', error);
toast.error('Connection error occurred');
});
};
const disconnect = () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
}
setIsConnected(false);
setIsConnecting(false);
reconnectAttemptsRef.current = 0;
};
const scheduleReconnect = () => {
if (reconnectAttemptsRef.current >= reconnectionAttempts) {
console.log('Max reconnection attempts reached');
toast.error('Connection lost. Please refresh the page.');
return;
}
const delay = reconnectionDelay * Math.pow(2, reconnectAttemptsRef.current);
console.log(`Scheduling reconnection attempt ${reconnectAttemptsRef.current + 1} in ${delay}ms`);
reconnectTimeoutRef.current = setTimeout(() => {
reconnectAttemptsRef.current++;
connect();
}, delay);
};
const handleGameEvent = (event: GameEvent) => {
console.log('Game event received:', event);
switch (event.type) {
case 'colony_update':
updateColony(event.data.colony_id, event.data.updates);
break;
case 'fleet_update':
updateFleet(event.data.fleet_id, event.data.updates);
break;
case 'research_complete':
updateResearch(event.data.research_id, {
is_researching: false,
level: event.data.new_level
});
toast.success(`Research completed: ${event.data.technology_name}`);
break;
case 'building_complete':
updateColony(event.data.colony_id, {
buildings: event.data.buildings
});
toast.success(`Building completed: ${event.data.building_name}`);
break;
case 'resource_update':
updateColony(event.data.colony_id, {
resources: event.data.resources
});
break;
default:
console.log('Unhandled game event type:', event.type);
}
};
const sendMessage = (type: string, data: any) => {
if (socketRef.current?.connected) {
socketRef.current.emit(type, data);
} else {
console.warn('Cannot send message: WebSocket not connected');
}
};
// Effect to handle connection lifecycle
useEffect(() => {
if (autoConnect && isAuthenticated && token) {
connect();
} else if (!isAuthenticated) {
disconnect();
}
return () => {
disconnect();
};
}, [isAuthenticated, token, autoConnect]);
// Cleanup on unmount
useEffect(() => {
return () => {
disconnect();
};
}, []);
return {
isConnected,
isConnecting,
connect,
disconnect,
sendMessage,
};
};

67
frontend/src/index.css Normal file
View file

@ -0,0 +1,67 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom scrollbar styles */
@layer utilities {
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: rgb(71 85 105) transparent;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: rgb(71 85 105);
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: rgb(100 116 139);
}
}
/* Game-specific styles */
@layer components {
.btn-primary {
@apply bg-primary-600 hover:bg-primary-700 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2;
}
.btn-secondary {
@apply bg-dark-700 hover:bg-dark-600 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-dark-500 focus:ring-offset-2;
}
.card {
@apply bg-dark-800 border border-dark-700 rounded-lg p-6 shadow-lg;
}
.input-field {
@apply w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white placeholder-dark-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500;
}
.resource-display {
@apply flex items-center space-x-2 px-3 py-2 bg-dark-700 rounded-lg border border-dark-600;
}
}
/* Base styles */
body {
@apply bg-dark-900 text-white font-sans antialiased;
margin: 0;
min-height: 100vh;
}
/* Loading animations */
.loading-pulse {
@apply animate-pulse bg-dark-700 rounded;
}
.loading-spinner {
@apply animate-spin rounded-full border-2 border-dark-600 border-t-primary-500;
}

193
frontend/src/lib/api.ts Normal file
View file

@ -0,0 +1,193 @@
import axios, { type AxiosResponse, AxiosError } from 'axios';
import type { ApiResponse } from '../types';
// Create axios instance with base configuration
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor to add auth token
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('auth_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor for error handling
api.interceptors.response.use(
(response: AxiosResponse) => {
return response;
},
(error: AxiosError) => {
// Handle token expiration
if (error.response?.status === 401) {
localStorage.removeItem('auth_token');
localStorage.removeItem('user_data');
window.location.href = '/login';
}
// Handle network errors
if (!error.response) {
console.error('Network error:', error.message);
}
return Promise.reject(error);
}
);
// API methods
export const apiClient = {
// Authentication
auth: {
login: (credentials: { email: string; password: string }) =>
api.post<ApiResponse<{ user: any; token: string }>>('/api/auth/login', credentials),
register: (userData: { username: string; email: string; password: string }) =>
api.post<ApiResponse<{ user: any; token: string }>>('/api/auth/register', userData),
logout: () =>
api.post<ApiResponse<void>>('/api/auth/logout'),
forgotPassword: (email: string) =>
api.post<ApiResponse<void>>('/api/auth/forgot-password', { email }),
resetPassword: (token: string, password: string) =>
api.post<ApiResponse<void>>('/api/auth/reset-password', { token, password }),
verifyEmail: (token: string) =>
api.post<ApiResponse<void>>('/api/auth/verify-email', { token }),
refreshToken: () =>
api.post<ApiResponse<{ token: string }>>('/api/auth/refresh'),
},
// Player
player: {
getProfile: () =>
api.get<ApiResponse<any>>('/api/player/profile'),
updateProfile: (profileData: any) =>
api.put<ApiResponse<any>>('/api/player/profile', profileData),
getStats: () =>
api.get<ApiResponse<any>>('/api/player/stats'),
},
// Colonies
colonies: {
getAll: () =>
api.get<ApiResponse<any[]>>('/api/player/colonies'),
getById: (id: number) =>
api.get<ApiResponse<any>>(`/api/player/colonies/${id}`),
create: (colonyData: { name: string; coordinates: string; planet_type_id: number }) =>
api.post<ApiResponse<any>>('/api/player/colonies', colonyData),
update: (id: number, colonyData: any) =>
api.put<ApiResponse<any>>(`/api/player/colonies/${id}`, colonyData),
delete: (id: number) =>
api.delete<ApiResponse<void>>(`/api/player/colonies/${id}`),
getBuildings: (colonyId: number) =>
api.get<ApiResponse<any[]>>(`/api/player/colonies/${colonyId}/buildings`),
constructBuilding: (colonyId: number, buildingData: { building_type_id: number }) =>
api.post<ApiResponse<any>>(`/api/player/colonies/${colonyId}/buildings`, buildingData),
upgradeBuilding: (colonyId: number, buildingId: number) =>
api.put<ApiResponse<any>>(`/api/player/colonies/${colonyId}/buildings/${buildingId}/upgrade`),
},
// Resources
resources: {
getByColony: (colonyId: number) =>
api.get<ApiResponse<any>>(`/api/player/colonies/${colonyId}/resources`),
getTotal: () =>
api.get<ApiResponse<any>>('/api/player/resources'),
},
// Fleets
fleets: {
getAll: () =>
api.get<ApiResponse<any[]>>('/api/player/fleets'),
getById: (id: number) =>
api.get<ApiResponse<any>>(`/api/player/fleets/${id}`),
create: (fleetData: { name: string; colony_id: number; ships: any[] }) =>
api.post<ApiResponse<any>>('/api/player/fleets', fleetData),
update: (id: number, fleetData: any) =>
api.put<ApiResponse<any>>(`/api/player/fleets/${id}`, fleetData),
delete: (id: number) =>
api.delete<ApiResponse<void>>(`/api/player/fleets/${id}`),
move: (id: number, destination: string) =>
api.post<ApiResponse<any>>(`/api/player/fleets/${id}/move`, { destination }),
},
// Research
research: {
getAll: () =>
api.get<ApiResponse<any[]>>('/api/player/research'),
getTechnologies: () =>
api.get<ApiResponse<any[]>>('/api/player/research/technologies'),
start: (technologyId: number) =>
api.post<ApiResponse<any>>('/api/player/research/start', { technology_id: technologyId }),
cancel: (researchId: number) =>
api.post<ApiResponse<void>>(`/api/player/research/${researchId}/cancel`),
},
// Galaxy
galaxy: {
getSectors: () =>
api.get<ApiResponse<any[]>>('/api/player/galaxy/sectors'),
getSector: (coordinates: string) =>
api.get<ApiResponse<any>>(`/api/player/galaxy/sectors/${coordinates}`),
scan: (coordinates: string) =>
api.post<ApiResponse<any>>('/api/player/galaxy/scan', { coordinates }),
},
// Events
events: {
getAll: (limit?: number) =>
api.get<ApiResponse<any[]>>('/api/player/events', { params: { limit } }),
markRead: (eventId: number) =>
api.put<ApiResponse<void>>(`/api/player/events/${eventId}/read`),
},
// Notifications
notifications: {
getAll: () =>
api.get<ApiResponse<any[]>>('/api/player/notifications'),
markRead: (notificationId: number) =>
api.put<ApiResponse<void>>(`/api/player/notifications/${notificationId}/read`),
markAllRead: () =>
api.put<ApiResponse<void>>('/api/player/notifications/read-all'),
},
};
export default api;

10
frontend/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,257 @@
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import {
BuildingOfficeIcon,
PlusIcon,
MapPinIcon,
UsersIcon,
HeartIcon,
} from '@heroicons/react/24/outline';
import { useGameStore } from '../store/gameStore';
const Colonies: React.FC = () => {
const {
colonies,
loading,
fetchColonies,
selectColony,
} = useGameStore();
const [sortBy, setSortBy] = useState<'name' | 'population' | 'founded_at'>('name');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
useEffect(() => {
fetchColonies();
}, [fetchColonies]);
const sortedColonies = [...colonies].sort((a, b) => {
let aValue: string | number;
let bValue: string | number;
switch (sortBy) {
case 'name':
aValue = a.name.toLowerCase();
bValue = b.name.toLowerCase();
break;
case 'population':
aValue = a.population;
bValue = b.population;
break;
case 'founded_at':
aValue = new Date(a.founded_at).getTime();
bValue = new Date(b.founded_at).getTime();
break;
default:
aValue = a.name.toLowerCase();
bValue = b.name.toLowerCase();
}
if (sortOrder === 'asc') {
return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
} else {
return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
}
});
const handleSort = (field: typeof sortBy) => {
if (sortBy === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortBy(field);
setSortOrder('asc');
}
};
const getMoraleColor = (morale: number) => {
if (morale >= 80) return 'text-green-400';
if (morale >= 60) return 'text-yellow-400';
if (morale >= 40) return 'text-orange-400';
return 'text-red-400';
};
const getMoraleIcon = (morale: number) => {
if (morale >= 80) return '😊';
if (morale >= 60) return '😐';
if (morale >= 40) return '😟';
return '😰';
};
if (loading.colonies) {
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-white">Colonies</h1>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => (
<div key={i} className="card">
<div className="loading-pulse h-32 rounded"></div>
</div>
))}
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-white">Colonies</h1>
<p className="text-dark-300">
Manage your {colonies.length} colonies across the galaxy
</p>
</div>
<Link
to="/colonies/new"
className="btn-primary inline-flex items-center"
>
<PlusIcon className="h-4 w-4 mr-2" />
Found Colony
</Link>
</div>
{/* Sort Controls */}
<div className="card">
<div className="flex items-center space-x-4">
<span className="text-dark-300">Sort by:</span>
<button
onClick={() => handleSort('name')}
className={`text-sm font-medium transition-colors duration-200 ${
sortBy === 'name'
? 'text-primary-400'
: 'text-dark-400 hover:text-white'
}`}
>
Name {sortBy === 'name' && (sortOrder === 'asc' ? '↑' : '↓')}
</button>
<button
onClick={() => handleSort('population')}
className={`text-sm font-medium transition-colors duration-200 ${
sortBy === 'population'
? 'text-primary-400'
: 'text-dark-400 hover:text-white'
}`}
>
Population {sortBy === 'population' && (sortOrder === 'asc' ? '↑' : '↓')}
</button>
<button
onClick={() => handleSort('founded_at')}
className={`text-sm font-medium transition-colors duration-200 ${
sortBy === 'founded_at'
? 'text-primary-400'
: 'text-dark-400 hover:text-white'
}`}
>
Founded {sortBy === 'founded_at' && (sortOrder === 'asc' ? '↑' : '↓')}
</button>
</div>
</div>
{/* Colonies Grid */}
{sortedColonies.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{sortedColonies.map((colony) => (
<Link
key={colony.id}
to={`/colonies/${colony.id}`}
onClick={() => selectColony(colony)}
className="card hover:bg-dark-700 transition-colors duration-200 cursor-pointer"
>
<div className="space-y-4">
{/* Colony Header */}
<div className="flex items-start justify-between">
<div>
<h3 className="text-lg font-semibold text-white">{colony.name}</h3>
<div className="flex items-center text-sm text-dark-300 mt-1">
<MapPinIcon className="h-4 w-4 mr-1" />
{colony.coordinates}
</div>
</div>
<BuildingOfficeIcon className="h-6 w-6 text-primary-500" />
</div>
{/* Colony Stats */}
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center space-x-2">
<UsersIcon className="h-4 w-4 text-green-400" />
<div>
<p className="text-xs text-dark-300">Population</p>
<p className="font-mono text-green-400">
{colony.population.toLocaleString()}
</p>
</div>
</div>
<div className="flex items-center space-x-2">
<HeartIcon className="h-4 w-4 text-red-400" />
<div>
<p className="text-xs text-dark-300">Morale</p>
<p className={`font-mono ${getMoraleColor(colony.morale)}`}>
{colony.morale}% {getMoraleIcon(colony.morale)}
</p>
</div>
</div>
</div>
{/* Planet Type */}
{colony.planet_type && (
<div className="border-t border-dark-700 pt-3">
<p className="text-xs text-dark-300">Planet Type</p>
<p className="text-sm text-white">{colony.planet_type.name}</p>
</div>
)}
{/* Resources Preview */}
{colony.resources && (
<div className="border-t border-dark-700 pt-3">
<p className="text-xs text-dark-300 mb-2">Resources</p>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex justify-between">
<span className="text-dark-400">Scrap:</span>
<span className="text-yellow-400 font-mono">
{colony.resources.scrap.toLocaleString()}
</span>
</div>
<div className="flex justify-between">
<span className="text-dark-400">Energy:</span>
<span className="text-blue-400 font-mono">
{colony.resources.energy.toLocaleString()}
</span>
</div>
</div>
</div>
)}
{/* Founded Date */}
<div className="border-t border-dark-700 pt-3">
<p className="text-xs text-dark-300">
Founded {new Date(colony.founded_at).toLocaleDateString()}
</p>
</div>
</div>
</Link>
))}
</div>
) : (
<div className="card text-center py-12">
<BuildingOfficeIcon className="h-16 w-16 text-dark-500 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-white mb-2">No Colonies Yet</h3>
<p className="text-dark-400 mb-6">
Start your galactic empire by founding your first colony
</p>
<Link
to="/colonies/new"
className="btn-primary inline-flex items-center"
>
<PlusIcon className="h-4 w-4 mr-2" />
Found Your First Colony
</Link>
</div>
)}
</div>
);
};
export default Colonies;

View file

@ -0,0 +1,259 @@
import React, { useEffect } from 'react';
import { Link } from 'react-router-dom';
import {
BuildingOfficeIcon,
RocketLaunchIcon,
BeakerIcon,
PlusIcon,
} from '@heroicons/react/24/outline';
import { useAuthStore } from '../store/authStore';
import { useGameStore } from '../store/gameStore';
const Dashboard: React.FC = () => {
const { user } = useAuthStore();
const {
colonies,
fleets,
research,
totalResources,
loading,
fetchColonies,
fetchFleets,
fetchResearch,
fetchTotalResources,
} = useGameStore();
useEffect(() => {
// Fetch initial data when component mounts
fetchColonies();
fetchFleets();
fetchResearch();
fetchTotalResources();
}, [fetchColonies, fetchFleets, fetchResearch, fetchTotalResources]);
const stats = [
{
name: 'Colonies',
value: colonies.length,
icon: BuildingOfficeIcon,
href: '/colonies',
color: 'text-green-400',
loading: loading.colonies,
},
{
name: 'Fleets',
value: fleets.length,
icon: RocketLaunchIcon,
href: '/fleets',
color: 'text-blue-400',
loading: loading.fleets,
},
{
name: 'Research Projects',
value: research.filter(r => r.is_researching).length,
icon: BeakerIcon,
href: '/research',
color: 'text-purple-400',
loading: loading.research,
},
];
const recentColonies = colonies.slice(0, 3);
const activeResearch = research.filter(r => r.is_researching).slice(0, 3);
return (
<div className="space-y-6">
{/* Welcome Header */}
<div className="bg-dark-800 border border-dark-700 rounded-lg p-6">
<h1 className="text-2xl font-bold text-white mb-2">
Welcome back, {user?.username}!
</h1>
<p className="text-dark-300">
Command your forces across the shattered galaxy. Your empire awaits your orders.
</p>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{stats.map((stat) => {
const Icon = stat.icon;
return (
<Link
key={stat.name}
to={stat.href}
className="card hover:bg-dark-700 transition-colors duration-200"
>
<div className="flex items-center">
<div className="flex-shrink-0">
<Icon className={`h-8 w-8 ${stat.color}`} />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-dark-300">{stat.name}</p>
<p className="text-2xl font-bold text-white">
{stat.loading ? (
<div className="loading-pulse w-8 h-6"></div>
) : (
stat.value
)}
</p>
</div>
</div>
</Link>
);
})}
</div>
{/* Resources Overview */}
{totalResources && (
<div className="card">
<h2 className="text-lg font-semibold text-white mb-4">Resource Overview</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="resource-display">
<span className="text-dark-300">Scrap</span>
<span className="text-yellow-400 font-mono text-lg">
{totalResources.scrap.toLocaleString()}
</span>
</div>
<div className="resource-display">
<span className="text-dark-300">Energy</span>
<span className="text-blue-400 font-mono text-lg">
{totalResources.energy.toLocaleString()}
</span>
</div>
<div className="resource-display">
<span className="text-dark-300">Research</span>
<span className="text-purple-400 font-mono text-lg">
{totalResources.research_points.toLocaleString()}
</span>
</div>
<div className="resource-display">
<span className="text-dark-300">Biomass</span>
<span className="text-green-400 font-mono text-lg">
{totalResources.biomass.toLocaleString()}
</span>
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Recent Colonies */}
<div className="card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white">Recent Colonies</h2>
<Link
to="/colonies"
className="text-primary-600 hover:text-primary-500 text-sm font-medium"
>
View all
</Link>
</div>
{loading.colonies ? (
<div className="space-y-3">
{[...Array(3)].map((_, i) => (
<div key={i} className="loading-pulse h-16 rounded"></div>
))}
</div>
) : recentColonies.length > 0 ? (
<div className="space-y-3">
{recentColonies.map((colony) => (
<Link
key={colony.id}
to={`/colonies/${colony.id}`}
className="block p-3 bg-dark-700 rounded-lg hover:bg-dark-600 transition-colors duration-200"
>
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-white">{colony.name}</h3>
<p className="text-sm text-dark-300">{colony.coordinates}</p>
</div>
<div className="text-right">
<p className="text-sm text-dark-300">Population</p>
<p className="font-mono text-green-400">
{colony.population.toLocaleString()}
</p>
</div>
</div>
</Link>
))}
</div>
) : (
<div className="text-center py-8">
<BuildingOfficeIcon className="h-12 w-12 text-dark-500 mx-auto mb-4" />
<p className="text-dark-400 mb-4">No colonies yet</p>
<Link
to="/colonies"
className="btn-primary inline-flex items-center"
>
<PlusIcon className="h-4 w-4 mr-2" />
Found your first colony
</Link>
</div>
)}
</div>
{/* Active Research */}
<div className="card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white">Active Research</h2>
<Link
to="/research"
className="text-primary-600 hover:text-primary-500 text-sm font-medium"
>
View all
</Link>
</div>
{loading.research ? (
<div className="space-y-3">
{[...Array(3)].map((_, i) => (
<div key={i} className="loading-pulse h-16 rounded"></div>
))}
</div>
) : activeResearch.length > 0 ? (
<div className="space-y-3">
{activeResearch.map((research) => (
<div
key={research.id}
className="p-3 bg-dark-700 rounded-lg"
>
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-white">
{research.technology?.name}
</h3>
<p className="text-sm text-dark-300">
Level {research.level}
</p>
</div>
<div className="text-right">
<div className="w-20 bg-dark-600 rounded-full h-2">
<div className="bg-purple-500 h-2 rounded-full w-1/2"></div>
</div>
<p className="text-xs text-dark-400 mt-1">In progress</p>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8">
<BeakerIcon className="h-12 w-12 text-dark-500 mx-auto mb-4" />
<p className="text-dark-400 mb-4">No active research</p>
<Link
to="/research"
className="btn-primary inline-flex items-center"
>
<PlusIcon className="h-4 w-4 mr-2" />
Start research
</Link>
</div>
)}
</div>
</div>
</div>
);
};
export default Dashboard;

View file

@ -0,0 +1,167 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { AuthState, LoginCredentials, RegisterCredentials } from '../types';
import { apiClient } from '../lib/api';
import toast from 'react-hot-toast';
interface AuthStore extends AuthState {
// Actions
login: (credentials: LoginCredentials) => Promise<boolean>;
register: (credentials: RegisterCredentials) => Promise<boolean>;
logout: () => void;
refreshUser: () => Promise<void>;
clearError: () => void;
setLoading: (loading: boolean) => void;
}
export const useAuthStore = create<AuthStore>()(
persist(
(set) => ({
// Initial state
user: null,
token: null,
isAuthenticated: false,
isLoading: false,
// Login action
login: async (credentials: LoginCredentials) => {
set({ isLoading: true });
try {
const response = await apiClient.auth.login(credentials);
if (response.data.success && response.data.data) {
const { user, token } = response.data.data;
// Store token in localStorage for API client
localStorage.setItem('auth_token', token);
set({
user,
token,
isAuthenticated: true,
isLoading: false,
});
toast.success(`Welcome back, ${user.username}!`);
return true;
} else {
toast.error(response.data.error || 'Login failed');
set({ isLoading: false });
return false;
}
} catch (error: any) {
const message = error.response?.data?.error || 'Login failed';
toast.error(message);
set({ isLoading: false });
return false;
}
},
// Register action
register: async (credentials: RegisterCredentials) => {
set({ isLoading: true });
try {
const { confirmPassword, ...registerData } = credentials;
// Validate passwords match
if (credentials.password !== confirmPassword) {
toast.error('Passwords do not match');
set({ isLoading: false });
return false;
}
const response = await apiClient.auth.register(registerData);
if (response.data.success && response.data.data) {
const { user, token } = response.data.data;
// Store token in localStorage for API client
localStorage.setItem('auth_token', token);
set({
user,
token,
isAuthenticated: true,
isLoading: false,
});
toast.success(`Welcome to Shattered Void, ${user.username}!`);
return true;
} else {
toast.error(response.data.error || 'Registration failed');
set({ isLoading: false });
return false;
}
} catch (error: any) {
const message = error.response?.data?.error || 'Registration failed';
toast.error(message);
set({ isLoading: false });
return false;
}
},
// Logout action
logout: () => {
try {
// Call logout endpoint to invalidate token on server
apiClient.auth.logout().catch(() => {
// Ignore errors on logout endpoint
});
} catch (error) {
// Ignore errors
}
// Clear local storage
localStorage.removeItem('auth_token');
localStorage.removeItem('user_data');
// Clear store state
set({
user: null,
token: null,
isAuthenticated: false,
isLoading: false,
});
toast.success('Logged out successfully');
},
// Refresh user data
refreshUser: async () => {
const token = localStorage.getItem('auth_token');
if (!token) return;
try {
const response = await apiClient.player.getProfile();
if (response.data.success && response.data.data) {
set({ user: response.data.data });
}
} catch (error) {
// If refresh fails, user might need to re-login
console.error('Failed to refresh user data:', error);
}
},
// Clear error state
clearError: () => {
// This can be extended if we add error state
},
// Set loading state
setLoading: (loading: boolean) => {
set({ isLoading: loading });
},
}),
{
name: 'auth-storage',
partialize: (state) => ({
user: state.user,
token: state.token,
isAuthenticated: state.isAuthenticated,
}),
}
)
);

View file

@ -0,0 +1,289 @@
import { create } from 'zustand';
import type { Colony, Fleet, Resources, Research } from '../types';
import { apiClient } from '../lib/api';
import toast from 'react-hot-toast';
interface GameState {
// Data
colonies: Colony[];
fleets: Fleet[];
totalResources: Resources | null;
research: Research[];
// Loading states
loading: {
colonies: boolean;
fleets: boolean;
resources: boolean;
research: boolean;
};
// Selected entities
selectedColony: Colony | null;
selectedFleet: Fleet | null;
}
interface GameStore extends GameState {
// Colony actions
fetchColonies: () => Promise<void>;
selectColony: (colony: Colony | null) => void;
createColony: (colonyData: { name: string; coordinates: string; planet_type_id: number }) => Promise<boolean>;
updateColony: (colonyId: number, updates: Partial<Colony>) => void;
// Fleet actions
fetchFleets: () => Promise<void>;
selectFleet: (fleet: Fleet | null) => void;
createFleet: (fleetData: { name: string; colony_id: number; ships: any[] }) => Promise<boolean>;
updateFleet: (fleetId: number, updates: Partial<Fleet>) => void;
// Resource actions
fetchTotalResources: () => Promise<void>;
updateColonyResources: (colonyId: number, resources: Resources) => void;
// Research actions
fetchResearch: () => Promise<void>;
startResearch: (technologyId: number) => Promise<boolean>;
updateResearch: (researchId: number, updates: Partial<Research>) => void;
// Utility actions
setLoading: (key: keyof GameState['loading'], loading: boolean) => void;
clearData: () => void;
}
export const useGameStore = create<GameStore>((set, get) => ({
// Initial state
colonies: [],
fleets: [],
totalResources: null,
research: [],
loading: {
colonies: false,
fleets: false,
resources: false,
research: false,
},
selectedColony: null,
selectedFleet: null,
// Colony actions
fetchColonies: async () => {
set(state => ({ loading: { ...state.loading, colonies: true } }));
try {
const response = await apiClient.colonies.getAll();
if (response.data.success && response.data.data) {
set({
colonies: response.data.data,
loading: { ...get().loading, colonies: false }
});
}
} catch (error: any) {
console.error('Failed to fetch colonies:', error);
toast.error('Failed to load colonies');
set(state => ({ loading: { ...state.loading, colonies: false } }));
}
},
selectColony: (colony: Colony | null) => {
set({ selectedColony: colony });
},
createColony: async (colonyData) => {
try {
const response = await apiClient.colonies.create(colonyData);
if (response.data.success && response.data.data) {
const newColony = response.data.data;
set(state => ({
colonies: [...state.colonies, newColony]
}));
toast.success(`Colony "${colonyData.name}" founded successfully!`);
return true;
} else {
toast.error(response.data.error || 'Failed to create colony');
return false;
}
} catch (error: any) {
const message = error.response?.data?.error || 'Failed to create colony';
toast.error(message);
return false;
}
},
updateColony: (colonyId: number, updates: Partial<Colony>) => {
set(state => ({
colonies: state.colonies.map(colony =>
colony.id === colonyId ? { ...colony, ...updates } : colony
),
selectedColony: state.selectedColony?.id === colonyId
? { ...state.selectedColony, ...updates }
: state.selectedColony
}));
},
// Fleet actions
fetchFleets: async () => {
set(state => ({ loading: { ...state.loading, fleets: true } }));
try {
const response = await apiClient.fleets.getAll();
if (response.data.success && response.data.data) {
set({
fleets: response.data.data,
loading: { ...get().loading, fleets: false }
});
}
} catch (error: any) {
console.error('Failed to fetch fleets:', error);
toast.error('Failed to load fleets');
set(state => ({ loading: { ...state.loading, fleets: false } }));
}
},
selectFleet: (fleet: Fleet | null) => {
set({ selectedFleet: fleet });
},
createFleet: async (fleetData) => {
try {
const response = await apiClient.fleets.create(fleetData);
if (response.data.success && response.data.data) {
const newFleet = response.data.data;
set(state => ({
fleets: [...state.fleets, newFleet]
}));
toast.success(`Fleet "${fleetData.name}" created successfully!`);
return true;
} else {
toast.error(response.data.error || 'Failed to create fleet');
return false;
}
} catch (error: any) {
const message = error.response?.data?.error || 'Failed to create fleet';
toast.error(message);
return false;
}
},
updateFleet: (fleetId: number, updates: Partial<Fleet>) => {
set(state => ({
fleets: state.fleets.map(fleet =>
fleet.id === fleetId ? { ...fleet, ...updates } : fleet
),
selectedFleet: state.selectedFleet?.id === fleetId
? { ...state.selectedFleet, ...updates }
: state.selectedFleet
}));
},
// Resource actions
fetchTotalResources: async () => {
set(state => ({ loading: { ...state.loading, resources: true } }));
try {
const response = await apiClient.resources.getTotal();
if (response.data.success && response.data.data) {
set({
totalResources: response.data.data,
loading: { ...get().loading, resources: false }
});
}
} catch (error: any) {
console.error('Failed to fetch resources:', error);
set(state => ({ loading: { ...state.loading, resources: false } }));
}
},
updateColonyResources: (colonyId: number, resources: Resources) => {
set(state => ({
colonies: state.colonies.map(colony =>
colony.id === colonyId
? {
...colony,
resources: colony.resources
? { ...colony.resources, ...resources }
: undefined
}
: colony
)
}));
},
// Research actions
fetchResearch: async () => {
set(state => ({ loading: { ...state.loading, research: true } }));
try {
const response = await apiClient.research.getAll();
if (response.data.success && response.data.data) {
set({
research: response.data.data,
loading: { ...get().loading, research: false }
});
}
} catch (error: any) {
console.error('Failed to fetch research:', error);
toast.error('Failed to load research');
set(state => ({ loading: { ...state.loading, research: false } }));
}
},
startResearch: async (technologyId: number) => {
try {
const response = await apiClient.research.start(technologyId);
if (response.data.success && response.data.data) {
const newResearch = response.data.data;
set(state => ({
research: [...state.research, newResearch]
}));
toast.success('Research started successfully!');
return true;
} else {
toast.error(response.data.error || 'Failed to start research');
return false;
}
} catch (error: any) {
const message = error.response?.data?.error || 'Failed to start research';
toast.error(message);
return false;
}
},
updateResearch: (researchId: number, updates: Partial<Research>) => {
set(state => ({
research: state.research.map(research =>
research.id === researchId ? { ...research, ...updates } : research
)
}));
},
// Utility actions
setLoading: (key: keyof GameState['loading'], loading: boolean) => {
set(state => ({
loading: { ...state.loading, [key]: loading }
}));
},
clearData: () => {
set({
colonies: [],
fleets: [],
totalResources: null,
research: [],
selectedColony: null,
selectedFleet: null,
loading: {
colonies: false,
fleets: false,
resources: false,
research: false,
}
});
},
}));

200
frontend/src/types/index.ts Normal file
View file

@ -0,0 +1,200 @@
// Authentication types
export interface User {
id: number;
username: string;
email: string;
created_at: string;
last_login?: string;
}
export interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
}
export interface LoginCredentials {
email: string;
password: string;
}
export interface RegisterCredentials {
username: string;
email: string;
password: string;
confirmPassword: string;
}
// Colony types
export interface Colony {
id: number;
player_id: number;
name: string;
coordinates: string;
planet_type_id: number;
population: number;
morale: number;
founded_at: string;
last_updated: string;
planet_type?: PlanetType;
buildings?: Building[];
resources?: ColonyResources;
}
export interface PlanetType {
id: number;
name: string;
description: string;
resource_modifiers: Record<string, number>;
}
export interface Building {
id: number;
colony_id: number;
building_type_id: number;
level: number;
construction_start?: string;
construction_end?: string;
is_constructing: boolean;
building_type?: BuildingType;
}
export interface BuildingType {
id: number;
name: string;
description: string;
category: string;
base_cost: Record<string, number>;
base_production: Record<string, number>;
max_level: number;
}
// Resource types
export interface Resources {
scrap: number;
energy: number;
research_points: number;
biomass: number;
}
export interface ColonyResources extends Resources {
colony_id: number;
last_updated: string;
production_rates: Resources;
}
// Fleet types
export interface Fleet {
id: number;
player_id: number;
name: string;
location_type: 'colony' | 'space';
location_id?: number;
coordinates?: string;
status: 'docked' | 'moving' | 'in_combat';
destination?: string;
arrival_time?: string;
ships: FleetShip[];
}
export interface FleetShip {
id: number;
fleet_id: number;
design_id: number;
quantity: number;
ship_design?: ShipDesign;
}
export interface ShipDesign {
id: number;
name: string;
hull_type: string;
cost: Record<string, number>;
stats: {
attack: number;
defense: number;
health: number;
speed: number;
cargo: number;
};
}
// Research types
export interface Research {
id: number;
player_id: number;
technology_id: number;
level: number;
research_start?: string;
research_end?: string;
is_researching: boolean;
technology?: Technology;
}
export interface Technology {
id: number;
name: string;
description: string;
category: string;
base_cost: number;
max_level: number;
prerequisites: number[];
unlocks: string[];
}
// WebSocket types
export interface WebSocketMessage {
type: string;
data: any;
timestamp: string;
}
export interface GameEvent {
id: string;
type: 'colony_update' | 'resource_update' | 'fleet_update' | 'research_complete' | 'building_complete';
data: any;
timestamp: string;
}
// API Response types
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
message?: string;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
// UI State types
export interface LoadingState {
[key: string]: boolean;
}
export interface ErrorState {
[key: string]: string | null;
}
// Navigation types
export interface NavItem {
name: string;
href: string;
icon?: React.ComponentType<any>;
current?: boolean;
badge?: number;
}
// Toast notification types
export interface ToastOptions {
type: 'success' | 'error' | 'warning' | 'info';
title: string;
message?: string;
duration?: number;
}

1
frontend/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -0,0 +1,56 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
dark: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
}
},
fontFamily: {
'mono': ['JetBrains Mono', 'Fira Code', 'Monaco', 'Consolas', 'monospace'],
},
animation: {
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'fade-in': 'fadeIn 0.5s ease-out',
'slide-in': 'slideIn 0.3s ease-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideIn: {
'0%': { transform: 'translateY(-10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
}
}
},
},
plugins: [],
}

View file

@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View file

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

View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

45
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,45 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
host: true,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
secure: false,
},
'/socket.io': {
target: 'http://localhost:3000',
changeOrigin: true,
ws: true,
}
}
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
build: {
outDir: 'dist',
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
router: ['react-router-dom'],
ui: ['@headlessui/react', '@heroicons/react'],
},
},
},
},
optimizeDeps: {
include: ['react', 'react-dom', 'react-router-dom'],
},
})