Initial commit
This commit is contained in:
1
frontend/.env
Normal file
1
frontend/.env
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_URL=http://26.62.117.104:8000
|
||||
3
frontend/.env.example
Normal file
3
frontend/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
# API URL (необязательно, по умолчанию определяется автоматически)
|
||||
# Раскомментируйте и укажите ваш IP для удаленного доступа
|
||||
# VITE_API_URL=http://26.123.45.67:8000
|
||||
10
frontend/.env.local.example
Normal file
10
frontend/.env.local.example
Normal file
@@ -0,0 +1,10 @@
|
||||
# Создайте файл .env.local и раскомментируйте нужную строку
|
||||
|
||||
# Для локального использования (по умолчанию)
|
||||
# VITE_API_URL=http://localhost:8000
|
||||
|
||||
# Для Radmin VPN (замените на ваш IP)
|
||||
# VITE_API_URL=http://26.62.117.104:8000
|
||||
|
||||
# Для Hamachi (замените на ваш IP)
|
||||
# VITE_API_URL=http://25.123.45.67:8000
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MC Panel</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2988
frontend/package-lock.json
generated
Normal file
2988
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
frontend/package.json
Normal file
28
frontend/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "mc-panel",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.22.0",
|
||||
"axios": "^1.6.7",
|
||||
"@xterm/xterm": "^5.3.0",
|
||||
"lucide-react": "^0.323.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.55",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.1.0"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
392
frontend/src/App.jsx
Normal file
392
frontend/src/App.jsx
Normal file
@@ -0,0 +1,392 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Server, Play, Square, Terminal, FolderOpen, HardDrive, Settings, Plus, Users as UsersIcon, LogOut, Menu, X } from 'lucide-react';
|
||||
import Console from './components/Console';
|
||||
import FileManager from './components/FileManager';
|
||||
import Stats from './components/Stats';
|
||||
import ServerSettings from './components/ServerSettings';
|
||||
import CreateServerModal from './components/CreateServerModal';
|
||||
import Users from './components/Users';
|
||||
import Auth from './components/Auth';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import ThemeSelector from './components/ThemeSelector';
|
||||
import axios from 'axios';
|
||||
import { API_URL } from './config';
|
||||
import { getTheme } from './themes';
|
||||
|
||||
function App() {
|
||||
const [token, setToken] = useState(localStorage.getItem('token'));
|
||||
const [user, setUser] = useState(null);
|
||||
const [servers, setServers] = useState([]);
|
||||
const [selectedServer, setSelectedServer] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState('console');
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showUsers, setShowUsers] = useState(false);
|
||||
const [connectionError, setConnectionError] = useState(false);
|
||||
const [theme, setTheme] = useState(localStorage.getItem('theme') || 'dark');
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
|
||||
const currentTheme = getTheme(theme);
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
loadUser();
|
||||
loadServers();
|
||||
const interval = setInterval(loadServers, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const loadUser = async () => {
|
||||
try {
|
||||
const { data } = await axios.get(`${API_URL}/api/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setUser(data);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки пользователя:', error);
|
||||
handleLogout();
|
||||
}
|
||||
};
|
||||
|
||||
const loadServers = async () => {
|
||||
try {
|
||||
const { data } = await axios.get(`${API_URL}/api/servers`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setServers(data);
|
||||
setConnectionError(false);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки серверов:', error);
|
||||
if (error.response?.status === 401) {
|
||||
handleLogout();
|
||||
} else {
|
||||
setConnectionError(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogin = async (username, password, isLogin) => {
|
||||
const endpoint = isLogin ? '/api/auth/login' : '/api/auth/register';
|
||||
const { data } = await axios.post(`${API_URL}${endpoint}`, {
|
||||
username,
|
||||
password
|
||||
});
|
||||
|
||||
localStorage.setItem('token', data.access_token);
|
||||
setToken(data.access_token);
|
||||
setUser({ username: data.username, role: data.role });
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
setServers([]);
|
||||
setSelectedServer(null);
|
||||
};
|
||||
|
||||
const handleServerDeleted = () => {
|
||||
setSelectedServer(null);
|
||||
loadServers();
|
||||
};
|
||||
|
||||
const handleThemeChange = (newTheme) => {
|
||||
setTheme(newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
};
|
||||
|
||||
const startServer = async (serverName) => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${API_URL}/api/servers/${serverName}/start`,
|
||||
{},
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
console.log('Сервер запущен:', response.data);
|
||||
setTimeout(() => {
|
||||
loadServers();
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('Ошибка запуска сервера:', error);
|
||||
alert(error.response?.data?.detail || 'Ошибка запуска сервера');
|
||||
}
|
||||
};
|
||||
|
||||
const stopServer = async (serverName) => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${API_URL}/api/servers/${serverName}/stop`,
|
||||
{},
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
console.log('Сервер остановлен:', response.data);
|
||||
setTimeout(() => {
|
||||
loadServers();
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('Ошибка остановки сервера:', error);
|
||||
alert(error.response?.data?.detail || 'Ошибка остановки сервера');
|
||||
}
|
||||
};
|
||||
|
||||
if (!token) {
|
||||
return <Auth onLogin={handleLogin} />;
|
||||
}
|
||||
|
||||
if (showUsers) {
|
||||
return (
|
||||
<div className={`min-h-screen ${currentTheme.primary} ${currentTheme.text} transition-colors duration-300`}>
|
||||
<header className={`${currentTheme.secondary} ${currentTheme.border} border-b backdrop-blur-sm bg-opacity-95 sticky top-0 z-40`}>
|
||||
<div className="px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`${currentTheme.accent} p-2 rounded-lg`}>
|
||||
<Server className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">MC Panel</h1>
|
||||
<p className={`text-xs ${currentTheme.textSecondary}`}>Управление серверами</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`px-3 py-1.5 rounded-lg ${currentTheme.card} ${currentTheme.border} border`}>
|
||||
<span className={`text-sm ${currentTheme.textSecondary}`}>
|
||||
{user?.username}
|
||||
</span>
|
||||
<span className={`ml-2 text-xs px-2 py-0.5 rounded ${currentTheme.accent} text-white`}>
|
||||
{user?.role === 'admin' ? 'Админ' : 'Пользователь'}
|
||||
</span>
|
||||
</div>
|
||||
<ThemeSelector currentTheme={theme} onThemeChange={handleThemeChange} />
|
||||
<button
|
||||
onClick={() => setShowUsers(false)}
|
||||
className={`${currentTheme.card} ${currentTheme.hover} px-4 py-2 rounded-lg transition flex items-center gap-2`}
|
||||
>
|
||||
<Server className="w-4 h-4" />
|
||||
Серверы
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className={`${currentTheme.danger} hover:opacity-90 px-4 py-2 rounded-lg transition flex items-center gap-2 text-white`}
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Выход
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<Users token={token} theme={currentTheme} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen ${currentTheme.primary} ${currentTheme.text} transition-colors duration-300`}>
|
||||
{/* Header */}
|
||||
<header className={`${currentTheme.secondary} ${currentTheme.border} border-b backdrop-blur-sm bg-opacity-95 sticky top-0 z-40`}>
|
||||
<div className="px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className={`lg:hidden ${currentTheme.hover} p-2 rounded-lg transition`}
|
||||
>
|
||||
{sidebarOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`${currentTheme.accent} p-2 rounded-lg`}>
|
||||
<Server className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className={`text-xl font-bold bg-gradient-to-r ${currentTheme.gradient} bg-clip-text text-transparent`}>MC Panel</h1>
|
||||
<p className={`text-xs ${currentTheme.textSecondary}`}>Управление серверами</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{connectionError && (
|
||||
<div className="bg-red-500 bg-opacity-20 border border-red-500 px-3 py-1.5 rounded-lg text-sm text-red-400">
|
||||
⚠️ Нет связи
|
||||
</div>
|
||||
)}
|
||||
<div className={`px-3 py-1.5 rounded-lg ${currentTheme.card} ${currentTheme.border} border`}>
|
||||
<span className={`text-sm ${currentTheme.textSecondary}`}>
|
||||
{user?.username}
|
||||
</span>
|
||||
<span className={`ml-2 text-xs px-2 py-0.5 rounded ${currentTheme.accent} text-white`}>
|
||||
{user?.role === 'admin' ? 'Админ' : 'Пользователь'}
|
||||
</span>
|
||||
</div>
|
||||
<ThemeSelector currentTheme={theme} onThemeChange={handleThemeChange} />
|
||||
{user?.role === 'admin' && (
|
||||
<button
|
||||
onClick={() => setShowUsers(true)}
|
||||
className={`${currentTheme.accent} ${currentTheme.accentHover} px-4 py-2 rounded-lg transition flex items-center gap-2 text-white`}
|
||||
>
|
||||
<UsersIcon className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Пользователи</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className={`${currentTheme.danger} hover:opacity-90 px-4 py-2 rounded-lg transition flex items-center gap-2 text-white`}
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Выход</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex h-[calc(100vh-89px)]">
|
||||
{/* Sidebar */}
|
||||
<aside className={`${sidebarOpen ? 'w-80' : 'w-0'} ${currentTheme.secondary} ${currentTheme.border} border-r transition-all duration-300 overflow-hidden`}>
|
||||
<div className="p-4 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Мои серверы</h2>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className={`${currentTheme.accent} ${currentTheme.accentHover} p-2 rounded-lg transition text-white`}
|
||||
title="Создать сервер"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-2">
|
||||
{servers.map((server) => (
|
||||
<div
|
||||
key={server.name}
|
||||
className={`p-4 rounded-lg cursor-pointer transition-all duration-200 ${
|
||||
selectedServer === server.name
|
||||
? `${currentTheme.accent} text-white shadow-lg`
|
||||
: `${currentTheme.card} ${currentTheme.hover}`
|
||||
}`}
|
||||
onClick={() => setSelectedServer(server.name)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="font-medium truncate">{server.displayName}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
server.status === 'running' ? 'bg-green-400 animate-pulse' : 'bg-gray-500'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{server.status === 'stopped' ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startServer(server.name);
|
||||
}}
|
||||
className="flex-1 bg-green-600 hover:bg-green-700 py-1.5 rounded-lg text-sm flex items-center justify-center gap-1.5 text-white transition"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5" />
|
||||
Запустить
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
stopServer(server.name);
|
||||
}}
|
||||
className="flex-1 bg-red-600 hover:bg-red-700 py-1.5 rounded-lg text-sm flex items-center justify-center gap-1.5 text-white transition"
|
||||
>
|
||||
<Square className="w-3.5 h-3.5" />
|
||||
Остановить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{servers.length === 0 && (
|
||||
<div className={`text-center py-12 ${currentTheme.textSecondary}`}>
|
||||
<Server className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">Нет серверов</p>
|
||||
<p className="text-xs mt-1">Создайте первый сервер</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 flex flex-col overflow-hidden">
|
||||
{selectedServer ? (
|
||||
<>
|
||||
{/* Tabs */}
|
||||
<div className={`${currentTheme.secondary} ${currentTheme.border} border-b flex overflow-x-auto`}>
|
||||
{[
|
||||
{ id: 'console', icon: Terminal, label: 'Консоль' },
|
||||
{ id: 'files', icon: FolderOpen, label: 'Файлы' },
|
||||
{ id: 'stats', icon: HardDrive, label: 'Статистика' },
|
||||
{ id: 'settings', icon: Settings, label: 'Настройки' },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-6 py-4 flex items-center gap-2 transition-all duration-200 border-b-2 ${
|
||||
activeTab === tab.id
|
||||
? `${currentTheme.accent.replace('bg-', 'border-')} ${currentTheme.text}`
|
||||
: `border-transparent ${currentTheme.textSecondary} ${currentTheme.hover}`
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
<span className="font-medium">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ErrorBoundary>
|
||||
{activeTab === 'console' && <Console serverName={selectedServer} token={token} theme={currentTheme} />}
|
||||
{activeTab === 'files' && <FileManager serverName={selectedServer} token={token} theme={currentTheme} />}
|
||||
{activeTab === 'stats' && <Stats serverName={selectedServer} token={token} theme={currentTheme} />}
|
||||
{activeTab === 'settings' && (
|
||||
<ServerSettings
|
||||
serverName={selectedServer}
|
||||
token={token}
|
||||
user={user}
|
||||
theme={currentTheme}
|
||||
onDeleted={handleServerDeleted}
|
||||
/>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className={`${currentTheme.card} p-8 rounded-2xl ${currentTheme.border} border`}>
|
||||
<Server className={`w-16 h-16 mx-auto mb-4 ${currentTheme.textSecondary} opacity-50`} />
|
||||
<p className="text-xl font-medium mb-2">Выберите сервер</p>
|
||||
<p className={`text-sm ${currentTheme.textSecondary}`}>
|
||||
Выберите сервер из списка слева или создайте новый
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{showCreateModal && (
|
||||
<CreateServerModal
|
||||
token={token}
|
||||
theme={currentTheme}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onCreated={loadServers}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
343
frontend/src/App1.jsx
Normal file
343
frontend/src/App1.jsx
Normal file
@@ -0,0 +1,343 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Server, Play, Square, Terminal, FolderOpen, HardDrive, Settings, Plus, Users as UsersIcon, LogOut } from 'lucide-react';
|
||||
import Console from './components/Console';
|
||||
import FileManager from './components/FileManager';
|
||||
import Stats from './components/Stats';
|
||||
import ServerSettings from './components/ServerSettings';
|
||||
import CreateServerModal from './components/CreateServerModal';
|
||||
import Users from './components/Users';
|
||||
import Auth from './components/Auth';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import axios from 'axios';
|
||||
import { API_URL } from './config';
|
||||
|
||||
function App() {
|
||||
const [token, setToken] = useState(localStorage.getItem('token'));
|
||||
const [user, setUser] = useState(null);
|
||||
const [servers, setServers] = useState([]);
|
||||
const [selectedServer, setSelectedServer] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState('console');
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showUsers, setShowUsers] = useState(false);
|
||||
const [connectionError, setConnectionError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
loadUser();
|
||||
loadServers();
|
||||
const interval = setInterval(loadServers, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const loadUser = async () => {
|
||||
try {
|
||||
const { data } = await axios.get(`${API_URL}/api/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setUser(data);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки пользователя:', error);
|
||||
handleLogout();
|
||||
}
|
||||
};
|
||||
|
||||
const loadServers = async () => {
|
||||
try {
|
||||
const { data } = await axios.get(`${API_URL}/api/servers`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setServers(data);
|
||||
setConnectionError(false);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки серверов:', error);
|
||||
if (error.response?.status === 401) {
|
||||
handleLogout();
|
||||
} else {
|
||||
setConnectionError(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogin = async (username, password, isLogin) => {
|
||||
const endpoint = isLogin ? '/api/auth/login' : '/api/auth/register';
|
||||
const { data } = await axios.post(`${API_URL}${endpoint}`, {
|
||||
username,
|
||||
password
|
||||
});
|
||||
|
||||
localStorage.setItem('token', data.access_token);
|
||||
setToken(data.access_token);
|
||||
setUser({ username: data.username, role: data.role });
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
setServers([]);
|
||||
setSelectedServer(null);
|
||||
};
|
||||
|
||||
const handleServerDeleted = () => {
|
||||
setSelectedServer(null);
|
||||
loadServers();
|
||||
};
|
||||
|
||||
const startServer = async (serverName) => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${API_URL}/api/servers/${serverName}/start`,
|
||||
{},
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
console.log('Сервер запущен:', response.data);
|
||||
setTimeout(() => {
|
||||
loadServers();
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('Ошибка запуска сервера:', error);
|
||||
alert(error.response?.data?.detail || 'Ошибка запуска сервера');
|
||||
}
|
||||
};
|
||||
|
||||
const stopServer = async (serverName) => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${API_URL}/api/servers/${serverName}/stop`,
|
||||
{},
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
console.log('Сервер остановлен:', response.data);
|
||||
setTimeout(() => {
|
||||
loadServers();
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('Ошибка остановки сервера:', error);
|
||||
alert(error.response?.data?.detail || 'Ошибка остановки сервера');
|
||||
}
|
||||
};
|
||||
|
||||
if (!token) {
|
||||
return <Auth onLogin={handleLogin} />;
|
||||
}
|
||||
|
||||
if (showUsers) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white">
|
||||
<header className="bg-gray-800 border-b border-gray-700 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Server className="w-8 h-8" />
|
||||
MC Panel
|
||||
</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-gray-400">
|
||||
{user?.username} ({user?.role === 'admin' ? 'Админ' : 'Пользователь'})
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowUsers(false)}
|
||||
className="bg-gray-700 hover:bg-gray-600 px-4 py-2 rounded"
|
||||
>
|
||||
← Назад к серверам
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="bg-red-600 hover:bg-red-700 px-4 py-2 rounded flex items-center gap-2"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Выход
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<Users token={token} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white">
|
||||
<header className="bg-gray-800 border-b border-gray-700 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Server className="w-8 h-8" />
|
||||
MC Panel
|
||||
</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
{connectionError && (
|
||||
<div className="bg-red-600 px-4 py-2 rounded text-sm">
|
||||
⚠️ Нет связи с сервером
|
||||
</div>
|
||||
)}
|
||||
<span className="text-gray-400">
|
||||
{user?.username} ({user?.role === 'admin' ? 'Админ' : 'Пользователь'})
|
||||
</span>
|
||||
{user?.role === 'admin' && (
|
||||
<button
|
||||
onClick={() => setShowUsers(true)}
|
||||
className="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded flex items-center gap-2"
|
||||
>
|
||||
<UsersIcon className="w-4 h-4" />
|
||||
Пользователи
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="bg-red-600 hover:bg-red-700 px-4 py-2 rounded flex items-center gap-2"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Выход
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex h-[calc(100vh-73px)]">
|
||||
<aside className="w-64 bg-gray-800 border-r border-gray-700 p-4 overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Серверы</h2>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="bg-blue-600 hover:bg-blue-700 p-2 rounded"
|
||||
title="Создать сервер"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{servers.map((server) => (
|
||||
<div
|
||||
key={server.name}
|
||||
className={`p-3 mb-2 rounded cursor-pointer transition ${
|
||||
selectedServer === server.name
|
||||
? 'bg-blue-600'
|
||||
: 'bg-gray-700 hover:bg-gray-600'
|
||||
}`}
|
||||
onClick={() => setSelectedServer(server.name)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium truncate">{server.displayName}</span>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full flex-shrink-0 ${
|
||||
server.status === 'running' ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
{server.status === 'stopped' ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startServer(server.name);
|
||||
}}
|
||||
className="flex-1 bg-green-600 hover:bg-green-700 p-1 rounded text-sm flex items-center justify-center gap-1"
|
||||
>
|
||||
<Play className="w-3 h-3" />
|
||||
Старт
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
stopServer(server.name);
|
||||
}}
|
||||
className="flex-1 bg-red-600 hover:bg-red-700 p-1 rounded text-sm flex items-center justify-center gap-1"
|
||||
>
|
||||
<Square className="w-3 h-3" />
|
||||
Стоп
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 flex flex-col">
|
||||
{selectedServer ? (
|
||||
<>
|
||||
<div className="bg-gray-800 border-b border-gray-700 flex">
|
||||
<button
|
||||
onClick={() => setActiveTab('console')}
|
||||
className={`px-6 py-3 flex items-center gap-2 ${
|
||||
activeTab === 'console'
|
||||
? 'bg-gray-900 border-b-2 border-blue-500'
|
||||
: 'hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Terminal className="w-4 h-4" />
|
||||
Консоль
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('files')}
|
||||
className={`px-6 py-3 flex items-center gap-2 ${
|
||||
activeTab === 'files'
|
||||
? 'bg-gray-900 border-b-2 border-blue-500'
|
||||
: 'hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
Файлы
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('stats')}
|
||||
className={`px-6 py-3 flex items-center gap-2 ${
|
||||
activeTab === 'stats'
|
||||
? 'bg-gray-900 border-b-2 border-blue-500'
|
||||
: 'hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<HardDrive className="w-4 h-4" />
|
||||
Статистика
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('settings')}
|
||||
className={`px-6 py-3 flex items-center gap-2 ${
|
||||
activeTab === 'settings'
|
||||
? 'bg-gray-900 border-b-2 border-blue-500'
|
||||
: 'hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
Настройки
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ErrorBoundary>
|
||||
{activeTab === 'console' && <Console serverName={selectedServer} token={token} />}
|
||||
{activeTab === 'files' && <FileManager serverName={selectedServer} token={token} />}
|
||||
{activeTab === 'stats' && <Stats serverName={selectedServer} token={token} />}
|
||||
{activeTab === 'settings' && (
|
||||
<ServerSettings
|
||||
serverName={selectedServer}
|
||||
token={token}
|
||||
user={user}
|
||||
onDeleted={handleServerDeleted}
|
||||
/>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-500">
|
||||
<div className="text-center">
|
||||
<Server className="w-16 h-16 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-xl">Выберите сервер</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{showCreateModal && (
|
||||
<CreateServerModal
|
||||
token={token}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onCreated={loadServers}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
148
frontend/src/components/Auth.jsx
Normal file
148
frontend/src/components/Auth.jsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useState } from 'react';
|
||||
import { Server, Eye, EyeOff } from 'lucide-react';
|
||||
import { getTheme } from '../themes';
|
||||
|
||||
export default function Auth({ onLogin }) {
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [theme] = useState(localStorage.getItem('theme') || 'dark');
|
||||
|
||||
const currentTheme = getTheme(theme);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await onLogin(username, password, isLogin);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Ошибка авторизации');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen ${currentTheme.primary} flex items-center justify-center p-4 transition-colors duration-300`}>
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className={`${currentTheme.accent} w-16 h-16 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg`}>
|
||||
<Server className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
<h1 className={`text-3xl font-bold bg-gradient-to-r ${currentTheme.gradient} bg-clip-text text-transparent mb-2`}>MC Panel</h1>
|
||||
<p className={`${currentTheme.textSecondary}`}>Панель управления Minecraft серверами</p>
|
||||
</div>
|
||||
|
||||
{/* Form Card */}
|
||||
<div className={`${currentTheme.secondary} rounded-2xl shadow-2xl ${currentTheme.border} border p-8`}>
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 mb-6">
|
||||
<button
|
||||
onClick={() => setIsLogin(true)}
|
||||
className={`flex-1 py-3 rounded-xl font-medium transition-all duration-200 ${
|
||||
isLogin
|
||||
? `${currentTheme.accent} text-white shadow-lg`
|
||||
: `${currentTheme.card} ${currentTheme.text} ${currentTheme.hover}`
|
||||
}`}
|
||||
>
|
||||
Вход
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsLogin(false)}
|
||||
className={`flex-1 py-3 rounded-xl font-medium transition-all duration-200 ${
|
||||
!isLogin
|
||||
? `${currentTheme.accent} text-white shadow-lg`
|
||||
: `${currentTheme.card} ${currentTheme.text} ${currentTheme.hover}`
|
||||
}`}
|
||||
>
|
||||
Регистрация
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Username */}
|
||||
<div>
|
||||
<label className={`block text-sm font-medium ${currentTheme.text} mb-2`}>
|
||||
Имя пользователя
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
className={`w-full ${currentTheme.input} ${currentTheme.border} border rounded-xl px-4 py-3 ${currentTheme.text} focus:outline-none focus:ring-2 focus:ring-blue-500 transition`}
|
||||
placeholder="admin"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className={`block text-sm font-medium ${currentTheme.text} mb-2`}>
|
||||
Пароль
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className={`w-full ${currentTheme.input} ${currentTheme.border} border rounded-xl px-4 py-3 pr-12 ${currentTheme.text} focus:outline-none focus:ring-2 focus:ring-blue-500 transition`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className={`absolute right-3 top-1/2 -translate-y-1/2 ${currentTheme.textSecondary} hover:${currentTheme.text} transition`}
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="bg-red-500 bg-opacity-10 border border-red-500 rounded-xl p-3 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={`w-full ${currentTheme.accent} ${currentTheme.accentHover} text-white py-3 rounded-xl font-medium disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-lg hover:shadow-xl`}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Загрузка...
|
||||
</span>
|
||||
) : (
|
||||
isLogin ? 'Войти' : 'Зарегистрироваться'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Default Credentials */}
|
||||
{isLogin && (
|
||||
<div className={`mt-6 text-center text-sm ${currentTheme.textSecondary}`}>
|
||||
<p>Учётные данные по умолчанию:</p>
|
||||
<p className={`${currentTheme.text} font-mono mt-1`}>admin / admin</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className={`text-center mt-6 text-sm ${currentTheme.textSecondary}`}>
|
||||
<p>© 2024 MC Panel. Все права защищены.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
frontend/src/components/Auth1.jsx
Normal file
109
frontend/src/components/Auth1.jsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useState } from 'react';
|
||||
import { Server } from 'lucide-react';
|
||||
|
||||
export default function Auth({ onLogin }) {
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await onLogin(username, password, isLogin);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Ошибка авторизации');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 flex items-center justify-center p-4">
|
||||
<div className="bg-gray-800 rounded-lg p-8 w-full max-w-md border border-gray-700">
|
||||
<div className="flex items-center justify-center mb-8">
|
||||
<Server className="w-12 h-12 text-blue-500 mr-3" />
|
||||
<h1 className="text-3xl font-bold text-white">MC Panel</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-6">
|
||||
<button
|
||||
onClick={() => setIsLogin(true)}
|
||||
className={`flex-1 py-2 rounded ${
|
||||
isLogin
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-700 text-gray-400 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
Вход
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsLogin(false)}
|
||||
className={`flex-1 py-2 rounded ${
|
||||
!isLogin
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-700 text-gray-400 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
Регистрация
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Имя пользователя
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
className="w-full bg-gray-700 border border-gray-600 rounded px-4 py-2 text-white focus:outline-none focus:border-blue-500"
|
||||
placeholder="admin"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Пароль
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full bg-gray-700 border border-gray-600 rounded px-4 py-2 text-white focus:outline-none focus:border-blue-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-600 bg-opacity-20 border border-red-600 rounded p-3 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 rounded font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Загрузка...' : isLogin ? 'Войти' : 'Зарегистрироваться'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{isLogin && (
|
||||
<div className="mt-4 text-center text-sm text-gray-400">
|
||||
<p>По умолчанию:</p>
|
||||
<p className="text-gray-300">admin / admin</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
frontend/src/components/Console.jsx
Normal file
90
frontend/src/components/Console.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Send } from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import { API_URL, WS_URL } from '../config';
|
||||
|
||||
export default function Console({ serverName, token, theme }) {
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [command, setCommand] = useState('');
|
||||
const logsEndRef = useRef(null);
|
||||
const wsRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLogs([]);
|
||||
|
||||
const ws = new WebSocket(`${WS_URL}/ws/servers/${serverName}/console`);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket подключен');
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
setLogs((prev) => [...prev, event.data]);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket ошибка:', error);
|
||||
};
|
||||
|
||||
wsRef.current = ws;
|
||||
|
||||
return () => {
|
||||
ws.close();
|
||||
};
|
||||
}, [serverName]);
|
||||
|
||||
useEffect(() => {
|
||||
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [logs]);
|
||||
|
||||
const sendCommand = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!command.trim()) return;
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
`${API_URL}/api/servers/${serverName}/command`,
|
||||
{ command: command.trim() },
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
setCommand('');
|
||||
} catch (error) {
|
||||
console.error('Ошибка отправки команды:', error);
|
||||
alert(error.response?.data?.detail || 'Ошибка отправки команды');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col h-full ${theme.primary}`}>
|
||||
<div className={`flex-1 overflow-y-auto p-4 font-mono text-sm ${theme.secondary}`}>
|
||||
{logs.length === 0 ? (
|
||||
<div className={theme.textSecondary}>Консоль пуста. Запустите сервер для просмотра логов.</div>
|
||||
) : (
|
||||
logs.map((log, index) => (
|
||||
<div key={index} className={`${theme.text} whitespace-pre-wrap leading-relaxed`}>
|
||||
{log}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
|
||||
<form onSubmit={sendCommand} className={`${theme.border} border-t p-4 flex gap-2`}>
|
||||
<input
|
||||
type="text"
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
placeholder="Введите команду..."
|
||||
className={`flex-1 ${theme.input} ${theme.border} border rounded-xl px-4 py-2 ${theme.text} focus:outline-none focus:ring-2 focus:ring-blue-500 transition`}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className={`${theme.accent} ${theme.accentHover} px-6 py-2 rounded-xl flex items-center gap-2 text-white transition`}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
Отправить
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
frontend/src/components/CreateServerModal.jsx
Normal file
108
frontend/src/components/CreateServerModal.jsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import { API_URL } from '../config';
|
||||
|
||||
export default function CreateServerModal({ token, theme, onClose, onCreated }) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
displayName: '',
|
||||
startCommand: 'java -Xmx2G -Xms1G -jar server.jar nogui'
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
`${API_URL}/api/servers/create`,
|
||||
formData,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
onCreated();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
alert(error.response?.data?.detail || 'Ошибка создания сервера');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className={`${theme.secondary} rounded-2xl p-6 w-full max-w-md shadow-2xl ${theme.border} border`}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className={`text-xl font-bold ${theme.text}`}>Создать сервер</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`${theme.textSecondary} hover:${theme.text} transition`}
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-2 ${theme.text}`}>
|
||||
Имя папки (только латиница, цифры, _ и -)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className={`w-full ${theme.input} ${theme.border} border rounded-xl px-4 py-2 ${theme.text} focus:outline-none focus:ring-2 focus:ring-blue-500 transition`}
|
||||
placeholder="my_server"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-2 ${theme.text}`}>
|
||||
Отображаемое имя
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.displayName}
|
||||
onChange={(e) => setFormData({ ...formData, displayName: e.target.value })}
|
||||
className={`w-full ${theme.input} ${theme.border} border rounded-xl px-4 py-2 ${theme.text} focus:outline-none focus:ring-2 focus:ring-blue-500 transition`}
|
||||
placeholder="Мой сервер"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-2 ${theme.text}`}>
|
||||
Команда запуска
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.startCommand}
|
||||
onChange={(e) => setFormData({ ...formData, startCommand: e.target.value })}
|
||||
className={`w-full ${theme.input} ${theme.border} border rounded-xl px-4 py-2 ${theme.text} focus:outline-none focus:ring-2 focus:ring-blue-500 transition`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={`flex-1 ${theme.card} ${theme.hover} px-4 py-2 rounded-xl transition`}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={`flex-1 ${theme.accent} ${theme.accentHover} px-4 py-2 rounded-xl disabled:opacity-50 transition text-white`}
|
||||
>
|
||||
{loading ? 'Создание...' : 'Создать'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
frontend/src/components/ErrorBoundary.jsx
Normal file
44
frontend/src/components/ErrorBoundary.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Component } from 'react';
|
||||
|
||||
class ErrorBoundary extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
console.error('Error caught by boundary:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full bg-gray-900 text-white p-8">
|
||||
<div className="max-w-md text-center">
|
||||
<h2 className="text-2xl font-bold mb-4">Что-то пошло не так</h2>
|
||||
<p className="text-gray-400 mb-4">
|
||||
Произошла ошибка при загрузке компонента
|
||||
</p>
|
||||
<pre className="bg-black p-4 rounded text-left text-sm overflow-auto mb-4">
|
||||
{this.state.error?.toString()}
|
||||
</pre>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="bg-blue-600 hover:bg-blue-700 px-6 py-2 rounded"
|
||||
>
|
||||
Перезагрузить страницу
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
64
frontend/src/components/FileEditorModal.jsx
Normal file
64
frontend/src/components/FileEditorModal.jsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, Save } from 'lucide-react';
|
||||
|
||||
export default function FileEditorModal({ file, onClose, onSave }) {
|
||||
const [content, setContent] = useState(file.content);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
await onSave(file.path, content);
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.ctrlKey && e.key === 's') {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [content]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-gray-800 rounded-lg w-full max-w-4xl h-[80vh] flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-700">
|
||||
<h2 className="text-xl font-bold">Редактирование: {file.name}</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden p-4 bg-gray-900">
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="w-full h-full bg-black text-gray-300 font-mono text-sm p-4 rounded border border-gray-700 focus:outline-none focus:border-blue-500 resize-none"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-gray-700 text-sm text-gray-400">
|
||||
Используйте Ctrl+S для быстрого сохранения
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
303
frontend/src/components/FileManager.jsx
Normal file
303
frontend/src/components/FileManager.jsx
Normal file
@@ -0,0 +1,303 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Folder, File, Download, Trash2, Upload, Edit, Eye } from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import FileEditorModal from './FileEditorModal';
|
||||
import FileViewerModal from './FileViewerModal';
|
||||
import { API_URL } from '../config';
|
||||
|
||||
export default function FileManager({ serverName, token }) {
|
||||
const [files, setFiles] = useState([]);
|
||||
const [currentPath, setCurrentPath] = useState('');
|
||||
const [editingFile, setEditingFile] = useState(null);
|
||||
const [viewingFile, setViewingFile] = useState(null);
|
||||
const [renamingFile, setRenamingFile] = useState(null);
|
||||
const [newFileName, setNewFileName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadFiles();
|
||||
}, [serverName, currentPath]);
|
||||
|
||||
const loadFiles = async () => {
|
||||
try {
|
||||
const { data } = await axios.get(`${API_URL}/api/servers/${serverName}/files`, {
|
||||
params: { path: currentPath },
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setFiles(data);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки файлов:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const openFolder = (folderName) => {
|
||||
setCurrentPath(currentPath ? `${currentPath}/${folderName}` : folderName);
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
const parts = currentPath.split('/');
|
||||
parts.pop();
|
||||
setCurrentPath(parts.join('/'));
|
||||
};
|
||||
|
||||
const downloadFile = async (fileName) => {
|
||||
const filePath = currentPath ? `${currentPath}/${fileName}` : fileName;
|
||||
window.open(`${API_URL}/api/servers/${serverName}/files/download?path=${filePath}`, '_blank');
|
||||
};
|
||||
|
||||
const deleteFile = async (fileName) => {
|
||||
if (!confirm(`Удалить ${fileName}?`)) return;
|
||||
|
||||
try {
|
||||
const filePath = currentPath ? `${currentPath}/${fileName}` : fileName;
|
||||
await axios.delete(`${API_URL}/api/servers/${serverName}/files`, {
|
||||
params: { path: filePath },
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
loadFiles();
|
||||
} catch (error) {
|
||||
alert('Ошибка удаления файла');
|
||||
}
|
||||
};
|
||||
|
||||
const uploadFile = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
`${API_URL}/api/servers/${serverName}/files/upload?path=${currentPath}`,
|
||||
formData,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
loadFiles();
|
||||
} catch (error) {
|
||||
alert('Ошибка загрузки файла');
|
||||
}
|
||||
};
|
||||
|
||||
const viewFile = async (fileName) => {
|
||||
const filePath = currentPath ? `${currentPath}/${fileName}` : fileName;
|
||||
try {
|
||||
const { data } = await axios.get(`${API_URL}/api/servers/${serverName}/files/content`, {
|
||||
params: { path: filePath },
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setViewingFile({ name: fileName, path: filePath, content: data.content });
|
||||
} catch (error) {
|
||||
alert(error.response?.data?.detail || 'Ошибка открытия файла');
|
||||
}
|
||||
};
|
||||
|
||||
const editFile = async (fileName) => {
|
||||
const filePath = currentPath ? `${currentPath}/${fileName}` : fileName;
|
||||
try {
|
||||
const { data } = await axios.get(`${API_URL}/api/servers/${serverName}/files/content`, {
|
||||
params: { path: filePath },
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setEditingFile({ name: fileName, path: filePath, content: data.content });
|
||||
} catch (error) {
|
||||
alert(error.response?.data?.detail || 'Ошибка открытия файла');
|
||||
}
|
||||
};
|
||||
|
||||
const saveFile = async (filePath, content) => {
|
||||
try {
|
||||
await axios.put(
|
||||
`${API_URL}/api/servers/${serverName}/files/content`,
|
||||
{ content },
|
||||
{
|
||||
params: { path: filePath },
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}
|
||||
);
|
||||
setEditingFile(null);
|
||||
alert('Файл сохранен');
|
||||
} catch (error) {
|
||||
alert(error.response?.data?.detail || 'Ошибка сохранения файла');
|
||||
}
|
||||
};
|
||||
|
||||
const startRename = (fileName) => {
|
||||
setRenamingFile(fileName);
|
||||
setNewFileName(fileName);
|
||||
};
|
||||
|
||||
const renameFile = async (oldName) => {
|
||||
if (!newFileName.trim() || newFileName === oldName) {
|
||||
setRenamingFile(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const oldPath = currentPath ? `${currentPath}/${oldName}` : oldName;
|
||||
|
||||
try {
|
||||
await axios.put(
|
||||
`${API_URL}/api/servers/${serverName}/files/rename`,
|
||||
null,
|
||||
{
|
||||
params: { old_path: oldPath, new_name: newFileName },
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}
|
||||
);
|
||||
setRenamingFile(null);
|
||||
loadFiles();
|
||||
} catch (error) {
|
||||
alert(error.response?.data?.detail || 'Ошибка переименования файла');
|
||||
}
|
||||
};
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
if (bytes === 0) return '-';
|
||||
const k = 1024;
|
||||
const sizes = ['Б', 'КБ', 'МБ', 'ГБ'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-gray-900">
|
||||
<div className="border-b border-gray-700 p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{currentPath && (
|
||||
<button
|
||||
onClick={goBack}
|
||||
className="bg-gray-700 hover:bg-gray-600 px-3 py-1 rounded"
|
||||
>
|
||||
← Назад
|
||||
</button>
|
||||
)}
|
||||
<span className="text-gray-400">/{currentPath || 'root'}</span>
|
||||
</div>
|
||||
<label className="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded cursor-pointer flex items-center gap-2">
|
||||
<Upload className="w-4 h-4" />
|
||||
Загрузить
|
||||
<input type="file" onChange={uploadFile} className="hidden" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-800 sticky top-0">
|
||||
<tr>
|
||||
<th className="text-left p-4">Имя</th>
|
||||
<th className="text-left p-4">Размер</th>
|
||||
<th className="text-right p-4">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{files.map((file) => (
|
||||
<tr
|
||||
key={file.name}
|
||||
className="border-b border-gray-800 hover:bg-gray-800"
|
||||
>
|
||||
<td className="p-4">
|
||||
{renamingFile === file.name ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{file.type === 'directory' ? (
|
||||
<Folder className="w-5 h-5 text-blue-400" />
|
||||
) : (
|
||||
<File className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={newFileName}
|
||||
onChange={(e) => setNewFileName(e.target.value)}
|
||||
onBlur={() => renameFile(file.name)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') renameFile(file.name);
|
||||
if (e.key === 'Escape') setRenamingFile(null);
|
||||
}}
|
||||
autoFocus
|
||||
className="bg-gray-700 border border-gray-600 rounded px-2 py-1 text-sm focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
onClick={() => file.type === 'directory' && openFolder(file.name)}
|
||||
onDoubleClick={() => file.type === 'file' && viewFile(file.name)}
|
||||
>
|
||||
{file.type === 'directory' ? (
|
||||
<Folder className="w-5 h-5 text-blue-400" />
|
||||
) : (
|
||||
<File className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
<span>{file.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4 text-gray-400">{formatSize(file.size)}</td>
|
||||
<td className="p-4">
|
||||
<div className="flex gap-2 justify-end">
|
||||
{file.type === 'file' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => viewFile(file.name)}
|
||||
className="bg-blue-600 hover:bg-blue-700 p-2 rounded"
|
||||
title="Просмотр"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editFile(file.name)}
|
||||
className="bg-purple-600 hover:bg-purple-700 p-2 rounded"
|
||||
title="Редактировать"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => downloadFile(file.name)}
|
||||
className="bg-green-600 hover:bg-green-700 p-2 rounded"
|
||||
title="Скачать"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => startRename(file.name)}
|
||||
className="bg-yellow-600 hover:bg-yellow-700 p-2 rounded"
|
||||
title="Переименовать"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteFile(file.name)}
|
||||
className="bg-red-600 hover:bg-red-700 p-2 rounded"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{viewingFile && (
|
||||
<FileViewerModal
|
||||
file={viewingFile}
|
||||
onClose={() => setViewingFile(null)}
|
||||
onEdit={() => {
|
||||
setEditingFile(viewingFile);
|
||||
setViewingFile(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingFile && (
|
||||
<FileEditorModal
|
||||
file={editingFile}
|
||||
onClose={() => setEditingFile(null)}
|
||||
onSave={saveFile}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/FileViewerModal.jsx
Normal file
34
frontend/src/components/FileViewerModal.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { X, Edit } from 'lucide-react';
|
||||
|
||||
export default function FileViewerModal({ file, onClose, onEdit }) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-gray-800 rounded-lg w-full max-w-4xl h-[80vh] flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-700">
|
||||
<h2 className="text-xl font-bold">{file.name}</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="bg-purple-600 hover:bg-purple-700 px-4 py-2 rounded flex items-center gap-2"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
Редактировать
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4 bg-gray-900">
|
||||
<pre className="text-sm text-gray-300 font-mono whitespace-pre-wrap">
|
||||
{file.content}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
frontend/src/components/Login.jsx
Normal file
106
frontend/src/components/Login.jsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useState } from 'react';
|
||||
import { Server } from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import { API_URL } from '../config';
|
||||
|
||||
export default function Login({ onLogin }) {
|
||||
const [isRegister, setIsRegister] = useState(false);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const endpoint = isRegister ? '/api/auth/register' : '/api/auth/login';
|
||||
const { data } = await axios.post(`${API_URL}${endpoint}`, {
|
||||
username,
|
||||
password
|
||||
});
|
||||
|
||||
localStorage.setItem('token', data.access_token);
|
||||
localStorage.setItem('username', data.username);
|
||||
localStorage.setItem('role', data.role);
|
||||
|
||||
onLogin(data);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Ошибка авторизации');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 flex items-center justify-center p-4">
|
||||
<div className="bg-gray-800 rounded-lg p-8 w-full max-w-md">
|
||||
<div className="flex items-center justify-center mb-8">
|
||||
<Server className="w-12 h-12 text-blue-500 mr-3" />
|
||||
<h1 className="text-3xl font-bold text-white">MC Panel</h1>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-semibold text-white mb-6 text-center">
|
||||
{isRegister ? 'Регистрация' : 'Вход'}
|
||||
</h2>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-600 text-white p-3 rounded mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Имя пользователя
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
minLength={3}
|
||||
className="w-full bg-gray-700 border border-gray-600 rounded px-4 py-2 text-white focus:outline-none focus:border-blue-500"
|
||||
placeholder="Введите имя пользователя"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Пароль
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
className="w-full bg-gray-700 border border-gray-600 rounded px-4 py-2 text-white focus:outline-none focus:border-blue-500"
|
||||
placeholder="Введите пароль"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 rounded font-medium disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Загрузка...' : (isRegister ? 'Зарегистрироваться' : 'Войти')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<button
|
||||
onClick={() => setIsRegister(!isRegister)}
|
||||
className="text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
{isRegister ? 'Уже есть аккаунт? Войти' : 'Нет аккаунта? Зарегистрироваться'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
frontend/src/components/ServerAccessModal.jsx
Normal file
132
frontend/src/components/ServerAccessModal.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, UserPlus, Trash2 } from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import { API_URL } from '../config';
|
||||
|
||||
export default function ServerAccessModal({ serverName, onClose }) {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [newUsername, setNewUsername] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
}, [serverName]);
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const { data } = await axios.get(
|
||||
`${API_URL}/api/servers/${serverName}/access`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
setUsers(data.users);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки пользователей:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const addUser = async () => {
|
||||
if (!newUsername.trim()) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
await axios.post(
|
||||
`${API_URL}/api/servers/${serverName}/access`,
|
||||
{ username: newUsername, server_name: serverName },
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
setNewUsername('');
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
alert(error.response?.data?.detail || 'Ошибка добавления пользователя');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeUser = async (username) => {
|
||||
if (!confirm(`Удалить доступ для ${username}?`)) return;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
await axios.delete(
|
||||
`${API_URL}/api/servers/${serverName}/access`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
params: { username, server_name: serverName }
|
||||
}
|
||||
);
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
alert(error.response?.data?.detail || 'Ошибка удаления доступа');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-gray-800 rounded-lg p-6 w-full max-w-md">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold">Управление доступом</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-white">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Добавить пользователя
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
placeholder="Имя пользователя"
|
||||
className="flex-1 bg-gray-700 border border-gray-600 rounded px-4 py-2 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<button
|
||||
onClick={addUser}
|
||||
disabled={loading}
|
||||
className="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Пользователи с доступом:</h3>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{users.length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">Нет пользователей</p>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<div
|
||||
key={user.username}
|
||||
className="bg-gray-700 p-3 rounded flex items-center justify-between"
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium">{user.username}</span>
|
||||
{user.role === 'admin' && (
|
||||
<span className="ml-2 text-xs bg-blue-600 px-2 py-1 rounded">
|
||||
Админ
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeUser(user.username)}
|
||||
className="text-red-400 hover:text-red-300"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
268
frontend/src/components/ServerSettings.jsx
Normal file
268
frontend/src/components/ServerSettings.jsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Save, Trash2, Users, UserPlus } from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import { API_URL } from '../config';
|
||||
|
||||
export default function ServerSettings({ serverName, token, user, onDeleted }) {
|
||||
const [config, setConfig] = useState({
|
||||
name: '',
|
||||
displayName: '',
|
||||
startCommand: '',
|
||||
owner: ''
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [allUsers, setAllUsers] = useState([]);
|
||||
const [serverUsers, setServerUsers] = useState([]);
|
||||
const [showUserManagement, setShowUserManagement] = useState(false);
|
||||
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const isOwner = config.owner === user?.username;
|
||||
const canManageAccess = isAdmin || isOwner;
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
if (canManageAccess) {
|
||||
loadUsers();
|
||||
}
|
||||
}, [serverName, canManageAccess]);
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const { data } = await axios.get(`${API_URL}/api/servers/${serverName}/config`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setConfig(data);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки настроек:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const { data } = await axios.get(`${API_URL}/api/users`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setAllUsers(data);
|
||||
|
||||
// Находим пользователей с доступом к этому серверу
|
||||
const usersWithAccess = data.filter(u =>
|
||||
u.role === 'admin' || u.servers?.includes(serverName)
|
||||
);
|
||||
setServerUsers(usersWithAccess);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки пользователей:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleUserAccess = async (username) => {
|
||||
const userHasAccess = serverUsers.some(u => u.username === username);
|
||||
const targetUser = allUsers.find(u => u.username === username);
|
||||
|
||||
if (!targetUser) return;
|
||||
|
||||
const newServers = userHasAccess
|
||||
? targetUser.servers.filter(s => s !== serverName)
|
||||
: [...(targetUser.servers || []), serverName];
|
||||
|
||||
try {
|
||||
await axios.put(
|
||||
`${API_URL}/api/users/${username}/servers`,
|
||||
{ servers: newServers },
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
alert(error.response?.data?.detail || 'Ошибка изменения доступа');
|
||||
}
|
||||
};
|
||||
|
||||
const saveConfig = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await axios.put(
|
||||
`${API_URL}/api/servers/${serverName}/config`,
|
||||
config,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
alert('Настройки сохранены');
|
||||
} catch (error) {
|
||||
alert(error.response?.data?.detail || 'Ошибка сохранения настроек');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteServer = async () => {
|
||||
if (!confirm(`Вы уверены, что хотите удалить сервер "${config.displayName}"? Все файлы будут удалены!`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.delete(`${API_URL}/api/servers/${serverName}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
onDeleted();
|
||||
} catch (error) {
|
||||
alert(error.response?.data?.detail || 'Ошибка удаления сервера');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-gray-400">Загрузка...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 bg-gray-900 h-full overflow-y-auto">
|
||||
<h2 className="text-2xl font-bold mb-6">Настройки сервера</h2>
|
||||
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Имя папки (нельзя изменить)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.name}
|
||||
disabled
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded px-4 py-2 text-gray-500 cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Отображаемое имя
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.displayName}
|
||||
onChange={(e) => setConfig({ ...config, displayName: e.target.value })}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded px-4 py-2 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Команда запуска
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.startCommand}
|
||||
onChange={(e) => setConfig({ ...config, startCommand: e.target.value })}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded px-4 py-2 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<p className="text-sm text-gray-400 mt-2">
|
||||
Пример: java -Xmx2G -Xms1G -jar server.jar nogui
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{config.owner && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Владелец
|
||||
</label>
|
||||
<div className="text-gray-300">{config.owner}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4 pt-4">
|
||||
<button
|
||||
onClick={saveConfig}
|
||||
disabled={saving}
|
||||
className="bg-blue-600 hover:bg-blue-700 px-6 py-2 rounded flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{canManageAccess && (
|
||||
<div className="border-t border-gray-700 pt-6 mt-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">Управление доступом</h3>
|
||||
<button
|
||||
onClick={() => setShowUserManagement(!showUserManagement)}
|
||||
className="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded flex items-center gap-2"
|
||||
>
|
||||
<Users className="w-4 h-4" />
|
||||
{showUserManagement ? 'Скрыть' : 'Показать пользователей'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showUserManagement && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-gray-400 mb-4">
|
||||
Выберите пользователей, которые могут управлять этим сервером:
|
||||
</p>
|
||||
{allUsers.filter(u => u.role !== 'admin').map((targetUser) => {
|
||||
const hasAccess = serverUsers.some(u => u.username === targetUser.username);
|
||||
return (
|
||||
<div
|
||||
key={targetUser.username}
|
||||
className="flex items-center justify-between bg-gray-800 p-3 rounded"
|
||||
>
|
||||
<span>{targetUser.username}</span>
|
||||
<button
|
||||
onClick={() => toggleUserAccess(targetUser.username)}
|
||||
className={`px-4 py-1 rounded text-sm ${
|
||||
hasAccess
|
||||
? 'bg-green-600 hover:bg-green-700'
|
||||
: 'bg-gray-600 hover:bg-gray-500'
|
||||
}`}
|
||||
>
|
||||
{hasAccess ? 'Есть доступ' : 'Нет доступа'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{allUsers.filter(u => u.role !== 'admin').length === 0 && (
|
||||
<p className="text-gray-500 text-sm">Нет обычных пользователей</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-medium mb-2 text-gray-400">
|
||||
Пользователи с доступом:
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{serverUsers.map((u) => (
|
||||
<span
|
||||
key={u.username}
|
||||
className={`px-3 py-1 rounded text-sm ${
|
||||
u.role === 'admin'
|
||||
? 'bg-blue-600'
|
||||
: 'bg-green-600'
|
||||
}`}
|
||||
>
|
||||
{u.username} {u.role === 'admin' && '(Админ)'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-gray-700 pt-6 mt-8">
|
||||
<h3 className="text-lg font-semibold mb-4 text-red-400">Опасная зона</h3>
|
||||
<button
|
||||
onClick={deleteServer}
|
||||
className="bg-red-600 hover:bg-red-700 px-6 py-2 rounded flex items-center gap-2"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Удалить сервер
|
||||
</button>
|
||||
<p className="text-sm text-gray-400 mt-2">
|
||||
Это действие нельзя отменить. Все файлы сервера будут удалены.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
frontend/src/components/Stats.jsx
Normal file
91
frontend/src/components/Stats.jsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Cpu, HardDrive, Activity } from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import { API_URL } from '../config';
|
||||
|
||||
export default function Stats({ serverName, token }) {
|
||||
const [stats, setStats] = useState({
|
||||
status: 'stopped',
|
||||
cpu: 0,
|
||||
memory: 0,
|
||||
disk: 0
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
const interval = setInterval(loadStats, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, [serverName]);
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const { data } = await axios.get(`${API_URL}/api/servers/${serverName}/stats`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setStats(data);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки статистики:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 bg-gray-900">
|
||||
<h2 className="text-2xl font-bold mb-6">Статистика сервера</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">CPU</h3>
|
||||
<Cpu className="w-6 h-6 text-blue-400" />
|
||||
</div>
|
||||
<div className="text-3xl font-bold mb-2">{stats.cpu}%</div>
|
||||
<div className="w-full bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full transition-all"
|
||||
style={{ width: `${Math.min(stats.cpu, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">ОЗУ</h3>
|
||||
<Activity className="w-6 h-6 text-green-400" />
|
||||
</div>
|
||||
<div className="text-3xl font-bold mb-2">{stats.memory} МБ</div>
|
||||
<div className="w-full bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-green-500 h-2 rounded-full transition-all"
|
||||
style={{ width: `${Math.min((stats.memory / 2048) * 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">Диск</h3>
|
||||
<HardDrive className="w-6 h-6 text-purple-400" />
|
||||
</div>
|
||||
<div className="text-3xl font-bold mb-2">{stats.disk} МБ</div>
|
||||
<div className="text-sm text-gray-400 mt-2">
|
||||
Использовано на диске
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<h3 className="text-lg font-semibold mb-4">Статус</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-4 h-4 rounded-full ${
|
||||
stats.status === 'running' ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xl">
|
||||
{stats.status === 'running' ? 'Запущен' : 'Остановлен'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
frontend/src/components/ThemeSelector.jsx
Normal file
43
frontend/src/components/ThemeSelector.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Palette } from 'lucide-react';
|
||||
import { themes, getTheme } from '../themes';
|
||||
|
||||
export default function ThemeSelector({ currentTheme, onThemeChange }) {
|
||||
const theme = getTheme(currentTheme);
|
||||
|
||||
const themeColors = {
|
||||
dark: 'bg-gray-800',
|
||||
light: 'bg-gray-100',
|
||||
purple: 'bg-purple-600',
|
||||
blue: 'bg-blue-600',
|
||||
green: 'bg-green-600',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<button className={`p-2 rounded-lg ${theme.hover} transition`}>
|
||||
<Palette className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className={`absolute right-0 mt-2 w-48 ${theme.secondary} rounded-lg shadow-xl ${theme.border} border opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50`}>
|
||||
<div className="p-2">
|
||||
<div className={`text-xs ${theme.textSecondary} px-2 py-1 mb-1`}>Выберите тему</div>
|
||||
{Object.entries(themes).map(([key, themeItem]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => onThemeChange(key)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg ${theme.hover} transition ${
|
||||
currentTheme === key ? theme.tertiary : ''
|
||||
}`}
|
||||
>
|
||||
<div className={`w-4 h-4 rounded ${themeColors[key]}`} />
|
||||
<span className="text-sm">{themeItem.name}</span>
|
||||
{currentTheme === key && (
|
||||
<span className="ml-auto text-xs text-green-500">✓</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
183
frontend/src/components/Users.jsx
Normal file
183
frontend/src/components/Users.jsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Users as UsersIcon, Trash2, Shield, User } from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import { API_URL } from '../config';
|
||||
|
||||
export default function Users({ token }) {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [servers, setServers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [usersRes, serversRes] = await Promise.all([
|
||||
axios.get(`${API_URL}/api/users`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}),
|
||||
axios.get(`${API_URL}/api/servers`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
]);
|
||||
setUsers(usersRes.data);
|
||||
setServers(serversRes.data);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки данных:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleServerAccess = async (username, serverName) => {
|
||||
const user = users.find(u => u.username === username);
|
||||
const currentServers = user.servers || [];
|
||||
const newServers = currentServers.includes(serverName)
|
||||
? currentServers.filter(s => s !== serverName)
|
||||
: [...currentServers, serverName];
|
||||
|
||||
try {
|
||||
await axios.put(
|
||||
`${API_URL}/api/users/${username}/servers`,
|
||||
{ servers: newServers },
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
alert(error.response?.data?.detail || 'Ошибка обновления доступа');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRole = async (username, currentRole) => {
|
||||
const newRole = currentRole === 'admin' ? 'user' : 'admin';
|
||||
|
||||
if (!confirm(`Изменить роль пользователя ${username} на ${newRole}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.put(
|
||||
`${API_URL}/api/users/${username}/role`,
|
||||
{ role: newRole },
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
alert(error.response?.data?.detail || 'Ошибка изменения роли');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteUser = async (username) => {
|
||||
if (!confirm(`Удалить пользователя ${username}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.delete(`${API_URL}/api/users/${username}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
loadData();
|
||||
} catch (error) {
|
||||
alert(error.response?.data?.detail || 'Ошибка удаления пользователя');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-gray-400">Загрузка...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 bg-gray-900 h-full overflow-y-auto">
|
||||
<h2 className="text-2xl font-bold mb-6 flex items-center gap-2">
|
||||
<UsersIcon className="w-8 h-8" />
|
||||
Управление пользователями
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{users.map((user) => (
|
||||
<div
|
||||
key={user.username}
|
||||
className="bg-gray-800 rounded-lg p-6 border border-gray-700"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded ${
|
||||
user.role === 'admin' ? 'bg-blue-600' : 'bg-gray-700'
|
||||
}`}>
|
||||
{user.role === 'admin' ? (
|
||||
<Shield className="w-6 h-6" />
|
||||
) : (
|
||||
<User className="w-6 h-6" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{user.username}</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
{user.role === 'admin' ? 'Администратор' : 'Пользователь'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => toggleRole(user.username, user.role)}
|
||||
className="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm"
|
||||
>
|
||||
{user.role === 'admin' ? 'Сделать пользователем' : 'Сделать админом'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteUser(user.username)}
|
||||
className="bg-red-600 hover:bg-red-700 p-2 rounded"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user.role !== 'admin' && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2 text-gray-400">
|
||||
Доступ к серверам:
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{servers.map((server) => {
|
||||
const hasAccess = user.servers?.includes(server.name);
|
||||
return (
|
||||
<button
|
||||
key={server.name}
|
||||
onClick={() => toggleServerAccess(user.username, server.name)}
|
||||
className={`px-3 py-1 rounded text-sm transition ${
|
||||
hasAccess
|
||||
? 'bg-green-600 hover:bg-green-700'
|
||||
: 'bg-gray-700 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{server.displayName}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{servers.length === 0 && (
|
||||
<p className="text-gray-500 text-sm">Нет доступных серверов</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user.role === 'admin' && (
|
||||
<p className="text-sm text-gray-400">
|
||||
Администратор имеет доступ ко всем серверам
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
frontend/src/config.js
Normal file
25
frontend/src/config.js
Normal file
@@ -0,0 +1,25 @@
|
||||
// Автоматически определяем API URL
|
||||
const getApiUrl = () => {
|
||||
// Если задана переменная окружения, используем её
|
||||
if (import.meta.env.VITE_API_URL) {
|
||||
return import.meta.env.VITE_API_URL;
|
||||
}
|
||||
|
||||
// Иначе используем текущий хост с портом 8000
|
||||
const protocol = window.location.protocol;
|
||||
const hostname = window.location.hostname;
|
||||
|
||||
// Если localhost, используем localhost:8000
|
||||
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
||||
return `${protocol}//localhost:8000`;
|
||||
}
|
||||
|
||||
// Для удаленного доступа используем IP:8000
|
||||
return `${protocol}//${hostname}:8000`;
|
||||
};
|
||||
|
||||
export const API_URL = getApiUrl();
|
||||
export const WS_URL = API_URL.replace('http', 'ws');
|
||||
|
||||
console.log('API URL:', API_URL);
|
||||
console.log('WS URL:', WS_URL);
|
||||
22
frontend/src/index.css
Normal file
22
frontend/src/index.css
Normal file
@@ -0,0 +1,22 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
111
frontend/src/themes.js
Normal file
111
frontend/src/themes.js
Normal file
@@ -0,0 +1,111 @@
|
||||
export const themes = {
|
||||
dark: {
|
||||
name: 'Тёмная',
|
||||
gradient: 'from-blue-400 to-purple-600',
|
||||
primary: 'bg-gray-950',
|
||||
secondary: 'bg-gray-900',
|
||||
tertiary: 'bg-gray-800',
|
||||
accent: 'bg-blue-600',
|
||||
accentHover: 'hover:bg-blue-700',
|
||||
text: 'text-white',
|
||||
textSecondary: 'text-gray-400',
|
||||
border: 'border-gray-800',
|
||||
hover: 'hover:bg-gray-800',
|
||||
input: 'bg-gray-900 border-gray-700',
|
||||
card: 'bg-gray-900',
|
||||
cardHover: 'hover:bg-gray-800',
|
||||
success: 'bg-green-600',
|
||||
successHover: 'hover:bg-green-700',
|
||||
danger: 'bg-red-600',
|
||||
dangerHover: 'hover:bg-red-700',
|
||||
warning: 'bg-yellow-600',
|
||||
},
|
||||
light: {
|
||||
name: 'Светлая',
|
||||
gradient: 'from-blue-600 to-purple-600',
|
||||
primary: 'bg-gray-50',
|
||||
secondary: 'bg-white',
|
||||
tertiary: 'bg-gray-100',
|
||||
accent: 'bg-blue-600',
|
||||
accentHover: 'hover:bg-blue-700',
|
||||
text: 'text-gray-900',
|
||||
textSecondary: 'text-gray-600',
|
||||
border: 'border-gray-200',
|
||||
hover: 'hover:bg-gray-100',
|
||||
input: 'bg-white border-gray-300',
|
||||
card: 'bg-white',
|
||||
cardHover: 'hover:bg-gray-50',
|
||||
success: 'bg-green-600',
|
||||
successHover: 'hover:bg-green-700',
|
||||
danger: 'bg-red-600',
|
||||
dangerHover: 'hover:bg-red-700',
|
||||
warning: 'bg-yellow-600',
|
||||
},
|
||||
purple: {
|
||||
name: 'Фиолетовая',
|
||||
gradient: 'from-purple-400 to-pink-600',
|
||||
primary: 'bg-slate-950',
|
||||
secondary: 'bg-slate-900',
|
||||
tertiary: 'bg-purple-900/30',
|
||||
accent: 'bg-purple-600',
|
||||
accentHover: 'hover:bg-purple-700',
|
||||
text: 'text-white',
|
||||
textSecondary: 'text-purple-300',
|
||||
border: 'border-purple-900/50',
|
||||
hover: 'hover:bg-purple-900/30',
|
||||
input: 'bg-slate-900 border-purple-900/50',
|
||||
card: 'bg-slate-900',
|
||||
cardHover: 'hover:bg-purple-900/30',
|
||||
success: 'bg-green-600',
|
||||
successHover: 'hover:bg-green-700',
|
||||
danger: 'bg-red-600',
|
||||
dangerHover: 'hover:bg-red-700',
|
||||
warning: 'bg-yellow-600',
|
||||
},
|
||||
blue: {
|
||||
name: 'Синяя',
|
||||
gradient: 'from-cyan-400 to-blue-600',
|
||||
primary: 'bg-slate-950',
|
||||
secondary: 'bg-slate-900',
|
||||
tertiary: 'bg-blue-900/30',
|
||||
accent: 'bg-blue-500',
|
||||
accentHover: 'hover:bg-blue-600',
|
||||
text: 'text-white',
|
||||
textSecondary: 'text-blue-300',
|
||||
border: 'border-blue-900/50',
|
||||
hover: 'hover:bg-blue-900/30',
|
||||
input: 'bg-slate-900 border-blue-900/50',
|
||||
card: 'bg-slate-900',
|
||||
cardHover: 'hover:bg-blue-900/30',
|
||||
success: 'bg-green-600',
|
||||
successHover: 'hover:bg-green-700',
|
||||
danger: 'bg-red-600',
|
||||
dangerHover: 'hover:bg-red-700',
|
||||
warning: 'bg-yellow-600',
|
||||
},
|
||||
green: {
|
||||
name: 'Зелёная',
|
||||
gradient: 'from-emerald-400 to-green-600',
|
||||
primary: 'bg-slate-950',
|
||||
secondary: 'bg-slate-900',
|
||||
tertiary: 'bg-green-900/30',
|
||||
accent: 'bg-green-600',
|
||||
accentHover: 'hover:bg-green-700',
|
||||
text: 'text-white',
|
||||
textSecondary: 'text-green-300',
|
||||
border: 'border-green-900/50',
|
||||
hover: 'hover:bg-green-900/30',
|
||||
input: 'bg-slate-900 border-green-900/50',
|
||||
card: 'bg-slate-900',
|
||||
cardHover: 'hover:bg-green-900/30',
|
||||
success: 'bg-green-600',
|
||||
successHover: 'hover:bg-green-700',
|
||||
danger: 'bg-red-600',
|
||||
dangerHover: 'hover:bg-red-700',
|
||||
warning: 'bg-yellow-600',
|
||||
},
|
||||
};
|
||||
|
||||
export const getTheme = (themeName) => {
|
||||
return themes[themeName] || themes.dark;
|
||||
};
|
||||
5
frontend/start.bat
Normal file
5
frontend/start.bat
Normal file
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
echo Starting MC Panel Frontend...
|
||||
cd /d "%~dp0"
|
||||
npm run dev
|
||||
pause
|
||||
11
frontend/tailwind.config.js
Normal file
11
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
10
frontend/vite.config.js
Normal file
10
frontend/vite.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
host: true
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user