13 Commits

Author SHA1 Message Date
nikita 4f69e002c6 Merge branch 'frontend' of gitea.d3m0k1d.ru:d3m0k1d/HellreigN into HEAD
ci-front / build (push) Successful in 2m27s
2026-04-04 06:19:17 +03:00
nikita 5209e8b2e9 fix: conflicts 2026-04-04 06:17:09 +03:00
NikitaTorbenko 95a6902dae feat: create logs
ci-front / build (push) Successful in 2m24s
2026-04-04 06:13:12 +03:00
nikita adbb0ee368 feat: page tempaltes 2026-04-04 06:05:51 +03:00
NikitaTorbenko 96f82b4162 feat: create register
ci-front / build (push) Successful in 2m1s
2026-04-04 05:57:34 +03:00
NikitaTorbenko ed439656f8 feat: add registration token
ci-front / build (push) Successful in 2m22s
2026-04-04 05:52:43 +03:00
NikitaTorbenko d62205b329 fix: add agent
ci-front / build (push) Successful in 2m28s
2026-04-04 05:46:01 +03:00
NikitaTorbenko 11cef95929 feat: add admin + deploy
ci-front / build (push) Successful in 2m28s
2026-04-04 05:39:00 +03:00
nikita 43e16b1360 fix: autocloseder for input search & button back
ci-front / build (push) Successful in 2m23s
2026-04-04 05:13:27 +03:00
nikita f537f1eab9 feat: IDE
ci-front / build (push) Successful in 2m19s
2026-04-04 04:59:42 +03:00
nikita 9d1096a9b4 fix: 2 2026-04-04 03:37:27 +03:00
NikitaTorbenko 57b43da2e3 feat: add layout
ci-front / build (push) Successful in 2m9s
2026-04-04 03:07:45 +03:00
NikitaTorbenko 691e1fced5 feat: add swagger docs
ci-front / build (push) Successful in 2m26s
2026-04-04 02:44:36 +03:00
46 changed files with 16256 additions and 647 deletions
-1
View File
@@ -1 +0,0 @@
# HellreigN
+7152
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -11,19 +11,24 @@
}, },
"dependencies": { "dependencies": {
"@codemirror/lang-sql": "^6.10.0", "@codemirror/lang-sql": "^6.10.0",
"@monaco-editor/react": "^4.7.0",
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",
"@uiw/react-codemirror": "^4.25.8", "@uiw/react-codemirror": "^4.25.8",
"axios": "^1.13.6", "axios": "^1.13.6",
"file-surf": "^1.0.3",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"monaco-languageclient": "^10.7.0",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primereact": "^10.9.7", "primereact": "^10.9.7",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-force-graph-2d": "^1.29.1",
"react-icons": "^5.6.0", "react-icons": "^5.6.0",
"react-router-dom": "^7.13.1", "react-router-dom": "^7.13.1",
"recharts": "^3.8.0", "recharts": "^3.8.0",
"tailwind": "^4.0.0", "tailwind": "^4.0.0",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",
"vscode-ws-jsonrpc": "^3.5.0",
"zustand": "^5.0.12" "zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
@@ -0,0 +1,31 @@
import { useState, useEffect, type ReactNode } from "react";
import { Sidebar } from "@/app/providers/layout/sidebar/sidebar";
import { Navigation } from "@/app/providers/layout/navigation/navigation";
import { useAgentStore } from "@/app/providers/layout/store/agent.store";
export const Layout = ({ children }: { children: ReactNode }) => {
const [isOpen, setOpen] = useState(true);
const { fetchAgents } = useAgentStore();
useEffect(() => {
fetchAgents();
}, [fetchAgents]);
useEffect(() => {
const interval = setInterval(() => {
fetchAgents();
}, 30000);
return () => clearInterval(interval);
}, [fetchAgents]);
return (
<div className="flex h-screen overflow-hidden" style={{ backgroundColor: "var(--bg-primary)" }}>
<Sidebar isOpen={isOpen} onToggle={() => setOpen(!isOpen)} />
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
<Navigation />
<div className="flex-1 overflow-auto p-4">{children}</div>
</div>
</div>
);
};
@@ -0,0 +1,142 @@
import { useNavigate, useLocation } from "react-router-dom";
import { FaCode } from "react-icons/fa";
import {
FaHome,
FaServer,
FaPalette,
FaUser,
FaUsers,
FaRocket,
FaKey,
FaFileAlt,
} from "react-icons/fa";
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
export const Navigation = () => {
const navigate = useNavigate();
const location = useLocation();
const { user, logout } = useAuthStore();
const navItems = [
{ path: "/", label: "Главная", icon: FaHome },
{ path: "/add-agents", label: "Агенты", icon: FaServer },
{ path: "/templates", label: "Шаблоны", icon: FaCode },
{ path: "/add-agents", label: "Деплой", icon: FaRocket },
{ path: "/registration", label: "Регистрация", icon: FaKey },
{ path: "/logs", label: "Логи", icon: FaFileAlt },
{ path: "/admin", label: "Админка", icon: FaUsers, adminOnly: true },
{ path: "/themes", label: "Темы", icon: FaPalette },
];
const isActive = (path: string) => location.pathname === path;
return (
<div
className="flex-shrink-0 border-b"
style={{
backgroundColor: "var(--card-bg)",
borderColor: "var(--border)",
}}
>
<div className="flex items-center justify-between px-4 py-2.5">
{/* Логотип */}
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => navigate("/")}
>
<FaServer style={{ color: "var(--accent)", fontSize: "18px" }} />
<span
className="text-sm font-semibold"
style={{ color: "var(--text-primary)" }}
>
HellreigN
</span>
</div>
{/* Навигация */}
<div className="flex items-center gap-1">
{navItems
.filter((item) => {
if (item.adminOnly && !user?.permission_admin) return false;
return true;
})
.map((item) => {
const Icon = item.icon;
const active = isActive(item.path);
return (
<button
key={item.path}
onClick={() => navigate(item.path)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all"
style={{
backgroundColor: active ? "var(--accent)" : "transparent",
color: active
? "var(--accent-text)"
: "var(--text-secondary)",
}}
onMouseEnter={(e) => {
if (!active) {
e.currentTarget.style.backgroundColor =
"var(--bg-secondary)";
e.currentTarget.style.color = "var(--text-primary)";
}
}}
onMouseLeave={(e) => {
if (!active) {
e.currentTarget.style.backgroundColor = "transparent";
e.currentTarget.style.color = "var(--text-secondary)";
}
}}
>
<Icon size={12} />
<span>{item.label}</span>
</button>
);
})}
</div>
{/* Профиль пользователя */}
<div className="flex items-center gap-2">
{user && (
<div className="flex items-center gap-2">
<div
className="w-8 h-8 rounded-full flex items-center justify-center"
style={{ backgroundColor: "var(--bg-secondary)" }}
>
<FaUser size={12} style={{ color: "var(--accent)" }} />
</div>
<span
className="text-xs"
style={{ color: "var(--text-secondary)" }}
>
{user.name}
</span>
</div>
)}
<button
onClick={() => {
logout();
navigate("/auth");
}}
className="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
style={{
backgroundColor: "var(--error-bg)",
color: "var(--error-text)",
border: "1px solid var(--error-border)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "var(--error-text)";
e.currentTarget.style.color = "#fff";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "var(--error-bg)";
e.currentTarget.style.color = "var(--error-text)";
}}
>
Выйти
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,294 @@
import React, { useMemo, useState } from "react";
import { FaBars, FaMicrochip, FaTimes, FaSpinner, FaCopy, FaCheck } from "react-icons/fa";
import { useAgentStore } from "@/app/providers/layout/store/agent.store";
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
interface SidebarProps {
isOpen?: boolean;
onToggle?: () => void;
}
export const Sidebar: React.FC<SidebarProps> = ({ isOpen = true, onToggle }) => {
const { agents, isLoading, error, fetchAgents } = useAgentStore();
const { token } = useAuthStore();
const [searchQuery, setSearchQuery] = useState("");
const [copied, setCopied] = useState(false);
const [showTokenModal, setShowTokenModal] = useState(false);
const filteredAgents = useMemo(() => {
if (!searchQuery) return agents;
const query = searchQuery.toLowerCase();
return agents.filter(
(agent) =>
agent.name.toLowerCase().includes(query) ||
agent.services.some((s) => s.name.toLowerCase().includes(query))
);
}, [agents, searchQuery]);
const handleCopyToken = () => {
if (token) {
navigator.clipboard.writeText(token);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
if (!isOpen) {
return (
<button
onClick={onToggle}
className="fixed top-4 left-4 z-50 p-2.5 rounded-lg shadow-lg transition-colors md:hidden"
style={{ backgroundColor: "var(--accent)", color: "var(--accent-text)" }}
aria-label="Открыть sidebar"
>
<FaBars size={18} />
</button>
);
}
return (
<>
{/* Overlay для мобильных */}
<div className="fixed inset-0 bg-black/50 z-40 md:hidden" onClick={onToggle} />
<aside
className={`fixed md:relative w-72 h-screen z-50 transition-transform duration-300 ease-in-out flex flex-col ${
isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
}`}
style={{
backgroundColor: "var(--card-bg)",
borderRight: "1px solid var(--border)",
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3 border-b"
style={{ borderColor: "var(--border)" }}
>
<div className="flex items-center gap-2">
<FaMicrochip style={{ color: "var(--accent)", fontSize: "18px" }} />
<h2 className="text-sm font-semibold" style={{ color: "var(--text-primary)" }}>
Агенты
</h2>
<span
className="text-xs px-1.5 py-0.5 rounded"
style={{ backgroundColor: "var(--bg-secondary)", color: "var(--text-secondary)" }}
>
{agents.length}
</span>
</div>
<button
onClick={onToggle}
className="p-1 rounded transition-colors md:hidden"
style={{ color: "var(--text-secondary)" }}
>
<FaTimes size={14} />
</button>
</div>
{/* Поиск */}
<div className="px-3 py-2">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Поиск агентов..."
className="w-full px-3 py-2 rounded-lg border text-sm focus:outline-none transition-all"
style={{
backgroundColor: "var(--input-bg)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = "var(--border-focus)";
e.currentTarget.style.boxShadow = `0 0 0 3px var(--border-focus)30`;
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = "var(--border)";
e.currentTarget.style.boxShadow = "none";
}}
/>
</div>
{/* Список агентов */}
<div className="flex-1 overflow-y-auto px-2 py-2">
{isLoading && agents.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<FaSpinner className="animate-spin mb-3" style={{ color: "var(--accent)", fontSize: "20px" }} />
<p className="text-xs" style={{ color: "var(--text-secondary)" }}>
Загрузка агентов...
</p>
</div>
) : error ? (
<div className="text-center py-8">
<div className="text-xs mb-2" style={{ color: "var(--error-text)" }}>
{error}
</div>
<button
onClick={fetchAgents}
className="text-xs hover:underline"
style={{ color: "var(--accent)" }}
>
Попробовать снова
</button>
</div>
) : filteredAgents.length === 0 ? (
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>
<FaMicrochip className="mx-auto mb-2 opacity-50" size={16} />
<p className="text-xs">
{searchQuery ? "Ничего не найдено" : "Нет агентов"}
</p>
</div>
) : (
<div className="space-y-1">
{filteredAgents.map((agent) => (
<div
key={agent.name}
className="rounded-lg border p-3 transition-all hover:shadow-md"
style={{
backgroundColor: "var(--bg-secondary)",
borderColor: "var(--border)",
}}
>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>
{agent.name}
</span>
</div>
<div className="space-y-1">
{agent.services.map((service) => (
<div
key={service.name}
className="flex items-center justify-between text-xs"
>
<span style={{ color: "var(--text-secondary)" }}>{service.name}</span>
<span
className="px-1.5 py-0.5 rounded text-[10px] font-medium"
style={{
backgroundColor:
service.status === "running"
? "var(--success-bg)"
: service.status === "error"
? "var(--error-bg)"
: "var(--bg-secondary)",
color:
service.status === "running"
? "var(--success-text)"
: service.status === "error"
? "var(--error-text)"
: "var(--text-muted)",
border: `1px solid ${
service.status === "running"
? "var(--success-border)"
: service.status === "error"
? "var(--error-border)"
: "var(--border)"
}`,
}}
>
{service.status}
</span>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
{/* Footer с кнопками */}
<div
className="p-2 border-t flex gap-2"
style={{ borderColor: "var(--border)", backgroundColor: "var(--card-bg)" }}
>
<button
onClick={() => setShowTokenModal(true)}
className="flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs rounded transition-colors"
style={{
backgroundColor: "var(--accent)",
color: "var(--accent-text)",
}}
>
<FaCopy size={10} />
Токен
</button>
</div>
</aside>
{/* Modal токена */}
{showTokenModal && (
<div
className="fixed inset-0 z-[60] flex items-center justify-center p-4"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
onClick={() => setShowTokenModal(false)}
>
<div
className="w-full max-w-md rounded-xl shadow-2xl border"
style={{ backgroundColor: "var(--card-bg)", borderColor: "var(--border)" }}
onClick={(e) => e.stopPropagation()}
>
<div
className="flex items-center justify-between px-4 py-3 border-b"
style={{ borderColor: "var(--border)" }}
>
<div className="flex items-center gap-2">
<FaCopy style={{ color: "var(--accent)" }} size={14} />
<h2 className="text-sm font-semibold" style={{ color: "var(--text-primary)" }}>
Ваш токен доступа
</h2>
</div>
<button
onClick={() => setShowTokenModal(false)}
className="p-1 rounded transition-colors"
style={{ color: "var(--text-secondary)" }}
>
<FaTimes size={14} />
</button>
</div>
<div className="p-4 space-y-3">
<div>
<label className="block text-xs font-medium mb-2" style={{ color: "var(--text-secondary)" }}>
Токен
</label>
<div
className="flex items-center gap-2 rounded-lg p-3 border"
style={{ backgroundColor: "var(--bg-secondary)", borderColor: "var(--border)" }}
>
<code
className="flex-1 text-xs font-mono break-all"
style={{ color: "var(--text-primary)" }}
>
{token || "Токен не найден"}
</code>
{token && (
<button
onClick={handleCopyToken}
className="p-1.5 rounded transition-colors"
style={{ color: "var(--text-secondary)" }}
>
{copied ? (
<FaCheck size={12} style={{ color: "var(--success-text)" }} />
) : (
<FaCopy size={12} />
)}
</button>
)}
</div>
</div>
<button
onClick={() => setShowTokenModal(false)}
className="w-full py-2 rounded-lg text-xs font-medium transition-colors"
style={{ backgroundColor: "var(--accent)", color: "var(--accent-text)" }}
>
Закрыть
</button>
</div>
</div>
</div>
)}
</>
);
};
@@ -0,0 +1,34 @@
import { create } from "zustand";
import { agentApiService } from "@/modules/agent/api/agent.api.service";
import type { AgentInfo } from "@/modules/agent/types/agent.types";
interface AgentState {
agents: AgentInfo[];
isLoading: boolean;
error: string | null;
fetchAgents: () => Promise<void>;
removeAgent: (name: string) => void;
}
export const useAgentStore = create<AgentState>()((set, get) => ({
agents: [],
isLoading: false,
error: null,
fetchAgents: async () => {
set({ isLoading: true, error: null });
try {
const agents = await agentApiService.getAgents();
set({ agents, isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to fetch agents",
isLoading: false,
});
}
},
removeAgent: (name: string) => {
set({ agents: get().agents.filter((a) => a.name !== name) });
},
}));
@@ -1,12 +1,12 @@
import { useAuthStore } from "@/store/auth/auth.store"; import { useAuthStore } from "@/modules/auth/store/useAuthStore";
import { Navigate } from "react-router-dom"; import { Navigate } from "react-router-dom";
export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated } = useAuthStore(); const { isAuthenticated } = useAuthStore();
if (!isAuthenticated) { // if (!isAuthenticated) {
return <Navigate to="/auth" replace />; // return <Navigate to="/auth" replace />;
} // }
return <>{children}</>; return <>{children}</>;
}; };
+114 -5
View File
@@ -2,10 +2,109 @@ import { Suspense } from "react";
import { Routes as ReactRoutes, Route, Navigate } from "react-router-dom"; import { Routes as ReactRoutes, Route, Navigate } from "react-router-dom";
import { HomePage } from "@/pages/home.page"; import { HomePage } from "@/pages/home.page";
import { ThemesPage } from "@/pages/themes.page"; import { ThemesPage } from "@/pages/themes.page";
import { TestPage } from "@/pages/test.page";
import { Test2Page, type GraphData } from "@/pages/test2.page";
import { AuthPage } from "@/pages/auth.page"; import { AuthPage } from "@/pages/auth.page";
import { RegisterPage } from "@/pages/register.page"; import { RegisterPage } from "@/pages/register.page";
import { AddAgentsPage } from "@/pages/add-agents.page";
import { DefaultLayout } from "@/shared/layouts/DefaultLayout"; import { DefaultLayout } from "@/shared/layouts/DefaultLayout";
import { AddAgentsPage } from "@/pages/add-agents.page";
import { IDEPage } from "@/pages/ide.page";
import { TemplatesPage } from "@/pages/templates.page";
import { AdminPage } from "@/pages/admin.page";
import { RegistrationTokenPage } from "@/pages/registration.page";
import { LogsPage } from "@/pages/logs.page";
export const mockGraphData: GraphData = {
nodes: [
{
id: "api-gateway",
name: "API Gateway",
type: "service",
val: 12,
description: "Входная точка API",
},
{
id: "auth-service",
name: "Auth Service",
type: "service",
val: 12,
description: "Аутентификация",
},
{
id: "db-service",
name: "Database",
type: "service",
val: 12,
description: "Хранилище данных",
},
{
id: "redis-service",
name: "Redis",
type: "service",
val: 12,
description: "Кэширование",
},
{
id: "queue-service",
name: "Message Queue",
type: "service",
val: 12,
description: "Очередь сообщений",
},
{
id: "user-agent",
name: "User Agent",
type: "agent",
val: 8,
description: "Обработка пользователей",
},
{
id: "payment-agent",
name: "Payment Agent",
type: "agent",
val: 8,
description: "Платежи",
},
{
id: "notification-agent",
name: "Notification Agent",
type: "agent",
val: 8,
description: "Уведомления",
},
{
id: "analytics-agent",
name: "Analytics Agent",
type: "agent",
val: 8,
description: "Аналитика",
},
{
id: "report-agent",
name: "Report Agent",
type: "agent",
val: 8,
description: "Отчеты",
},
],
links: [
{ source: "user-agent", target: "api-gateway", type: "uses" },
{ source: "user-agent", target: "auth-service", type: "uses" },
{ source: "user-agent", target: "db-service", type: "uses" },
{ source: "payment-agent", target: "api-gateway", type: "uses" },
{ source: "payment-agent", target: "auth-service", type: "uses" },
{ source: "payment-agent", target: "queue-service", type: "uses" },
{ source: "notification-agent", target: "redis-service", type: "uses" },
{ source: "notification-agent", target: "queue-service", type: "uses" },
{ source: "analytics-agent", target: "db-service", type: "uses" },
{ source: "report-agent", target: "db-service", type: "uses" },
{ source: "report-agent", target: "redis-service", type: "uses" },
{ source: "api-gateway", target: "auth-service", type: "depends_on" },
{ source: "auth-service", target: "db-service", type: "depends_on" },
{ source: "api-gateway", target: "queue-service", type: "depends_on" },
{ source: "queue-service", target: "redis-service", type: "depends_on" },
],
};
export const Routing = () => { export const Routing = () => {
return ( return (
@@ -17,15 +116,25 @@ export const Routing = () => {
} }
> >
<ReactRoutes> <ReactRoutes>
<Route path="/auth" element={<AuthPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route element={<DefaultLayout />}> <Route element={<DefaultLayout />}>
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/auth" element={<AuthPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/themes" element={<ThemesPage />} /> <Route path="/themes" element={<ThemesPage />} />
<Route path="/add-agents" element={<AddAgentsPage />} /> <Route path="/add-agents" element={<AddAgentsPage />} />
<Route path="/registration" element={<RegistrationTokenPage />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="/logs" element={<LogsPage />} />
<Route path="/admin" element={<AdminPage />} />
<Route path="/IDE" element={<IDEPage />} />
<Route path="/templates" element={<TemplatesPage />} />
</Route> </Route>
<Route path="/test" element={<TestPage />} />
<Route path="/test2" element={<Test2Page data={mockGraphData} />} />
<Route path="*" element={<Navigate to="/" replace />} />
</ReactRoutes> </ReactRoutes>
</Suspense> </Suspense>
); );
@@ -0,0 +1,154 @@
import { apiClient } from "@/shared/api/axios.instance";
import type {
AgentInfo,
TokenCreate,
TokenUser,
LogEntry,
LogFilters,
InsertLogRequest,
InsertLogsRequest,
TokenUpdate,
TokenUpdatePermissions,
TokenPasswordReset,
RegistrationRequest,
DeployAgentsRequest,
DeployResponse,
} from "../types/agent.types";
class AgentApiService {
private readonly basePath = "/agents";
private readonly authBasePath = "/auth";
private readonly logsBasePath = "/logs";
async getAgents(): Promise<AgentInfo[]> {
const response = await apiClient.get<AgentInfo[]>(this.basePath);
return response.data;
}
async getUsers(): Promise<TokenUser[]> {
const response = await apiClient.get<TokenUser[]>(
`${this.authBasePath}/tokens`,
);
return response.data;
}
async createUser(data: TokenCreate): Promise<void> {
await apiClient.post(`${this.authBasePath}/token`, data);
}
async deleteUser(login: string): Promise<void> {
await apiClient.delete(`${this.authBasePath}/tokens/${login}`);
}
async deleteMyAccount(): Promise<void> {
await apiClient.delete(`${this.authBasePath}/token`);
}
async searchLogs(filters?: LogFilters): Promise<LogEntry[]> {
const response = await apiClient.get<LogEntry[]>(this.logsBasePath, {
params: {
level: filters?.level,
service: filters?.service,
agent: filters?.agent,
date_from: filters?.date_from,
date_to: filters?.date_to,
limit: filters?.limit ?? 100,
offset: filters?.offset ?? 0,
},
});
return response.data;
}
async insertLog(entry: InsertLogRequest): Promise<void> {
await apiClient.post(this.logsBasePath, entry);
}
async insertLogsBatch(data: InsertLogsRequest): Promise<void> {
await apiClient.post(`${this.logsBasePath}/batch`, data);
}
async getDistinctAgents(): Promise<string[]> {
const response = await apiClient.get<string[]>(
`${this.logsBasePath}/agents`,
);
return response.data;
}
async getDistinctLevels(): Promise<string[]> {
const response = await apiClient.get<string[]>(
`${this.logsBasePath}/levels`,
);
return response.data;
}
async getDistinctServices(): Promise<string[]> {
const response = await apiClient.get<string[]>(
`${this.logsBasePath}/services`,
);
return response.data;
}
// User management methods
async getUserByLogin(login: string): Promise<TokenUser> {
const response = await apiClient.get<TokenUser>(
`${this.authBasePath}/users/${login}`,
);
return response.data;
}
async getInactiveUsers(): Promise<TokenUser[]> {
const response = await apiClient.get<TokenUser[]>(
`${this.authBasePath}/users/inactive`,
);
return response.data;
}
async updateUser(login: string, data: TokenUpdate): Promise<void> {
await apiClient.put(`${this.authBasePath}/users/${login}`, data);
}
async updateUserPermissions(
login: string,
data: TokenUpdatePermissions,
): Promise<void> {
await apiClient.put(
`${this.authBasePath}/users/${login}/permissions`,
data,
);
}
async resetUserPassword(
login: string,
data: TokenPasswordReset,
): Promise<void> {
await apiClient.put(`${this.authBasePath}/users/${login}/password`, data);
}
async activateUser(login: string): Promise<void> {
await apiClient.post(`${this.authBasePath}/users/${login}/activate`);
}
async deactivateUser(login: string): Promise<void> {
await apiClient.post(`${this.authBasePath}/users/${login}/deactivate`);
}
async createRegistrationToken(
data: RegistrationRequest,
): Promise<Record<string, string>> {
const response = await apiClient.post<Record<string, string>>(
`${this.basePath}/register-token`,
data,
);
return response.data;
}
async deployAgents(data: DeployAgentsRequest): Promise<DeployResponse> {
const response = await apiClient.post<DeployResponse>(
`${this.basePath}/deploy`,
data,
);
return response.data;
}
}
export const agentApiService = new AgentApiService();
@@ -0,0 +1,36 @@
import { useState, useEffect, useCallback } from "react";
import { agentApiService } from "../api/agent.api.service";
import type { AgentInfo } from "../types/agent.types";
interface UseAgentsResult {
agents: AgentInfo[];
isLoading: boolean;
error: string | null;
refetch: () => Promise<void>;
}
export function useAgents(): UseAgentsResult {
const [agents, setAgents] = useState<AgentInfo[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchAgents = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const data = await agentApiService.getAgents();
setAgents(data);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to fetch agents";
setError(message);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchAgents();
}, [fetchAgents]);
return { agents, isLoading, error, refetch: fetchAgents };
}
+26
View File
@@ -0,0 +1,26 @@
export { SSHAgentForm } from "./ui/SSHAgentForm";
export type { SSHAgentConfig, ExtraField } from "./ui/SSHAgentForm";
export { useAgents } from "./hooks/useAgents.hook";
export { agentApiService } from "./api/agent.api.service";
export type {
AgentInfo,
LoginRequest,
LoginResponse,
TokenCreate,
TokenUser,
LogEntry,
InsertLogRequest,
InsertLogsRequest,
LogFilters,
TokenUpdate,
TokenUpdatePermissions,
TokenPasswordReset,
RegistrationRequest,
DeployResult,
DeployAgentsRequest,
AgentDeployConfig,
DeployResponse,
} from "./types/agent.types";
@@ -0,0 +1,86 @@
import { create } from "zustand";
export type LogLevel = "INFO" | "WARNING" | "ERROR" | "FATAL";
interface LogFilterState {
searchQuery: string;
startDate: Date | null;
endDate: Date | null;
selectedLogLevels: LogLevel[];
selectedService: string;
selectedAgent: string;
limit: number;
offset: number;
setSearchQuery: (query: string) => void;
setStartDate: (date: Date | null) => void;
setEndDate: (date: Date | null) => void;
toggleLogLevel: (level: LogLevel) => void;
setSelectedService: (service: string) => void;
setSelectedAgent: (agent: string) => void;
setLimit: (limit: number) => void;
setOffset: (offset: number) => void;
resetFilters: () => void;
getFilters: () => {
level?: string;
service?: string;
agent?: string;
date_from?: string;
date_to?: string;
limit: number;
offset: number;
};
}
export const useLogFilterStore = create<LogFilterState>((set, get) => ({
searchQuery: "",
startDate: null,
endDate: null,
selectedLogLevels: ["INFO", "WARNING", "ERROR", "FATAL"],
selectedService: "",
selectedAgent: "",
limit: 100,
offset: 0,
setSearchQuery: (query) => set({ searchQuery: query }),
setStartDate: (date) => set({ startDate: date }),
setEndDate: (date) => set({ endDate: date }),
toggleLogLevel: (level) => {
const { selectedLogLevels } = get();
if (selectedLogLevels.includes(level)) {
set({ selectedLogLevels: selectedLogLevels.filter((l) => l !== level) });
} else {
set({ selectedLogLevels: [...selectedLogLevels, level] });
}
},
setSelectedService: (service) => set({ selectedService: service }),
setSelectedAgent: (agent) => set({ selectedAgent: agent }),
setLimit: (limit) => set({ limit }),
setOffset: (offset) => set({ offset }),
resetFilters: () => {
set({
searchQuery: "",
startDate: null,
endDate: null,
selectedLogLevels: ["INFO", "WARNING", "ERROR", "FATAL"],
selectedService: "",
selectedAgent: "",
limit: 100,
offset: 0,
});
},
getFilters: () => {
const { selectedLogLevels, selectedService, selectedAgent, startDate, endDate, limit, offset } = get();
return {
level: selectedLogLevels.length > 0 ? selectedLogLevels.join(",") : undefined,
service: selectedService || undefined,
agent: selectedAgent || undefined,
date_from: startDate ? startDate.toISOString() : undefined,
date_to: endDate ? endDate.toISOString() : undefined,
limit,
offset,
};
},
}));
@@ -0,0 +1,124 @@
export interface AgentService {
name: string;
status: string;
}
export interface AgentInfo {
name: string;
services: AgentService[];
token: string;
}
export interface LoginRequest {
login: string;
password: string;
}
export interface LoginResponse {
last_name: string;
login: string;
name: string;
permission_admin: boolean;
permission_manage_agent: boolean;
permission_view: boolean;
token: string;
}
export interface TokenCreate {
login: string;
name: string;
last_name: string;
password: string;
permission_admin?: boolean;
permission_manage_agent?: boolean;
permission_view?: boolean;
}
export interface TokenUser {
id: number;
login: string;
name: string;
last_name: string;
permission_admin: boolean;
permission_manage_agent: boolean;
permission_view: boolean;
token: string;
}
export interface LogEntry {
agent: string;
level: string;
message: string;
service: string;
timestamp: string;
}
export interface InsertLogRequest {
agent: string;
level: string;
message: string;
service: string;
timestamp?: string;
}
export interface InsertLogsRequest {
logs: InsertLogRequest[];
}
export interface LogFilters {
level?: string;
service?: string;
agent?: string;
date_from?: string;
date_to?: string;
limit?: number;
offset?: number;
}
export interface TokenUpdate {
name?: string;
last_name?: string;
}
export interface TokenUpdatePermissions {
is_active?: boolean;
permission_admin?: boolean;
permission_manage_agent?: boolean;
permission_view?: boolean;
}
export interface TokenPasswordReset {
new_password: string;
}
export interface RegistrationRequest {
label: string;
}
export interface DeployResult {
agent_label: string;
error?: string;
ip: string;
success: boolean;
token?: string;
}
export interface DeployAgentsRequest {
servers: AgentDeployConfig[];
}
export interface AgentDeployConfig {
agentLabel: string;
authMethod: "key" | "password";
deployType: "docker" | "binary";
ip: string;
password?: string;
port?: number;
sshKey?: string;
user: string;
}
export interface DeployResponse {
message?: string;
results: DeployResult[];
}
@@ -0,0 +1,399 @@
import React, { useState, useEffect, useCallback } from "react";
import {
FiSearch,
FiX,
FiFilter,
FiCalendar,
FiTag,
FiCheck,
} from "react-icons/fi";
import { useLogFilterStore, type LogLevel } from "../store/logFilter.store";
const logLevelColors: Record<LogLevel, { bg: string; text: string; border: string }> = {
INFO: { bg: "var(--info-bg)", text: "var(--info-text)", border: "var(--info-border)" },
WARNING: { bg: "var(--warning-bg)", text: "var(--warning-text)", border: "var(--warning-border)" },
ERROR: { bg: "var(--error-bg)", text: "var(--error-text)", border: "var(--error-border)" },
FATAL: { bg: "var(--fatal-bg)", text: "var(--fatal-text)", border: "var(--fatal-border)" },
};
interface LogFiltersProps {
onApply: () => void;
availableServices: string[];
availableAgents: string[];
}
export const LogFilters: React.FC<LogFiltersProps> = ({ onApply, availableServices, availableAgents }) => {
const {
searchQuery,
startDate,
endDate,
selectedLogLevels,
selectedService,
selectedAgent,
setSearchQuery,
setStartDate,
setEndDate,
toggleLogLevel,
setSelectedService,
setSelectedAgent,
resetFilters,
} = useLogFilterStore();
const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
const [localStartDate, setLocalStartDate] = useState<Date | null>(startDate);
const [localEndDate, setLocalEndDate] = useState<Date | null>(endDate);
const [localService, setLocalService] = useState(selectedService);
const [localAgent, setLocalAgent] = useState(selectedAgent);
useEffect(() => {
setLocalSearchQuery(searchQuery);
}, [searchQuery]);
useEffect(() => {
setLocalStartDate(startDate);
}, [startDate]);
useEffect(() => {
setLocalEndDate(endDate);
}, [endDate]);
useEffect(() => {
setLocalService(selectedService);
}, [selectedService]);
useEffect(() => {
setLocalAgent(selectedAgent);
}, [selectedAgent]);
const handleApply = useCallback(() => {
setSearchQuery(localSearchQuery);
setStartDate(localStartDate);
setEndDate(localEndDate);
setSelectedService(localService);
setSelectedAgent(localAgent);
onApply();
}, [localSearchQuery, localStartDate, localEndDate, localService, localAgent, onApply]);
const handleReset = useCallback(() => {
setLocalSearchQuery("");
setLocalStartDate(null);
setLocalEndDate(null);
setLocalService("");
setLocalAgent("");
resetFilters();
onApply();
}, [resetFilters, onApply]);
const getActiveFiltersCount = () => {
let count = 0;
if (searchQuery) count++;
if (startDate) count++;
if (endDate) count++;
if (selectedService) count++;
if (selectedAgent) count++;
if (selectedLogLevels.length < 4) count++;
return count;
};
const formatDate = (date: Date | null) => {
if (!date) return null;
return date.toLocaleDateString("ru-RU");
};
const activeFiltersCount = getActiveFiltersCount();
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border)",
borderRadius: "6px",
backgroundColor: "var(--input-bg)",
color: "var(--text-primary)",
fontSize: "13px",
};
const selectStyle: React.CSSProperties = {
...inputStyle,
cursor: "pointer",
};
return (
<div
className="rounded-xl border"
style={{
backgroundColor: "var(--card-bg)",
borderColor: "var(--border)",
}}
>
<div className="p-4">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<FiFilter size={14} style={{ color: "var(--accent)" }} />
<h3 className="text-sm font-semibold" style={{ color: "var(--text-primary)" }}>
Фильтры логов
</h3>
</div>
<span className="text-xs" style={{ color: "var(--text-secondary)" }}>
Активно: {activeFiltersCount}
</span>
</div>
{/* Filters Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
{/* Search */}
<div className="relative">
<FiSearch
style={{
position: "absolute",
left: "10px",
top: "50%",
transform: "translateY(-50%)",
color: "var(--text-muted)",
fontSize: "14px",
}}
/>
<input
type="text"
value={localSearchQuery}
onChange={(e) => setLocalSearchQuery(e.target.value)}
placeholder="Поиск по сообщению..."
style={{ ...inputStyle, paddingLeft: "32px" }}
onKeyDown={(e) => e.key === "Enter" && handleApply()}
/>
</div>
{/* Service Select */}
<select
value={localService}
onChange={(e) => setLocalService(e.target.value)}
style={selectStyle}
>
<option value="">Все сервисы</option>
{availableServices.map((service) => (
<option key={service} value={service}>
{service}
</option>
))}
</select>
{/* Agent Select */}
<select
value={localAgent}
onChange={(e) => setLocalAgent(e.target.value)}
style={selectStyle}
>
<option value="">Все агенты</option>
{availableAgents.map((agent) => (
<option key={agent} value={agent}>
{agent}
</option>
))}
</select>
{/* Date Range */}
<div className="flex gap-2">
<input
type="date"
value={localStartDate ? localStartDate.toISOString().split("T")[0] : ""}
onChange={(e) => setLocalStartDate(e.target.value ? new Date(e.target.value) : null)}
style={inputStyle}
placeholder="Дата от"
/>
<input
type="date"
value={localEndDate ? localEndDate.toISOString().split("T")[0] : ""}
onChange={(e) => setLocalEndDate(e.target.value ? new Date(e.target.value) : null)}
style={inputStyle}
placeholder="Дата до"
/>
</div>
</div>
{/* Log Levels */}
<div className="mb-4">
<div className="flex items-center gap-2 mb-2">
<FiTag size={12} style={{ color: "var(--text-secondary)" }} />
<span className="text-xs font-medium" style={{ color: "var(--text-secondary)" }}>
Уровни логов
</span>
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
({selectedLogLevels.length}/4)
</span>
</div>
<div className="flex flex-wrap gap-2">
{(["INFO", "WARNING", "ERROR", "FATAL"] as LogLevel[]).map((level) => {
const isSelected = selectedLogLevels.includes(level);
const colors = logLevelColors[level];
return (
<button
key={level}
onClick={() => toggleLogLevel(level)}
className="px-3 py-1.5 rounded-lg text-xs font-medium transition-all border"
style={{
backgroundColor: isSelected ? colors.bg : "transparent",
color: isSelected ? colors.text : "var(--text-secondary)",
borderColor: isSelected ? colors.border : "var(--border)",
}}
>
{isSelected && <FiCheck size={10} className="inline mr-1" />}
{level}
</button>
);
})}
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-2">
<button
onClick={handleApply}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg transition-all text-sm font-medium"
style={{
backgroundColor: "var(--button-primary)",
color: "var(--button-primary-text)",
}}
>
<FiCheck size={14} />
Применить
</button>
<button
onClick={handleReset}
className="px-4 py-2 rounded-lg transition-all text-sm font-medium border"
style={{
backgroundColor: "transparent",
color: "var(--text-secondary)",
borderColor: "var(--border)",
}}
>
<FiX size={14} />
</button>
</div>
{/* Active Filters Display */}
{activeFiltersCount > 0 && (
<div className="mt-4 pt-4 border-t" style={{ borderColor: "var(--border)" }}>
<div className="flex items-center gap-2 mb-2">
<FiFilter size={10} style={{ color: "var(--accent)" }} />
<span className="text-xs" style={{ color: "var(--text-secondary)" }}>
Активные фильтры:
</span>
</div>
<div className="flex flex-wrap gap-2">
{searchQuery && (
<div
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
style={{
backgroundColor: "var(--bg-secondary)",
borderColor: "var(--border)",
}}
>
<FiSearch size={10} />
<span style={{ color: "var(--text-primary)" }}>Поиск: {searchQuery}</span>
<button
onClick={() => {
setLocalSearchQuery("");
setSearchQuery("");
onApply();
}}
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-muted)" }}
>
<FiX size={10} />
</button>
</div>
)}
{selectedService && (
<div
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
style={{
backgroundColor: "var(--bg-secondary)",
borderColor: "var(--border)",
}}
>
<FiTag size={10} />
<span style={{ color: "var(--text-primary)" }}>Сервис: {selectedService}</span>
<button
onClick={() => {
setLocalService("");
setSelectedService("");
onApply();
}}
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-muted)" }}
>
<FiX size={10} />
</button>
</div>
)}
{selectedAgent && (
<div
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
style={{
backgroundColor: "var(--bg-secondary)",
borderColor: "var(--border)",
}}
>
<FiTag size={10} />
<span style={{ color: "var(--text-primary)" }}>Агент: {selectedAgent}</span>
<button
onClick={() => {
setLocalAgent("");
setSelectedAgent("");
onApply();
}}
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-muted)" }}
>
<FiX size={10} />
</button>
</div>
)}
{startDate && (
<div
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
style={{
backgroundColor: "var(--bg-secondary)",
borderColor: "var(--border)",
}}
>
<FiCalendar size={10} />
<span style={{ color: "var(--text-primary)" }}>С: {formatDate(startDate)}</span>
<button
onClick={() => {
setLocalStartDate(null);
setStartDate(null);
onApply();
}}
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-muted)" }}
>
<FiX size={10} />
</button>
</div>
)}
{endDate && (
<div
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
style={{
backgroundColor: "var(--bg-secondary)",
borderColor: "var(--border)",
}}
>
<FiCalendar size={10} />
<span style={{ color: "var(--text-primary)" }}>По: {formatDate(endDate)}</span>
<button
onClick={() => {
setLocalEndDate(null);
setEndDate(null);
onApply();
}}
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-muted)" }}
>
<FiX size={10} />
</button>
</div>
)}
</div>
</div>
)}
</div>
</div>
);
};
+51 -3
View File
@@ -7,6 +7,7 @@ import {
FiPlus, FiPlus,
FiTrash2, FiTrash2,
FiSettings, FiSettings,
FiLink,
} from "react-icons/fi"; } from "react-icons/fi";
import { SiDocker } from "react-icons/si"; import { SiDocker } from "react-icons/si";
import { FiPackage, FiUploadCloud } from "react-icons/fi"; import { FiPackage, FiUploadCloud } from "react-icons/fi";
@@ -20,8 +21,10 @@ interface ExtraField {
} }
export interface SSHAgentConfig { export interface SSHAgentConfig {
agentLabel: string;
user: string; user: string;
ip: string; ip: string;
port: number;
authMethod: AuthMethod; authMethod: AuthMethod;
sshKey?: string; sshKey?: string;
password?: string; password?: string;
@@ -189,11 +192,31 @@ export const SSHAgentForm: React.FC<SSHAgentFormProps> = ({
</div> </div>
<div style={{ display: "grid", gap: "20px" }}> <div style={{ display: "grid", gap: "20px" }}>
{/* User и IP */} {/* Agent Label */}
<div>
<label style={labelStyle}>
<span style={{ display: "flex", alignItems: "center", gap: "6px" }}>
<FiServer size={14} />
Метка агента *
</span>
</label>
<input
type="text"
value={config.agentLabel}
onChange={(e) => handleChange("agentLabel", e.target.value)}
required
style={inputBaseStyle}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder="production-server-1"
/>
</div>
{/* User, IP и Port */}
<div <div
style={{ style={{
display: "grid", display: "grid",
gridTemplateColumns: "1fr 1fr", gridTemplateColumns: "1fr 1fr 1fr",
gap: "16px", gap: "16px",
}} }}
> >
@@ -238,6 +261,31 @@ export const SSHAgentForm: React.FC<SSHAgentFormProps> = ({
placeholder="192.168.1.1" placeholder="192.168.1.1"
/> />
</div> </div>
<div>
<label style={labelStyle}>
<span
style={{ display: "flex", alignItems: "center", gap: "6px" }}
>
<FiLink size={14} />
Порт *
</span>
</label>
<input
type="number"
value={config.port}
onChange={(e) =>
handleChange("port", parseInt(e.target.value) || 22)
}
required
min={1}
max={65535}
style={inputBaseStyle}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder="22"
/>
</div>
</div> </div>
{/* Метод аутентификации */} {/* Метод аутентификации */}
@@ -457,7 +505,7 @@ export const SSHAgentForm: React.FC<SSHAgentFormProps> = ({
<div <div
style={{ style={{
display: "grid", display: "grid",
gridTemplateColumns: "1fr 1fr 1fr", gridTemplateColumns: "1fr 1fr",
gap: "8px", gap: "8px",
}} }}
> >
@@ -17,12 +17,18 @@ const login = async (credentials: LoginCredentials): Promise<LoginResponse> => {
return response.data; return response.data;
}; };
const register = async (data: RegisterData): Promise<LoginResponse> => { const register = async (
const response = await apiClient.post<LoginResponse>("/auth/register", { data: RegisterData,
): Promise<Record<string, string>> => {
const response = await apiClient.post<Record<string, string>>("/auth/token", {
login: data.login, login: data.login,
password: data.password, password: data.password,
name: data.firstName, name: data.firstName,
last_name: data.lastName, last_name: data.lastName,
is_active: data.is_active,
permission_admin: data.permission_admin,
permission_manage_agent: data.permission_manage_agent,
permission_view: data.permission_view,
}); });
return response.data; return response.data;
}; };
@@ -62,9 +68,10 @@ export const useAuthStore = create<AuthState>()(
register: async (data: RegisterData) => { register: async (data: RegisterData) => {
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
const response = await register(data); await register(data);
const user = mapResponseToUser(response); // После регистрации пользователь не авторизуется автоматически
set({ user, token: response.token, isLoading: false }); // Нужно войти через /auth/login
set({ isLoading: false });
} catch (error) { } catch (error) {
set({ set({
error: error:
@@ -8,6 +8,10 @@ export interface RegisterData {
password: string; password: string;
firstName: string; firstName: string;
lastName: string; lastName: string;
is_active?: boolean;
permission_admin?: boolean;
permission_manage_agent?: boolean;
permission_view?: boolean;
} }
export interface LoginResponse { export interface LoginResponse {
+263
View File
@@ -0,0 +1,263 @@
import React, { useEffect } from "react";
import { MdAdd, MdArrowBack } from "react-icons/md";
import { GoTrash } from "react-icons/go";
import {
useIDEStore,
initialFiles as defaultInitialFiles,
} from "./store/useIDEStore";
import type { FileNode } from "./types";
import {
FileExplorer,
TabBar,
CodeEditor,
TitleBar,
StatusBar,
} from "./components";
interface IDEProps {
initialFiles?: FileNode;
onBack?: () => void;
}
export const IDE: React.FC<IDEProps> = ({
initialFiles: externalFiles,
onBack,
}: IDEProps = {}) => {
const files = useIDEStore((state) => state.files);
const openFiles = useIDEStore((state) => state.openFiles);
const activeFile = useIDEStore((state) => state.activeFile);
const createNewProject = useIDEStore((state) => state.createNewProject);
const selectFile = useIDEStore((state) => state.selectFile);
const updateFileContent = useIDEStore((state) => state.updateFileContent);
const closeFile = useIDEStore((state) => state.closeFile);
const closeAllFiles = useIDEStore((state) => state.closeAllFiles);
const closeOtherFiles = useIDEStore((state) => state.closeOtherFiles);
const initialize = useIDEStore((state) => state.initialize);
const isInitialized = useIDEStore((state) => state.isInitialized);
// Инициализация файлов
useEffect(() => {
if (!isInitialized) {
const filesToInit = externalFiles || defaultInitialFiles;
initialize(filesToInit);
}
}, [isInitialized, externalFiles, initialize]);
// Если проект не открыт
if (!files) {
return (
<div
style={{
height: "100vh",
display: "flex",
flexDirection: "column",
backgroundColor: "#1e1e1e",
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
position: "relative",
}}
>
<TitleBar />
{onBack && (
<button
onClick={onBack}
style={{
position: "absolute",
top: "40px",
left: "12px",
background: "transparent",
border: "1px solid #3e3e42",
color: "#cccccc",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "6px",
padding: "6px 12px",
borderRadius: "6px",
fontSize: "12px",
transition: "all 0.1s",
zIndex: 10,
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#3e3e42";
e.currentTarget.style.color = "#fff";
e.currentTarget.style.borderColor = "#555";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
e.currentTarget.style.color = "#cccccc";
e.currentTarget.style.borderColor = "#3e3e42";
}}
title="Go back"
>
<MdArrowBack size={16} />
<span>Back</span>
</button>
)}
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div style={{ textAlign: "center" }}>
<div
style={{
marginBottom: "24px",
display: "flex",
justifyContent: "center",
opacity: 0.3,
}}
>
<GoTrash size={72} />
</div>
<div
style={{
fontSize: "22px",
marginBottom: "12px",
color: "#cccccc",
fontWeight: 300,
}}
>
No project open
</div>
<div
style={{
fontSize: "13px",
marginBottom: "32px",
color: "#858585",
}}
>
Create a new project to get started
</div>
<button
onClick={createNewProject}
style={{
padding: "10px 24px",
backgroundColor: "#0e639c",
border: "none",
borderRadius: "4px",
color: "#fff",
cursor: "pointer",
fontSize: "13px",
fontWeight: 500,
transition: "background-color 0.1s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#1177bb";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "#0e639c";
}}
>
<MdAdd size={14} /> New Project
</button>
</div>
</div>
<StatusBar activeFile={null} />
</div>
);
}
return (
<div
style={{
height: "100vh",
display: "flex",
flexDirection: "column",
overflow: "hidden",
backgroundColor: "#1e1e1e",
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
}}
>
<div
style={{
height: "30px",
backgroundColor: "#323233",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0 8px",
borderBottom: "1px solid #1e1e1e",
fontSize: "12px",
color: "#cccccc",
userSelect: "none",
flexShrink: 0,
}}
>
{onBack && (
<button
onClick={onBack}
style={{
background: "transparent",
border: "none",
color: "#cccccc",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "4px",
padding: "4px 8px",
borderRadius: "4px",
fontSize: "11px",
transition: "all 0.1s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#3e3e42";
e.currentTarget.style.color = "#fff";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
e.currentTarget.style.color = "#cccccc";
}}
title="Go back"
>
<MdArrowBack size={14} />
<span>Back</span>
</button>
)}
{!onBack && <div />}
<span style={{ fontWeight: 400 }}>
{activeFile ? `${activeFile.name} - ` : ""}
{files.name}
</span>
<div style={{ width: 60 }} />
</div>
<div style={{ display: "flex", flex: 1, overflow: "hidden" }}>
<div style={{ width: "260px", flexShrink: 0 }}>
<FileExplorer
files={files}
onDeleteRoot={useIDEStore.getState().deleteRoot}
/>
</div>
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<TabBar
openFiles={openFiles}
activeFile={activeFile}
onSelectFile={selectFile}
onCloseFile={closeFile}
onCloseAll={closeAllFiles}
onCloseOthers={closeOtherFiles}
/>
<CodeEditor
filePath={activeFile?.path || ""}
content={activeFile?.content || ""}
onChange={updateFileContent}
/>
</div>
</div>
<StatusBar activeFile={activeFile} />
</div>
);
};
export default IDE;
@@ -0,0 +1,89 @@
import React from "react";
import Editor from "@monaco-editor/react";
import { FiFolder } from "react-icons/fi";
import { getLanguage } from "../helpers/fileTree";
interface CodeEditorProps {
filePath: string;
content: string;
onChange: (content: string) => void;
}
export const CodeEditor: React.FC<CodeEditorProps> = ({
filePath,
content,
onChange,
}) => {
return (
<div
style={{
height: "100%",
display: "flex",
flexDirection: "column",
backgroundColor: "#1e1e1e",
}}
>
<div style={{ flex: 1 }}>
{filePath ? (
<Editor
height="100%"
language={getLanguage(filePath)}
value={content}
onChange={(value) => onChange(value || "")}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 14,
fontFamily: "'Cascadia Code', 'Fira Code', monospace",
tabSize: 4,
wordWrap: "on",
lineNumbers: "on",
automaticLayout: true,
renderWhitespace: "selection",
smoothScrolling: true,
}}
/>
) : (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%",
color: "#858585",
textAlign: "center",
}}
>
<div>
<div
style={{
marginBottom: "24px",
display: "flex",
justifyContent: "center",
opacity: 0.5,
}}
>
<FiFolder size={64} />
</div>
<div
style={{
fontSize: "18px",
marginBottom: "12px",
color: "#cccccc",
}}
>
Welcome to Web VS Code
</div>
<div style={{ fontSize: "13px", marginBottom: "8px" }}>
Right-click on a folder to create files
</div>
<div style={{ fontSize: "12px", color: "#0e639c" }}>
Or right-click anywhere in the explorer
</div>
</div>
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,99 @@
import React, { useEffect } from "react";
import { FiFile, FiFolder, FiEdit3, FiTrash2 } from "react-icons/fi";
const MenuItem: React.FC<{
onClick: () => void;
danger?: boolean;
children: React.ReactNode;
}> = ({ onClick, danger, children }) => (
<div
onClick={onClick}
style={{
padding: "8px 16px",
cursor: "pointer",
color: danger ? "#f48771" : "#cccccc",
fontSize: "13px",
transition: "background-color 0.1s",
display: "flex",
alignItems: "center",
gap: "10px",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#2a2d2e";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
}}
>
{children}
</div>
);
interface ContextMenuProps {
x: number;
y: number;
onClose: () => void;
onNewFile: () => void;
onNewFolder: () => void;
onRename: () => void;
onDelete: () => void;
hasNode: boolean;
}
export const ContextMenu: React.FC<ContextMenuProps> = ({
x,
y,
onClose,
onNewFile,
onNewFolder,
onRename,
onDelete,
hasNode,
}) => {
useEffect(() => {
const handleClick = () => onClose();
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, [onClose]);
return (
<div
style={{
position: "fixed",
top: y,
left: x,
backgroundColor: "#252526",
border: "1px solid #3e3e42",
borderRadius: "6px",
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
zIndex: 1000,
minWidth: "180px",
overflow: "hidden",
}}
>
<MenuItem onClick={onNewFile}>
<FiFile /> New File
</MenuItem>
<MenuItem onClick={onNewFolder}>
<FiFolder /> New Folder
</MenuItem>
{hasNode && (
<>
<div
style={{
height: "1px",
backgroundColor: "#3e3e42",
margin: "4px 0",
}}
/>
<MenuItem onClick={onRename}>
<FiEdit3 /> Rename
</MenuItem>
<MenuItem onClick={onDelete} danger>
<FiTrash2 /> Delete
</MenuItem>
</>
)}
</div>
);
};
@@ -0,0 +1,348 @@
import React, { useEffect, useState, useRef, useCallback } from "react";
import { FiSearch, FiFile, FiFolder, FiMinus } from "react-icons/fi";
import { GoKebabHorizontal } from "react-icons/go";
import { MdClose, MdAdd } from "react-icons/md";
import { FileTreeItem } from "./FileTreeItem";
import { ContextMenu } from "./ContextMenu";
import { InputDialog } from "./InputDialog";
import { filterTree, collectPathsToExpand } from "../helpers/fileTree";
import { useIDEStore } from "../store/useIDEStore";
import type { FileNode } from "../types";
interface FileExplorerProps {
files: FileNode;
onDeleteRoot: () => void;
}
export const FileExplorer: React.FC<FileExplorerProps> = ({
files,
onDeleteRoot,
}) => {
const store = useIDEStore();
const [showSearch, setShowSearch] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
// Фокус на инпут при открытии поиска
useEffect(() => {
if (showSearch) {
searchInputRef.current?.focus();
}
}, [showSearch]);
const handleSearchBlur = useCallback(() => {
// Скрываем поиск при потере фокуса с небольшой задержкой,
// чтобы клики по кнопке очистки успели сработать
setTimeout(() => {
if (
searchInputRef.current &&
!searchInputRef.current.contains(document.activeElement)
) {
setShowSearch(false);
store.setSearchQuery("");
}
}, 100);
}, [store]);
const handleEmptyContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
store.setContextMenu({ x: e.clientX, y: e.clientY, node: null });
};
const handleNodeContextMenu = (e: React.MouseEvent, node: FileNode) => {
e.preventDefault();
e.stopPropagation();
store.setContextMenu({ x: e.clientX, y: e.clientY, node });
};
const filteredFiles = store.searchQuery
? filterTree(files, store.searchQuery)
: files;
useEffect(() => {
if (store.searchQuery && files) {
const pathsToExpand = collectPathsToExpand(files, store.searchQuery);
if (pathsToExpand.size > 0) {
store.autoExpandPaths(pathsToExpand);
}
}
}, [store.searchQuery, files, store.autoExpandPaths]);
return (
<div
style={{
height: "100%",
display: "flex",
flexDirection: "column",
backgroundColor: "#252526",
}}
onContextMenu={handleEmptyContextMenu}
>
<div
style={{
padding: "0 8px",
height: "35px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
borderBottom: "1px solid #3e3e42",
}}
>
<span
style={{
color: "#bbbbbb",
fontWeight: 500,
fontSize: "11px",
letterSpacing: "0.8px",
}}
>
EXPLORER
</span>
<div style={{ display: "flex", gap: "2px", alignItems: "center" }}>
<button
onClick={() => {
if (!showSearch) {
setShowSearch(true);
} else {
setShowSearch(false);
store.setSearchQuery("");
}
}}
style={{
background: "transparent",
border: "none",
color: showSearch ? "#cccccc" : "#858585",
cursor: "pointer",
padding: "4px",
borderRadius: "4px",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "all 0.1s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#2a2d2e";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
}}
title="Search in files"
>
<FiSearch size={14} />
</button>
<button
onClick={store.collapseAllFolders}
style={{
background: "transparent",
border: "none",
color: "#858585",
cursor: "pointer",
padding: "4px",
borderRadius: "4px",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "all 0.1s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#2a2d2e";
e.currentTarget.style.color = "#cccccc";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
e.currentTarget.style.color = "#858585";
}}
title="Collapse All"
>
<FiMinus size={14} />
</button>
<button
onClick={store.expandAllFolders}
style={{
background: "transparent",
border: "none",
color: "#858585",
cursor: "pointer",
padding: "4px",
borderRadius: "4px",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "all 0.1s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#2a2d2e";
e.currentTarget.style.color = "#cccccc";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
e.currentTarget.style.color = "#858585";
}}
title="Expand All"
>
<GoKebabHorizontal size={14} />
</button>
</div>
</div>
<div
style={{
padding: "6px 12px",
display: "flex",
alignItems: "center",
gap: "6px",
borderBottom: "1px solid #3e3e42",
}}
>
<FiFolder size={14} color="#858585" />
<span
style={{
color: "#cccccc",
fontWeight: 600,
fontSize: "11px",
letterSpacing: "0.3px",
flex: 1,
}}
>
{files.name}
</span>
</div>
{showSearch && (
<div style={{ padding: "6px 8px", borderBottom: "1px solid #3e3e42" }}>
<div
style={{
display: "flex",
alignItems: "center",
backgroundColor: "#3c3c3c",
border: store.searchQuery
? "1px solid #007acc"
: "1px solid transparent",
borderRadius: "4px",
padding: "0 6px",
transition: "border-color 0.1s",
}}
>
<FiSearch size={13} color="#858585" />
<input
ref={searchInputRef}
type="text"
value={store.searchQuery}
onChange={(e) => store.setSearchQuery(e.target.value)}
onBlur={handleSearchBlur}
placeholder="Search..."
style={{
flex: 1,
padding: "5px 6px",
backgroundColor: "transparent",
border: "none",
color: "#cccccc",
fontSize: "12px",
outline: "none",
}}
/>
{store.searchQuery && (
<button
onClick={() => store.setSearchQuery("")}
style={{
background: "none",
border: "none",
color: "#858585",
cursor: "pointer",
padding: "2px",
display: "flex",
alignItems: "center",
}}
>
<MdClose size={12} />
</button>
)}
</div>
</div>
)}
<div style={{ flex: 1, overflowY: "auto" }}>
{filteredFiles ? (
<FileTreeItem
node={filteredFiles}
level={0}
onFileSelect={store.selectFile}
selectedFile={store.activeFile?.path || null}
onContextMenu={handleNodeContextMenu}
expandedFolders={store.expandedFolders}
onToggleFolder={store.toggleFolder}
onDelete={store.handleDeleteNode}
isRoot
searchQuery={store.searchQuery}
/>
) : (
<div
style={{
padding: "16px",
color: "#858585",
fontSize: "13px",
textAlign: "center",
}}
>
No results found
</div>
)}
</div>
{store.contextMenu && (
<ContextMenu
x={store.contextMenu.x}
y={store.contextMenu.y}
onClose={() => store.setContextMenu(null)}
onNewFile={() => {
store.setDialog({
type: "newFile",
node: store.contextMenu?.node || null,
});
store.setContextMenu(null);
}}
onNewFolder={() => {
store.setDialog({
type: "newFolder",
node: store.contextMenu?.node || null,
});
store.setContextMenu(null);
}}
onRename={() => {
store.setDialog({
type: "rename",
node: store.contextMenu?.node || null,
});
store.setContextMenu(null);
}}
onDelete={() => {
if (store.contextMenu?.node) {
store.handleDeleteNode(store.contextMenu.node);
}
store.setContextMenu(null);
}}
hasNode={!!store.contextMenu.node}
/>
)}
{store.dialog && (
<InputDialog
title={
store.dialog.type === "newFile"
? "New File"
: store.dialog.type === "newFolder"
? "New Folder"
: "Rename"
}
initialValue={
store.dialog.type === "rename" && store.dialog.node
? store.dialog.node.name
: ""
}
onConfirm={store.handleDialogConfirm}
onCancel={() => store.setDialog(null)}
/>
)}
</div>
);
};
@@ -0,0 +1,65 @@
import React from "react";
import type { FileNode } from "../types";
import { FilePickerItem } from "./FilePickerItem";
import { useFilePickerStore } from "../store/useFilePickerStore";
interface FilePickerProps {
files: FileNode;
}
const FilePickerTree: React.FC<{ node: FileNode; level: number }> = ({
node,
level,
}) => {
const expandedFolders = useFilePickerStore((s) => s.expandedFolders);
const selectedPaths = useFilePickerStore((s) => s.selectedPaths);
const toggleSelection = useFilePickerStore((s) => s.toggleSelection);
const toggleFolder = useFilePickerStore((s) => s.toggleFolder);
const nodePath = node.path || node.name;
const isExpanded = expandedFolders.has(nodePath);
const isSelected = node.type === "file" && selectedPaths.has(nodePath);
if (node.type === "file") {
return (
<FilePickerItem
name={node.name}
type="file"
path={nodePath}
isSelected={isSelected}
level={level}
onToggleSelect={toggleSelection}
/>
);
}
return (
<>
<FilePickerItem
name={node.name}
type="folder"
path={nodePath}
isExpanded={isExpanded}
level={level}
onToggleFolder={toggleFolder}
>
{node.children?.map((child, idx) => (
<FilePickerTree key={idx} node={child} level={level + 1} />
))}
</FilePickerItem>
</>
);
};
export const FilePicker: React.FC<FilePickerProps> = ({ files }) => {
return (
<div
style={{
height: "100%",
overflowY: "auto",
}}
>
<FilePickerTree node={files} level={0} />
</div>
);
};
@@ -0,0 +1,156 @@
import React from "react";
import {
FiChevronRight,
FiChevronDown,
FiFile,
FiFolder,
} from "react-icons/fi";
interface FilePickerItemProps {
name: string;
type: "file" | "folder";
path: string;
isSelected?: boolean;
isExpanded?: boolean;
children?: React.ReactNode;
level: number;
onToggleSelect?: (path: string) => void;
onToggleFolder?: (path: string) => void;
}
export const FilePickerItem: React.FC<FilePickerItemProps> = ({
name,
type,
path,
isSelected,
isExpanded,
children,
level,
onToggleSelect,
onToggleFolder,
}) => {
const isFolder = type === "folder";
const extension = name.includes(".")
? name.split(".").pop()?.toUpperCase()
: "";
const paddingLeft = 12 + level * 20;
return (
<div>
<div
style={{
display: "flex",
alignItems: "center",
paddingLeft: `${paddingLeft}px`,
paddingRight: "12px",
height: "36px",
borderBottom: "1px solid #1a1a1a",
cursor: "pointer",
transition: "background-color 0.1s",
gap: "8px",
}}
onClick={() => {
if (isFolder && onToggleFolder) {
onToggleFolder(path);
} else if (!isFolder && onToggleSelect) {
onToggleSelect(path);
}
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#2a2a2a";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
}}
>
{/* Folder expand icon */}
{isFolder && (
<span style={{ color: "#858585", display: "flex", flexShrink: 0 }}>
{isExpanded ? (
<FiChevronDown size={14} />
) : (
<FiChevronRight size={14} />
)}
</span>
)}
{/* File/Folder icon */}
<span style={{ display: "flex", flexShrink: 0 }}>
{isFolder ? (
<FiFolder size={15} color="#dcb67a" />
) : (
<FiFile size={15} color="#858585" />
)}
</span>
{/* Name */}
<span
style={{
flex: 1,
color: "#cccccc",
fontSize: "13px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{name}
</span>
{/* Extension badge — только у файлов */}
{!isFolder && extension && (
<span
style={{
color: "#858585",
fontSize: "11px",
fontFamily: "monospace",
padding: "2px 6px",
backgroundColor: "#2a2a2a",
borderRadius: "3px",
flexShrink: 0,
}}
>
{extension}
</span>
)}
{/* Checkbox — только у файлов */}
{!isFolder && onToggleSelect && (
<div
style={{
width: "18px",
height: "18px",
border: isSelected ? "2px solid #0e639c" : "2px solid #555",
borderRadius: "3px",
backgroundColor: isSelected ? "#0e639c" : "transparent",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
transition: "all 0.15s",
}}
onClick={(e) => {
e.stopPropagation();
onToggleSelect(path);
}}
>
{isSelected && (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path
d="M2 6L5 9L10 3"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</div>
)}
</div>
{/* Children */}
{isFolder && isExpanded && children}
</div>
);
};
@@ -0,0 +1,169 @@
import React, { useState } from "react";
import { FiChevronRight, FiChevronDown, FiTrash2 } from "react-icons/fi";
import { GoFile } from "react-icons/go";
import type { FileNode } from "../types";
interface FileTreeItemProps {
node: FileNode;
level: number;
onFileSelect: (node: FileNode) => void;
selectedFile: string | null;
onContextMenu: (e: React.MouseEvent, node: FileNode) => void;
expandedFolders: Set<string>;
onToggleFolder: (path: string) => void;
onDelete: (node: FileNode) => void;
isRoot?: boolean;
searchQuery?: string;
}
export const FileTreeItem: React.FC<FileTreeItemProps> = ({
node,
level,
onFileSelect,
selectedFile,
onContextMenu,
expandedFolders,
onToggleFolder,
onDelete,
isRoot,
searchQuery,
}) => {
const isFolder = node.type === "folder";
const isSelected = selectedFile === node.path && !isFolder;
const isExpanded = expandedFolders.has(node.path || node.name);
const [hovered, setHovered] = useState(false);
const handleClick = () => {
if (isFolder) {
onToggleFolder(node.path || node.name);
} else {
onFileSelect(node);
}
};
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation();
onDelete(node);
};
const highlightText = (text: string, query: string) => {
if (!query) return text;
const idx = text.toLowerCase().indexOf(query.toLowerCase());
if (idx === -1) return text;
return (
<>
{text.slice(0, idx)}
<span style={{ backgroundColor: "#613214", color: "#f9f9a4" }}>
{text.slice(idx, idx + query.length)}
</span>
{text.slice(idx + query.length)}
</>
);
};
return (
<div>
<div
onClick={handleClick}
onContextMenu={(e) => onContextMenu(e, node)}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
paddingLeft: isRoot ? "8px" : `${level * 16 + 8}px`,
paddingTop: "4px",
paddingBottom: "4px",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "6px",
backgroundColor: isSelected ? "#094771" : "transparent",
color: isSelected ? "#fff" : "#cccccc",
fontSize: "13px",
transition: "background-color 0.1s",
userSelect: "none",
minHeight: "28px",
}}
>
<span
style={{
fontSize: "14px",
width: "16px",
textAlign: "center",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
{isFolder ? (
isExpanded ? (
<FiChevronDown />
) : (
<FiChevronRight />
)
) : (
<GoFile />
)}
</span>
<span
style={{
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{searchQuery ? highlightText(node.name, searchQuery) : node.name}
</span>
{hovered && !isRoot && (
<button
onClick={handleDelete}
title={`Delete ${node.name}`}
style={{
background: "none",
border: "none",
color: "#858585",
cursor: "pointer",
padding: "2px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "3px",
flexShrink: 0,
width: "20px",
height: "20px",
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = "#f48771";
e.currentTarget.style.backgroundColor = "#3e3e42";
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = "#858585";
e.currentTarget.style.backgroundColor = "transparent";
}}
>
<FiTrash2 size={13} />
</button>
)}
</div>
{isFolder && isExpanded && node.children && (
<div>
{node.children.map((child, idx) => (
<FileTreeItem
key={idx}
node={child}
level={level + 1}
onFileSelect={onFileSelect}
selectedFile={selectedFile}
onContextMenu={onContextMenu}
expandedFolders={expandedFolders}
onToggleFolder={onToggleFolder}
onDelete={onDelete}
searchQuery={searchQuery}
/>
))}
</div>
)}
</div>
);
};
@@ -0,0 +1,119 @@
import React, { useState, useRef, useEffect } from "react";
interface InputDialogProps {
title: string;
initialValue?: string;
onConfirm: (value: string) => void;
onCancel: () => void;
}
export const InputDialog: React.FC<InputDialogProps> = ({
title,
initialValue = "",
onConfirm,
onCancel,
}) => {
const [value, setValue] = useState(initialValue);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, []);
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0,0,0,0.6)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 2000,
}}
onClick={onCancel}
>
<div
style={{
backgroundColor: "#2d2d30",
borderRadius: "8px",
padding: "24px",
minWidth: "320px",
border: "1px solid #3e3e42",
boxShadow: "0 8px 24px rgba(0,0,0,0.4)",
}}
onClick={(e) => e.stopPropagation()}
>
<h3
style={{
margin: "0 0 8px 0",
color: "#fff",
fontSize: "16px",
fontWeight: 500,
}}
>
{title}
</h3>
<p style={{ margin: "0 0 16px 0", color: "#858585", fontSize: "12px" }}>
Enter a new name
</p>
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) =>
e.key === "Enter" && value.trim() && onConfirm(value.trim())
}
style={{
width: "100%",
padding: "10px",
backgroundColor: "#3c3c3c",
border: "1px solid #3e3e42",
borderRadius: "6px",
color: "#ccc",
fontSize: "14px",
marginBottom: "20px",
outline: "none",
}}
/>
<div
style={{ display: "flex", gap: "12px", justifyContent: "flex-end" }}
>
<button
onClick={onCancel}
style={{
padding: "6px 16px",
backgroundColor: "transparent",
border: "1px solid #0e639c",
borderRadius: "4px",
color: "#0e639c",
cursor: "pointer",
fontSize: "12px",
}}
>
Cancel
</button>
<button
onClick={() => value.trim() && onConfirm(value.trim())}
style={{
padding: "6px 16px",
backgroundColor: "#0e639c",
border: "none",
borderRadius: "4px",
color: "#fff",
cursor: "pointer",
fontSize: "12px",
}}
>
OK
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,44 @@
import React from "react";
import { FiGitBranch, FiCheckCircle, FiAlertCircle } from "react-icons/fi";
import type { FileNode } from "../types";
interface StatusBarProps {
activeFile: FileNode | null;
}
export const StatusBar: React.FC<StatusBarProps> = ({ activeFile }) => {
return (
<div
style={{
height: "22px",
backgroundColor: "#007acc",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0 12px",
fontSize: "12px",
color: "#ffffff",
userSelect: "none",
flexShrink: 0,
}}
>
<div style={{ display: "flex", gap: "16px", alignItems: "center" }}>
<span style={{ display: "flex", alignItems: "center", gap: "4px" }}>
<FiGitBranch size={12} /> main
</span>
<span style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<FiCheckCircle size={12} /> 0 <FiAlertCircle size={12} /> 0
</span>
</div>
<div style={{ display: "flex", gap: "16px", alignItems: "center" }}>
{activeFile && (
<span>
Ln 1, Col 1 | Spaces: 4 | UTF-8 |{" "}
{activeFile.path?.split(".").pop()?.toUpperCase() || "TXT"}
</span>
)}
<span>Web VS Code</span>
</div>
</div>
);
};
@@ -0,0 +1,196 @@
import React, { useState } from "react";
import { GoFile } from "react-icons/go";
import { MdClose } from "react-icons/md";
import type { FileNode } from "../types";
interface TabBarProps {
openFiles: FileNode[];
activeFile: FileNode | null;
onSelectFile: (file: FileNode) => void;
onCloseFile: (file: FileNode) => void;
onCloseAll: () => void;
onCloseOthers: (file: FileNode) => void;
}
export const TabBar: React.FC<TabBarProps> = ({
openFiles,
activeFile,
onSelectFile,
onCloseFile,
onCloseAll,
onCloseOthers,
}) => {
const [showContextMenu, setShowContextMenu] = useState<{
x: number;
y: number;
file: FileNode;
} | null>(null);
const handleContextMenu = (e: React.MouseEvent, file: FileNode) => {
e.preventDefault();
setShowContextMenu({ x: e.clientX, y: e.clientY, file });
};
return (
<>
<div
style={{
display: "flex",
alignItems: "center",
backgroundColor: "#1e1e1e",
borderBottom: "1px solid #3e3e42",
overflowX: "auto",
minHeight: "40px",
}}
>
<div
style={{
display: "flex",
padding: "0 12px",
gap: "8px",
borderRight: "1px solid #3e3e42",
height: "100%",
alignItems: "center",
}}
>
<button
onClick={onCloseAll}
style={{
background: "transparent",
border: "none",
color: "#cccccc",
cursor: "pointer",
fontSize: "14px",
padding: "6px 8px",
borderRadius: "4px",
display: "flex",
alignItems: "center",
gap: "6px",
transition: "all 0.1s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#2a2d2e";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
}}
title="Close All"
>
<MdClose size={14} />
<span style={{ fontSize: "11px" }}>Close All</span>
</button>
</div>
{openFiles.map((file) => (
<div
key={file.path}
onClick={() => onSelectFile(file)}
onContextMenu={(e) => handleContextMenu(e, file)}
style={{
display: "flex",
alignItems: "center",
padding: "8px 16px",
backgroundColor:
activeFile?.path === file.path ? "#1e1e1e" : "#2d2d30",
color: activeFile?.path === file.path ? "#fff" : "#cccccc",
borderRight: "1px solid #3e3e42",
cursor: "pointer",
fontSize: "13px",
gap: "10px",
whiteSpace: "nowrap",
transition: "all 0.1s",
borderTop:
activeFile?.path === file.path
? "2px solid #0e639c"
: "2px solid transparent",
}}
>
<GoFile />
<span>{file.name}</span>
<button
onClick={(e) => {
e.stopPropagation();
onCloseFile(file);
}}
style={{
background: "none",
border: "none",
color: "#858585",
cursor: "pointer",
fontSize: "16px",
padding: "0 4px",
borderRadius: "4px",
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = "#fff";
e.currentTarget.style.backgroundColor = "#3e3e42";
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = "#858585";
e.currentTarget.style.backgroundColor = "transparent";
}}
>
<MdClose size={14} />
</button>
</div>
))}
</div>
{showContextMenu && (
<div
style={{
position: "fixed",
top: showContextMenu.y,
left: showContextMenu.x,
backgroundColor: "#252526",
border: "1px solid #3e3e42",
borderRadius: "6px",
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
zIndex: 1000,
minWidth: "160px",
overflow: "hidden",
}}
>
<div
onClick={() => {
onCloseOthers(showContextMenu.file);
setShowContextMenu(null);
}}
style={{
padding: "8px 16px",
cursor: "pointer",
color: "#cccccc",
fontSize: "13px",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#2a2d2e";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
}}
>
Close Others
</div>
<div
onClick={() => {
onCloseAll();
setShowContextMenu(null);
}}
style={{
padding: "8px 16px",
cursor: "pointer",
color: "#cccccc",
fontSize: "13px",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#2a2d2e";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
}}
>
Close All
</div>
</div>
)}
</>
);
};
@@ -0,0 +1,55 @@
import React from "react";
import { FiGitBranch, FiCheckCircle } from "react-icons/fi";
export const TitleBar: React.FC = () => {
return (
<div
style={{
height: "32px",
backgroundColor: "#2d2d30",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0 12px",
borderBottom: "1px solid #3e3e42",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<div style={{ display: "flex", gap: "8px" }}>
<div
style={{
width: "12px",
height: "12px",
borderRadius: "50%",
backgroundColor: "#ed6a5e",
}}
/>
<div
style={{
width: "12px",
height: "12px",
borderRadius: "50%",
backgroundColor: "#f5bd4f",
}}
/>
<div
style={{
width: "12px",
height: "12px",
borderRadius: "50%",
backgroundColor: "#61c454",
}}
/>
</div>
<span style={{ color: "#cccccc", fontSize: "12px", fontWeight: 500 }}>
Web VS Code
</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<FiGitBranch size={12} color="#858585" />
<span style={{ color: "#858585", fontSize: "11px" }}>main</span>
<FiCheckCircle size={12} color="#61c454" />
</div>
</div>
);
};
@@ -0,0 +1,10 @@
export { ContextMenu } from "./ContextMenu";
export { InputDialog } from "./InputDialog";
export { FileTreeItem } from "./FileTreeItem";
export { FileExplorer } from "./FileExplorer";
export { TabBar } from "./TabBar";
export { CodeEditor } from "./CodeEditor";
export { TitleBar } from "./TitleBar";
export { StatusBar } from "./StatusBar";
export { FilePickerItem } from "./FilePickerItem";
export { FilePicker } from "./FilePicker";
@@ -0,0 +1,174 @@
import type { FileNode } from "../types";
export const addPaths = (node: FileNode, parentPath: string = ""): FileNode => {
const currentPath = parentPath ? `${parentPath}/${node.name}` : node.name;
const newNode = { ...node, path: currentPath };
if (newNode.children) {
newNode.children = newNode.children.map((child) =>
addPaths(child, currentPath),
);
}
return newNode;
};
export const getAllFolderPaths = (node: FileNode): string[] => {
let paths: string[] = [];
if (node.type === "folder") {
paths.push(node.path || node.name);
if (node.children) {
node.children.forEach((child) => {
paths = [...paths, ...getAllFolderPaths(child)];
});
}
}
return paths;
};
export const findNode = (node: FileNode, path: string): FileNode | null => {
if (node.path === path) return node;
if (node.children) {
for (const child of node.children) {
const found = findNode(child, path);
if (found) return found;
}
}
return null;
};
export const deleteNode = (node: FileNode, path: string): FileNode | null => {
if (node.path === path) return null;
if (node.children) {
const filtered = node.children.filter((child) => child.path !== path);
const mapped = filtered
.map((child) => deleteNode(child, path))
.filter((child): child is FileNode => child !== null);
return { ...node, children: mapped };
}
return node;
};
export const addNode = (
node: FileNode,
parentPath: string,
newNode: FileNode,
): FileNode => {
if (node.path === parentPath) {
const newPath = addPaths(newNode, node.path);
return { ...node, children: [...(node.children || []), newPath] };
}
if (node.children) {
return {
...node,
children: node.children.map((child) =>
addNode(child, parentPath, newNode),
),
};
}
return node;
};
export const renameNode = (
node: FileNode,
oldPath: string,
newName: string,
): FileNode | null => {
if (node.path === oldPath) {
const pathParts = node.path?.split("/") || [];
pathParts[pathParts.length - 1] = newName;
const newPath = pathParts.join("/");
const renamedNode = { ...node, name: newName, path: newPath };
if (renamedNode.children) {
renamedNode.children = renamedNode.children.map((child) => {
const oldChildPath = child.path || "";
const newChildPath = oldChildPath.replace(oldPath, newPath);
return (
renameNode(
child,
oldChildPath,
newChildPath.split("/").pop() || "",
) || child
);
});
}
return renamedNode;
}
if (node.children) {
return {
...node,
children: node.children.map(
(child) => renameNode(child, oldPath, newName) || child,
),
};
}
return node;
};
export const filterTree = (node: FileNode, query: string): FileNode | null => {
if (!query) return node;
const lowerQuery = query.toLowerCase();
if (node.type === "file") {
if (node.name.toLowerCase().includes(lowerQuery)) return node;
return null;
}
if (node.children) {
const filteredChildren = node.children
.map((child) => filterTree(child, query))
.filter((child): child is FileNode => child !== null);
if (filteredChildren.length > 0) {
return { ...node, children: filteredChildren };
}
}
if (node.name.toLowerCase().includes(lowerQuery)) return node;
return null;
};
export const collectPathsToExpand = (
node: FileNode,
query: string,
): Set<string> => {
const paths = new Set<string>();
if (!query) return paths;
const lowerQuery = query.toLowerCase();
const search = (n: FileNode, currentPath: string) => {
if (n.name.toLowerCase().includes(lowerQuery)) {
const pathParts = currentPath.split("/");
for (let i = 1; i < pathParts.length; i++) {
paths.add(pathParts.slice(0, i).join("/"));
}
}
if (n.children) {
n.children.forEach((child) => {
const childPath = child.path || `${currentPath}/${child.name}`;
search(child, childPath);
});
}
};
search(node, node.path || node.name);
return paths;
};
export const getLanguage = (path: string) => {
const ext = path.split(".").pop();
const map: Record<string, string> = {
py: "python",
js: "javascript",
ts: "typescript",
jsx: "javascript",
tsx: "typescript",
json: "json",
md: "markdown",
css: "css",
html: "html",
};
return map[ext || ""] || "plaintext";
};
+5
View File
@@ -0,0 +1,5 @@
export { IDE } from "./IDE";
export { FilePicker } from "./components/FilePicker";
export { useIDEStore, initialFiles } from "./store/useIDEStore";
export { useFilePickerStore } from "./store/useFilePickerStore";
export type { FileNode } from "./types";
@@ -0,0 +1,57 @@
import { create } from "zustand";
interface FilePickerState {
selectedPaths: Set<string>;
expandedFolders: Set<string>;
toggleSelection: (path: string) => void;
selectAll: (paths: string[]) => void;
clearSelection: () => void;
toggleFolder: (path: string) => void;
getSelectedPaths: () => string[];
}
export const useFilePickerStore = create<FilePickerState>((set, get) => ({
selectedPaths: new Set(),
expandedFolders: new Set(),
toggleSelection: (path: string) => {
set((state) => {
const newSet = new Set(state.selectedPaths);
if (newSet.has(path)) {
newSet.delete(path);
} else {
newSet.add(path);
}
return { selectedPaths: newSet };
});
},
selectAll: (paths: string[]) => {
set((state) => {
const newSet = new Set(state.selectedPaths);
paths.forEach((p) => newSet.add(p));
return { selectedPaths: newSet };
});
},
clearSelection: () => {
set({ selectedPaths: new Set() });
},
toggleFolder: (path: string) => {
set((state) => {
const newSet = new Set(state.expandedFolders);
if (newSet.has(path)) {
newSet.delete(path);
} else {
newSet.add(path);
}
return { expandedFolders: newSet };
});
},
getSelectedPaths: () => {
return Array.from(get().selectedPaths);
},
}));
@@ -0,0 +1,385 @@
import { create } from "zustand";
import type { FileNode } from "../types";
import {
addPaths,
getAllFolderPaths,
findNode,
deleteNode,
addNode,
renameNode,
} from "../helpers/fileTree";
export const initialFiles: FileNode = {
name: "my-project",
type: "folder",
children: [
{
name: "src",
type: "folder",
children: [
{
name: "main.py",
type: "file",
content:
'print("Hello, World!")\n\ndef main():\n print("Welcome!")\n\nif __name__ == "__main__":\n main()',
},
{
name: "utils.py",
type: "file",
content: "def helper():\n return 42",
},
],
},
{
name: "README.md",
type: "file",
content: "# My Project\n\nWelcome!",
},
],
};
interface IDEState {
// Файловая система
files: FileNode | null;
openFiles: FileNode[];
activeFile: FileNode | null;
expandedFolders: Set<string>;
searchQuery: string;
showSearch: boolean;
isInitialized: boolean;
// Диалоги и контекстные меню
contextMenu: { x: number; y: number; node: FileNode | null } | null;
dialog: {
type: "newFile" | "newFolder" | "rename";
node: FileNode | null;
} | null;
tabContextMenu: { x: number; y: number; file: FileNode } | null;
// Действия с файлами
selectFile: (node: FileNode) => void;
updateFileContent: (content: string) => void;
closeFile: (file: FileNode) => void;
closeAllFiles: () => void;
closeOtherFiles: (file: FileNode) => void;
// Действия с деревом
refreshFiles: (newFiles: FileNode | null, newFile?: FileNode) => void;
toggleFolder: (path: string) => void;
expandAllFolders: () => void;
collapseAllFolders: () => void;
autoExpandPaths: (paths: Set<string>) => void;
deleteRoot: () => void;
createNewProject: () => void;
// Поиск
setSearchQuery: (query: string) => void;
toggleSearch: () => void;
// Контекстные меню и диалоги
setContextMenu: (
menu: { x: number; y: number; node: FileNode | null } | null,
) => void;
setDialog: (
dialog: {
type: "newFile" | "newFolder" | "rename";
node: FileNode | null;
} | null,
) => void;
setTabContextMenu: (
menu: { x: number; y: number; file: FileNode } | null,
) => void;
// Инициализация
initialize: (initialFiles: FileNode) => void;
// Диалог подтверждения
handleDialogConfirm: (value: string) => void;
handleDeleteNode: (node: FileNode) => void;
}
export const useIDEStore = create<IDEState>((set, get) => ({
// Начальное состояние
files: null,
openFiles: [],
activeFile: null,
expandedFolders: new Set(),
searchQuery: "",
showSearch: false,
isInitialized: false,
contextMenu: null,
dialog: null,
tabContextMenu: null,
// Инициализация
initialize: (initialFiles: FileNode) => {
const filesWithPaths = addPaths(initialFiles);
set({
files: filesWithPaths,
expandedFolders: new Set([filesWithPaths.path || filesWithPaths.name]),
isInitialized: true,
});
},
// Выбор файла
selectFile: (node: FileNode) => {
if (node.type === "file") {
const { openFiles } = get();
if (!openFiles.find((f) => f.path === node.path)) {
set((state) => ({ openFiles: [...state.openFiles, node] }));
}
set({ activeFile: node });
}
},
// Обновление содержимого файла
updateFileContent: (content: string) => {
const { activeFile } = get();
if (activeFile) {
const updatedFile = { ...activeFile, content };
set({ activeFile: updatedFile });
set((state) => ({
openFiles: state.openFiles.map((f) =>
f.path === activeFile.path ? updatedFile : f,
),
}));
}
},
// Закрытие файла
closeFile: (file: FileNode) => {
const { openFiles, activeFile } = get();
const newOpenFiles = openFiles.filter((f) => f.path !== file.path);
set({ openFiles: newOpenFiles });
if (activeFile?.path === file.path) {
set({ activeFile: newOpenFiles[newOpenFiles.length - 1] || null });
}
},
// Закрыть все файлы
closeAllFiles: () => {
set({ openFiles: [], activeFile: null });
},
// Закрыть другие файлы
closeOtherFiles: (file: FileNode) => {
set({ openFiles: [file], activeFile: file });
},
// Обновить файловую систему
refreshFiles: (newFiles: FileNode | null, newFile?: FileNode) => {
const { openFiles, activeFile, selectFile } = get();
set({ files: newFiles });
if (!newFiles) {
set({ openFiles: [], activeFile: null });
return;
}
const updatedOpenFiles = openFiles
.map((f) => {
const found = findNode(newFiles, f.path || "");
return found && found.type === "file" ? found : null;
})
.filter((f): f is FileNode => f !== null);
set({ openFiles: updatedOpenFiles });
if (newFile) {
selectFile(newFile);
} else if (activeFile) {
const stillExists = findNode(newFiles, activeFile.path || "");
if (!stillExists) {
set({
activeFile: updatedOpenFiles[updatedOpenFiles.length - 1] || null,
});
} else if (stillExists.type === "file") {
set({ activeFile: stillExists });
}
}
},
// Переключить папку
toggleFolder: (path: string) => {
set((state) => {
const newSet = new Set(state.expandedFolders);
if (newSet.has(path)) {
newSet.delete(path);
} else {
newSet.add(path);
}
return { expandedFolders: newSet };
});
},
// Раскрыть все папки
expandAllFolders: () => {
const { files } = get();
if (files) {
set({ expandedFolders: new Set(getAllFolderPaths(files)) });
}
},
// Свернуть все папки
collapseAllFolders: () => {
set({ expandedFolders: new Set() });
},
// Автоматически раскрыть пути
autoExpandPaths: (paths: Set<string>) => {
set((state) => ({
expandedFolders: new Set([...state.expandedFolders, ...paths]),
}));
},
// Удалить корень
deleteRoot: () => {
set({
files: null,
openFiles: [],
activeFile: null,
expandedFolders: new Set(),
});
},
// Создать новый проект
createNewProject: () => {
const newProject = addPaths(initialFiles);
set({
files: newProject,
expandedFolders: new Set([newProject.path || newProject.name]),
searchQuery: "",
});
},
// Поиск
setSearchQuery: (query: string) => {
set({ searchQuery: query });
},
toggleSearch: () => {
set((state) => ({ showSearch: !state.showSearch }));
},
// Контекстные меню и диалоги
setContextMenu: (menu) => set({ contextMenu: menu }),
setDialog: (dialog) => set({ dialog: dialog }),
setTabContextMenu: (menu) => set({ tabContextMenu: menu }),
// Подтверждение диалога
handleDialogConfirm: (value: string) => {
const { dialog, files, refreshFiles, toggleFolder, autoExpandPaths } =
get();
if (!dialog) return;
if (dialog.type === "rename" && dialog.node) {
const parentPath =
dialog.node.path?.split("/").slice(0, -1).join("/") || "";
const parentNode = parentPath ? findNode(files!, parentPath) : files;
if (
parentNode?.children?.some(
(c) =>
c.name.toLowerCase() === value.toLowerCase() &&
c.path !== dialog.node?.path,
)
) {
alert(`"${value}" already exists.`);
return;
}
const newFiles = renameNode(
files!,
dialog.node.path || dialog.node.name,
value,
);
if (newFiles) {
refreshFiles(newFiles);
}
set({ dialog: null });
return;
}
let parentPath: string;
if (!dialog.node) {
parentPath = files!.path || files!.name;
} else if (dialog.node.type === "folder") {
parentPath = dialog.node.path || dialog.node.name;
} else {
const pathParts = (dialog.node.path || dialog.node.name).split("/");
pathParts.pop();
parentPath = pathParts.join("/") || files!.path || files!.name;
}
const parentNode = findNode(files!, parentPath);
if (
parentNode?.children?.some(
(c) => c.name.toLowerCase() === value.toLowerCase(),
)
) {
alert(`"${value}" already exists in this folder.`);
set({ dialog: null });
return;
}
let newFiles: FileNode | null = null;
let createdNode: FileNode | null = null;
if (dialog.type === "newFile") {
createdNode = { name: value, type: "file", content: "" };
newFiles = addNode(files!, parentPath, createdNode);
} else if (dialog.type === "newFolder") {
createdNode = { name: value, type: "folder", children: [] };
newFiles = addNode(files!, parentPath, createdNode);
}
if (newFiles) {
const allParentPaths: string[] = [];
let current = parentPath;
while (current) {
allParentPaths.push(current);
const parts = current.split("/");
parts.pop();
current = parts.join("/");
}
allParentPaths.forEach((p) => {
if (!get().expandedFolders.has(p)) {
toggleFolder(p);
}
});
autoExpandPaths(new Set(allParentPaths));
if (createdNode && createdNode.type === "file") {
const findAndOpen = (node: FileNode, name: string): FileNode | null => {
if (node.name === name && node.type === "file") return node;
if (node.children) {
for (const child of node.children) {
const found = findAndOpen(child, name);
if (found) return found;
}
}
return null;
};
const openedFile = findAndOpen(newFiles, value);
refreshFiles(newFiles, openedFile || undefined);
} else {
refreshFiles(newFiles);
}
}
set({ dialog: null });
},
// Удаление узла
handleDeleteNode: (node: FileNode) => {
const { files, refreshFiles } = get();
const isRootNode = node.path === files?.path;
if (isRootNode) {
get().deleteRoot();
} else if (window.confirm(`Delete "${node.name}"?`)) {
const newFiles = deleteNode(files!, node.path || node.name);
if (newFiles) refreshFiles(newFiles);
}
},
}));
+24
View File
@@ -0,0 +1,24 @@
export interface FileNode {
name: string;
type: "file" | "folder";
content?: string;
children?: FileNode[];
path?: string;
}
export interface ContextMenuState {
x: number;
y: number;
node: FileNode | null;
}
export interface DialogState {
type: "newFile" | "newFolder" | "rename";
node: FileNode | null;
}
export interface TabContextMenuState {
x: number;
y: number;
file: FileNode;
}
+71 -24
View File
@@ -1,20 +1,24 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { SSHAgentForm } from "../modules/agent/ui/SSHAgentForm"; import { SSHAgentForm } from "../modules/agent/ui/SSHAgentForm";
import { FiPlusCircle, FiSend } from "react-icons/fi"; import { agentApiService } from "../modules/agent/api/agent.api.service";
import type { SSHAgentConfig } from "../modules/agent/ui/SSHAgentForm";
interface SSHAgentConfig { import type {
user: string; DeployAgentsRequest,
ip: string; DeployResult,
authMethod: string; } from "../modules/agent/types/agent.types";
sshKey?: string; import {
password?: string; FiPlusCircle,
extraFields: { key: string; value: string }[]; FiSend,
deployType: string; FiCheck,
} FiX,
FiAlertCircle,
} from "react-icons/fi";
const createEmptyAgentConfig = (): SSHAgentConfig => ({ const createEmptyAgentConfig = (): SSHAgentConfig => ({
agentLabel: "",
user: "", user: "",
ip: "", ip: "",
port: 22,
authMethod: "key", authMethod: "key",
sshKey: "", sshKey: "",
password: "", password: "",
@@ -50,7 +54,9 @@ export const AddAgentsPage: React.FC = () => {
// Валидация // Валидация
const isValid = agents.every((agent) => { const isValid = agents.every((agent) => {
if (!agent.user || !agent.ip) return false; if (!agent.agentLabel || !agent.user || !agent.ip || !agent.port)
return false;
if (agent.port < 1 || agent.port > 65535) return false;
if (agent.authMethod === "key" && !agent.sshKey) return false; if (agent.authMethod === "key" && !agent.sshKey) return false;
if (agent.authMethod === "password" && !agent.password) return false; if (agent.authMethod === "password" && !agent.password) return false;
return true; return true;
@@ -66,18 +72,53 @@ export const AddAgentsPage: React.FC = () => {
setSubmitError(null); setSubmitError(null);
try { try {
// TODO: Реальный API вызов для развертывания агентов // Преобразуем данные из формы в формат API
console.log("Deploying agents:", agents); const deployData: DeployAgentsRequest = {
servers: agents.map((agent) => ({
agentLabel: agent.agentLabel,
ip: agent.ip,
user: agent.user,
port: agent.port,
authMethod: agent.authMethod as "key" | "password",
deployType: (agent.deployType === "deploy"
? "docker"
: agent.deployType) as "docker" | "binary",
...(agent.authMethod === "key"
? { sshKey: agent.sshKey }
: { password: agent.password }),
})),
};
// Имитация задержки API // Вызываем API для развертывания агентов
await new Promise((resolve) => setTimeout(resolve, 1500)); const response = await agentApiService.deployAgents(deployData);
setSubmitMessage( // Формируем сообщение о результатах
`Успешно отправлено ${agents.length} сервер(ов) на развертывание`, const successCount = response.results.filter(
); (r: DeployResult) => r.success,
setAgents([createEmptyAgentConfig()]); ).length;
const failCount = response.results.length - successCount;
if (failCount === 0) {
setSubmitMessage(
`Успешно развернуто ${successCount} агент(ов) на ${agents.length} сервер(ах)`,
);
setAgents([createEmptyAgentConfig()]);
} else {
const errorMsg = response.results
.filter((r: DeployResult) => !r.success)
.map(
(r: DeployResult) => `${r.ip}: ${r.error || "Неизвестная ошибка"}`,
)
.join("\n");
setSubmitMessage(`Успешно: ${successCount}, Ошибки: ${failCount}`);
setSubmitError(`Ошибки при развертывании:\n${errorMsg}`);
}
} catch (error) { } catch (error) {
setSubmitError("Ошибка при развертывании на серверах"); setSubmitError(
error instanceof Error
? error.message
: "Ошибка при развертывании агентов",
);
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@@ -162,20 +203,26 @@ export const AddAgentsPage: React.FC = () => {
color: "var(--success-text)", color: "var(--success-text)",
}} }}
> >
{submitMessage} <div className="flex items-start gap-2">
<FiCheck className="mt-0.5 flex-shrink-0" size={16} />
<span>{submitMessage}</span>
</div>
</div> </div>
)} )}
{submitError && ( {submitError && (
<div <div
className="mb-6 p-4 rounded-lg border text-sm" className="mb-6 p-4 rounded-lg border text-sm whitespace-pre-wrap"
style={{ style={{
backgroundColor: "var(--error-bg)", backgroundColor: "var(--error-bg)",
borderColor: "var(--error-border)", borderColor: "var(--error-border)",
color: "var(--error-text)", color: "var(--error-text)",
}} }}
> >
{submitError} <div className="flex items-start gap-2">
<FiAlertCircle className="mt-0.5 flex-shrink-0" size={16} />
<span>{submitError}</span>
</div>
</div> </div>
)} )}
+730
View File
@@ -0,0 +1,730 @@
import React, { useState, useEffect, useCallback } from "react";
import { agentApiService } from "@/modules/agent";
import type { TokenUser, TokenCreate, TokenUpdatePermissions, TokenPasswordReset } from "@/modules/agent";
import { FiUsers, FiUserPlus, FiEdit2, FiTrash2, FiUnlock, FiLock, FiKey, FiX, FiCheck, FiSearch } from "react-icons/fi";
export const AdminPage: React.FC = () => {
const [users, setUsers] = useState<TokenUser[]>([]);
const [inactiveUsers, setInactiveUsers] = useState<TokenUser[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<"active" | "inactive">("active");
const [searchQuery, setSearchQuery] = useState("");
// Modal states
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showPasswordModal, setShowPasswordModal] = useState(false);
const [selectedUser, setSelectedUser] = useState<TokenUser | null>(null);
// Form states
const [createData, setCreateData] = useState<TokenCreate>({
login: "",
name: "",
last_name: "",
password: "",
permission_admin: false,
permission_manage_agent: false,
permission_view: false,
is_active: false,
});
const [editData, setEditData] = useState<TokenUpdatePermissions>({
is_active: false,
permission_admin: false,
permission_manage_agent: false,
permission_view: false,
});
const [passwordData, setPasswordData] = useState<TokenPasswordReset>({
new_password: "",
});
const fetchUsers = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const [active, inactive] = await Promise.all([
agentApiService.getUsers(),
agentApiService.getInactiveUsers(),
]);
setUsers(active);
setInactiveUsers(inactive);
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка при загрузке пользователей");
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
const handleCreateUser = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
try {
await agentApiService.createUser(createData);
setSuccessMessage("Пользователь успешно создан");
setShowCreateModal(false);
setCreateData({
login: "",
name: "",
last_name: "",
password: "",
permission_admin: false,
permission_manage_agent: false,
permission_view: false,
is_active: false,
});
await fetchUsers();
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка при создании пользователя");
}
};
const handleUpdatePermissions = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedUser) return;
setError(null);
try {
await agentApiService.updateUserPermissions(selectedUser.login, editData);
setSuccessMessage("Права пользователя обновлены");
setShowEditModal(false);
await fetchUsers();
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка при обновлении прав");
}
};
const handleResetPassword = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedUser) return;
setError(null);
try {
await agentApiService.resetUserPassword(selectedUser.login, passwordData);
setSuccessMessage("Пароль изменен");
setShowPasswordModal(false);
setPasswordData({ new_password: "" });
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка при сбросе пароля");
}
};
const handleActivateUser = async (login: string) => {
setError(null);
try {
await agentApiService.activateUser(login);
setSuccessMessage("Пользователь активирован");
await fetchUsers();
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка при активации пользователя");
}
};
const handleDeactivateUser = async (login: string) => {
setError(null);
try {
await agentApiService.deactivateUser(login);
setSuccessMessage("Пользователь деактивирован");
await fetchUsers();
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка при деактивации пользователя");
}
};
const handleDeleteUser = async (login: string) => {
if (!confirm(`Вы уверены, что хотите удалить пользователя ${login}?`)) return;
setError(null);
try {
await agentApiService.deleteUser(login);
setSuccessMessage("Пользователь удален");
await fetchUsers();
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка при удалении пользователя");
}
};
const openEditModal = (user: TokenUser) => {
setSelectedUser(user);
setEditData({
is_active: user.is_active,
permission_admin: user.permission_admin,
permission_manage_agent: user.permission_manage_agent,
permission_view: user.permission_view,
});
setShowEditModal(true);
};
const openPasswordModal = (user: TokenUser) => {
setSelectedUser(user);
setPasswordData({ new_password: "" });
setShowPasswordModal(true);
};
const filteredUsers = (activeTab === "active" ? users : inactiveUsers).filter(
(user) =>
user.login.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.last_name.toLowerCase().includes(searchQuery.toLowerCase())
);
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "10px 12px",
border: "1px solid var(--border)",
borderRadius: "8px",
backgroundColor: "var(--input-bg)",
color: "var(--text-primary)",
fontSize: "14px",
};
const labelStyle: React.CSSProperties = {
display: "block",
marginBottom: "8px",
color: "var(--text-secondary)",
fontSize: "14px",
fontWeight: 500,
};
const buttonBaseStyle: React.CSSProperties = {
padding: "8px 16px",
borderRadius: "8px",
border: "none",
fontSize: "14px",
fontWeight: 500,
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: "6px",
};
return (
<div
className="min-h-screen py-8 px-4"
style={{ backgroundColor: "var(--bg-primary)" }}
>
<div style={{ maxWidth: "1200px", margin: "0 auto" }}>
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-4 mb-4">
<div
className="w-14 h-14 rounded-xl flex items-center justify-center"
style={{ backgroundColor: "var(--bg-secondary)" }}
>
<FiUsers className="w-7 h-7" style={{ color: "var(--accent)" }} />
</div>
<div>
<h1
className="text-3xl font-bold mb-1"
style={{ color: "var(--text-primary)" }}
>
Управление пользователями
</h1>
<p style={{ color: "var(--text-secondary)", fontSize: "16px" }}>
Администрирование учетных записей
</p>
</div>
</div>
</div>
{/* Messages */}
{successMessage && (
<div
className="mb-6 p-4 rounded-lg border text-sm"
style={{
backgroundColor: "var(--success-bg)",
borderColor: "var(--success-border)",
color: "var(--success-text)",
}}
>
<div className="flex items-center justify-between">
<span>{successMessage}</span>
<button onClick={() => setSuccessMessage(null)} style={{ background: "none", border: "none", cursor: "pointer", color: "inherit" }}>
<FiX size={16} />
</button>
</div>
</div>
)}
{error && (
<div
className="mb-6 p-4 rounded-lg border text-sm"
style={{
backgroundColor: "var(--error-bg)",
borderColor: "var(--error-border)",
color: "var(--error-text)",
}}
>
<div className="flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} style={{ background: "none", border: "none", cursor: "pointer", color: "inherit" }}>
<FiX size={16} />
</button>
</div>
</div>
)}
{/* Tabs and Actions */}
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
{/* Tabs */}
<div className="flex gap-2">
<button
onClick={() => setActiveTab("active")}
className="px-4 py-2 rounded-lg border transition-all font-medium"
style={{
backgroundColor: activeTab === "active" ? "var(--accent)" : "var(--input-bg)",
color: activeTab === "active" ? "var(--accent-text)" : "var(--text-primary)",
borderColor: activeTab === "active" ? "var(--accent)" : "var(--border)",
}}
>
Активные ({users.length})
</button>
<button
onClick={() => setActiveTab("inactive")}
className="px-4 py-2 rounded-lg border transition-all font-medium"
style={{
backgroundColor: activeTab === "inactive" ? "var(--accent)" : "var(--input-bg)",
color: activeTab === "inactive" ? "var(--accent-text)" : "var(--text-primary)",
borderColor: activeTab === "inactive" ? "var(--accent)" : "var(--border)",
}}
>
Неактивные ({inactiveUsers.length})
</button>
</div>
{/* Create Button */}
<button
onClick={() => setShowCreateModal(true)}
className="flex items-center gap-2 px-4 py-2 rounded-lg transition-all font-medium"
style={{
backgroundColor: "var(--button-primary)",
color: "var(--button-primary-text)",
boxShadow: "0 4px 14px var(--shadow-color)",
}}
>
<FiUserPlus size={16} />
Создать пользователя
</button>
</div>
{/* Search */}
<div className="relative">
<FiSearch
style={{
position: "absolute",
left: "12px",
top: "50%",
transform: "translateY(-50%)",
color: "var(--text-muted)",
}}
/>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Поиск по логину, имени или фамилии..."
className="w-full pl-10 pr-4 py-2.5 rounded-lg border"
style={{
backgroundColor: "var(--input-bg)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
/>
</div>
</div>
{/* Users Table */}
{isLoading ? (
<div className="text-center py-12" style={{ color: "var(--text-secondary)" }}>
Загрузка...
</div>
) : filteredUsers.length === 0 ? (
<div
className="text-center py-12 rounded-xl border border-dashed"
style={{ color: "var(--text-muted)", borderColor: "var(--border)" }}
>
Пользователи не найдены
</div>
) : (
<div
className="rounded-xl border overflow-hidden"
style={{
backgroundColor: "var(--card-bg)",
borderColor: "var(--border)",
}}
>
<table className="w-full">
<thead>
<tr style={{ backgroundColor: "var(--bg-secondary)" }}>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider" style={{ color: "var(--text-secondary)" }}>Логин</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider" style={{ color: "var(--text-secondary)" }}>Имя</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider" style={{ color: "var(--text-secondary)" }}>Фамилия</th>
<th className="px-4 py-3 text-center text-xs font-semibold uppercase tracking-wider" style={{ color: "var(--text-secondary)" }}>Админ</th>
<th className="px-4 py-3 text-center text-xs font-semibold uppercase tracking-wider" style={{ color: "var(--text-secondary)" }}>Управление</th>
<th className="px-4 py-3 text-center text-xs font-semibold uppercase tracking-wider" style={{ color: "var(--text-secondary)" }}>Просмотр</th>
<th className="px-4 py-3 text-center text-xs font-semibold uppercase tracking-wider" style={{ color: "var(--text-secondary)" }}>Действия</th>
</tr>
</thead>
<tbody>
{filteredUsers.map((user, index) => (
<tr
key={user.id}
className="border-t"
style={{
borderColor: "var(--border)",
backgroundColor: index % 2 === 0 ? "var(--card-bg)" : "var(--bg-secondary)",
}}
>
<td className="px-4 py-3 font-mono text-sm" style={{ color: "var(--text-primary)" }}>{user.login}</td>
<td className="px-4 py-3 text-sm" style={{ color: "var(--text-primary)" }}>{user.name}</td>
<td className="px-4 py-3 text-sm" style={{ color: "var(--text-primary)" }}>{user.last_name}</td>
<td className="px-4 py-3 text-center">
{user.permission_admin ? (
<FiCheck style={{ color: "var(--success-text)", display: "inline" }} />
) : (
<FiX style={{ color: "var(--error-text)", display: "inline" }} />
)}
</td>
<td className="px-4 py-3 text-center">
{user.permission_manage_agent ? (
<FiCheck style={{ color: "var(--success-text)", display: "inline" }} />
) : (
<FiX style={{ color: "var(--error-text)", display: "inline" }} />
)}
</td>
<td className="px-4 py-3 text-center">
{user.permission_view ? (
<FiCheck style={{ color: "var(--success-text)", display: "inline" }} />
) : (
<FiX style={{ color: "var(--error-text)", display: "inline" }} />
)}
</td>
<td className="px-4 py-3 text-center">
<div className="flex items-center justify-center gap-2">
<button
onClick={() => openEditModal(user)}
className="p-2 rounded-lg transition-all"
style={{
backgroundColor: "var(--accent)",
color: "var(--accent-text)",
}}
title="Редактировать права"
>
<FiEdit2 size={14} />
</button>
<button
onClick={() => openPasswordModal(user)}
className="p-2 rounded-lg transition-all"
style={{
backgroundColor: "var(--warning-bg)",
color: "var(--warning-text)",
}}
title="Сбросить пароль"
>
<FiKey size={14} />
</button>
{activeTab === "active" ? (
<button
onClick={() => handleDeactivateUser(user.login)}
className="p-2 rounded-lg transition-all"
style={{
backgroundColor: "var(--warning-bg)",
color: "var(--warning-text)",
}}
title="Деактивировать"
>
<FiLock size={14} />
</button>
) : (
<button
onClick={() => handleActivateUser(user.login)}
className="p-2 rounded-lg transition-all"
style={{
backgroundColor: "var(--success-bg)",
color: "var(--success-text)",
border: "1px solid var(--success-border)",
}}
title="Активировать"
>
<FiUnlock size={14} />
</button>
)}
<button
onClick={() => handleDeleteUser(user.login)}
className="p-2 rounded-lg transition-all"
style={{
backgroundColor: "var(--error-bg)",
color: "var(--error-text)",
border: "1px solid var(--error-border)",
}}
title="Удалить"
>
<FiTrash2 size={14} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Create User Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div
className="rounded-2xl shadow-2xl border w-full max-w-md"
style={{ backgroundColor: "var(--card-bg)" }}
>
<div className="flex items-center justify-between p-6 border-b" style={{ borderColor: "var(--border)" }}>
<h2 className="text-xl font-bold" style={{ color: "var(--text-primary)" }}>Создать пользователя</h2>
<button onClick={() => setShowCreateModal(false)} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-secondary)" }}>
<FiX size={24} />
</button>
</div>
<form onSubmit={handleCreateUser} className="p-6 space-y-4">
<div>
<label style={labelStyle}>Логин *</label>
<input
type="text"
value={createData.login}
onChange={(e) => setCreateData({ ...createData, login: e.target.value })}
required
style={inputStyle}
/>
</div>
<div>
<label style={labelStyle}>Имя *</label>
<input
type="text"
value={createData.name}
onChange={(e) => setCreateData({ ...createData, name: e.target.value })}
required
style={inputStyle}
/>
</div>
<div>
<label style={labelStyle}>Фамилия *</label>
<input
type="text"
value={createData.last_name}
onChange={(e) => setCreateData({ ...createData, last_name: e.target.value })}
required
style={inputStyle}
/>
</div>
<div>
<label style={labelStyle}>Пароль *</label>
<input
type="password"
value={createData.password}
onChange={(e) => setCreateData({ ...createData, password: e.target.value })}
required
style={inputStyle}
/>
</div>
<div className="space-y-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={createData.permission_admin}
onChange={(e) => setCreateData({ ...createData, permission_admin: e.target.checked })}
/>
<span style={{ color: "var(--text-primary)" }}>Администратор</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={createData.permission_manage_agent}
onChange={(e) => setCreateData({ ...createData, permission_manage_agent: e.target.checked })}
/>
<span style={{ color: "var(--text-primary)" }}>Управление агентами</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={createData.permission_view}
onChange={(e) => setCreateData({ ...createData, permission_view: e.target.checked })}
/>
<span style={{ color: "var(--text-primary)" }}>Просмотр</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={createData.is_active}
onChange={(e) => setCreateData({ ...createData, is_active: e.target.checked })}
/>
<span style={{ color: "var(--text-primary)" }}>Активен сразу</span>
</label>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="flex-1 px-4 py-2 rounded-lg border transition-all"
style={{
backgroundColor: "var(--input-bg)",
color: "var(--text-primary)",
}}
>
Отмена
</button>
<button
type="submit"
className="flex-1 px-4 py-2 rounded-lg transition-all"
style={{
backgroundColor: "var(--button-primary)",
color: "var(--button-primary-text)",
}}
>
Создать
</button>
</div>
</form>
</div>
</div>
)}
{/* Edit Permissions Modal */}
{showEditModal && selectedUser && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div
className="rounded-2xl shadow-2xl border w-full max-w-md"
style={{ backgroundColor: "var(--card-bg)" }}
>
<div className="flex items-center justify-between p-6 border-b" style={{ borderColor: "var(--border)" }}>
<h2 className="text-xl font-bold" style={{ color: "var(--text-primary)" }}>
Редактировать: {selectedUser.login}
</h2>
<button onClick={() => setShowEditModal(false)} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-secondary)" }}>
<FiX size={24} />
</button>
</div>
<form onSubmit={handleUpdatePermissions} className="p-6 space-y-4">
<div className="space-y-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={editData.permission_admin || false}
onChange={(e) => setEditData({ ...editData, permission_admin: e.target.checked })}
/>
<span style={{ color: "var(--text-primary)" }}>Администратор</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={editData.permission_manage_agent || false}
onChange={(e) => setEditData({ ...editData, permission_manage_agent: e.target.checked })}
/>
<span style={{ color: "var(--text-primary)" }}>Управление агентами</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={editData.permission_view || false}
onChange={(e) => setEditData({ ...editData, permission_view: e.target.checked })}
/>
<span style={{ color: "var(--text-primary)" }}>Просмотр</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={editData.is_active || false}
onChange={(e) => setEditData({ ...editData, is_active: e.target.checked })}
/>
<span style={{ color: "var(--text-primary)" }}>Активен</span>
</label>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => setShowEditModal(false)}
className="flex-1 px-4 py-2 rounded-lg border transition-all"
style={{
backgroundColor: "var(--input-bg)",
color: "var(--text-primary)",
}}
>
Отмена
</button>
<button
type="submit"
className="flex-1 px-4 py-2 rounded-lg transition-all"
style={{
backgroundColor: "var(--button-primary)",
color: "var(--button-primary-text)",
}}
>
Сохранить
</button>
</div>
</form>
</div>
</div>
)}
{/* Reset Password Modal */}
{showPasswordModal && selectedUser && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div
className="rounded-2xl shadow-2xl border w-full max-w-md"
style={{ backgroundColor: "var(--card-bg)" }}
>
<div className="flex items-center justify-between p-6 border-b" style={{ borderColor: "var(--border)" }}>
<h2 className="text-xl font-bold" style={{ color: "var(--text-primary)" }}>
Сброс пароля: {selectedUser.login}
</h2>
<button onClick={() => setShowPasswordModal(false)} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-secondary)" }}>
<FiX size={24} />
</button>
</div>
<form onSubmit={handleResetPassword} className="p-6 space-y-4">
<div>
<label style={labelStyle}>Новый пароль *</label>
<input
type="password"
value={passwordData.new_password}
onChange={(e) => setPasswordData({ new_password: e.target.value })}
required
style={inputStyle}
/>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => setShowPasswordModal(false)}
className="flex-1 px-4 py-2 rounded-lg border transition-all"
style={{
backgroundColor: "var(--input-bg)",
color: "var(--text-primary)",
}}
>
Отмена
</button>
<button
type="submit"
className="flex-1 px-4 py-2 rounded-lg transition-all"
style={{
backgroundColor: "var(--button-primary)",
color: "var(--button-primary-text)",
}}
>
Сбросить
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
+15
View File
@@ -0,0 +1,15 @@
import { useLocation, useNavigate } from "react-router-dom";
import { IDE } from "../modules/ide";
import type { FileNode } from "../modules/ide";
export const IDEPage = () => {
const navigate = useNavigate();
const location = useLocation();
const files: FileNode | undefined = location.state?.files;
return (
<div className="absolute top-0 left-0 w-full h-full z-90">
<IDE onBack={() => navigate("/templates")} initialFiles={files} />
</div>
);
};
+295
View File
@@ -0,0 +1,295 @@
import React, { useState, useEffect, useCallback } from "react";
import { agentApiService } from "@/modules/agent";
import type { LogEntry } from "@/modules/agent";
import { LogFilters } from "@/modules/agent/ui/LogFilters";
import { useLogFilterStore } from "@/modules/agent/store/logFilter.store";
import {
FiFileText,
FiRefreshCw,
FiChevronLeft,
FiChevronRight,
FiInfo,
FiAlertTriangle,
FiAlertCircle,
FiXOctagon,
} from "react-icons/fi";
const logLevelIcons: Record<string, React.ReactNode> = {
INFO: <FiInfo size={14} />,
WARNING: <FiAlertTriangle size={14} />,
ERROR: <FiAlertCircle size={14} />,
FATAL: <FiXOctagon size={14} />,
};
const logLevelColors: Record<string, { bg: string; text: string; border: string }> = {
INFO: { bg: "var(--info-bg)", text: "var(--info-text)", border: "var(--info-border)" },
WARNING: { bg: "var(--warning-bg)", text: "var(--warning-text)", border: "var(--warning-border)" },
ERROR: { bg: "var(--error-bg)", text: "var(--error-text)", border: "var(--error-border)" },
FATAL: { bg: "var(--fatal-bg)", text: "var(--fatal-text)", border: "var(--fatal-border)" },
};
export const LogsPage: React.FC = () => {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [availableServices, setAvailableServices] = useState<string[]>([]);
const [availableAgents, setAvailableAgents] = useState<string[]>([]);
const [totalLogs, setTotalLogs] = useState(0);
const { getFilters, limit, offset, setOffset } = useLogFilterStore();
const fetchLogs = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const filters = getFilters();
const data = await agentApiService.searchLogs(filters);
setLogs(data);
setTotalLogs(data.length);
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка при загрузке логов");
} finally {
setIsLoading(false);
}
}, [getFilters]);
const fetchDistinctData = useCallback(async () => {
try {
const [services, agents] = await Promise.all([
agentApiService.getDistinctServices(),
agentApiService.getDistinctAgents(),
]);
setAvailableServices(services);
setAvailableAgents(agents);
} catch (err) {
console.error("Failed to fetch distinct data:", err);
}
}, []);
useEffect(() => {
fetchDistinctData();
}, [fetchDistinctData]);
useEffect(() => {
fetchLogs();
}, [fetchLogs, offset, limit]);
const handleFilterApply = () => {
setOffset(0);
fetchLogs();
};
const handleNextPage = () => {
setOffset(offset + limit);
};
const handlePrevPage = () => {
setOffset(Math.max(0, offset - limit));
};
const formatTimestamp = (timestamp: string) => {
const date = new Date(timestamp);
return date.toLocaleString("ru-RU", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
};
return (
<div
className="min-h-screen py-8 px-4"
style={{ backgroundColor: "var(--bg-primary)" }}
>
<div style={{ maxWidth: "1400px", margin: "0 auto" }}>
{/* Header */}
<div className="mb-6">
<div className="flex items-center gap-4 mb-4">
<div
className="w-14 h-14 rounded-xl flex items-center justify-center"
style={{ backgroundColor: "var(--bg-secondary)" }}
>
<FiFileText className="w-7 h-7" style={{ color: "var(--accent)" }} />
</div>
<div>
<h1
className="text-3xl font-bold mb-1"
style={{ color: "var(--text-primary)" }}
>
Поиск логов
</h1>
<p style={{ color: "var(--text-secondary)", fontSize: "16px" }}>
Фильтрация и анализ логов системы
</p>
</div>
</div>
</div>
{/* Filters */}
<div className="mb-6">
<LogFilters
onApply={handleFilterApply}
availableServices={availableServices}
availableAgents={availableAgents}
/>
</div>
{/* Error Message */}
{error && (
<div
className="mb-6 p-4 rounded-lg border text-sm"
style={{
backgroundColor: "var(--error-bg)",
borderColor: "var(--error-border)",
color: "var(--error-text)",
}}
>
{error}
</div>
)}
{/* Logs Table */}
<div
className="rounded-xl border overflow-hidden"
style={{
backgroundColor: "var(--card-bg)",
borderColor: "var(--border)",
}}
>
{/* Table Header */}
<div className="flex items-center justify-between px-4 py-3 border-b" style={{ borderColor: "var(--border)" }}>
<span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>
Найдено: {totalLogs} записей
</span>
<button
onClick={fetchLogs}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all text-xs font-medium border"
style={{
backgroundColor: "transparent",
color: "var(--accent)",
borderColor: "var(--border)",
}}
>
<FiRefreshCw size={12} className={isLoading ? "animate-spin" : ""} />
Обновить
</button>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12" style={{ color: "var(--text-secondary)" }}>
<FiRefreshCw size={24} className="animate-spin mr-3" />
Загрузка логов...
</div>
) : logs.length === 0 ? (
<div className="text-center py-12" style={{ color: "var(--text-muted)" }}>
Логи не найдены
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr style={{ backgroundColor: "var(--bg-secondary)" }}>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider" style={{ color: "var(--text-secondary)" }}>
Время
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider" style={{ color: "var(--text-secondary)" }}>
Уровень
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider" style={{ color: "var(--text-secondary)" }}>
Сервис
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider" style={{ color: "var(--text-secondary)" }}>
Агент
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider" style={{ color: "var(--text-secondary)" }}>
Сообщение
</th>
</tr>
</thead>
<tbody>
{logs.map((log, index) => {
const colors = logLevelColors[log.level] || logLevelColors.INFO;
return (
<tr
key={index}
className="border-t"
style={{
borderColor: "var(--border)",
backgroundColor: index % 2 === 0 ? "var(--card-bg)" : "var(--bg-secondary)",
}}
>
<td className="px-4 py-3 text-sm font-mono whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
{formatTimestamp(log.timestamp)}
</td>
<td className="px-4 py-3">
<span
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium border"
style={{
backgroundColor: colors.bg,
color: colors.text,
borderColor: colors.border,
}}
>
{logLevelIcons[log.level]}
{log.level}
</span>
</td>
<td className="px-4 py-3 text-sm" style={{ color: "var(--text-primary)" }}>
{log.service}
</td>
<td className="px-4 py-3 text-sm font-mono" style={{ color: "var(--text-primary)" }}>
{log.agent}
</td>
<td className="px-4 py-3 text-sm" style={{ color: "var(--text-primary)" }}>
{log.message}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between px-4 py-3 border-t" style={{ borderColor: "var(--border)" }}>
<button
onClick={handlePrevPage}
disabled={offset === 0}
className="flex items-center gap-2 px-4 py-2 rounded-lg transition-all text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed border"
style={{
backgroundColor: "var(--input-bg)",
color: "var(--text-primary)",
borderColor: "var(--border)",
}}
>
<FiChevronLeft size={16} />
Назад
</button>
<span className="text-sm" style={{ color: "var(--text-secondary)" }}>
Показано {logs.length} записей (смещение: {offset})
</span>
<button
onClick={handleNextPage}
disabled={logs.length < limit}
className="flex items-center gap-2 px-4 py-2 rounded-lg transition-all text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed border"
style={{
backgroundColor: "var(--input-bg)",
color: "var(--text-primary)",
borderColor: "var(--border)",
}}
>
Далее
<FiChevronRight size={16} />
</button>
</div>
</>
)}
</div>
</div>
</div>
);
};
+43 -12
View File
@@ -5,7 +5,7 @@ import { useAuthStore } from "@/modules/auth/store/useAuthStore";
export const RegisterPage: React.FC = () => { export const RegisterPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { register, isLoading, error, clearError, token } = useAuthStore(); const { register, isLoading, error, clearError } = useAuthStore();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
login: "", login: "",
password: "", password: "",
@@ -14,12 +14,7 @@ export const RegisterPage: React.FC = () => {
lastName: "", lastName: "",
}); });
const [passwordError, setPasswordError] = useState<string | null>(null); const [passwordError, setPasswordError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
useEffect(() => {
if (token) {
navigate("/");
}
}, [token, navigate]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -38,7 +33,17 @@ export const RegisterPage: React.FC = () => {
firstName: formData.firstName, firstName: formData.firstName,
lastName: formData.lastName, lastName: formData.lastName,
}); });
navigate("/"); setSuccessMessage("Аккаунт успешно создан! Теперь вы можете войти.");
setFormData({
login: "",
password: "",
confirmPassword: "",
firstName: "",
lastName: "",
});
setTimeout(() => {
navigate("/auth");
}, 2000);
} catch (err) { } catch (err) {
// Error is handled by store // Error is handled by store
} }
@@ -82,7 +87,10 @@ export const RegisterPage: React.FC = () => {
className="w-16 h-16 mx-auto mb-4 rounded-full flex items-center justify-center" className="w-16 h-16 mx-auto mb-4 rounded-full flex items-center justify-center"
style={{ backgroundColor: "var(--bg-secondary)" }} style={{ backgroundColor: "var(--bg-secondary)" }}
> >
<FiUserPlus className="w-8 h-8" style={{ color: "var(--accent)" }} /> <FiUserPlus
className="w-8 h-8"
style={{ color: "var(--accent)" }}
/>
</div> </div>
<h1 <h1
className="text-3xl font-bold mb-2" className="text-3xl font-bold mb-2"
@@ -109,6 +117,20 @@ export const RegisterPage: React.FC = () => {
</div> </div>
)} )}
{/* Success Message */}
{successMessage && (
<div
className="mb-6 p-4 rounded-lg border text-sm"
style={{
backgroundColor: "var(--success-bg)",
borderColor: "var(--success-border)",
color: "var(--success-text)",
}}
>
{successMessage}
</div>
)}
{/* Form */} {/* Form */}
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{/* Name Fields */} {/* Name Fields */}
@@ -293,8 +315,16 @@ export const RegisterPage: React.FC = () => {
className="mt-2 text-sm flex items-center gap-1" className="mt-2 text-sm flex items-center gap-1"
style={{ color: "var(--error-text)" }} style={{ color: "var(--error-text)" }}
> >
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"> <svg
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" /> className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg> </svg>
{passwordError} {passwordError}
</p> </p>
@@ -311,7 +341,8 @@ export const RegisterPage: React.FC = () => {
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
if (!isLoading) { if (!isLoading) {
e.currentTarget.style.backgroundColor = "var(--button-primary-hover)"; e.currentTarget.style.backgroundColor =
"var(--button-primary-hover)";
} }
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
+423
View File
@@ -0,0 +1,423 @@
import React, { useState } from "react";
import { agentApiService } from "@/modules/agent/api/agent.api.service";
import { FiKey, FiPlus, FiTrash2, FiCopy, FiCheck, FiX } from "react-icons/fi";
interface RegistrationTokenForm {
label: string;
}
interface RegistrationResult {
label: string;
token: string;
}
export const RegistrationTokenPage: React.FC = () => {
const [tokens, setTokens] = useState<RegistrationTokenForm[]>([
{ label: "" },
]);
const [results, setResults] = useState<RegistrationResult[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const handleTokenChange = (index: number, label: string) => {
const newTokens = [...tokens];
newTokens[index] = { label };
setTokens(newTokens);
};
const handleAddToken = () => {
setTokens([...tokens, { label: "" }]);
};
const handleRemoveToken = (index: number) => {
const newTokens = tokens.filter((_, i) => i !== index);
setTokens(newTokens);
};
const handleCopyToken = async (token: string, index: number) => {
await navigator.clipboard.writeText(token);
setCopiedIndex(index);
setTimeout(() => setCopiedIndex(null), 2000);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Валидация
const validTokens = tokens.filter((t) => t.label.trim());
if (validTokens.length === 0) {
setError("Введите хотя бы одну метку для токена");
return;
}
setIsSubmitting(true);
setError(null);
setSuccessMessage(null);
setResults([]);
try {
const createdTokens: RegistrationResult[] = [];
for (const tokenData of validTokens) {
const response = await agentApiService.createRegistrationToken({
label: tokenData.label,
});
// API возвращает объект с токеном
const token = response.token || Object.values(response)[0] as string;
createdTokens.push({
label: tokenData.label,
token,
});
}
setResults(createdTokens);
setSuccessMessage(
`Успешно создано ${createdTokens.length} токен(ов)`
);
setTokens([{ label: "" }]);
} catch (err) {
setError(
err instanceof Error
? err.message
: "Ошибка при создании токенов"
);
} finally {
setIsSubmitting(false);
}
};
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "10px 12px",
border: "1px solid var(--border)",
borderRadius: "8px",
backgroundColor: "var(--input-bg)",
color: "var(--text-primary)",
fontSize: "14px",
transition: "border-color 0.2s, box-shadow 0.2s",
};
const labelStyle: React.CSSProperties = {
display: "block",
marginBottom: "8px",
color: "var(--text-secondary)",
fontSize: "14px",
fontWeight: 500,
};
return (
<div
className="min-h-screen py-8 px-4"
style={{ backgroundColor: "var(--bg-primary)" }}
>
<div style={{ maxWidth: "900px", margin: "0 auto" }}>
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-4 mb-4">
<div
className="w-14 h-14 rounded-xl flex items-center justify-center"
style={{ backgroundColor: "var(--bg-secondary)" }}
>
<FiKey className="w-7 h-7" style={{ color: "var(--accent)" }} />
</div>
<div>
<h1
className="text-3xl font-bold mb-1"
style={{ color: "var(--text-primary)" }}
>
Регистрация токенов для агентов
</h1>
<p style={{ color: "var(--text-secondary)", fontSize: "16px" }}>
Создайте токены для регистрации новых агентов
</p>
</div>
</div>
</div>
<form onSubmit={handleSubmit}>
{/* Token Forms */}
<div className="space-y-5">
{tokens.map((token, index) => (
<div
key={index}
className="rounded-2xl shadow-lg border"
style={{
backgroundColor: "var(--card-bg)",
borderColor: "var(--border)",
padding: "24px",
marginBottom: "20px",
}}
>
{/* Header */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "20px",
paddingBottom: "16px",
borderBottom: "1px solid var(--border)",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<div
className="w-10 h-10 rounded-lg flex items-center justify-center"
style={{ backgroundColor: "var(--bg-secondary)" }}
>
<FiKey style={{ color: "var(--accent)", fontSize: "20px" }} />
</div>
<h3
style={{
margin: 0,
color: "var(--text-primary)",
fontSize: "18px",
fontWeight: 600,
}}
>
Токен #{index + 1}
</h3>
</div>
{tokens.length > 1 && (
<button
type="button"
onClick={() => handleRemoveToken(index)}
className="flex items-center gap-2 px-4 py-2 rounded-lg transition-all"
style={{
background: "var(--error-bg)",
color: "var(--error-text)",
border: "1px solid var(--error-border)",
cursor: "pointer",
fontSize: "14px",
fontWeight: 500,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--error-text)";
e.currentTarget.style.color = "#fff";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "var(--error-bg)";
e.currentTarget.style.color = "var(--error-text)";
}}
>
<FiTrash2 size={14} />
Удалить
</button>
)}
</div>
{/* Label Input */}
<div>
<label style={labelStyle}>
<span
style={{ display: "flex", alignItems: "center", gap: "6px" }}
>
<FiKey size={14} />
Метка токена *
</span>
</label>
<input
type="text"
value={token.label}
onChange={(e) => handleTokenChange(index, e.target.value)}
required
style={inputStyle}
onFocus={(e) => {
e.currentTarget.style.borderColor = "var(--border-focus)";
e.currentTarget.style.boxShadow = `0 0 0 3px var(--border-focus)30`;
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = "var(--border)";
e.currentTarget.style.boxShadow = "none";
}}
placeholder="agent-production-1"
/>
</div>
</div>
))}
</div>
{/* Add Token Button */}
<button
type="button"
onClick={handleAddToken}
className="w-full flex items-center justify-center gap-2 py-3.5 px-4 rounded-xl border-2 border-dashed transition-all mb-6 font-medium"
style={{
borderColor: "var(--border)",
backgroundColor: "transparent",
color: "var(--accent)",
fontSize: "15px",
cursor: "pointer",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "var(--accent)";
e.currentTarget.style.backgroundColor = "var(--accent)10";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "var(--border)";
e.currentTarget.style.backgroundColor = "transparent";
}}
>
<FiPlus size={18} />
Добавить ещё один токен
</button>
{/* Messages */}
{successMessage && (
<div
className="mb-6 p-4 rounded-lg border text-sm"
style={{
backgroundColor: "var(--success-bg)",
borderColor: "var(--success-border)",
color: "var(--success-text)",
}}
>
<div className="flex items-center justify-between">
<span>{successMessage}</span>
<button
onClick={() => setSuccessMessage(null)}
style={{ background: "none", border: "none", cursor: "pointer", color: "inherit" }}
>
<FiX size={16} />
</button>
</div>
</div>
)}
{error && (
<div
className="mb-6 p-4 rounded-lg border text-sm"
style={{
backgroundColor: "var(--error-bg)",
borderColor: "var(--error-border)",
color: "var(--error-text)",
}}
>
<div className="flex items-center justify-between">
<span>{error}</span>
<button
onClick={() => setError(null)}
style={{ background: "none", border: "none", cursor: "pointer", color: "inherit" }}
>
<FiX size={16} />
</button>
</div>
</div>
)}
{/* Submit Button */}
<button
type="submit"
disabled={isSubmitting}
className="w-full flex items-center justify-center gap-2 px-4 py-3.5 rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed font-medium text-base mb-8"
style={{
backgroundColor: isSubmitting
? "var(--bg-secondary)"
: "var(--button-primary)",
color: "var(--button-primary-text)",
boxShadow: isSubmitting
? "none"
: "0 4px 14px var(--shadow-color)",
}}
onMouseEnter={(e) => {
if (!isSubmitting) {
e.currentTarget.style.backgroundColor =
"var(--button-primary-hover)";
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = isSubmitting
? "var(--bg-secondary)"
: "var(--button-primary)";
}}
>
{isSubmitting ? (
<>
<div className="w-5 h-5 border-2 border-current border-t-transparent rounded-full animate-spin" />
Создание токенов...
</>
) : (
<>
<FiKey size={18} />
Создать {tokens.filter((t) => t.label.trim()).length || 1} токен(ов)
</>
)}
</button>
{/* Results */}
{results.length > 0 && (
<div>
<h2
className="text-xl font-bold mb-4"
style={{ color: "var(--text-primary)" }}
>
Созданные токены
</h2>
<div className="space-y-4">
{results.map((result, index) => (
<div
key={index}
className="rounded-xl border p-4"
style={{
backgroundColor: "var(--card-bg)",
borderColor: "var(--border)",
}}
>
<div className="flex items-center justify-between mb-3">
<span
className="font-medium"
style={{ color: "var(--text-primary)" }}
>
{result.label}
</span>
<button
type="button"
onClick={() => handleCopyToken(result.token, index)}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all text-xs font-medium"
style={{
backgroundColor:
copiedIndex === index
? "var(--success-text)"
: "var(--accent)",
color:
copiedIndex === index
? "#fff"
: "var(--accent-text)",
}}
>
{copiedIndex === index ? (
<>
<FiCheck size={12} />
Скопировано
</>
) : (
<>
<FiCopy size={12} />
Копировать
</>
)}
</button>
</div>
<code
className="block p-3 rounded-lg text-xs font-mono break-all"
style={{
backgroundColor: "var(--bg-secondary)",
color: "var(--accent)",
border: "1px solid var(--border)",
}}
>
{result.token}
</code>
</div>
))}
</div>
</div>
)}
</form>
</div>
</div>
);
};
+189
View File
@@ -0,0 +1,189 @@
import { useNavigate } from "react-router-dom";
import { FiEdit3, FiPlay } from "react-icons/fi";
import { FilePicker, useFilePickerStore } from "../modules/ide";
import type { FileNode } from "../modules/ide";
const mockFiles: FileNode = {
name: "templates",
type: "folder",
children: [
{
name: "python-basic",
type: "folder",
children: [
{
name: "src",
type: "folder",
children: [
{
name: "main.py",
type: "file",
content:
'print("Hello, World!")\n\ndef main():\n print("Welcome!")\n\nif __name__ == "__main__":\n main()',
},
{
name: "utils.py",
type: "file",
content: "def helper():\n return 42",
},
],
},
{
name: "README.md",
type: "file",
content: "# Python Project\n\nA basic Python project.",
},
],
},
{
name: "react-starter",
type: "folder",
children: [
{
name: "src",
type: "folder",
children: [
{
name: "App.tsx",
type: "file",
content:
'import React from "react";\n\nexport const App: React.FC = () => {\n return <div>Hello React!</div>;\n};',
},
{
name: "index.tsx",
type: "file",
content:
'import React from "react";\nimport { createRoot } from "react-dom/client";\nimport { App } from "./App";\n\ncreateRoot(document.getElementById("root")!).render(<App />);',
},
],
},
{
name: "package.json",
type: "file",
content: '{\n "name": "react-project",\n "version": "1.0.0"\n}',
},
],
},
{
name: "node-api",
type: "folder",
children: [
{
name: "src",
type: "folder",
children: [
{
name: "index.js",
type: "file",
content:
'const express = require("express");\nconst app = express();\nconst PORT = 3000;\n\napp.get("/", (req, res) => {\n res.json({ message: "Hello!" });\n});\n\napp.listen(PORT, () => {\n console.log(`Server running on port ${PORT}`);\n});',
},
],
},
{
name: "package.json",
type: "file",
content:
'{\n "name": "api-project",\n "dependencies": {\n "express": "^4.18.0"\n }\n}',
},
],
},
{
name: "html-css",
type: "folder",
children: [
{
name: "index.html",
type: "file",
content:
'<!DOCTYPE html>\n<html>\n<head>\n <title>My Landing</title>\n <link rel="stylesheet" href="styles.css">\n</head>\n<body>\n <h1>Welcome!</h1>\n</body>\n</html>',
},
{
name: "styles.css",
type: "file",
content:
"body {\n font-family: sans-serif;\n margin: 0;\n padding: 2rem;\n background: #f5f5f5;\n}\n\nh1 {\n color: #333;\n}",
},
],
},
],
};
export const TemplatesPage = () => {
const navigate = useNavigate();
const selectedPaths = useFilePickerStore((s) => s.selectedPaths);
return (
<div
style={{
height: "100vh",
position: "relative",
}}
>
{/* Floating header */}
<div
style={{
position: "absolute",
top: "16px",
right: "16px",
zIndex: 10,
display: "flex",
alignItems: "center",
gap: "16px",
}}
>
{/* Running scripts counter */}
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
padding: "6px 12px",
backgroundColor: "#1a1a1a",
borderRadius: "4px",
border: "1px solid #2a2a2a",
}}
>
<FiPlay size={13} color="#61c454" />
<span style={{ fontSize: "12px", color: "#858585" }}>
{selectedPaths.size} script{selectedPaths.size !== 1 ? "s" : ""}{" "}
running
</span>
</div>
{/* Open in Editor button */}
<button
onClick={() => navigate("/ide")}
style={{
display: "flex",
alignItems: "center",
gap: "8px",
padding: "6px 16px",
backgroundColor: "#0e639c",
border: "none",
borderRadius: "4px",
color: "#ffffff",
cursor: "pointer",
fontSize: "12px",
fontWeight: 500,
transition: "all 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#1177bb";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "#0e639c";
}}
>
<FiEdit3 size={14} />
Open Editor
</button>
</div>
{/* File Picker */}
<div style={{ height: "100%", overflow: "hidden" }}>
<FilePicker files={mockFiles} />
</div>
</div>
);
};
File diff suppressed because it is too large Load Diff
+534
View File
@@ -0,0 +1,534 @@
import React, { useRef, useState, useEffect } from "react";
import ForceGraph2D from "react-force-graph-2d";
import {
FiDownload,
FiZoomIn,
FiZoomOut,
FiMove,
FiCpu,
FiServer,
FiPlus,
FiTrash2,
FiLink,
FiMinusCircle,
} from "react-icons/fi";
interface GraphNode {
id: string;
name: string;
type: "agent" | "service";
val?: number;
description?: string;
x?: number;
y?: number;
}
interface GraphLink {
source: string;
target: string;
type?: string;
}
export interface GraphData {
nodes: GraphNode[];
links: GraphLink[];
}
interface CustomGraphProps {
data: GraphData;
onExport?: () => void;
onDataChange?: (newData: GraphData) => void;
}
export const Test2Page: React.FC<CustomGraphProps> = ({
data: initialData,
onExport,
onDataChange,
}) => {
const fgRef = useRef<any>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [data, setData] = useState<GraphData>(initialData);
const [highlightNodes, setHighlightNodes] = useState<Set<string>>(new Set());
const [highlightLinks, setHighlightLinks] = useState<Set<any>>(new Set());
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
const [isLinkMode, setIsLinkMode] = useState(false);
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
node: GraphNode | null;
link: GraphLink | null;
} | null>(null);
useEffect(() => {
if (initialData) setData(initialData);
}, [initialData]);
// Отслеживаем размеры контейнера через ResizeObserver
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const updateDimensions = () => {
setDimensions({
width: container.clientWidth,
height: container.clientHeight || window.innerHeight - 160,
});
};
updateDimensions();
const observer = new ResizeObserver(updateDimensions);
observer.observe(container);
return () => observer.disconnect();
}, []);
// Функция для подсветки связанных элементов
const handleNodeHover = (node: GraphNode | null) => {
const newHighlightNodes = new Set<string>();
const newHighlightLinks = new Set<any>();
if (node) {
newHighlightNodes.add(node.id);
data.links.forEach((link) => {
if (link.source === node.id || link.target === node.id) {
newHighlightLinks.add(link);
newHighlightNodes.add(link.source as string);
newHighlightNodes.add(link.target as string);
}
});
}
setHighlightNodes(newHighlightNodes);
setHighlightLinks(newHighlightLinks);
};
// Обработчик клика по узлу для создания связей
const handleNodeClick = (node: GraphNode) => {
if (isLinkMode) {
if (selectedNode === null) {
setSelectedNode(node);
} else if (selectedNode.id !== node.id) {
const newLink: GraphLink = {
source: selectedNode.id,
target: node.id,
type: "custom",
};
const linkExists = data.links.some(
(link) =>
(link.source === selectedNode.id && link.target === node.id) ||
(link.source === node.id && link.target === selectedNode.id),
);
if (!linkExists) {
const newData = {
nodes: [...data.nodes],
links: [...data.links, newLink],
};
setData(newData);
onDataChange?.(newData);
}
setSelectedNode(null);
setIsLinkMode(false);
} else {
setSelectedNode(null);
}
}
};
// УДАЛЕНИЕ СВЯЗИ
const handleDeleteLink = (linkToDelete: GraphLink) => {
const filteredLinks = data.links.filter((link) => link !== linkToDelete);
const newData = {
nodes: [...data.nodes],
links: filteredLinks,
};
setData(newData);
onDataChange?.(newData);
setContextMenu(null);
};
// УДАЛЕНИЕ УЗЛА
const handleDeleteNode = (nodeToDelete: GraphNode) => {
const filteredNodes = data.nodes.filter(
(node) => node.id !== nodeToDelete.id,
);
const filteredLinks = data.links.filter(
(link) =>
link.source !== nodeToDelete.id && link.target !== nodeToDelete.id,
);
const newData = {
nodes: filteredNodes,
links: filteredLinks,
};
setData(newData);
onDataChange?.(newData);
setContextMenu(null);
};
// Добавление нового узла
const handleAddNode = () => {
const newNodeName = prompt(
"Введите имя узла:",
`Node ${data.nodes.length + 1}`,
);
if (newNodeName) {
const isService = window.confirm(
"Выберите тип: OK - Сервис, Отмена - Агент",
);
const newNode: GraphNode = {
id: `node-${Date.now()}`,
name: newNodeName,
type: isService ? "service" : "agent",
val: isService ? 12 : 8,
description: "Новый узел",
};
const newData = {
nodes: [...data.nodes, newNode],
links: [...data.links],
};
setData(newData);
onDataChange?.(newData);
}
};
// Открытие контекстного меню
const openContextMenu = (
e: React.MouseEvent,
node?: GraphNode,
link?: GraphLink,
) => {
e.preventDefault();
e.stopPropagation();
if (node) {
setContextMenu({ x: e.clientX, y: e.clientY, node, link: null });
} else if (link) {
setContextMenu({ x: e.clientX, y: e.clientY, node: null, link });
}
};
// Закрыть контекстное меню
useEffect(() => {
const handleClickOutside = () => setContextMenu(null);
document.addEventListener("click", handleClickOutside);
return () => document.removeEventListener("click", handleClickOutside);
}, []);
// Функция для определения цвета узла
const getNodeColor = (node: GraphNode) => {
if (highlightNodes.has(node.id)) return "#fbbf24";
if (selectedNode?.id === node.id && isLinkMode) return "#f97316";
switch (node.type) {
case "service":
return "#3b82f6";
case "agent":
return "#8b5cf6";
default:
return "#6b7280";
}
};
// Функция для размера узла
const getNodeSize = (node: GraphNode) => {
switch (node.type) {
case "service":
return 3;
case "agent":
return 3;
default:
return 5;
}
};
// Кастомный рендер узла
const renderNode = (
node: GraphNode,
ctx: CanvasRenderingContext2D,
globalScale: number,
) => {
const size = getNodeSize(node);
const color = getNodeColor(node);
if (!node.x || !node.y) return;
ctx.beginPath();
ctx.arc(node.x, node.y, size, 0, 2 * Math.PI);
ctx.fillStyle = color;
ctx.fill();
ctx.fillStyle = "#ffffff";
ctx.font = `${size}px "Segoe UI Emoji", "Apple Color Emoji", sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
if (node.type === "service") {
ctx.fillText("S", node.x, node.y);
} else if (node.type === "agent") {
ctx.fillText("A", node.x, node.y);
}
if (globalScale > 0.5) {
ctx.fillStyle = "#e5e7eb";
ctx.font = `${Math.min(12, 12 / globalScale)}px "Arial", sans-serif`;
ctx.textAlign = "center";
ctx.fillText(node.name, node.x, node.y + size + 8);
}
};
const handleExport = () => {
if (onExport) {
onExport();
} else {
const dataStr = JSON.stringify(data, null, 2);
const blob = new Blob([dataStr], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "graph-data.json";
link.click();
URL.revokeObjectURL(url);
}
};
const handleZoomIn = () => {
if (fgRef.current) {
const currentZoom = fgRef.current.zoom();
fgRef.current.zoom(currentZoom * 1.2);
}
};
const handleZoomOut = () => {
if (fgRef.current) {
const currentZoom = fgRef.current.zoom();
fgRef.current.zoom(currentZoom / 1.2);
}
};
const handleFit = () => {
if (fgRef.current) {
fgRef.current.zoomToFit(400);
}
};
if (!data || data.nodes.length === 0) {
return (
<div className="bg-gray-900 rounded-xl shadow-lg p-6">
<div className="flex items-center justify-center h-96">
<div className="text-center">
<p className="text-gray-400 mb-4">Нет данных для отображения</p>
<button
onClick={handleAddNode}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg transition-colors text-white mx-auto"
>
<FiPlus /> Добавить первый узел
</button>
</div>
</div>
</div>
);
}
return (
<div className="bg-gray-900 rounded-xl shadow-lg p-6">
<div
ref={containerRef}
className="graph-container border border-gray-800 rounded-lg overflow-hidden relative"
style={{
height: "calc(100vh - 200px)",
position: "relative",
width: "100%",
}}
>
<ForceGraph2D
ref={fgRef}
graphData={data}
width={dimensions.width}
height={dimensions.height}
nodeCanvasObject={renderNode}
nodeLabel={(node: GraphNode) => {
return `${node.name}\n${node.description || ""}\n${node.type === "service" ? "Сервис" : "Агент"}\nПКМ для удаления`;
}}
linkLabel={(link: GraphLink) => {
// ВОЗВРАЩАЕМ СТРОКУ
const sourceName =
data.nodes.find((n) => n.id === link.source)?.name || link.source;
const targetName =
data.nodes.find((n) => n.id === link.target)?.name || link.target;
return `Связь: ${sourceName}${targetName}\nПКМ для удаления`;
}}
linkColor={(link: any) => {
return highlightLinks.has(link) ? "#fbbf24" : "#4b5563";
}}
linkWidth={(link: any) => (highlightLinks.has(link) ? 3 : 1.5)}
linkDirectionalParticles={0}
onNodeClick={handleNodeClick}
onNodeRightClick={(node, event) =>
openContextMenu(event as any, node, undefined)
}
onLinkRightClick={(link, event) =>
openContextMenu(event as any, undefined, link)
}
onNodeHover={handleNodeHover}
cooldownTicks={50}
cooldownTime={2000}
d3AlphaDecay={0.03}
d3VelocityDecay={0.4}
warmupTicks={50}
onEngineStop={() => {
if (fgRef.current) {
setTimeout(() => {
if (fgRef.current) {
fgRef.current.zoomToFit(400);
}
}, 100);
}
}}
/>
{contextMenu && (
<div
className="fixed bg-gray-800 rounded-lg shadow-lg border border-gray-700 py-1 z-50"
style={{ top: contextMenu.y, left: contextMenu.x }}
onClick={(e) => e.stopPropagation()}
>
{contextMenu.node && (
<>
<div className="px-3 py-1 text-xs text-gray-400 border-b border-gray-700">
{contextMenu.node.name}
</div>
<button
onClick={() => {
setIsLinkMode(true);
setSelectedNode(contextMenu.node);
setContextMenu(null);
}}
className="w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 flex items-center gap-2"
>
<FiLink size={14} /> Создать связь
</button>
<button
onClick={() => handleDeleteNode(contextMenu.node!)}
className="w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-gray-700 flex items-center gap-2"
>
<FiTrash2 size={14} /> Удалить узел
</button>
</>
)}
{contextMenu.link && (
<>
<div className="px-3 py-1 text-xs text-gray-400 border-b border-gray-700">
Связь:{" "}
{typeof contextMenu.link.source === "string"
? contextMenu.link.source
: (contextMenu.link.source as any).name ||
(contextMenu.link.source as any).id}{" "}
{" "}
{typeof contextMenu.link.target === "string"
? contextMenu.link.target
: (contextMenu.link.target as any).name ||
(contextMenu.link.target as any).id}
</div>
<button
onClick={() => handleDeleteLink(contextMenu.link!)}
className="w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-gray-700 flex items-center gap-2"
>
<FiMinusCircle size={14} /> Удалить связь
</button>
</>
)}
</div>
)}
{isLinkMode && (
<div className="absolute bottom-4 left-4 bg-green-600 text-white px-3 py-1 rounded-lg text-sm flex items-center gap-2">
<FiLink /> Режим создания связей: кликните на два узла для
соединения
{selectedNode && (
<span className="ml-2">Выбран: {selectedNode.name}</span>
)}
</div>
)}
</div>
<div className="mt-4 text-sm text-gray-500 flex justify-between items-center">
<div className="flex gap-6">
<div className="flex items-center gap-2">
<FiServer className="text-gray-400" />
<span>
Сервисы: {data.nodes.filter((n) => n.type === "service").length}
</span>
</div>
<div className="flex items-center gap-2">
<FiCpu className="text-gray-400" />
<span>
Агенты: {data.nodes.filter((n) => n.type === "agent").length}
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-gray-500 rounded-sm"></div>
<span>Связи: {data.links.length}</span>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => {
setIsLinkMode(!isLinkMode);
setSelectedNode(null);
}}
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors ${
isLinkMode
? "bg-green-600 hover:bg-green-700 text-white"
: "bg-gray-800 hover:bg-gray-700 text-gray-300"
}`}
>
<FiLink />
<span className="text-sm">
{isLinkMode ? "Создание связи..." : "Добавить связь"}
</span>
</button>
<button
onClick={handleAddNode}
className="flex items-center gap-2 px-3 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg transition-colors text-gray-300"
>
<FiPlus />
<span className="text-sm">Узел</span>
</button>
<button
onClick={handleZoomIn}
className="p-2 bg-gray-800 hover:bg-gray-700 rounded-lg transition-colors text-gray-300"
>
<FiZoomIn />
</button>
<button
onClick={handleZoomOut}
className="p-2 bg-gray-800 hover:bg-gray-700 rounded-lg transition-colors text-gray-300"
>
<FiZoomOut />
</button>
<button
onClick={handleFit}
className="p-2 bg-gray-800 hover:bg-gray-700 rounded-lg transition-colors text-gray-300"
>
<FiMove />
</button>
<button
onClick={handleExport}
className="flex items-center gap-2 px-3 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg transition-colors text-gray-300"
>
<FiDownload />
<span className="text-sm">Экспорт</span>
</button>
</div>
</div>
</div>
);
};
+10 -83
View File
@@ -1,90 +1,17 @@
import { useAuthStore } from "@/modules/auth/store/useAuthStore"; import { useAuthStore } from "@/modules/auth/store/useAuthStore";
import { ThemeToggle } from "@/modules/theme-bw/ui/ThemeToggle"; import { Navigate, Outlet } from "react-router-dom";
import React from "react"; import { Layout } from "@/app/providers/layout/layout";
import { Outlet, useNavigate } from "react-router-dom";
interface DefaultLayoutProps { export const DefaultLayout = () => {
children?: React.ReactNode; const { token } = useAuthStore();
}
export const DefaultLayout: React.FC<DefaultLayoutProps> = ({ children }) => { // if (!token) {
const { user, logout } = useAuthStore(); // return <Navigate to="/auth" replace />;
const navigate = useNavigate(); // }
const handleLogout = () => {
logout();
navigate("/auth");
};
return ( return (
<div className="min-h-screen flex flex-col" style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-primary)" }}> <Layout>
{/* Header */} <Outlet />
<header </Layout>
className="border-b sticky top-0 z-50"
style={{
backgroundColor: "var(--header-bg)",
borderColor: "var(--border)",
}}
>
<div className="container mx-auto px-4 py-3">
<div className="flex justify-between items-center">
{/* Logo */}
<div
className="text-xl font-bold cursor-pointer hover:opacity-80 transition-opacity"
style={{ color: "var(--text-primary)" }}
onClick={() => navigate("/")}
>
HellreigN
</div>
{/* Right side */}
<div className="flex items-center gap-3">
<ThemeToggle />
{user && (
<div className="flex items-center gap-3">
<span className="text-sm" style={{ color: "var(--text-secondary)" }}>
{user.firstName} {user.lastName}
</span>
<button
onClick={handleLogout}
className="px-3 py-1.5 text-sm rounded-lg transition-colors font-medium"
style={{
backgroundColor: "var(--button-danger)",
color: "var(--button-danger-text)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "var(--button-danger-hover)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "var(--button-danger)";
}}
>
Выйти
</button>
</div>
)}
</div>
</div>
</div>
</header>
{/* Main content */}
<main className="flex-1">{children || <Outlet />}</main>
{/* Footer */}
<footer
className="border-t py-4 mt-auto"
style={{
backgroundColor: "var(--bg-secondary)",
borderColor: "var(--border)",
}}
>
<div className="container mx-auto px-4">
<p className="text-center text-sm" style={{ color: "var(--text-muted)" }}>
© 2026 HellreigN. Все права защищены.
</p>
</div>
</footer>
</div>
); );
}; };
+1332 -510
View File
File diff suppressed because it is too large Load Diff