Initial commit

This commit is contained in:
2026-01-14 20:23:10 +06:00
commit 954dd473d1
57 changed files with 8854 additions and 0 deletions

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

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

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

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

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

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

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

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

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

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

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

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

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

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