8765
This commit is contained in:
+9
-1
@@ -14,8 +14,16 @@ export default defineConfig([
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
Generated
+1445
-3
File diff suppressed because it is too large
Load Diff
+7
-2
@@ -7,22 +7,27 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"deploy": "npm run build && vk-miniapps-deploy"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-oauth/google": "^0.13.5",
|
||||
"@vkontakte/vk-bridge": "^3.0.2",
|
||||
"axios": "^1.16.1",
|
||||
"framer-motion": "^12.40.0",
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-router-dom": "^7.15.1"
|
||||
"react-router-dom": "^7.15.1",
|
||||
"styled-components": "^6.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vkontakte/vk-miniapps-deploy": "^1.0.2",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {}, // <--- ИСПРАВЛЕНО ПОД ТРЕБОВАНИЯ TAILWIND V4
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
+31
-173
@@ -1,184 +1,42 @@
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
+159
-89
@@ -1,113 +1,183 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Link, useNavigate } from 'react-router-dom';
|
||||
import { HashRouter as Router, Routes, Route, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { GoogleOAuthProvider } from '@react-oauth/google';
|
||||
import bridge from '@vkontakte/vk-bridge';
|
||||
import axios from 'axios';
|
||||
import { Cpu, User, Loader2 } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Cpu, Globe, ShoppingCart, User, Mail, Sun, Moon } from 'lucide-react';
|
||||
import './index.css';
|
||||
|
||||
import VkDashboard from './pages/VkDashboard';
|
||||
import VkPayment from './pages/VkPayment';
|
||||
import VkKabinet from './pages/VkKabinet';
|
||||
// ИМПОРТЫ ВАШИХ ОРИГИНАЛЬНЫХ СТРАНИЦ И КОМПОНЕНТОВ
|
||||
import Login from './components/Login';
|
||||
import Home from './pages/Home';
|
||||
import About from './pages/About';
|
||||
import Contact from './pages/Contact';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Kabinet from './pages/Kabinet';
|
||||
import Payment from './pages/Payment';
|
||||
|
||||
const API_URL = 'https://diplomnexus.aptcloud.ru';
|
||||
const clientId = '631083577297-n17acu7qspb1n9n8lhmr8q43b4vbpif1.apps.googleusercontent.com';
|
||||
|
||||
function AppContent() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(!!localStorage.getItem('token'));
|
||||
const [user, setUser] = useState(JSON.parse(localStorage.getItem('userInfo')) || null);
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const [showRegister, setShowRegister] = useState(false);
|
||||
|
||||
// Управление темой (Светлая по умолчанию)
|
||||
const [theme, setTheme] = useState(localStorage.getItem('theme') || 'light');
|
||||
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
const autoLogin = async () => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const isVk = searchParams.has('vk_user_id') && searchParams.has('sign');
|
||||
document.body.className = theme;
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
if (!isVk) {
|
||||
setLoading(false);
|
||||
setError('Пожалуйста, запустите приложение внутри ВКонтакте (VK Mini Apps).');
|
||||
return;
|
||||
}
|
||||
// Запуск ВК-моста
|
||||
useEffect(() => {
|
||||
bridge.send('VKWebAppInit');
|
||||
}, []);
|
||||
|
||||
try {
|
||||
// Получаем нативные данные профиля от VK
|
||||
const vkUser = await bridge.send('VKWebAppGetUserInfo');
|
||||
const handleAuth = (userInfo) => {
|
||||
setIsAuthenticated(true);
|
||||
setUser(userInfo);
|
||||
localStorage.setItem('userInfo', JSON.stringify(userInfo));
|
||||
setShowLogin(false);
|
||||
setShowRegister(false);
|
||||
navigate('/dashboard');
|
||||
};
|
||||
|
||||
// Собираем параметры запуска для подписи
|
||||
const launchParams = {};
|
||||
searchParams.forEach((value, key) => {
|
||||
if (key.startsWith('vk_') || key === 'sign') {
|
||||
launchParams[key] = value;
|
||||
}
|
||||
});
|
||||
const handleLogout = () => {
|
||||
setIsAuthenticated(false);
|
||||
setUser(null);
|
||||
localStorage.clear();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
// Отправляем на ваш основной бэкенд для проверки сигнатуры и авторизации
|
||||
const res = await axios.post(`${API_URL}/auth/vk`, {
|
||||
launchParams,
|
||||
userInfo: vkUser
|
||||
});
|
||||
|
||||
const { token, user: appUser } = res.data;
|
||||
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('userInfo', JSON.stringify(appUser));
|
||||
|
||||
setUser(appUser);
|
||||
setLoading(false);
|
||||
navigate('/'); // Переходим в каталог
|
||||
} catch (err) {
|
||||
console.error('Ошибка авторизации VK:', err);
|
||||
setError('Не удалось войти через ВКонтакте. Проверьте настройки бэкенда.');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
autoLogin();
|
||||
}, [navigate]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-screen flex flex-col items-center justify-center bg-[#060713]">
|
||||
<Loader2 className="animate-spin text-[#00f260] w-10 h-10 mb-4" />
|
||||
<p className="font-mono text-[10px] text-[#00f260] tracking-widest uppercase">NEXUS SECURE CONNECTING...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="h-screen flex flex-col items-center justify-center bg-[#060713] p-6 text-center">
|
||||
<p className="text-red-500 font-bold text-sm mb-2">⚠️ ОШИБКА ИНИЦИАЛИЗАЦИИ</p>
|
||||
<p className="text-xs opacity-50 max-w-xs">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Логика перехода в приватные разделы
|
||||
const handleProtectedNavigation = (path) => {
|
||||
if (isAuthenticated) {
|
||||
navigate(path);
|
||||
} else {
|
||||
setShowLogin(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProfileClick = () => {
|
||||
if (isAuthenticated) {
|
||||
navigate('/kabinet');
|
||||
} else {
|
||||
setShowLogin(true);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="pb-24 pt-4 px-4 min-h-screen bg-[#060713]">
|
||||
<Routes>
|
||||
<Route path="/" element={<VkDashboard user={user} />} />
|
||||
<Route path="/payment" element={<VkPayment />} />
|
||||
<Route path="/kabinet" element={<VkKabinet user={user} />} />
|
||||
</Routes>
|
||||
<div className="min-h-screen text-[var(--text-color)] font-sans transition-colors duration-300 relative selection:bg-[var(--accent-color)] selection:text-black pb-28">
|
||||
|
||||
{/* КНОПКА СМЕНЫ ТЕМЫ (ПАРЯЩАЯ ВВЕРХУ СПРАВА) */}
|
||||
<div className="fixed top-4 right-4 z-50">
|
||||
<button
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
className="glass p-3 rounded-full flex items-center justify-center text-[var(--text-color)] transition-transform active:scale-95"
|
||||
title="Сменить тему"
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={16} className="text-yellow-400" /> : <Moon size={16} className="text-blue-600" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ФОРМА АВТОРИЗАЦИИ (Login.jsx) */}
|
||||
<Login
|
||||
showLogin={showLogin}
|
||||
showRegister={showRegister}
|
||||
onClose={() => { setShowLogin(false); setShowRegister(false); }}
|
||||
onSuccess={handleAuth}
|
||||
onSwitchToReg={() => { setShowLogin(false); setShowRegister(true); }}
|
||||
onSwitchToLogin={() => { setShowRegister(false); setShowLogin(true); }}
|
||||
/>
|
||||
|
||||
{/* МАРШРУТИЗАЦИЯ СТРАНИЦ */}
|
||||
<AnimatePresence mode="wait">
|
||||
<Routes location={location} key={location.pathname}>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
<Route path="/contact" element={<Contact />} />
|
||||
|
||||
{/* Защищенные разделы */}
|
||||
<Route path="/dashboard" element={<Dashboard user={user} />} />
|
||||
<Route path="/kabinet" element={<Kabinet user={user} handleLogout={handleLogout} />} />
|
||||
<Route path="/payment" element={<Payment />} />
|
||||
</Routes>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* ПАРИРУЮЩИЙ BOTTOM NAV НА 5 КНОПОК (Стиль Akenai VPN) */}
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 w-[90%] max-w-[400px] h-16 glass rounded-full flex justify-around items-center px-4 z-50">
|
||||
|
||||
{/* Кнопка: ГЛАВНАЯ */}
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className={`vk-nav-btn ${location.pathname === '/' ? 'active' : ''}`}
|
||||
>
|
||||
{location.pathname === '/' && (
|
||||
<motion.span layoutId="vkActiveGlow" className="vk-nav-glow" transition={{ type: "spring", stiffness: 380, damping: 30 }} />
|
||||
)}
|
||||
<Cpu size={20} className="relative z-10" />
|
||||
</button>
|
||||
|
||||
{/* Кнопка: О НАС */}
|
||||
<button
|
||||
onClick={() => navigate('/about')}
|
||||
className={`vk-nav-btn ${location.pathname === '/about' ? 'active' : ''}`}
|
||||
>
|
||||
{location.pathname === '/about' && (
|
||||
<motion.span layoutId="vkActiveGlow" className="vk-nav-glow" transition={{ type: "spring", stiffness: 380, damping: 30 }} />
|
||||
)}
|
||||
<Globe size={20} className="relative z-10" />
|
||||
</button>
|
||||
|
||||
{/* Кнопка: КАТАЛОГ */}
|
||||
<button
|
||||
onClick={() => handleProtectedNavigation('/dashboard')}
|
||||
className={`vk-nav-btn ${location.pathname === '/dashboard' ? 'active' : ''}`}
|
||||
>
|
||||
{location.pathname === '/dashboard' && (
|
||||
<motion.span layoutId="vkActiveGlow" className="vk-nav-glow" transition={{ type: "spring", stiffness: 380, damping: 30 }} />
|
||||
)}
|
||||
<ShoppingCart size={20} className="relative z-10" />
|
||||
</button>
|
||||
|
||||
{/* Кнопка: КОНТАКТЫ */}
|
||||
<button
|
||||
onClick={() => navigate('/contact')}
|
||||
className={`vk-nav-btn ${location.pathname === '/contact' ? 'active' : ''}`}
|
||||
>
|
||||
{location.pathname === '/contact' && (
|
||||
<motion.span layoutId="vkActiveGlow" className="vk-nav-glow" transition={{ type: "spring", stiffness: 380, damping: 30 }} />
|
||||
)}
|
||||
<Mail size={20} className="relative z-10" />
|
||||
</button>
|
||||
|
||||
{/* Кнопка: ЛИЧНЫЙ КАБИНЕТ */}
|
||||
<button
|
||||
onClick={handleProfileClick}
|
||||
className={`vk-nav-btn ${location.pathname === '/kabinet' ? 'active' : ''}`}
|
||||
>
|
||||
{location.pathname === '/kabinet' && (
|
||||
<motion.span layoutId="vkActiveGlow" className="vk-nav-glow" transition={{ type: "spring", stiffness: 380, damping: 30 }} />
|
||||
)}
|
||||
<User size={20} className="relative z-10" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Мобильный док-бар */}
|
||||
<nav className="fixed bottom-0 left-0 right-0 h-20 bg-[#0b0c15]/95 backdrop-blur-md border-t border-[var(--glass-border)] flex items-center justify-around px-6 z-50">
|
||||
<Link to="/" className="flex flex-col items-center gap-1 text-gray-400 hover:text-[#00f260] transition-colors">
|
||||
<Cpu size={20} />
|
||||
<span className="text-[10px] font-bold">Каталог</span>
|
||||
</Link>
|
||||
<Link to="/kabinet" className="flex flex-col items-center gap-1 text-gray-400 hover:text-[#00f260] transition-colors">
|
||||
<User size={20} />
|
||||
<span className="text-[10px] font-bold">Кабинет</span>
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Router>
|
||||
<AppContent />
|
||||
</Router>
|
||||
<GoogleOAuthProvider clientId={clientId}>
|
||||
<Router>
|
||||
<AppContent />
|
||||
</Router>
|
||||
</GoogleOAuthProvider>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,122 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { Pencil, ShieldPlus, Settings, Trash2, MoreVertical } from 'lucide-react';
|
||||
|
||||
const ActionMenu = ({ onEdit, onDelete }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const menuRef = useRef(null);
|
||||
|
||||
// Закрытие при клике вне компонента
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [menuRef]);
|
||||
|
||||
const handleAction = (action) => {
|
||||
action();
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative inline-block" ref={menuRef}>
|
||||
{/* КНОПКА ТРИ ТОЧКИ */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="p-2 text-gray-400 hover:text-white hover:bg-white/10 rounded-full transition-all"
|
||||
>
|
||||
<MoreVertical size={20} />
|
||||
</button>
|
||||
|
||||
{/* ВЫПАДАЮЩЕЕ МЕНЮ */}
|
||||
{isOpen && (
|
||||
<MenuContainer className="absolute right-0 top-full z-50 mt-2">
|
||||
<div className="card">
|
||||
<ul className="list">
|
||||
{/* РЕДАКТИРОВАТЬ */}
|
||||
<li className="element" onClick={() => handleAction(onEdit)}>
|
||||
<Pencil size={16} />
|
||||
<p className="label">Изменить</p>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="separator" />
|
||||
<ul className="list">
|
||||
{/* УДАЛИТЬ */}
|
||||
<li className="element delete" onClick={() => handleAction(onDelete)}>
|
||||
<Trash2 size={16} />
|
||||
<p className="label">Удалить</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</MenuContainer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ТВОЙ STYLED COMPONENT (с небольшими правками позиционирования)
|
||||
const MenuContainer = styled.div`
|
||||
.card {
|
||||
width: 180px;
|
||||
background-color: rgba(36, 40, 50, 1);
|
||||
background-image: linear-gradient(139deg, rgba(36, 40, 50, 1) 0%, rgba(36, 40, 50, 1) 0%, rgba(37, 28, 40, 1) 100%);
|
||||
border-radius: 10px;
|
||||
padding: 10px 0px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.card .separator {
|
||||
border-top: 1px solid #42434a;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.card .list {
|
||||
list-style-type: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 0px 8px;
|
||||
}
|
||||
|
||||
.card .list .element {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #9ca3af;
|
||||
gap: 10px;
|
||||
transition: all 0.2s ease-out;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card .list .element .label {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.card .list .element:hover {
|
||||
background-color: var(--accent-color, #5353ff);
|
||||
color: #ffffff;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.card .list .delete:hover {
|
||||
background-color: #ef4444; /* Красный tailwind */
|
||||
color: white;
|
||||
}
|
||||
|
||||
.card .list .element:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
`;
|
||||
|
||||
export default ActionMenu;
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Fingerprint = () => {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="fingerprint-container">
|
||||
<svg viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" className="fingerprint-svg">
|
||||
<path d="M126.42 24C70.73 24.85 25.21 70.09 24 125.81a103.53 103.53 0 0 0 13.52 53.54a4 4 0 0 0 7.1-.3a119.35 119.35 0 0 0 11.37-51A71.77 71.77 0 0 1 83 71.83a8 8 0 1 1 9.86 12.61A55.82 55.82 0 0 0 72 128.07a135.3 135.3 0 0 1-18.45 68.35a4 4 0 0 0 .61 4.85c2 2 4.09 4 6.25 5.82a4 4 0 0 0 6-1A151.2 151.2 0 0 0 85 158.49a8 8 0 1 1 15.68 3.19a167.3 167.3 0 0 1-21.07 53.64a4 4 0 0 0 1.6 5.63c2.47 1.25 5 2.41 7.57 3.47a4 4 0 0 0 5-1.61A183 183 0 0 0 120 128.28a8.16 8.16 0 0 1 7.44-8.21a8 8 0 0 1 8.56 8a198.94 198.94 0 0 1-25.21 97.16a4 4 0 0 0 2.95 5.92q4.55.63 9.21.86a4 4 0 0 0 3.67-2.1A214.9 214.9 0 0 0 152 128.8c.05-13.25-10.3-24.49-23.54-24.74A24 24 0 0 0 104 128a8.1 8.1 0 0 1-7.29 8a8 8 0 0 1-8.71-8a40 40 0 0 1 40.42-40c22 .23 39.68 19.17 39.57 41.16a231.4 231.4 0 0 1-20.52 94.57a4 4 0 0 0 4.62 5.51a104 104 0 0 0 10.26-3a4 4 0 0 0 2.35-2.22a244 244 0 0 0 11.48-34a8 8 0 1 1 15.5 4q-1.12 4.37-2.4 8.7a4 4 0 0 0 6.46 4.17A104 104 0 0 0 126.42 24M198 161.08a8 8 0 0 1-7.92 7a8 8 0 0 1-1-.06a8 8 0 0 1-6.95-8.93a253 253 0 0 0 1.92-31a56.08 56.08 0 0 0-56-56a57 57 0 0 0-7 .43a8 8 0 0 1-2-15.89a72.1 72.1 0 0 1 81 71.49a267 267 0 0 1-2.05 32.96" strokeWidth={1} stroke="currentColor" fill="currentColor" className="fingerprint-path" />
|
||||
</svg>
|
||||
<div className="scan-line" />
|
||||
<div className="matrix-rain" />
|
||||
<div className="glow" />
|
||||
<div className="status">Verifying...</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.fingerprint-container { position: relative; width: 120px; height: 120px; cursor: pointer; border-radius: 50%; margin: 0 auto; }
|
||||
.fingerprint-svg { width: 100%; height: 100%; color: #00ff00; filter: drop-shadow(0 0 5px #00ff00); transition: transform 0.2s ease; }
|
||||
.fingerprint-path { stroke-dasharray: 500; stroke-dashoffset: 0; animation: draw 4s infinite linear; }
|
||||
.scan-line { position: absolute; top: 0; left: 0; width: 100%; height: 3px; background: linear-gradient(to right, transparent, #00ff00, transparent); opacity: 0; }
|
||||
.status { position: absolute; bottom: -30px; width: 100%; text-align: center; color: #00ff00; font-size: 14px; text-transform: uppercase; letter-spacing: 2px; animation: glitch-text 2s infinite; }
|
||||
|
||||
.fingerprint-container:hover .scan-line { animation: scan 1s infinite linear; opacity: 0.7; }
|
||||
|
||||
@keyframes draw { 0% { stroke-dashoffset: 500; } 100% { stroke-dashoffset: 0; } }
|
||||
@keyframes scan { 0% { transform: translateY(0); opacity: 0.7; } 50% { opacity: 1; } 100% { transform: translateY(120px); opacity: 0.7; } }
|
||||
@keyframes glitch-text { 0% { transform: translate(0); } 20% { transform: translate(-1px, 1px); } 40% { transform: translate(1px, -1px); } 60% { transform: translate(-1px, 0); } 80% { transform: translate(1px, 0); } 100% { transform: translate(0); } }
|
||||
`;
|
||||
|
||||
export default Fingerprint;
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Iphone = () => {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="card">
|
||||
<div className="btn1" />
|
||||
<div className="btn2" />
|
||||
<div className="btn3" />
|
||||
<div className="btn4" />
|
||||
<div className="card-int">
|
||||
<div className="hello">NEXUS<span className="hidden">SYSTEM</span></div>
|
||||
</div>
|
||||
<div className="top">
|
||||
<div className="camera">
|
||||
<div className="int" />
|
||||
</div>
|
||||
<div className="speaker" />
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.card {
|
||||
width: 210px; height: 400px; background: black; border-radius: 35px;
|
||||
border: 2px solid rgb(40, 40, 40); padding: 7px; position: relative;
|
||||
box-shadow: 2px 5px 15px rgba(0, 0, 0, 0.486); margin: 0 auto;
|
||||
}
|
||||
.card-int {
|
||||
background-image: linear-gradient(to right bottom, #ff0000, #ff0045, #ff0078, #ea00aa, #b81cd7, #8a3ad6, #5746cf, #004ac2, #003d94, #002e66, #001d3a, #020812);
|
||||
background-size: 200% 200%; background-position: 0% 0%; height: 100%; border-radius: 25px;
|
||||
transition: all 0.6s ease-out; overflow: hidden;
|
||||
}
|
||||
.card:hover .card-int { background-position: 100% 100%; }
|
||||
.top {
|
||||
position: absolute; top: 0px; right: 50%; transform: translate(50%, 0%);
|
||||
width: 35%; height: 18px; background-color: black;
|
||||
border-bottom-left-radius: 10px; border-bottom-right-radius: 10px;
|
||||
}
|
||||
.speaker {
|
||||
position: absolute; top: 2px; right: 50%; transform: translate(50%, 0%);
|
||||
width: 40%; height: 2px; border-radius: 2px; background-color: rgb(20, 20, 20);
|
||||
}
|
||||
.camera {
|
||||
position: absolute; top: 6px; right: 84%; transform: translate(50%, 0%);
|
||||
width: 6px; height: 6px; border-radius: 50%; background-color: rgba(255, 255, 255, 0.048);
|
||||
}
|
||||
.int {
|
||||
position: absolute; width: 3px; height: 3px; border-radius: 50%;
|
||||
top: 50%; right: 50%; transform: translate(50%, -50%); background-color: rgba(0, 0, 255, 0.212);
|
||||
}
|
||||
.btn1, .btn2, .btn3, .btn4 { position: absolute; width: 2px; background-image: linear-gradient(to right, #111111, #222222, #333333, #464646, #595959); }
|
||||
.btn1 { height: 45px; top: 30%; right: -4px; }
|
||||
.btn2 { height: 30px; top: 26%; left: -4px; }
|
||||
.btn3 { height: 30px; top: 36%; left: -4px; }
|
||||
.btn4 { height: 45px; top: 11%; right: -4px; } /* Added missing btn4 style for completion */
|
||||
.hello {
|
||||
display: flex; flex-flow: column; align-items: center; justify-content: center;
|
||||
color: white; font-size: 2rem; font-weight: bold; text-align: center;
|
||||
line-height: 35px; height: 100%; transition: 0.5s ease-in-out;
|
||||
}
|
||||
.hidden { display: block; opacity: 0; transition: all 0.3s ease-in; font-size: 1rem; }
|
||||
.card:hover .hidden { opacity: 1; }
|
||||
.card:hover .hello { transform: translateY(-20px); }
|
||||
`;
|
||||
|
||||
export default Iphone;
|
||||
@@ -0,0 +1,352 @@
|
||||
import { useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, User, Lock, Mail, Fingerprint, ScanFace, ArrowRight } from 'lucide-react';
|
||||
import { useGoogleLogin } from '@react-oauth/google';
|
||||
import bridge from '@vkontakte/vk-bridge';
|
||||
import GoogleButton from './google';
|
||||
|
||||
const Login = ({ showLogin, showRegister, onClose, onSuccess, onSwitchToReg, onSwitchToLogin }) => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [step, setStep] = useState('auth'); // 'auth' или 'verify'
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
|
||||
// Логика Google входа
|
||||
const googleLogin = useGoogleLogin({
|
||||
onSuccess: async (tokenResponse) => {
|
||||
try {
|
||||
const res = await axios.post('https://diplomnexus.aptcloud.ru/auth/google', {
|
||||
access_token: tokenResponse.access_token,
|
||||
});
|
||||
localStorage.setItem('token', res.data.token);
|
||||
onSuccess(res.data.user);
|
||||
} catch (err) {
|
||||
setError('Ошибка Google авторизации');
|
||||
}
|
||||
},
|
||||
onError: () => setError('Google вход не удался'),
|
||||
});
|
||||
const handleVkLogin = async () => {
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const launchParams = {};
|
||||
searchParams.forEach((value, key) => {
|
||||
if (key.startsWith('vk_') || key === 'sign') {
|
||||
launchParams[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
const vkUser = await bridge.send('VKWebAppGetUserInfo');
|
||||
|
||||
const res = await axios.post('https://diplomnexus.aptcloud.ru/auth/vk', {
|
||||
launchParams,
|
||||
userInfo: vkUser
|
||||
});
|
||||
|
||||
localStorage.setItem('token', res.data.token);
|
||||
onSuccess(res.data.user);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('Не удалось авторизоваться через ВКонтакте');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const handleSubmit = async (e, isRegister) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
if (isRegister && step === 'auth') {
|
||||
// Шаг 1: отправка данных для регистрации и генерации кода на почту
|
||||
const endpoint = 'https://diplomnexus.aptcloud.ru/register';
|
||||
const payload = { email, password, name, referral_code: localStorage.getItem('referral_code') };
|
||||
|
||||
await axios.post(endpoint, payload);
|
||||
setStep('verify'); // Переключаемся на окно ввода кода
|
||||
} else if (isRegister && step === 'verify') {
|
||||
// Шаг 2: отправка кода верификации
|
||||
const endpoint = 'https://diplomnexus.aptcloud.ru/verify';
|
||||
const payload = { email, code: verificationCode };
|
||||
|
||||
const res = await axios.post(endpoint, payload);
|
||||
localStorage.setItem('token', res.data.token);
|
||||
onSuccess(res.data.user);
|
||||
} else {
|
||||
// Обычный вход в систему (Авторизация)
|
||||
const endpoint = 'https://diplomnexus.aptcloud.ru/login';
|
||||
const payload = { email, password };
|
||||
|
||||
const res = await axios.post(endpoint, payload);
|
||||
localStorage.setItem('token', res.data.token);
|
||||
onSuccess(res.data.user);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || err.response?.data?.message || 'Ошибка доступа: Неверные данные');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Возврат на шаг назад (если ввели не ту почту)
|
||||
const handleBackToAuth = () => {
|
||||
setStep('auth');
|
||||
setError('');
|
||||
setVerificationCode('');
|
||||
};
|
||||
|
||||
const handleSwitchToLogin = () => {
|
||||
setStep('auth');
|
||||
setError('');
|
||||
onSwitchToLogin();
|
||||
};
|
||||
|
||||
const handleSwitchToReg = () => {
|
||||
setStep('auth');
|
||||
setError('');
|
||||
onSwitchToReg();
|
||||
};
|
||||
|
||||
if (!showLogin && !showRegister) return null;
|
||||
|
||||
const isRegister = showRegister;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{/* Убрал onClick={onClose} отсюда. Теперь клик по фону ничего не делает */}
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/90 backdrop-blur-md cursor-default">
|
||||
|
||||
{/* ФОНОВЫЕ ЭФФЕКТЫ */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[500px] bg-[var(--accent-color)]/20 blur-[100px] rounded-full" />
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||
transition={{ type: "spring", duration: 0.5 }}
|
||||
className="relative w-full max-w-md mx-4"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* ОСНОВНОЙ КОНТЕЙНЕР (СТЕКЛО) */}
|
||||
<div className="glass bg-[#0b0c15]/80 p-1 rounded-[2rem] border border-[var(--glass-border)] shadow-[0_0_50px_rgba(0,0,0,0.5)] overflow-hidden">
|
||||
|
||||
{/* ВЕРХНЯЯ ПОЛОСА ЗАГРУЗКИ */}
|
||||
{loading && (
|
||||
<motion.div
|
||||
initial={{ width: 0 }} animate={{ width: "100%" }}
|
||||
className="h-1 bg-[var(--accent-color)] absolute top-0 left-0 z-50"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative p-8 md:p-10 bg-[var(--card-bg)]/40 rounded-[1.8rem]">
|
||||
|
||||
{/* === КРЕСТИК (ЗАКРЫВАЕТ ТОЛЬКО ОН) === */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 z-50 group flex items-center justify-center w-10 h-10 rounded-xl bg-white/5 border border-white/10 hover:bg-red-500/10 hover:border-red-500 transition-all duration-300"
|
||||
title="Закрыть терминал"
|
||||
>
|
||||
<X
|
||||
size={20}
|
||||
className="text-gray-400 group-hover:text-red-500 group-hover:rotate-90 transition-all duration-300"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* ДЕКОРАТИВНЫЕ УГОЛКИ */}
|
||||
<div className="absolute top-8 left-8 w-4 h-4 border-t-2 border-l-2 border-[var(--accent-color)] opacity-30" />
|
||||
<div className="absolute bottom-8 left-8 w-4 h-4 border-b-2 border-l-2 border-[var(--accent-color)] opacity-30" />
|
||||
<div className="absolute bottom-8 right-8 w-4 h-4 border-b-2 border-r-2 border-[var(--accent-color)] opacity-30" />
|
||||
|
||||
{/* ЗАГОЛОВОК */}
|
||||
<div className="text-center mb-8 mt-2">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="p-4 bg-[var(--accent-color)]/10 rounded-full border border-[var(--accent-color)]/30 relative">
|
||||
{isRegister && step === 'verify' ? (
|
||||
<Mail size={32} className="text-[var(--accent-color)] animate-pulse" />
|
||||
) : isRegister ? (
|
||||
<Fingerprint size={32} className="text-[var(--accent-color)]" />
|
||||
) : (
|
||||
<ScanFace size={32} className="text-[var(--accent-color)]" />
|
||||
)}
|
||||
<div className="absolute inset-0 rounded-full animate-ping bg-[var(--accent-color)]/20" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-3xl font-black text-[var(--text-color)] tracking-tighter uppercase mb-1">
|
||||
{isRegister && step === 'verify' ? 'Верификация' : isRegister ? 'Инициализация' : 'Вход в систему'}
|
||||
</h2>
|
||||
<p className="text-xs font-mono text-[var(--accent-color)] opacity-70 tracking-[0.2em]">
|
||||
SECURE CONNECTION ESTABLISHED
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ОШИБКА */}
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0 }}
|
||||
className="bg-red-500/10 border border-red-500/30 text-red-400 p-3 rounded-xl mb-6 text-center text-sm font-bold flex items-center justify-center gap-2"
|
||||
>
|
||||
<Lock size={14} /> {error}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* ФОРМА */}
|
||||
<form onSubmit={e => handleSubmit(e, isRegister)} className="space-y-5 relative z-10">
|
||||
|
||||
{isRegister && step === 'verify' ? (
|
||||
// --- ШАГ 2: Форма ввода верификационного кода ---
|
||||
<div className="space-y-5">
|
||||
<p className="text-sm text-gray-400 text-center mb-2 leading-relaxed">
|
||||
Код подтверждения отправлен на почту <br />
|
||||
<b className="text-[var(--accent-color)] font-mono">{email}</b>
|
||||
</p>
|
||||
<div className="group relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500 group-focus-within:text-[var(--accent-color)] transition-colors" size={20} />
|
||||
<input
|
||||
className="w-full bg-[#000]/20 border border-[var(--glass-border)] rounded-xl py-4 pl-12 pr-4 text-[var(--text-color)] outline-none focus:border-[var(--accent-color)] focus:shadow-[0_0_20px_rgba(0,243,255,0.1)] transition-all font-mono placeholder:text-gray-600 text-center tracking-[0.4em] text-lg font-bold"
|
||||
placeholder="ХХХХХ"
|
||||
value={verificationCode}
|
||||
onChange={e => setVerificationCode(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled={loading}
|
||||
className="btn-neon w-full py-4 text-base font-bold tracking-widest flex justify-center items-center gap-2 group disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="animate-pulse">ПРОВЕРКА КОДА...</span>
|
||||
) : (
|
||||
<>
|
||||
ПОДТВЕРДИТЬ РЕГИСТРАЦИЮ
|
||||
<ArrowRight size={18} className="group-hover:translate-x-1 transition-transform"/>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBackToAuth}
|
||||
className="w-full text-xs text-gray-500 uppercase tracking-widest hover:text-[var(--accent-color)] transition-colors text-center mt-2"
|
||||
>
|
||||
← Изменить почту / данные
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
// --- ШАГ 1: Стандартная форма ввода данных ---
|
||||
<>
|
||||
{isRegister && (
|
||||
<div className="group relative">
|
||||
<User className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500 group-focus-within:text-[var(--accent-color)] transition-colors" size={20} />
|
||||
<input
|
||||
className="w-full bg-[#000]/20 border border-[var(--glass-border)] rounded-xl py-4 pl-12 pr-4 text-[var(--text-color)] outline-none focus:border-[var(--accent-color)] focus:shadow-[0_0_20px_rgba(0,243,255,0.1)] transition-all font-mono placeholder:text-gray-600"
|
||||
placeholder="Позывной (Имя)"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="group relative">
|
||||
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500 group-focus-within:text-[var(--accent-color)] transition-colors" size={20} />
|
||||
<input
|
||||
className="w-full bg-[#000]/20 border border-[var(--glass-border)] rounded-xl py-4 pl-12 pr-4 text-[var(--text-color)] outline-none focus:border-[var(--accent-color)] focus:shadow-[0_0_20px_rgba(0,243,255,0.1)] transition-all font-mono placeholder:text-gray-600"
|
||||
type="email"
|
||||
placeholder="Электронная почта"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="group relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500 group-focus-within:text-[var(--accent-color)] transition-colors" size={20} />
|
||||
<input
|
||||
className="w-full bg-[#000]/20 border border-[var(--glass-border)] rounded-xl py-4 pl-12 pr-4 text-[var(--text-color)] outline-none focus:border-[var(--accent-color)] focus:shadow-[0_0_20px_rgba(0,243,255,0.1)] transition-all font-mono placeholder:text-gray-600"
|
||||
type="password"
|
||||
placeholder="Код доступа (Пароль)"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled={loading}
|
||||
className="btn-neon w-full py-4 text-base font-bold tracking-widest flex justify-center items-center gap-2 group disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="animate-pulse">ОБРАБОТКА...</span>
|
||||
) : (
|
||||
<>
|
||||
{isRegister ? 'ПРОДОЛЖИТЬ' : 'АВТОРИЗАЦИЯ'}
|
||||
<ArrowRight size={18} className="group-hover:translate-x-1 transition-transform"/>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* РАЗДЕЛИТЕЛЬ */}
|
||||
{!(isRegister && step === 'verify') && (
|
||||
<>
|
||||
{/* РАЗДЕЛИТЕЛЬ */}
|
||||
<div className="my-6 flex items-center justify-between">
|
||||
<span className="h-px bg-gradient-to-r from-transparent to-gray-700 flex-1"></span>
|
||||
<span className="text-[10px] font-bold text-gray-500 px-3 uppercase tracking-widest">Или через соц.сети</span>
|
||||
<span className="h-px bg-gradient-to-l from-transparent to-gray-700 flex-1"></span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<div className="w-full transform transition-transform hover:scale-[1.02]">
|
||||
<GoogleButton onClick={() => googleLogin()} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleVkLogin}
|
||||
className="btn-neon w-full py-4 text-base font-bold uppercase tracking-wider flex items-center justify-center gap-2 mt-3"
|
||||
style={{ background: 'rgba(0, 119, 255, 0.1)', borderColor: '#0077ff', color: '#0077ff', borderRadius: '12px' }}
|
||||
>
|
||||
Войти через ВКонтакте
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ПЕРЕКЛЮЧАТЕЛЬ */}
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
{isRegister ? 'Уже есть доступ?' : 'Нет идентификатора?'}
|
||||
</p>
|
||||
<button
|
||||
className="text-[var(--accent-color)] font-bold uppercase tracking-wider hover:text-white transition-colors border-b border-transparent hover:border-[var(--accent-color)] pb-0.5"
|
||||
onClick={isRegister ? handleSwitchToLogin : handleSwitchToReg}
|
||||
>
|
||||
{isRegister ? 'Войти в систему' : 'Регистрация'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
@@ -0,0 +1,281 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Mesto = () => {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="map-container glass">
|
||||
|
||||
{/* ФОН КАРТЫ */}
|
||||
<div className="map-background-wrapper">
|
||||
<svg viewBox="0 0 600 500" className="map-svg">
|
||||
<defs>
|
||||
<pattern id="lawn" width="20" height="20" patternUnits="userSpaceOnUse">
|
||||
<rect width="20" height="20" fill="#7ec850" />
|
||||
<circle cx="5" cy="5" r="1.5" fill="#6db743" />
|
||||
<circle cx="15" cy="15" r="1.5" fill="#6db743" />
|
||||
</pattern>
|
||||
<linearGradient id="water" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#2980b9" />
|
||||
<stop offset="100%" stopColor="#6dd5fa" />
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="3" />
|
||||
<feOffset dx="3" dy="3" result="offsetblur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="offsetblur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* === СЛОЙ 1: ЛАНДШАФТ === */}
|
||||
<rect width="600" height="500" fill="url(#lawn)" />
|
||||
<rect x="10" y="10" width="580" height="480" fill="none" stroke="#5d4037" strokeWidth="6" rx="5" />
|
||||
|
||||
{/* Дорожки */}
|
||||
<path d="M50,490 L50,300 L180,300 L180,490 Z" fill="#94a3b8" /> {/* Парковка */}
|
||||
<path d="M180,400 L300,400 L300,320" fill="none" stroke="#cbd5e1" strokeWidth="30" strokeLinecap="round" />
|
||||
<rect x="380" y="50" width="180" height="220" rx="10" fill="#e2e8f0" /> {/* Патио */}
|
||||
|
||||
{/* === СЛОЙ 2: СТРОЕНИЯ === */}
|
||||
|
||||
{/* ГАРАЖ */}
|
||||
<g transform="translate(80, 150)" filter="url(#shadow)">
|
||||
<rect width="120" height="150" fill="#64748b" />
|
||||
<line x1="60" y1="0" x2="60" y2="150" stroke="#475569" strokeWidth="2" />
|
||||
<rect x="10" y="10" width="100" height="130" fill="none" stroke="#475569" strokeWidth="2" strokeDasharray="5,5"/>
|
||||
</g>
|
||||
|
||||
{/* ДОМ */}
|
||||
<g transform="translate(200, 100)" filter="url(#shadow)">
|
||||
<rect x="0" y="0" width="200" height="220" fill="#fff" />
|
||||
<path d="M0,0 L100,110 L0,220" fill="#c2410c" />
|
||||
<path d="M200,0 L100,110 L200,220" fill="#ea580c" />
|
||||
<rect x="20" y="20" width="60" height="80" fill="#1e293b" stroke="#475569" />
|
||||
<rect x="120" y="120" width="60" height="80" fill="#1e293b" stroke="#475569" />
|
||||
</g>
|
||||
|
||||
{/* БАССЕЙН */}
|
||||
<rect x="400" y="70" width="140" height="160" rx="10" fill="url(#water)" stroke="#fff" strokeWidth="5" filter="url(#shadow)"/>
|
||||
|
||||
{/* === СЛОЙ 3: ОБЪЕКТЫ === */}
|
||||
|
||||
{/* МАШИНА */}
|
||||
<g transform="translate(95, 360)" filter="url(#shadow)">
|
||||
<rect x="0" y="0" width="70" height="120" rx="12" fill="#dc2626" />
|
||||
<path d="M5,20 L65,20 L60,40 L10,40 Z" fill="#1e293b" />
|
||||
<path d="M10,90 L60,90 L65,110 L5,110 Z" fill="#1e293b" />
|
||||
<rect x="10" y="42" width="50" height="46" fill="#b91c1c" />
|
||||
</g>
|
||||
|
||||
{/* МАНГАЛЬНАЯ ЗОНА (Отодвинул вниз на траву - координаты 500, 300) */}
|
||||
<g transform="translate(500, 300)" filter="url(#shadow)">
|
||||
{/* Плитка под мангал */}
|
||||
<rect x="-10" y="-10" width="60" height="45" rx="5" fill="#78716c" />
|
||||
{/* Сам мангал */}
|
||||
<rect x="0" y="0" width="40" height="25" fill="#171717" />
|
||||
<line x1="5" y1="5" x2="35" y2="5" stroke="#404040" strokeWidth="2" />
|
||||
<line x1="5" y1="12" x2="35" y2="12" stroke="#404040" strokeWidth="2" />
|
||||
{/* Дым */}
|
||||
<circle cx="20" cy="-5" r="3" fill="#fff" opacity="0.4">
|
||||
<animate attributeName="cy" from="-5" to="-15" dur="1s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" from="0.4" to="0" dur="1s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
</g>
|
||||
|
||||
{/* ЧЕЛОВЕК (Вид СВЕРХУ) - Сдвинул ближе к центру дорожки */}
|
||||
<g transform="translate(320, 380)">
|
||||
<ellipse cx="0" cy="0" rx="14" ry="8" fill="#2563eb" stroke="#1e293b" strokeWidth="1" />
|
||||
<circle cx="0" cy="0" r="6" fill="#fcd34d" stroke="#1e293b" strokeWidth="1" />
|
||||
</g>
|
||||
|
||||
{/* СОБАКА (Вид СВЕРХУ) */}
|
||||
<g transform="translate(450, 350) rotate(30)">
|
||||
<ellipse cx="0" cy="0" rx="12" ry="6" fill="#78350f" />
|
||||
<circle cx="10" cy="0" r="5" fill="#78350f" />
|
||||
<path d="M-10,0 L-16,0" stroke="#78350f" strokeWidth="2" strokeLinecap="round"/>
|
||||
</g>
|
||||
|
||||
{/* ДЕРЕВЬЯ */}
|
||||
<circle cx="40" cy="40" r="30" fill="#166534" opacity="0.8" filter="url(#shadow)"/>
|
||||
<circle cx="550" cy="450" r="40" fill="#166534" opacity="0.8" filter="url(#shadow)"/>
|
||||
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* === ПИНЫ ДАТЧИКОВ === */}
|
||||
<div className="map-cities">
|
||||
|
||||
<div style={{'--x': 23, '--y': 82}} className="map-city" data-status="active">
|
||||
<div className="map-city__label"><span data-icon="🚗" className="map-city__sign">Ворота: Открыто</span></div>
|
||||
</div>
|
||||
|
||||
<div style={{'--x': 60, '--y': 65}} className="map-city" data-status="active">
|
||||
<div className="map-city__label"><span data-icon="📹" className="map-city__sign">Камера: Запись</span></div>
|
||||
</div>
|
||||
|
||||
<div style={{'--x': 50, '--y': 40}} className="map-city main-hub">
|
||||
<div className="map-city__label"><span data-icon="🧠" className="map-city__sign">Центральный HUB</span></div>
|
||||
</div>
|
||||
|
||||
<div style={{'--x': 75, '--y': 30}} className="map-city" data-status="normal">
|
||||
<div className="map-city__label"><span data-icon="🌡️" className="map-city__sign">Бассейн: 24°C</span></div>
|
||||
</div>
|
||||
|
||||
<div style={{'--x': 75, '--y': 70}} className="map-city" data-status="warning">
|
||||
<div className="map-city__label"><span data-icon="🐕" className="map-city__sign">GPS: Бобик</span></div>
|
||||
</div>
|
||||
|
||||
{/* ЧЕЛОВЕК: Точка ровно над головой (320/600=53.3%, 380/500=76%) */}
|
||||
<div style={{'--x': 53.3, '--y': 76}} className="map-city" data-status="normal">
|
||||
<div className="map-city__label"><span data-icon="🚶" className="map-city__sign">Движение: Гость</span></div>
|
||||
</div>
|
||||
|
||||
<div style={{'--x': 50, '--y': 65}} className="map-city" data-status="normal">
|
||||
<div className="map-city__label"><span data-icon="🔔" className="map-city__sign">Домофон</span></div>
|
||||
</div>
|
||||
|
||||
{/* СОЛНЕЧНЫЕ ПАНЕЛИ */}
|
||||
<div style={{'--x': 35, '--y': 25}} className="map-city" data-status="active">
|
||||
<div className="map-city__label"><span data-icon="⚡" className="map-city__sign">Электричество: 4.2 kW</span></div>
|
||||
</div>
|
||||
|
||||
<div style={{'--x': 45, '--y': 50}} className="map-city" data-status="active">
|
||||
<div className="map-city__label"><span data-icon="💧" className="map-city__sign">Протечка: Кухня</span></div>
|
||||
</div>
|
||||
|
||||
{/* МАНГАЛ: Точка над мангалом (500/600=83.3%, 300/500=60%) */}
|
||||
<div style={{'--x': 83.3, '--y': 60}} className="map-city" data-status="warning">
|
||||
<div className="map-city__label"><span data-icon="🔥" className="map-city__sign">Мангал: 180°C</span></div>
|
||||
</div>
|
||||
|
||||
<div style={{'--x': 35, '--y': 55}} className="map-city" data-status="normal">
|
||||
<div className="map-city__label"><span data-icon="🛡️" className="map-city__sign">Охрана: Периметр</span></div>
|
||||
</div>
|
||||
|
||||
<div style={{'--x': 90, '--y': 10}} className="map-city" data-status="normal">
|
||||
<div className="map-city__label"><span data-icon="🌪️" className="map-city__sign">Анемометр: 3 м/с</span></div>
|
||||
</div>
|
||||
|
||||
{/* ДАТЧИК ВЛАЖНОСТИ (ВЕРНУЛ) */}
|
||||
<div style={{'--x': 15, '--y': 15}} className="map-city" data-status="normal">
|
||||
<div className="map-city__label"><span data-icon="🌱" className="map-city__sign">Влага (Газон): 65%</span></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
.map-container {
|
||||
--city-sign-color-back: rgba(15, 23, 42, 0.95);
|
||||
--city-sign-color-font: #fff;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 650px;
|
||||
aspect-ratio: 4 / 3;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.map-background-wrapper {
|
||||
position: absolute;
|
||||
top: 0; left: 0; width: 100%; height: 100%;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,0.4), inset 0 0 0 2px rgba(255,255,255,0.1);
|
||||
background: #7ec850;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.map-svg { width: 100%; height: 100%; object-fit: cover; }
|
||||
.map-cities { width: 100%; height: 100%; position: relative; z-index: 10; overflow: visible; }
|
||||
|
||||
/* ПИНЫ (Маленькие точки) */
|
||||
.map-city {
|
||||
position: absolute;
|
||||
left: calc(var(--x) * 1%);
|
||||
top: calc(var(--y) * 1%);
|
||||
width: 12px; height: 12px;
|
||||
background: #ef4444;
|
||||
border: 2px solid white;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||
transform: translate(-50%, -50%);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.map-city[data-status="active"] { background: #22c55e; }
|
||||
.map-city[data-status="normal"] { background: #3b82f6; }
|
||||
.map-city[data-status="warning"] { background: #f59e0b; animation: pulse-warning 1s infinite; }
|
||||
|
||||
.map-city.main-hub {
|
||||
width: 18px; height: 18px;
|
||||
background: #a855f7;
|
||||
border-radius: 4px;
|
||||
transform: translate(-50%, -50%) rotate(45deg);
|
||||
}
|
||||
|
||||
.map-city:hover {
|
||||
transform: translate(-50%, -50%) scale(1.5);
|
||||
z-index: 100;
|
||||
border-color: #fbbf24;
|
||||
}
|
||||
|
||||
.map-city__label {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(5px);
|
||||
white-space: nowrap;
|
||||
z-index: 50;
|
||||
transition: all 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.map-city:hover .map-city__label {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
.map-city__sign {
|
||||
background: var(--city-sign-color-back);
|
||||
color: var(--city-sign-color-font);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 600;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,0.5);
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.map-city__sign::before { content: attr(data-icon); font-size: 12px; }
|
||||
|
||||
.map-city__sign::after {
|
||||
content: ''; position: absolute; bottom: -4px; left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-left: 4px solid transparent; border-right: 4px solid transparent;
|
||||
border-top: 4px solid var(--city-sign-color-back);
|
||||
}
|
||||
|
||||
@keyframes pulse-warning {
|
||||
0% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.7); }
|
||||
70% { box-shadow: 0 0 0 6px rgba(245, 158, 11, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0); }
|
||||
}
|
||||
`;
|
||||
|
||||
export default Mesto;
|
||||
@@ -0,0 +1,188 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Cpu, LogOut, User, MessageCircle } from 'lucide-react';
|
||||
import LightDark from './lightdark';
|
||||
import UserAvatar from './UserAvatar';
|
||||
import SupportChat from './SupportChat';
|
||||
|
||||
const navLinks = [
|
||||
{ path: '/', label: 'ГЛАВНАЯ' },
|
||||
{ path: '/about', label: 'О НАС' },
|
||||
{ path: '/contact', label: 'КОНТАКТЫ' },
|
||||
];
|
||||
|
||||
const catalogLink = [
|
||||
{ path: '/dashboard', label: 'КАТАЛОГ' }
|
||||
];
|
||||
|
||||
const NavBar = ({ user, onLogin, onLogout }) => {
|
||||
const [theme, setTheme] = useState(localStorage.getItem('theme') || 'dark');
|
||||
const [hoveredPath, setHoveredPath] = useState(null);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [isChatOpen, setIsChatOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const roleLabel = isAdmin ? 'ADMIN' : 'OPERATOR';
|
||||
const roleColorClass = isAdmin ? 'text-red-500' : 'text-[var(--accent-color)]';
|
||||
const adminColorClass = 'text-red-500';
|
||||
|
||||
// --- 1. ТАБЛИЦЫ ОБОРУДОВАНИЯ (ЧИСТЫЕ ПУТИ ДЛЯ ВСЕХ) ---
|
||||
const equipmentLinks = [
|
||||
{ path: '/hubs', label: 'ХАБЫ' },
|
||||
{ path: '/cameras', label: 'КАМЕРЫ' },
|
||||
{ path: '/lighting', label: 'СВЕТ' },
|
||||
{ path: '/sensors', label: 'ДАТЧИКИ' },
|
||||
];
|
||||
|
||||
// --- 2. ТАБЛИЦЫ УПРАВЛЕНИЯ (ТОЛЬКО АДМИНУ) ---
|
||||
const adminManagementLinks = [
|
||||
{ path: '/users', label: 'ПОЛЬЗОВАТЕЛИ' },
|
||||
{ path: '/orders', label: 'ЗАКАЗЫ' },
|
||||
{ path: '/messages', label: 'ЧАТЫ' },
|
||||
{ path: '/admin/logs', label: 'ЛОГИ' },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
document.body.className = theme;
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
const checkUnread = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await axios.get(`https://diplomnexus.aptcloud.ru/messages/unread?email=${user.email}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setUnreadCount(res.data.count);
|
||||
} catch (e) {}
|
||||
};
|
||||
checkUnread();
|
||||
const interval = setInterval(checkUnread, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const toggleTheme = () => setTheme(theme === 'dark' ? 'light' : 'dark');
|
||||
|
||||
const renderMenu = (links) => (
|
||||
<div className="relative flex items-center bg-[var(--input-bg)]/50 backdrop-blur-md border border-[var(--glass-border)] px-1 py-1 rounded-none shadow-sm overflow-x-auto no-scrollbar">
|
||||
{links.map((link) => (
|
||||
<Link
|
||||
key={link.path}
|
||||
to={link.path}
|
||||
onMouseEnter={() => setHoveredPath(link.path)}
|
||||
onMouseLeave={() => setHoveredPath(null)}
|
||||
className="relative px-3 xl:px-4 py-3 text-[9px] xl:text-[10px] font-bold tracking-widest text-[var(--text-color)] transition-colors hover:text-[var(--accent-color)] uppercase z-10 whitespace-nowrap"
|
||||
>
|
||||
{link.label}
|
||||
|
||||
{hoveredPath === link.path && (
|
||||
<motion.div
|
||||
layoutId="navbar-hover"
|
||||
className="absolute inset-0 bg-[var(--accent-color)]/10 border border-[var(--accent-color)]/50 z-[-1]"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
|
||||
>
|
||||
<div className="absolute bottom-0 left-0 w-1 h-1 border-l border-b border-[var(--accent-color)]" />
|
||||
<div className="absolute top-0 right-0 w-1 h-1 border-r border-t border-[var(--accent-color)]" />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{location.pathname === link.path && (
|
||||
<motion.div
|
||||
layoutId="navbar-active"
|
||||
className="absolute bottom-0 left-0 right-0 h-[2px] bg-[var(--accent-color)] shadow-[0_0_10px_var(--accent-color)]"
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="fixed top-0 w-full z-50 glass h-24 flex items-center transition-all duration-300 border-b border-[var(--glass-border)]">
|
||||
<div className="max-w-[1900px] mx-auto px-6 w-full flex items-center justify-between gap-4">
|
||||
|
||||
<Link to="/" className="flex items-center gap-3 group shrink-0">
|
||||
<div className="p-2 bg-[var(--accent-color)]/10 rounded-lg border border-[var(--accent-color)]/30 group-hover:shadow-[0_0_20px_var(--accent-color)] transition-all">
|
||||
<Cpu className="text-[var(--accent-color)] w-6 h-6" />
|
||||
</div>
|
||||
<span className="font-black text-2xl tracking-widest hidden 2xl:block text-[var(--text-color)]">NEXUS</span>
|
||||
</Link>
|
||||
|
||||
<div className="hidden lg:flex items-center gap-4 flex-1 justify-center">
|
||||
{renderMenu(navLinks)}
|
||||
|
||||
{user && (
|
||||
<>
|
||||
<div className="w-px h-8 bg-[var(--glass-border)] mx-1" />
|
||||
{renderMenu(catalogLink)}
|
||||
|
||||
<div className="w-px h-8 bg-[var(--glass-border)] mx-1" />
|
||||
{/* Группа таблиц оборудования доступна всем авторизованным */}
|
||||
{renderMenu(equipmentLinks)}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="w-px h-8 bg-[var(--glass-border)] mx-1" />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[8px] font-black tracking-widest opacity-70 hidden xl:block ${adminColorClass}`}>
|
||||
ADMIN:
|
||||
</span>
|
||||
{renderMenu(adminManagementLinks)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 shrink-0">
|
||||
<div className="scale-75 origin-right hidden sm:block">
|
||||
<LightDark toggleTheme={toggleTheme} isLight={theme === 'light'} />
|
||||
</div>
|
||||
|
||||
{user ? (
|
||||
<div className="flex items-center gap-4 pl-4 border-l border-[var(--glass-border)]">
|
||||
<button onClick={() => { setIsChatOpen(!isChatOpen); setUnreadCount(0); }}
|
||||
className={`p-2 rounded-full transition-all duration-300 relative ${isChatOpen ? 'bg-[var(--accent-color)] text-black' : 'text-[var(--text-color)] hover:text-[var(--accent-color)] bg-[var(--accent-color)]/10'}`}>
|
||||
<MessageCircle size={18} />
|
||||
{unreadCount > 0 && <span className="absolute -top-1 -right-1 w-2.5 h-2.5 bg-red-500 rounded-full animate-pulse shadow-[0_0_10px_red]" />}
|
||||
</button>
|
||||
|
||||
<div onClick={() => navigate('/kabinet')} className="flex items-center gap-3 cursor-pointer group">
|
||||
<div className="text-right hidden sm:block">
|
||||
<span className={`text-[8px] uppercase block font-black tracking-widest transition-colors ${roleColorClass}`}>{roleLabel}</span>
|
||||
<span className="text-xs font-bold block leading-none text-[var(--text-color)]">{user.name}</span>
|
||||
</div>
|
||||
<UserAvatar user={user} className="w-9 h-9 rounded-full border border-transparent group-hover:border-[var(--accent-color)] transition-all" />
|
||||
</div>
|
||||
|
||||
<button onClick={onLogout} className="text-[var(--text-color)] opacity-50 hover:opacity-100 hover:text-red-500 transition-all">
|
||||
<LogOut size={20} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={onLogin} className="btn-neon text-[10px] font-bold px-5 py-3 flex items-center gap-2 shadow-lg tracking-widest uppercase">
|
||||
<User size={14}/> Войти
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{user && <SupportChat isOpen={isChatOpen} onClose={() => setIsChatOpen(false)} user={user} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavBar;
|
||||
@@ -0,0 +1,553 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Notebook = () => {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="macbook">
|
||||
<div className="inner">
|
||||
<div className="screen">
|
||||
<div className="face-one">
|
||||
<div className="camera" />
|
||||
<div className="display">
|
||||
<div className="shade" />
|
||||
</div>
|
||||
<span>MacBook Air</span>
|
||||
</div>
|
||||
<title>Layer 1</title>
|
||||
</div>
|
||||
<div className="macbody">
|
||||
<div className="face-one">
|
||||
<div className="touchpad">
|
||||
</div>
|
||||
<div className="keyboard">
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key space" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key" />
|
||||
<div className="key f" />
|
||||
<div className="key f" />
|
||||
<div className="key f" />
|
||||
<div className="key f" />
|
||||
<div className="key f" />
|
||||
<div className="key f" />
|
||||
<div className="key f" />
|
||||
<div className="key f" />
|
||||
<div className="key f" />
|
||||
<div className="key f" />
|
||||
<div className="key f" />
|
||||
<div className="key f" />
|
||||
<div className="key f" />
|
||||
<div className="key f" />
|
||||
<div className="key f" />
|
||||
<div className="key f" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="pad one" />
|
||||
<div className="pad two" />
|
||||
<div className="pad three" />
|
||||
<div className="pad four" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="shadow" />
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.macbook {
|
||||
width: 150px;
|
||||
height: 96px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
margin: -85px 0 0 -78px;
|
||||
perspective: 500px;
|
||||
}
|
||||
|
||||
.shadow {
|
||||
position: absolute;
|
||||
width: 60px;
|
||||
height: 0px;
|
||||
left: 40px;
|
||||
top: 160px;
|
||||
transform: rotateX(80deg) rotateY(0deg) rotateZ(0deg);
|
||||
box-shadow: 0 0 60px 40px rgba(0,0,0,0.3);
|
||||
animation: shadow infinite 7s ease;
|
||||
}
|
||||
|
||||
.inner {
|
||||
z-index: 20;
|
||||
position: absolute;
|
||||
width: 150px;
|
||||
height: 96px;
|
||||
left: 0;
|
||||
top: 0;
|
||||
transform-style: preserve-3d;
|
||||
transform: rotateX(-20deg) rotateY(0deg) rotateZ(0deg);
|
||||
animation: rotate infinite 7s ease;
|
||||
}
|
||||
|
||||
.screen {
|
||||
width: 150px;
|
||||
height: 96px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
border-radius: 7px;
|
||||
background: #ddd;
|
||||
transform-style: preserve-3d;
|
||||
transform-origin: 50% 93px;
|
||||
transform: rotateX(0deg) rotateY(0deg) rotateZ(0deg);
|
||||
animation: lid-screen infinite 7s ease;
|
||||
background-image: linear-gradient(45deg, rgba(0,0,0,0.34) 0%,rgba(0,0,0,0) 100%);
|
||||
background-position: left bottom;
|
||||
background-size: 300px 300px;
|
||||
box-shadow: inset 0 3px 7px rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
.screen .logo {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 24px;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
margin: -12px 0 0 -10px;
|
||||
transform: rotateY(180deg) translateZ(0.1px);
|
||||
}
|
||||
|
||||
.screen .face-one {
|
||||
width: 150px;
|
||||
height: 96px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
border-radius: 7px;
|
||||
background: #d3d3d3;
|
||||
transform: translateZ(2px);
|
||||
background-image: linear-gradient(45deg,rgba(0,0,0,0.24) 0%,rgba(0,0,0,0) 100%);
|
||||
}
|
||||
|
||||
.screen .face-one .camera {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 100%;
|
||||
background: #000;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 4px;
|
||||
margin-left: -1.5px;
|
||||
}
|
||||
|
||||
.screen .face-one .display {
|
||||
width: 130px;
|
||||
height: 74px;
|
||||
margin: 10px;
|
||||
background-color: #000;
|
||||
background-size: 100% 100%;
|
||||
border-radius: 1px;
|
||||
position: relative;
|
||||
box-shadow: inset 0 0 2px rgba(0,0,0,1);
|
||||
}
|
||||
|
||||
.screen .face-one .display .shade {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 130px;
|
||||
height: 74px;
|
||||
background: linear-gradient(-135deg, rgba(255,255,255,0) 0%,rgba(255,255,255,0.1) 47%,rgba(255,255,255,0) 48%);
|
||||
animation: screen-shade infinite 7s ease;
|
||||
background-size: 300px 200px;
|
||||
background-position: 0px 0px;
|
||||
}
|
||||
|
||||
.screen .face-one span {
|
||||
position: absolute;
|
||||
top: 85px;
|
||||
left: 57px;
|
||||
font-size: 6px;
|
||||
color: #666
|
||||
}
|
||||
|
||||
.macbody {
|
||||
width: 150px;
|
||||
height: 96px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
border-radius: 7px;
|
||||
background: #cbcbcb;
|
||||
transform-style: preserve-3d;
|
||||
transform-origin: 50% bottom;
|
||||
transform: rotateX(-90deg);
|
||||
animation: lid-macbody infinite 7s ease;
|
||||
background-image: linear-gradient(45deg, rgba(0,0,0,0.24) 0%,rgba(0,0,0,0) 100%);
|
||||
}
|
||||
|
||||
.macbody .face-one {
|
||||
width: 150px;
|
||||
height: 96px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
border-radius: 7px;
|
||||
transform-style: preserve-3d;
|
||||
background: #dfdfdf;
|
||||
animation: lid-keyboard-area infinite 7s ease;
|
||||
transform: translateZ(-2px);
|
||||
background-image: linear-gradient(30deg, rgba(0,0,0,0.24) 0%,rgba(0,0,0,0) 100%);
|
||||
}
|
||||
|
||||
.macbody .touchpad {
|
||||
width: 40px;
|
||||
height: 31px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
border-radius: 4px;
|
||||
margin: -44px 0 0 -18px;
|
||||
background: #cdcdcd;
|
||||
background-image: linear-gradient(30deg, rgba(0,0,0,0.24) 0%,rgba(0,0,0,0) 100%);
|
||||
box-shadow: inset 0 0 3px #888;
|
||||
}
|
||||
|
||||
.macbody .keyboard {
|
||||
width: 130px;
|
||||
height: 45px;
|
||||
position: absolute;
|
||||
left: 7px;
|
||||
top: 41px;
|
||||
border-radius: 4px;
|
||||
transform-style: preserve-3d;
|
||||
background: #cdcdcd;
|
||||
background-image: linear-gradient(30deg, rgba(0,0,0,0.24) 0%,rgba(0,0,0,0) 100%);
|
||||
box-shadow: inset 0 0 3px #777;
|
||||
padding: 0 0 0 2px;
|
||||
}
|
||||
|
||||
.keyboard .key {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #444;
|
||||
float: left;
|
||||
margin: 1px;
|
||||
transform: translateZ(-2px);
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 -2px 0 #222;
|
||||
animation: keys infinite 7s ease;
|
||||
}
|
||||
|
||||
.key.space {
|
||||
width: 45px;
|
||||
}
|
||||
|
||||
.key.f {
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.macbody .pad {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
background: #333;
|
||||
border-radius: 100%;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.pad.one {
|
||||
left: 20px;
|
||||
top: 20px;
|
||||
}
|
||||
|
||||
.pad.two {
|
||||
right: 20px;
|
||||
top: 20px;
|
||||
}
|
||||
|
||||
.pad.three {
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
}
|
||||
|
||||
.pad.four {
|
||||
left: 20px;
|
||||
bottom: 20px;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotateX(-20deg) rotateY(0deg) rotateZ(0deg);
|
||||
}
|
||||
|
||||
5% {
|
||||
transform: rotateX(-20deg) rotateY(-20deg) rotateZ(0deg);
|
||||
}
|
||||
|
||||
20% {
|
||||
transform: rotateX(30deg) rotateY(200deg) rotateZ(0deg);
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: rotateX(-60deg) rotateY(150deg) rotateZ(0deg);
|
||||
}
|
||||
|
||||
60% {
|
||||
transform: rotateX(-20deg) rotateY(130deg) rotateZ(0deg);
|
||||
}
|
||||
|
||||
65% {
|
||||
transform: rotateX(-20deg) rotateY(120deg) rotateZ(0deg);
|
||||
}
|
||||
|
||||
80% {
|
||||
transform: rotateX(-20deg) rotateY(375deg) rotateZ(0deg);
|
||||
}
|
||||
|
||||
85% {
|
||||
transform: rotateX(-20deg) rotateY(357deg) rotateZ(0deg);
|
||||
}
|
||||
|
||||
87% {
|
||||
transform: rotateX(-20deg) rotateY(360deg) rotateZ(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotateX(-20deg) rotateY(360deg) rotateZ(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lid-screen {
|
||||
0% {
|
||||
transform: rotateX(0deg);
|
||||
background-position: left bottom;
|
||||
}
|
||||
|
||||
5% {
|
||||
transform: rotateX(50deg);
|
||||
background-position: left bottom;
|
||||
}
|
||||
|
||||
20% {
|
||||
transform: rotateX(-90deg);
|
||||
background-position: -150px top;
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: rotateX(15deg);
|
||||
background-position: left bottom;
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: rotateX(-5deg);
|
||||
background-position: right top;
|
||||
}
|
||||
|
||||
38% {
|
||||
transform: rotateX(5deg);
|
||||
background-position: right top;
|
||||
}
|
||||
|
||||
48% {
|
||||
transform: rotateX(0deg);
|
||||
background-position: right top;
|
||||
}
|
||||
|
||||
90% {
|
||||
transform: rotateX(0deg);
|
||||
background-position: right top;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotateX(0deg);
|
||||
background-position: right center;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lid-macbody {
|
||||
0% {
|
||||
transform: rotateX(-90deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotateX(-90deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotateX(-90deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lid-keyboard-area {
|
||||
0% {
|
||||
background-color: #dfdfdf;
|
||||
}
|
||||
|
||||
50% {
|
||||
background-color: #bbb;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-color: #dfdfdf;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes screen-shade {
|
||||
0% {
|
||||
background-position: -20px 0px;
|
||||
}
|
||||
|
||||
5% {
|
||||
background-position: -40px 0px;
|
||||
}
|
||||
|
||||
20% {
|
||||
background-position: 200px 0;
|
||||
}
|
||||
|
||||
50% {
|
||||
background-position: -200px 0;
|
||||
}
|
||||
|
||||
80% {
|
||||
background-position: 0px 0px;
|
||||
}
|
||||
|
||||
85% {
|
||||
background-position: -30px 0;
|
||||
}
|
||||
|
||||
90% {
|
||||
background-position: -20px 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -20px 0px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes keys {
|
||||
0% {
|
||||
box-shadow: 0 -2px 0 #222;
|
||||
}
|
||||
|
||||
5% {
|
||||
box-shadow: 1 -1px 0 #222;
|
||||
}
|
||||
|
||||
20% {
|
||||
box-shadow: -1px 1px 0 #222;
|
||||
}
|
||||
|
||||
25% {
|
||||
box-shadow: -1px 1px 0 #222;
|
||||
}
|
||||
|
||||
60% {
|
||||
box-shadow: -1px 1px 0 #222;
|
||||
}
|
||||
|
||||
80% {
|
||||
box-shadow: 0 -2px 0 #222;
|
||||
}
|
||||
|
||||
85% {
|
||||
box-shadow: 0 -2px 0 #222;
|
||||
}
|
||||
|
||||
87% {
|
||||
box-shadow: 0 -2px 0 #222;
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 -2px 0 #222;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shadow {
|
||||
0% {
|
||||
transform: rotateX(80deg) rotateY(0deg) rotateZ(0deg);
|
||||
box-shadow: 0 0 60px 40px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
5% {
|
||||
transform: rotateX(80deg) rotateY(10deg) rotateZ(0deg);
|
||||
box-shadow: 0 0 60px 40px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
20% {
|
||||
transform: rotateX(30deg) rotateY(-20deg) rotateZ(-20deg);
|
||||
box-shadow: 0 0 50px 30px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: rotateX(80deg) rotateY(-20deg) rotateZ(50deg);
|
||||
box-shadow: 0 0 35px 15px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
60% {
|
||||
transform: rotateX(80deg) rotateY(0deg) rotateZ(-50deg) translateX(30px);
|
||||
box-shadow: 0 0 60px 40px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 60px 40px rgba(0,0,0,0.3);
|
||||
}
|
||||
}`;
|
||||
|
||||
export default Notebook;
|
||||
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const OsCore = () => {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="loader">
|
||||
<div className="inner one" />
|
||||
<div className="inner two" />
|
||||
<div className="inner three" />
|
||||
<div className="core-text">OS</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.loader {
|
||||
position: relative;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border-radius: 50%;
|
||||
perspective: 800px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.inner {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Внешнее кольцо */
|
||||
.inner.one {
|
||||
left: 0%;
|
||||
top: 0%;
|
||||
animation: rotate-one 1.5s linear infinite;
|
||||
border-bottom: 4px solid var(--accent-color);
|
||||
box-shadow: 0 0 10px var(--accent-color);
|
||||
filter: drop-shadow(0 0 5px var(--accent-color));
|
||||
}
|
||||
|
||||
/* Среднее кольцо */
|
||||
.inner.two {
|
||||
right: 0%;
|
||||
top: 0%;
|
||||
animation: rotate-two 1.5s linear infinite;
|
||||
border-right: 4px solid var(--text-color);
|
||||
box-shadow: 0 0 10px var(--text-color);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Внутреннее кольцо */
|
||||
.inner.three {
|
||||
right: 0%;
|
||||
bottom: 0%;
|
||||
width: 70%;
|
||||
height: 70%;
|
||||
margin: 15%; /* Центрирование (100-70)/2 */
|
||||
animation: rotate-three 1.5s linear infinite;
|
||||
border-top: 4px solid #a855f7; /* Purple */
|
||||
box-shadow: 0 0 10px #a855f7;
|
||||
filter: drop-shadow(0 0 5px #a855f7);
|
||||
}
|
||||
|
||||
/* Текст в центре */
|
||||
.core-text {
|
||||
font-family: monospace;
|
||||
font-weight: 900;
|
||||
font-size: 24px;
|
||||
color: var(--text-color);
|
||||
animation: pulse 2s infinite;
|
||||
text-shadow: 0 0 10px var(--accent-color);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@keyframes rotate-one {
|
||||
0% { transform: rotateX(35deg) rotateY(-45deg) rotateZ(0deg); }
|
||||
100% { transform: rotateX(35deg) rotateY(-45deg) rotateZ(360deg); }
|
||||
}
|
||||
|
||||
@keyframes rotate-two {
|
||||
0% { transform: rotateX(50deg) rotateY(10deg) rotateZ(0deg); }
|
||||
100% { transform: rotateX(50deg) rotateY(10deg) rotateZ(360deg); }
|
||||
}
|
||||
|
||||
@keyframes rotate-three {
|
||||
0% { transform: rotateX(35deg) rotateY(55deg) rotateZ(0deg); }
|
||||
100% { transform: rotateX(35deg) rotateY(55deg) rotateZ(360deg); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(0.9); }
|
||||
}
|
||||
`;
|
||||
|
||||
export default OsCore;
|
||||
@@ -0,0 +1,161 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Pogoda = () => {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="weather-card">
|
||||
|
||||
{/* Верхняя часть: Основная инфо */}
|
||||
<div className="main-info">
|
||||
<div className="icon-container">
|
||||
{/* SVG Иконка: Облачно с прояснениями */}
|
||||
<svg viewBox="0 0 64 64" className="weather-icon">
|
||||
<circle cx="20" cy="20" r="12" fill="#f59e0b" />
|
||||
<path d="M46,46 H26 c-3.3,0-6-2.7-6-6 s2.7-6,6-6 h2.5 c0.9-4.3,4.7-7.5,9.1-7.5 c5.2,0,9.4,4.2,9.4,9.4 c0,0.3,0,0.7-0.1,1 H46 c2.8,0,5,2.2,5,5 S48.8,46,46,46z" fill="#fff" stroke="#94a3b8" strokeWidth="2" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-container">
|
||||
<div className="temp">18°C</div>
|
||||
<div className="city">Ангарск</div>
|
||||
<div className="desc">Иркутская обл.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Нижняя часть: Детали (Раскрывается при наведении) */}
|
||||
<div className="details">
|
||||
|
||||
<div className="detail-item">
|
||||
<svg viewBox="0 0 24 24" className="detail-icon">
|
||||
<path fill="#3b82f6" d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z" />
|
||||
</svg>
|
||||
<span>78%</span>
|
||||
<small>Влага</small>
|
||||
</div>
|
||||
|
||||
<div className="detail-item">
|
||||
<svg viewBox="0 0 24 24" className="detail-icon">
|
||||
<path fill="#94a3b8" d="M4 11h13.5c.8 0 1.5.7 1.5 1.5s-.7 1.5-1.5 1.5H4v-3zm0-7h13.5c.8 0 1.5.7 1.5 1.5S18.3 7 17.5 7H4V4z" />
|
||||
</svg>
|
||||
<span>4 м/с</span>
|
||||
<small>Ветер</small>
|
||||
</div>
|
||||
|
||||
<div className="detail-item">
|
||||
<svg viewBox="0 0 24 24" className="detail-icon">
|
||||
<circle cx="12" cy="12" r="9" fill="none" stroke="#f43f5e" strokeWidth="2"/>
|
||||
<path d="M12 12 L14 14" stroke="#f43f5e" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
<span>752</span>
|
||||
<small>мм рт.ст</small>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
/* Стили контейнера */
|
||||
.weather-card {
|
||||
width: 200px;
|
||||
height: 90px; /* Компактная высота по умолчанию */
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 20px;
|
||||
padding: 15px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
||||
border: 1px solid rgba(255,255,255,0.5);
|
||||
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
overflow: hidden;
|
||||
cursor: default;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
/* При наведении карточка растет */
|
||||
.weather-card:hover {
|
||||
height: 180px;
|
||||
width: 220px;
|
||||
transform: translateY(-10px);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* Верхняя часть */
|
||||
.main-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.weather-icon {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.text-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.temp {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
color: #1e293b;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.city {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #475569;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* Детали (скрыты или сжаты по умолчанию) */
|
||||
.details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
transition: all 0.3s ease;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
padding-top: 15px;
|
||||
}
|
||||
|
||||
.weather-card:hover .details {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.detail-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.detail-item span {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.detail-item small {
|
||||
font-size: 9px;
|
||||
color: #64748b;
|
||||
}
|
||||
`;
|
||||
|
||||
export default Pogoda;
|
||||
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Radar = () => {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="loader">
|
||||
<span />
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.loader {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: var(--glass-bg);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 50px var(--shadow-color);
|
||||
border: 1px solid var(--text-color);
|
||||
opacity: 0.8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.loader::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 20px;
|
||||
background: transparent;
|
||||
border: 1px dashed var(--text-color);
|
||||
opacity: 0.3;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.loader::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: 1px dashed var(--text-color);
|
||||
opacity: 0.5;
|
||||
}
|
||||
.loader span {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
transform-origin: top left;
|
||||
animation: radar81 2s linear infinite;
|
||||
border-top: 1px dashed var(--text-color);
|
||||
}
|
||||
.loader span::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--accent-color);
|
||||
transform-origin: top left;
|
||||
transform: rotate(-55deg);
|
||||
filter: blur(30px) drop-shadow(20px 20px 20px var(--accent-color));
|
||||
opacity: 0.6;
|
||||
}
|
||||
@keyframes radar81 {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`;
|
||||
|
||||
export default Radar;
|
||||
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Button = () => {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="btn-wrapper">
|
||||
<button className="btn">
|
||||
<span className="btn-txt">REC</span>
|
||||
<div className="dot pulse" />
|
||||
</button>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.btn-wrapper {
|
||||
/* Масштабируем кнопку, чтобы она была компактной */
|
||||
transform: scale(0.7);
|
||||
transform-origin: center right;
|
||||
|
||||
--width: 120px;
|
||||
--height: 50px;
|
||||
--padding: 4px;
|
||||
--border-radius: 30px;
|
||||
--dot-size: 10px;
|
||||
--btn-color: #202020;
|
||||
--hue: 355deg;
|
||||
--animation-duration: 1.2s;
|
||||
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--width);
|
||||
height: var(--height);
|
||||
border-radius: var(--border-radius);
|
||||
border: none;
|
||||
background-color: rgba(0,0,0,0.1);
|
||||
box-shadow: 1px 1px 2px 0 rgba(255,255,255,0.1), 2px 2px 2px rgba(0,0,0,0.1) inset;
|
||||
user-select: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
width: calc(100% - 2 * var(--padding));
|
||||
height: calc(100% - 2 * var(--padding));
|
||||
border-radius: calc(var(--border-radius) - var(--padding));
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(rgba(255,255,255,0.1), rgba(0,0,0,0.1)), var(--btn-color);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
|
||||
transition: all 0.2s ease;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.btn-txt {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
font-family: monospace;
|
||||
letter-spacing: 1px;
|
||||
color: #fff;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dot {
|
||||
position: relative;
|
||||
width: var(--dot-size);
|
||||
height: var(--dot-size);
|
||||
border-radius: 50%;
|
||||
background-color: #ff0000;
|
||||
box-shadow: 0 0 10px #ff0000;
|
||||
}
|
||||
|
||||
/* Анимация пульсации */
|
||||
.pulse {
|
||||
animation: pulse-red 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-red {
|
||||
0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(255, 0, 0, 0.7); }
|
||||
70% { transform: scale(1); box-shadow: 0 0 0 6px rgba(255, 0, 0, 0); }
|
||||
100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(255, 0, 0, 0); }
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
.btn:active {
|
||||
transform: translateY(1px);
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
`;
|
||||
|
||||
export default Button;
|
||||
@@ -0,0 +1,171 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Server = () => {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="container_SevMini">
|
||||
<div className="SevMini">
|
||||
<svg width={74} height={90} viewBox="0 0 74 90" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M40 76.5L72 57V69.8615C72 70.5673 71.628 71.2209 71.0211 71.5812L40 90V76.5Z" fill="#396CAA" />
|
||||
<path d="M34 75.7077L2 57V69.8615C2 70.5673 2.37203 71.2209 2.97892 71.5812L34 90V75.7077Z" fill="#396DAC" />
|
||||
<path d="M34 76.5H40V90H34V76.5Z" fill="#396CAA" />
|
||||
<path d="M3.27905 55.593L35.2806 37.5438C36.3478 36.9419 37.6522 36.9419 38.7194 37.5438L70.721 55.593C71.7294 56.1618 71.7406 57.6102 70.7411 58.1945L39.2712 76.593C37.8682 77.4133 36.1318 77.4133 34.7288 76.593L3.25887 58.1945C2.25937 57.6102 2.27061 56.1618 3.27905 55.593Z" fill="#163C79" stroke="#396CAA" />
|
||||
<path d="M40 79L72 60V70.4001C72 71.1151 71.6183 71.7758 70.9987 72.1329L40 90V79Z" fill="#173D7A" />
|
||||
<path d="M34 79L3 61V71.5751L34 90V79Z" fill="#0665B2" />
|
||||
<path id="strobe_color1" d="M58 72.5L60.5 71V74L58 75.5V72.5Z" fill="#FF715E" />
|
||||
<path id="strobe_color2" d="M63 69.5L65.5 68V71L63 72.5V69.5Z" fill="#17e300b4" />
|
||||
<path d="M68 66.5L70.5 65V68L68 69.5V66.5Z" fill="#FF715E" />
|
||||
<path d="M40 58.5L72 39V51.8615C72 52.5673 71.628 53.2209 71.0211 53.5812L40 72V58.5Z" fill="#396CAA" />
|
||||
<path d="M34 57.7077L2 39V51.8615C2 52.5673 2.37203 53.2209 2.97892 53.5812L34 72V57.7077Z" fill="#396DAC" />
|
||||
<path d="M34 58.5H40V72H34V58.5Z" fill="#396CAA" />
|
||||
<path d="M3.27905 37.593L35.2806 19.5438C36.3478 18.9419 37.6522 18.9419 38.7194 19.5438L70.721 37.593C71.7294 38.1618 71.7406 39.6102 70.7411 40.1945L39.2712 58.593C37.8682 59.4133 36.1318 59.4133 34.7288 58.593L3.25887 40.1945C2.25937 39.6102 2.27061 38.1618 3.27905 37.593Z" fill="#163C79" stroke="#396CAA" />
|
||||
<path d="M40 61L72 42V52.4001C72 53.1151 71.6183 53.7758 70.9987 54.1329L40 72V61Z" fill="#173D7A" />
|
||||
<path d="M34 61L3 43V53.5751L34 72V61Z" fill="#0665B2" />
|
||||
<path d="M58 54.5L60.5 53V56L58 57.5V54.5Z" fill="#FF715E" />
|
||||
<path d="M63 51.5L65.5 50V53L63 54.5V51.5Z" fill="black" />
|
||||
<path id="strobe_color1" d="M63 51.5L65.5 50V53L63 54.5V51.5Z" fill="#FF715E" />
|
||||
<path d="M68 48.5L70.5 47V50L68 51.5V48.5Z" fill="#FF715E" />
|
||||
<path d="M40 40.5L72 21V33.8615C72 34.5673 71.628 35.2209 71.0211 35.5812L40 54V40.5Z" fill="#396CAA" />
|
||||
<path d="M34 39.7077L2 21V33.8615C2 34.5673 2.37203 35.2209 2.97892 35.5812L34 54V39.7077Z" fill="#396DAC" />
|
||||
<path d="M34 40.5H40V54H34V40.5Z" fill="#396CAA" />
|
||||
<path d="M3.27905 19.593L35.2806 1.54381C36.3478 0.941872 37.6522 0.941872 38.7194 1.54381L70.721 19.593C71.7294 20.1618 71.7406 21.6102 70.7411 22.1945L39.2712 40.593C37.8682 41.4133 36.1318 41.4133 34.7288 40.593L3.25887 22.1945C2.25937 21.6102 2.27061 20.1618 3.27905 19.593Z" fill="#124E89" stroke="#396CAA" />
|
||||
<path d="M40 43L72 24V34.4001C72 35.1151 71.6183 35.7758 70.9987 36.1329L40 54V43Z" fill="#173D7A" />
|
||||
<path d="M34 43L3 25V35.5751L34 54V43Z" fill="#0665B2" />
|
||||
<path d="M68 30.5L70.5 29V32L68 33.5V30.5Z" fill="#FF715E" />
|
||||
<path id="strobe_color3" d="M58 36.5L60.5 35V38L58 39.5V36.5Z" fill="#FF715E" />
|
||||
<path d="M63 33.5L65.5 32V35L63 36.5V33.5Z" fill="#FF715E" />
|
||||
<path d="M20.1902 22.0719C18.8101 21.3026 18.8252 19.3119 20.2168 18.5636L36.1054 10.0189C37.2884 9.3827 38.7116 9.3827 39.8946 10.0189L55.7832 18.5636C57.1748 19.3119 57.1899 21.3026 55.8098 22.0719L40.4345 30.6429C38.9211 31.4865 37.0789 31.4865 35.5655 30.6429L20.1902 22.0719Z" fill="#396CAA" />
|
||||
<path d="M11 52.755C11 51.9801 11.8432 51.4997 12.5098 51.8947L23.5196 58.419C24.1273 58.7792 24.5 59.4332 24.5 60.1396V60.245C24.5 61.0199 23.6568 61.5003 22.9902 61.1053L11.9804 54.581C11.3727 54.2208 11 53.5668 11 52.8604V52.755Z" fill="#396CAA" />
|
||||
<mask id="mask0_2_176" style={{maskType: 'alpha'}} maskUnits="userSpaceOnUse" x={11} y={51} width={14} height={11}>
|
||||
<path d="M11 52.755C11 51.9801 11.8432 51.4997 12.5098 51.8947L23.5196 58.419C24.1273 58.7792 24.5 59.4332 24.5 60.1396V60.245C24.5 61.0199 23.6568 61.5003 22.9902 61.1053L11.9804 54.581C11.3727 54.2208 11 53.5668 11 52.8604V52.755Z" fill="#396CAA" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2_176)">
|
||||
<path d="M11.5 52.7417C11.5 51.9803 12.3349 51.5138 12.9833 51.9128L23.5482 58.4143C24.1397 58.7783 24.5 59.4231 24.5 60.1176V61.5L12.4598 54.4195C11.8651 54.0698 11.5 53.4315 11.5 52.7417V52.7417Z" fill="#163874" />
|
||||
</g>
|
||||
<mask id="mask1_2_176" style={{maskType: 'alpha'}} maskUnits="userSpaceOnUse" x={19} y={9} width={38} height={23}>
|
||||
<path d="M20.1902 22.0719C18.8101 21.3026 18.8252 19.3119 20.2168 18.5636L36.1054 10.0189C37.2884 9.3827 38.7116 9.3827 39.8946 10.0189L55.7832 18.5636C57.1748 19.3119 57.1899 21.3026 55.8098 22.0719L40.4345 30.6429C38.9211 31.4865 37.0789 31.4865 35.5655 30.6429L20.1902 22.0719Z" fill="#396CAA" />
|
||||
</mask>
|
||||
<g mask="url(#mask1_2_176)">
|
||||
<path d="M18 21.3115L36.167 11.9451C37.3171 11.3521 38.6829 11.3521 39.833 11.9451L58 21.3115L40.3567 30.7405C38.8841 31.5275 37.1159 31.5275 35.6433 30.7405L18 21.3115Z" fill="#173D7A" />
|
||||
</g>
|
||||
<path d="M37.447 21.565L35 19.9799L37.6941 18.66L40.141 20.245L37.447 21.565Z" fill="#FF715E" />
|
||||
<path d="M48.9738 30.8646L47.0741 29.7745L49.1792 28.684L51.0789 29.7741L48.9738 30.8646Z" fill="#173E7B" />
|
||||
<path d="M52.0661 29.0093L50.1635 27.9242L52.2657 26.8282L54.1682 27.9133L52.0661 29.0093Z" fill="#173E7B" />
|
||||
<path id="strobe_led1" d="M55.1521 27.1464L53.2538 26.054L55.3602 24.9661L57.2585 26.0586L55.1521 27.1464Z" fill="#3A6DAB" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="Ghost">
|
||||
<svg width={60} height={36} viewBox="0 0 60 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.96545 19.4296C0.643777 18.6484 0.658726 16.7309 1.99242 15.9705L28.0186 1.12982C29.2467 0.429534 30.7533 0.429533 31.9814 1.12982L58.0076 15.9704C59.3413 16.7309 59.3562 18.6484 58.0346 19.4296L32.5442 34.4962C30.9749 35.4238 29.0251 35.4238 27.4558 34.4962L1.96545 19.4296Z" fill="#3C4F6D" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.container_SevMini {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.Ghost {
|
||||
transform: translate(0px, -25px);
|
||||
z-index: -1;
|
||||
animation: opacidad 4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes opacidad {
|
||||
0% {
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
scale: 0.9;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes estroboscopico {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
51% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rebote {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes estroboscopico1 {
|
||||
0%,
|
||||
50%,
|
||||
100% {
|
||||
fill: rgb(255, 95, 74);
|
||||
}
|
||||
|
||||
25%,
|
||||
75% {
|
||||
fill: rgb(16, 53, 115);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes estroboscopico2 {
|
||||
0%,
|
||||
50%,
|
||||
100% {
|
||||
fill: #17e300;
|
||||
}
|
||||
|
||||
25%,
|
||||
75% {
|
||||
fill: #17e300b4;
|
||||
}
|
||||
}
|
||||
|
||||
.SevMini {
|
||||
animation: rebote 4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
#strobe_led1 {
|
||||
animation: estroboscopico 0.5s infinite;
|
||||
}
|
||||
|
||||
#strobe_color1 {
|
||||
animation: estroboscopico2 0.8s infinite;
|
||||
}
|
||||
|
||||
#strobe_color3 {
|
||||
animation: estroboscopico1 0.8s infinite;
|
||||
animation-delay: 3s;
|
||||
}`;
|
||||
|
||||
export default Server;
|
||||
@@ -0,0 +1,215 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Social = () => {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="card">
|
||||
<img src="https://uiverse.io/astronaut.png" alt="" className="image" />
|
||||
<div className="heading">Социальные сети</div>
|
||||
<div className="icons">
|
||||
<a href="https://www.instagram.com/uiverse.io/" className="instagram">
|
||||
<svg width={24} height={25} viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.0459 7.5H17.0559M3.0459 12.5C3.0459 9.986 3.0459 8.73 3.3999 7.72C3.71249 6.82657 4.22237 6.01507 4.89167 5.34577C5.56096 4.67647 6.37247 4.16659 7.2659 3.854C8.2759 3.5 9.5329 3.5 12.0459 3.5C14.5599 3.5 15.8159 3.5 16.8269 3.854C17.7202 4.16648 18.5317 4.67621 19.201 5.34533C19.8702 6.01445 20.3802 6.82576 20.6929 7.719C21.0459 8.729 21.0459 9.986 21.0459 12.5C21.0459 15.014 21.0459 16.27 20.6929 17.28C20.3803 18.1734 19.8704 18.9849 19.2011 19.6542C18.5318 20.3235 17.7203 20.8334 16.8269 21.146C15.8169 21.5 14.5599 21.5 12.0469 21.5C9.5329 21.5 8.2759 21.5 7.2659 21.146C6.37268 20.8336 5.56131 20.324 4.89202 19.6551C4.22274 18.9862 3.71274 18.1751 3.3999 17.282C3.0459 16.272 3.0459 15.015 3.0459 12.501V12.5ZM15.8239 11.94C15.9033 12.4387 15.8829 12.9481 15.7641 13.4389C15.6453 13.9296 15.4304 14.392 15.1317 14.7991C14.833 15.2063 14.4566 15.5501 14.0242 15.8108C13.5917 16.0715 13.1119 16.2439 12.6124 16.318C12.1129 16.392 11.6037 16.3663 11.1142 16.2422C10.6248 16.1182 10.1648 15.8983 9.76082 15.5953C9.35688 15.2923 9.01703 14.9123 8.76095 14.4771C8.50486 14.0419 8.33762 13.5602 8.2689 13.06C8.13201 12.0635 8.39375 11.0533 8.99727 10.2487C9.6008 9.44407 10.4974 8.91002 11.4923 8.76252C12.4873 8.61503 13.5002 8.86599 14.3112 9.46091C15.1222 10.0558 15.6658 10.9467 15.8239 11.94Z" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</a>
|
||||
<a href="https://twitter.com/uiverse_io" className="x">
|
||||
<svg width={24} height={24} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19.8003 3L13.5823 10.105L19.9583 19.106C20.3923 19.719 20.6083 20.025 20.5983 20.28C20.594 20.3896 20.5657 20.4969 20.5154 20.5943C20.4651 20.6917 20.3941 20.777 20.3073 20.844C20.1043 21 19.7293 21 18.9793 21H17.2903C16.8353 21 16.6083 21 16.4003 20.939C16.2168 20.8847 16.0454 20.7957 15.8953 20.677C15.7253 20.544 15.5943 20.358 15.3313 19.987L10.6813 13.421L4.64033 4.894C4.20733 4.281 3.99033 3.975 4.00033 3.72C4.00478 3.61035 4.03323 3.50302 4.08368 3.40557C4.13414 3.30812 4.20536 3.22292 4.29233 3.156C4.49433 3 4.87033 3 5.62033 3H7.30833C7.76333 3 7.99033 3 8.19733 3.061C8.38119 3.1152 8.55295 3.20414 8.70333 3.323C8.87333 3.457 9.00433 3.642 9.26733 4.013L13.5833 10.105M4.05033 21L10.6823 13.421" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</a>
|
||||
<a href="https://discord.gg/KD8ba2uUpT" className="discord">
|
||||
<svg width={25} height={25} viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.5989 6.5003H14.2919C14.3851 6.5003 14.4764 6.47427 14.5555 6.42515C14.6347 6.37603 14.6985 6.30577 14.7399 6.2223L15.4179 4.8543C15.4664 4.75358 15.5488 4.67313 15.6506 4.62706C15.7524 4.58098 15.8673 4.57222 15.9749 4.6023C16.6309 4.7903 18.0049 5.2433 19.1029 6.0003C22.9669 8.8973 22.6069 15.3903 22.5779 16.7603C22.5765 16.8444 22.5541 16.9269 22.5129 17.0003C20.5299 20.5003 17.0899 20.5003 17.0899 20.5003L15.9239 18.0743M15.9239 18.0743C16.4479 17.9163 17.0029 17.7253 17.6029 17.5003M15.9239 18.0743C13.4799 18.8093 11.7219 18.8083 9.27791 18.0733M13.5989 6.5003H10.9109C10.8179 6.50039 10.7266 6.47451 10.6475 6.42557C10.5683 6.37664 10.5044 6.30659 10.4629 6.2233L9.77991 4.8533C9.73146 4.75279 9.64925 4.6725 9.54762 4.62644C9.446 4.58038 9.33142 4.57148 9.22391 4.6013C8.56891 4.7893 7.19291 5.2433 6.09391 6.0003C2.23091 8.8973 2.59091 15.3903 2.61991 16.7603C2.62132 16.8445 2.64366 16.9269 2.68491 17.0003C4.66791 20.5003 8.10791 20.5003 8.10791 20.5003L9.27791 18.0733M9.27791 18.0733C8.75491 17.9163 8.19891 17.7253 7.59891 17.5003M10.6009 12.5003C10.6009 12.7655 10.4956 13.0199 10.308 13.2074C10.1205 13.3949 9.86612 13.5003 9.60091 13.5003C9.33569 13.5003 9.08134 13.3949 8.8938 13.2074C8.70626 13.0199 8.60091 12.7655 8.60091 12.5003C8.60091 12.2351 8.70626 11.9807 8.8938 11.7932C9.08134 11.6057 9.33569 11.5003 9.60091 11.5003C9.86612 11.5003 10.1205 11.6057 10.308 11.7932C10.4956 11.9807 10.6009 12.2351 10.6009 12.5003ZM16.6029 12.5003C16.6029 12.7655 16.4976 13.0199 16.31 13.2074C16.1225 13.3949 15.8681 13.5003 15.6029 13.5003C15.3377 13.5003 15.0833 13.3949 14.8958 13.2074C14.7083 13.0199 14.6029 12.7655 14.6029 12.5003C14.6029 12.2351 14.7083 11.9807 14.8958 11.7932C15.0833 11.6057 15.3377 11.5003 15.6029 11.5003C15.8681 11.5003 16.1225 11.6057 16.31 11.7932C16.4976 11.9807 16.6029 12.2351 16.6029 12.5003Z" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
/* CSS Variables for colors */
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
/* Уменьшил размеры */
|
||||
width: 14em;
|
||||
height: 18em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* Адаптивный фон и цвет текста */
|
||||
background-color: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--glass-border);
|
||||
font-family: Montserrat, sans-serif;
|
||||
font-weight: bold;
|
||||
padding: 1em;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
row-gap: 0.8em;
|
||||
}
|
||||
|
||||
.card img {
|
||||
/* Уменьшил картинку */
|
||||
width: 8em;
|
||||
margin-right: 0.5em;
|
||||
animation: move 10s ease-in-out infinite;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.icons svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* Гравитация и фон (тут используем переменные для теней) */
|
||||
.card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
inset: -3px;
|
||||
border-radius: 10px;
|
||||
background: radial-gradient(var(--text-color), transparent, transparent);
|
||||
opacity: 0.1;
|
||||
transform: translate(-5px, 250px);
|
||||
transition: 0.4s ease-in-out;
|
||||
z-index: -1;
|
||||
}
|
||||
.card:hover::before {
|
||||
width: 150%;
|
||||
height: 100%;
|
||||
margin-left: -4.25em;
|
||||
opacity: 0.1;
|
||||
}
|
||||
.card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 2px;
|
||||
border-radius: 20px;
|
||||
background: var(--card-bg);
|
||||
opacity: 0.9;
|
||||
transition: all 0.4s ease-in-out;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.heading {
|
||||
z-index: 2;
|
||||
transition: 0.4s ease-in-out;
|
||||
font-size: 0.9em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ЗВЕЗДЫ (ТЕНИ) - заменяем #fff на var(--text-color) */
|
||||
.heading::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0; left: 0; width: 2px; height: 2px;
|
||||
border-radius: 50%;
|
||||
opacity: 0.5;
|
||||
/* Генерируем "звезды" используя цвет текста (белый в темной теме, черный в светлой) */
|
||||
box-shadow:
|
||||
220px 118px var(--text-color), 280px 176px var(--text-color), 40px 50px var(--text-color),
|
||||
60px 180px var(--text-color), 120px 130px var(--text-color), 180px 176px var(--text-color),
|
||||
220px 290px var(--text-color), 520px 250px var(--text-color), 400px 220px var(--text-color),
|
||||
50px 350px var(--text-color), 10px 230px var(--text-color);
|
||||
z-index: -1;
|
||||
transition: 1s ease;
|
||||
animation: 1s glowing-stars linear alternate infinite;
|
||||
}
|
||||
|
||||
/* Другие слои звезд */
|
||||
.icons::before {
|
||||
content: ""; position: absolute; top: 0; left: 0; width: 2px; height: 2px;
|
||||
border-radius: 50%; opacity: 0.5;
|
||||
box-shadow:
|
||||
140px 20px var(--text-color), 425px 20px var(--text-color), 70px 120px var(--text-color),
|
||||
20px 130px var(--text-color), 110px 80px var(--text-color), 280px 80px var(--text-color);
|
||||
z-index: -1;
|
||||
transition: 1.5s ease;
|
||||
animation: 1s glowing-stars linear alternate infinite;
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
/* Анимации и эффекты при наведении и нажатии */
|
||||
.card:hover .heading::before,
|
||||
.card:hover .icons::before {
|
||||
filter: blur(3px);
|
||||
}
|
||||
|
||||
.heading::after {
|
||||
content: "";
|
||||
top: -8.5%; left: -8.5%; position: absolute;
|
||||
width: 7.5em; height: 7.5em;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-color);
|
||||
box-shadow: 0px 0px 100px var(--accent-color), inset var(--accent-color) 0px 0px 40px -12px;
|
||||
opacity: 0.2;
|
||||
transition: 0.4s ease-in-out;
|
||||
z-index: -1;
|
||||
}
|
||||
.card:hover .heading::after {
|
||||
box-shadow: 0px 0px 200px var(--accent-color), inset var(--accent-color) 0px 0px 40px -12px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.icons {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-direction: row; column-gap: 1em; z-index: 1;
|
||||
}
|
||||
|
||||
.instagram, .x, .discord {
|
||||
position: relative; transition: 0.4s ease-in-out;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.instagram:after, .x:after, .discord:after {
|
||||
content: ""; position: absolute; width: 0.5em; height: 0.5em; left: 0;
|
||||
background-color: var(--text-color);
|
||||
box-shadow: 0px 0px 10px var(--shadow-color);
|
||||
border-radius: 50%; z-index: -1; transition: 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.instagram svg path, .x svg path, .discord svg path {
|
||||
stroke: var(--text-color); opacity: 0.7; transition: 0.4s ease-in-out;
|
||||
}
|
||||
.instagram:hover svg path { stroke: #cc39a4; opacity: 1; }
|
||||
.x:hover svg path { stroke: var(--text-color); opacity: 1; }
|
||||
.discord:hover svg path { stroke: #8c9eff; opacity: 1; }
|
||||
|
||||
.instagram:hover svg { scale: 1.4; }
|
||||
.x:hover svg, .discord:hover svg { scale: 1.25; }
|
||||
|
||||
.instagram:hover:after, .x:hover:after, .discord:hover:after {
|
||||
scale: 4; transform: translateX(0.09em) translateY(0.09em);
|
||||
}
|
||||
|
||||
/* Shooting stars logic changed to adapt variables */
|
||||
@keyframes shootingStar {
|
||||
0% { transform: translateX(0) translateY(0); opacity: 1; }
|
||||
50% { transform: translateX(-55em) translateY(0); opacity: 1; }
|
||||
70% { transform: translateX(-70em) translateY(0); opacity: 0; }
|
||||
100% { transform: translateX(0) translateY(0); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes move {
|
||||
0% { transform: translateX(0em) translateY(0em); }
|
||||
25% { transform: translateY(-1em) translateX(-1em); rotate: -10deg; }
|
||||
50% { transform: translateY(1em) translateX(-1em); }
|
||||
75% { transform: translateY(-1.25em) translateX(1em); rotate: 10deg; }
|
||||
100% { transform: translateX(0em) translateY(0em); }
|
||||
}
|
||||
|
||||
@keyframes glowing-stars {
|
||||
0% { opacity: 0; }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
`;
|
||||
|
||||
export default Social;
|
||||
@@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Status = ({ status, date }) => {
|
||||
// Определяем активный шаг на основе статуса
|
||||
const getStatusIndex = (s) => {
|
||||
if (s === 'new') return 1;
|
||||
if (s === 'processing') return 2;
|
||||
if (s === 'shipping') return 3;
|
||||
if (s === 'completed') return 4;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const currentStep = getStatusIndex(status || 'new');
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="stepper-box">
|
||||
{/* STEP 1: PLACED */}
|
||||
<div className={`stepper-step ${currentStep > 1 ? 'stepper-completed' : currentStep === 1 ? 'stepper-active' : 'stepper-pending'}`}>
|
||||
<div className="stepper-circle">{currentStep > 1 ? '✓' : '1'}</div>
|
||||
<div className="stepper-line" />
|
||||
<div className="stepper-content">
|
||||
<div className="stepper-title">Размещен</div>
|
||||
<div className="stepper-status">{currentStep > 1 ? 'Готово' : currentStep === 1 ? 'Сейчас' : 'Ожидание'}</div>
|
||||
<div className="stepper-time">{date || '---'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* STEP 2: PROCESSING */}
|
||||
<div className={`stepper-step ${currentStep > 2 ? 'stepper-completed' : currentStep === 2 ? 'stepper-active' : 'stepper-pending'}`}>
|
||||
<div className="stepper-circle">{currentStep > 2 ? '✓' : '2'}</div>
|
||||
<div className="stepper-line" />
|
||||
<div className="stepper-content">
|
||||
<div className="stepper-title">В обработке</div>
|
||||
<div className="stepper-status">{currentStep > 2 ? 'Готово' : currentStep === 2 ? 'В работе' : 'Ожидание'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* STEP 3: SHIPPING */}
|
||||
<div className={`stepper-step ${currentStep > 3 ? 'stepper-completed' : currentStep === 3 ? 'stepper-active' : 'stepper-pending'}`}>
|
||||
<div className="stepper-circle">{currentStep > 3 ? '✓' : '3'}</div>
|
||||
<div className="stepper-content">
|
||||
<div className="stepper-title">Доставка / Установка</div>
|
||||
<div className="stepper-status">{currentStep > 3 ? 'Готово' : currentStep === 3 ? 'В пути' : 'Ожидание'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.stepper-box {
|
||||
background-color: var(--card-bg, #1e293b);
|
||||
border: 1px solid var(--glass-border, rgba(255,255,255,0.1));
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
width: 100%;
|
||||
color: var(--text-color, #fff);
|
||||
}
|
||||
|
||||
.stepper-step { display: flex; margin-bottom: 20px; position: relative; }
|
||||
.stepper-step:last-child { margin-bottom: 0; }
|
||||
|
||||
.stepper-line {
|
||||
position: absolute; left: 15px; top: 35px; bottom: -25px; width: 2px;
|
||||
background-color: rgba(255,255,255,0.1); z-index: 1;
|
||||
}
|
||||
.stepper-step:last-child .stepper-line { display: none; }
|
||||
|
||||
.stepper-circle {
|
||||
width: 32px; height: 32px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin-right: 16px; z-index: 2; font-weight: bold; font-size: 14px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.stepper-completed .stepper-circle { background-color: #22c55e; color: white; }
|
||||
.stepper-active .stepper-circle { border: 2px solid #3b82f6; color: #3b82f6; background: rgba(59, 130, 246, 0.1); }
|
||||
.stepper-pending .stepper-circle { border: 2px solid #64748b; color: #64748b; }
|
||||
|
||||
.stepper-content { flex: 1; }
|
||||
.stepper-title { font-weight: 600; font-size: 14px; margin-bottom: 2px; }
|
||||
.stepper-status { font-size: 12px; opacity: 0.7; }
|
||||
.stepper-time { font-size: 11px; opacity: 0.5; margin-top: 2px; }
|
||||
`;
|
||||
|
||||
export default Status;
|
||||
@@ -0,0 +1,295 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import axios from 'axios';
|
||||
import { Send, X, MessageSquare, User, Shield, RefreshCw, Check, CheckCheck } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
const API_URL = 'https://diplomnexus.aptcloud.ru';
|
||||
|
||||
const SupportChat = ({ isOpen, onClose, user }) => {
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
|
||||
// Для админа - список пользователей, написавших сообщения
|
||||
const [uniqueSenders, setUniqueSenders] = useState([]);
|
||||
const [selectedEmail, setSelectedEmail] = useState(null); // Фильтр для админа (выбранный чат)
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Проверка прав админа (по роли или имени)
|
||||
const isAdmin = user?.role === 'admin' || user?.name === 'seth1nk' || user?.name === 'SuperAdmin';
|
||||
|
||||
const messagesEndRef = useRef(null);
|
||||
|
||||
// --- ЗАГРУЗКА СООБЩЕНИЙ ---
|
||||
const fetchMessages = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
// Если админ - загружаем все сообщения
|
||||
if (isAdmin) {
|
||||
const res = await axios.get(`${API_URL}/messages`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
const allMsgs = res.data;
|
||||
setMessages(allMsgs);
|
||||
|
||||
// Извлекаем уникальных отправителей для списка контактов
|
||||
// (фильтруем тех, кто писал, исключая админские ответы)
|
||||
const senders = [];
|
||||
const seen = new Set();
|
||||
|
||||
allMsgs.forEach(m => {
|
||||
// Если сообщение от юзера (не админ) и мы его еще не видели
|
||||
if (!m.is_admin && m.email && !seen.has(m.email)) {
|
||||
seen.add(m.email);
|
||||
senders.push({ name: m.user_name, email: m.email });
|
||||
}
|
||||
});
|
||||
setUniqueSenders(senders);
|
||||
}
|
||||
// Если обычный юзер - загружаем только его сообщения (фильтр на сервере по email)
|
||||
else {
|
||||
const res = await axios.get(`${API_URL}/messages?email=${user.email}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setMessages(res.data);
|
||||
|
||||
// Если чат открыт, помечаем сообщения от админа как прочитанные
|
||||
if (isOpen) {
|
||||
await axios.post(`${API_URL}/messages/read`, { email: user.email }, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Ошибка загрузки сообщений:", e);
|
||||
}
|
||||
};
|
||||
|
||||
// Автообновление сообщений каждые 3 секунды
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchMessages();
|
||||
const interval = setInterval(fetchMessages, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [isOpen, isAdmin, user]);
|
||||
|
||||
// Скролл вниз при новом сообщении
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, selectedEmail, isOpen]);
|
||||
|
||||
// --- ОТПРАВКА СООБЩЕНИЯ ---
|
||||
const handleSendMessage = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!newMessage.trim()) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Если пишет АДМИН -> используем специальный эндпоинт ответа (эмуляция ответа бота)
|
||||
// Или используем /contact, но сервер должен понять, что это админ.
|
||||
// В текущей реализации сервера /contact всегда ставит is_admin = FALSE.
|
||||
// Поэтому для админа лучше использовать /api/bot/reply (как будто бот ответил),
|
||||
// ЧТОБЫ СОХРАНИЛОСЬ КАК is_admin = TRUE.
|
||||
|
||||
if (isAdmin && selectedEmail) {
|
||||
await axios.post(`${API_URL}/api/bot/reply`, {
|
||||
email: selectedEmail,
|
||||
text: newMessage
|
||||
});
|
||||
} else {
|
||||
// Если пишет ЮЗЕР
|
||||
await axios.post(`${API_URL}/contact`, {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
message: newMessage
|
||||
});
|
||||
}
|
||||
|
||||
setNewMessage('');
|
||||
fetchMessages(); // Обновляем список сразу
|
||||
} catch (error) {
|
||||
console.error('Ошибка отправки:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- ФИЛЬТРАЦИЯ СООБЩЕНИЙ ДЛЯ ОТОБРАЖЕНИЯ ---
|
||||
// Если админ: показываем только сообщения выбранного юзера (и ответы ему)
|
||||
// Если юзер: показываем все загруженные (они уже отфильтрованы сервером)
|
||||
const displayedMessages = isAdmin
|
||||
? (selectedEmail ? messages.filter(m => m.email === selectedEmail) : [])
|
||||
: messages;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.9 }}
|
||||
className="fixed bottom-24 right-4 md:right-8 w-[90vw] md:w-[400px] h-[550px] z-50 glass border border-[var(--accent-color)] rounded-2xl shadow-2xl overflow-hidden flex flex-col"
|
||||
>
|
||||
{/* --- HEADER --- */}
|
||||
<div className="bg-[var(--accent-color)]/10 p-4 border-b border-[var(--glass-border)] flex justify-between items-center backdrop-blur-md">
|
||||
<div className="flex items-center gap-2 text-[var(--accent-color)]">
|
||||
<MessageSquare size={20} />
|
||||
<h3 className="font-black tracking-widest text-sm uppercase">
|
||||
{isAdmin ? 'ПАНЕЛЬ ПОДДЕРЖКИ' : 'ЧАТ С ПОДДЕРЖКОЙ'}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={fetchMessages}
|
||||
className="text-[var(--text-color)] hover:text-[var(--accent-color)] hover:rotate-180 transition-all duration-500"
|
||||
title="Обновить"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[var(--text-color)] hover:text-red-500 transition-colors"
|
||||
title="Закрыть"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- BODY --- */}
|
||||
<div className="flex-1 overflow-hidden relative bg-[var(--bg-color)]/95 flex flex-col">
|
||||
|
||||
{/* --- СПИСОК ЮЗЕРОВ (ТОЛЬКО ДЛЯ АДМИНА) --- */}
|
||||
{isAdmin && !selectedEmail && (
|
||||
<div className="w-full h-full overflow-y-auto custom-scroll p-2 space-y-2">
|
||||
<p className="text-xs text-center opacity-50 my-2 uppercase font-bold tracking-wider">Входящие обращения</p>
|
||||
|
||||
{uniqueSenders.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-40 opacity-30">
|
||||
<MessageSquare size={40} />
|
||||
<p className="mt-2 text-sm">Сообщений нет</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uniqueSenders.map((u) => (
|
||||
<div
|
||||
key={u.email}
|
||||
onClick={() => setSelectedEmail(u.email)}
|
||||
className="p-3 glass rounded-xl cursor-pointer hover:bg-[var(--accent-color)]/10 transition-colors flex items-center gap-3 border border-[var(--glass-border)] group"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-600 to-purple-600 flex items-center justify-center text-white text-sm font-bold shadow-lg group-hover:scale-110 transition-transform">
|
||||
{u.name ? u.name[0].toUpperCase() : '?'}
|
||||
</div>
|
||||
<div className="overflow-hidden flex-1">
|
||||
<div className="font-bold text-sm text-[var(--text-color)] truncate flex justify-between">
|
||||
{u.name}
|
||||
<span className="text-[9px] opacity-40 font-normal">Открыть</span>
|
||||
</div>
|
||||
<div className="text-[10px] opacity-50 truncate font-mono">{u.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* --- ОКНО ЧАТА --- */}
|
||||
{(!isAdmin || selectedEmail) && (
|
||||
<div className="w-full flex flex-col h-full">
|
||||
|
||||
{/* Кнопка "Назад" для админа */}
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => setSelectedEmail(null)}
|
||||
className="text-xs text-[var(--text-color)] w-full py-2 bg-black/20 hover:bg-black/40 border-b border-[var(--glass-border)] flex items-center justify-center gap-2 transition-colors"
|
||||
>
|
||||
← Назад к списку диалогов ({selectedEmail})
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4 custom-scroll">
|
||||
{displayedMessages.length === 0 && (
|
||||
<div className="h-full flex flex-col items-center justify-center opacity-30 text-center select-none">
|
||||
<div className="w-16 h-16 bg-[var(--accent-color)]/20 rounded-full flex items-center justify-center mb-4">
|
||||
<MessageSquare size={32} className="text-[var(--accent-color)]"/>
|
||||
</div>
|
||||
<p className="text-sm font-bold">Напишите нам!</p>
|
||||
<p className="text-xs max-w-[200px]">Мы ответим в ближайшее время. История сохраняется.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayedMessages.map((msg) => {
|
||||
// ОПРЕДЕЛЯЕМ КТО ПИСАЛ
|
||||
// is_admin=true -> Поддержка
|
||||
// is_admin=false -> Юзер
|
||||
|
||||
// Если я Админ -> Мои сообщения это is_admin=true
|
||||
// Если я Юзер -> Мои сообщения это is_admin=false
|
||||
const isMe = isAdmin ? msg.is_admin : !msg.is_admin;
|
||||
|
||||
return (
|
||||
<div key={msg.id} className={`flex ${isMe ? 'justify-end' : 'justify-start'}`}>
|
||||
<div
|
||||
className={`max-w-[85%] p-3 rounded-2xl text-sm relative border shadow-sm ${
|
||||
isMe
|
||||
? 'bg-[var(--accent-color)] text-black border-[var(--accent-color)] rounded-tr-none text-right'
|
||||
: 'bg-[#1a1a20] text-white border-gray-700 rounded-tl-none text-left'
|
||||
}`}
|
||||
>
|
||||
{/* Имя отправителя (если не я) */}
|
||||
{!isMe && (
|
||||
<p className={`text-[10px] mb-1 font-bold uppercase tracking-wider ${isAdmin ? 'text-[var(--accent-color)]' : 'opacity-50'}`}>
|
||||
{msg.is_admin ? 'Поддержка' : msg.user_name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="whitespace-pre-wrap leading-relaxed">{msg.text}</p>
|
||||
|
||||
{/* Время и Галочки */}
|
||||
<div className={`flex items-center gap-1 mt-1 ${isMe ? 'justify-end opacity-70' : 'justify-start opacity-40'}`}>
|
||||
<span className="text-[9px] font-mono">
|
||||
{new Date(msg.created_at).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
|
||||
</span>
|
||||
|
||||
{/* Галочки показываем только для СВОИХ сообщений */}
|
||||
{isMe && (
|
||||
msg.is_read
|
||||
? <CheckCheck size={14} className="text-blue-600" strokeWidth={3} /> // Прочитано (Синие)
|
||||
: <Check size={14} strokeWidth={2} /> // Отправлено (Серые/Черные)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* --- INPUT --- */}
|
||||
<form onSubmit={handleSendMessage} className="p-3 bg-black/40 border-t border-[var(--glass-border)] flex gap-2 backdrop-blur-sm">
|
||||
<input
|
||||
type="text"
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
placeholder="Введите сообщение..."
|
||||
disabled={isAdmin && !selectedEmail} // Админ не может писать, не выбрав чат
|
||||
className="flex-1 bg-[var(--input-bg)] border border-[var(--glass-border)] rounded-xl px-4 py-3 text-sm text-[var(--text-color)] focus:border-[var(--accent-color)] outline-none transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || (isAdmin && !selectedEmail)}
|
||||
className="bg-[var(--accent-color)] text-black p-3 rounded-xl hover:bg-white hover:scale-105 transition-all shadow-lg shadow-[var(--accent-color)]/20 disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none"
|
||||
>
|
||||
{loading ? <RefreshCw size={20} className="animate-spin"/> : <Send size={20} />}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
export default SupportChat;
|
||||
@@ -0,0 +1,136 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Telephone = () => {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="container">
|
||||
<div className="loader" />
|
||||
<div className="loader" />
|
||||
<div className="loader" />
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
/* Добавил стили для контейнера, чтобы уменьшить весь блок */
|
||||
.container {
|
||||
position: relative;
|
||||
width: 100px; /* Ограничиваем место, которое занимает блок */
|
||||
height: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: scale(0.6); /* Уменьшаем визуально до 60% */
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.loader {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
z-index: 10;
|
||||
width: 160px;
|
||||
height: 100px;
|
||||
margin-left: -80px;
|
||||
margin-top: -50px;
|
||||
border-radius: 5px;
|
||||
background: #1e3f57;
|
||||
animation: dot1_ 3s cubic-bezier(0.55,0.3,0.24,0.99) infinite;
|
||||
}
|
||||
|
||||
.loader:nth-child(2) {
|
||||
z-index: 11;
|
||||
width: 150px;
|
||||
height: 90px;
|
||||
margin-top: -45px;
|
||||
margin-left: -75px;
|
||||
border-radius: 3px;
|
||||
background: #3c517d;
|
||||
animation-name: dot2_;
|
||||
}
|
||||
|
||||
.loader:nth-child(3) {
|
||||
z-index: 12;
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
margin-top: 50px;
|
||||
margin-left: -20px;
|
||||
border-radius: 0 0 5px 5px;
|
||||
background: #6bb2cd;
|
||||
animation-name: dot3_;
|
||||
}
|
||||
|
||||
@keyframes dot1_ {
|
||||
3%,97% {
|
||||
width: 160px;
|
||||
height: 100px;
|
||||
margin-top: -50px;
|
||||
margin-left: -80px;
|
||||
}
|
||||
|
||||
30%,36% {
|
||||
width: 80px;
|
||||
height: 120px;
|
||||
margin-top: -60px;
|
||||
margin-left: -40px;
|
||||
}
|
||||
|
||||
63%,69% {
|
||||
width: 40px;
|
||||
height: 80px;
|
||||
margin-top: -40px;
|
||||
margin-left: -20px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dot2_ {
|
||||
3%,97% {
|
||||
height: 90px;
|
||||
width: 150px;
|
||||
margin-left: -75px;
|
||||
margin-top: -45px;
|
||||
}
|
||||
|
||||
30%,36% {
|
||||
width: 70px;
|
||||
height: 96px;
|
||||
margin-left: -35px;
|
||||
margin-top: -48px;
|
||||
}
|
||||
|
||||
63%,69% {
|
||||
width: 32px;
|
||||
height: 60px;
|
||||
margin-left: -16px;
|
||||
margin-top: -30px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dot3_ {
|
||||
3%,97% {
|
||||
height: 20px;
|
||||
width: 40px;
|
||||
margin-left: -20px;
|
||||
margin-top: 50px;
|
||||
}
|
||||
|
||||
30%,36% {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-left: -5px;
|
||||
margin-top: 49px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
63%,69% {
|
||||
width: 16px;
|
||||
height: 4px;
|
||||
margin-left: -8px;
|
||||
margin-top: -37px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}`;
|
||||
|
||||
export default Telephone;
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import { User } from 'lucide-react';
|
||||
|
||||
const UserAvatar = ({ user, className }) => {
|
||||
// 1. Если есть фото от Google — показываем его (круглое)
|
||||
if (user.picture) {
|
||||
return (
|
||||
<img
|
||||
src={user.picture}
|
||||
className={`${className} rounded-full object-cover border border-[var(--glass-border)]`}
|
||||
alt={user.name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Если фото нет — показываем простую заглушку (как ты просил)
|
||||
return (
|
||||
<div className={`${className} rounded-full flex items-center justify-center bg-[var(--input-bg)] border border-[var(--glass-border)] overflow-hidden`}>
|
||||
{/* Иконка человека, залитая цветом (fill) */}
|
||||
<User
|
||||
className="w-3/5 h-3/5 text-[var(--text-color)] opacity-40"
|
||||
fill="currentColor"
|
||||
strokeWidth={0} // Убираем обводку, оставляем только заливку для силуэта
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserAvatar;
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const GoogleButton = ({ onClick }) => {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<button className="button" onClick={onClick} type="button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid" viewBox="0 0 256 262">
|
||||
<path fill="#4285F4" d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622 38.755 30.023 2.685.268c24.659-22.774 38.875-56.282 38.875-96.027" />
|
||||
<path fill="#34A853" d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055-34.523 0-63.824-22.773-74.269-54.25l-1.531.13-40.298 31.187-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1" />
|
||||
<path fill="#FBBC05" d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82 0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602l42.356-32.782" />
|
||||
<path fill="#EB4335" d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0 79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251" />
|
||||
</svg>
|
||||
Continue with Google
|
||||
</button>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
width: 100%;
|
||||
|
||||
.button {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
padding: 0.5rem 1.4rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
vertical-align: middle;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.25);
|
||||
gap: 0.75rem;
|
||||
color: rgb(65, 63, 63);
|
||||
background-color: #fff;
|
||||
cursor: pointer;
|
||||
transition: all .6s ease;
|
||||
}
|
||||
|
||||
.button svg {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
transform: scale(1.02);
|
||||
}`;
|
||||
|
||||
export default GoogleButton;
|
||||
@@ -0,0 +1,125 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const LightDark = ({ toggleTheme, isLight }) => {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<label className="bb8-toggle">
|
||||
<input
|
||||
className="bb8-toggle__checkbox"
|
||||
type="checkbox"
|
||||
onChange={toggleTheme}
|
||||
checked={isLight}
|
||||
/>
|
||||
<div className="bb8-toggle__container">
|
||||
<div className="bb8-toggle__scenery">
|
||||
{/* Звезды и прочее (оставил как было для краткости, код большой, но рабочий) */}
|
||||
<div className="bb8-toggle__star"></div><div className="bb8-toggle__star"></div><div className="bb8-toggle__star"></div><div className="bb8-toggle__star"></div><div className="bb8-toggle__star"></div><div className="bb8-toggle__star"></div><div className="bb8-toggle__star"></div>
|
||||
<div className="tatto-1"></div><div className="tatto-2"></div><div className="gomrassen"></div><div className="hermes"></div><div className="chenini"></div><div className="bb8-toggle__cloud"></div><div className="bb8-toggle__cloud"></div><div className="bb8-toggle__cloud"></div>
|
||||
</div>
|
||||
<div className="bb8">
|
||||
<div className="bb8__head-container">
|
||||
<div className="bb8__antenna"></div>
|
||||
<div className="bb8__antenna"></div>
|
||||
<div className="bb8__head"></div>
|
||||
</div>
|
||||
<div className="bb8__body"></div>
|
||||
</div>
|
||||
<div className="artificial__hidden">
|
||||
<div className="bb8__shadow"></div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
/* УВЕЛИЧИЛ МАСШТАБ */
|
||||
transform: scale(0.6);
|
||||
transform-origin: center;
|
||||
|
||||
.bb8-toggle {
|
||||
--toggle-size: 16px;
|
||||
--toggle-width: 10.625em;
|
||||
--toggle-height: 5.625em;
|
||||
--toggle-offset: calc((var(--toggle-height) - var(--bb8-diameter)) / 2);
|
||||
--toggle-bg: linear-gradient(#2c4770, #070e2b 35%, #628cac 50% 70%, #a6c5d4) no-repeat;
|
||||
--bb8-diameter: 4.375em;
|
||||
--radius: 99em;
|
||||
--transition: 0.4s;
|
||||
--accent: #de7d2f;
|
||||
--bb8-bg: #fff;
|
||||
}
|
||||
|
||||
/* ... (Остальной CSS код с Uiverse без изменений, он верный) ... */
|
||||
/* Скопируй весь CSS из твоего сообщения сюда, он большой, но я его проверил - рабочий */
|
||||
.bb8-toggle, .bb8-toggle *, .bb8-toggle *::before, .bb8-toggle *::after { box-sizing: border-box; }
|
||||
.bb8-toggle { cursor: pointer; margin-top: var(--margin-top-for-head); font-size: var(--toggle-size); }
|
||||
.bb8-toggle__checkbox { appearance: none; display: none; }
|
||||
.bb8-toggle__container { width: var(--toggle-width); height: var(--toggle-height); background: var(--toggle-bg); background-size: 100% 11.25em; background-position-y: -5.625em; border-radius: var(--radius); position: relative; transition: var(--transition); }
|
||||
.bb8 { display: flex; flex-direction: column; align-items: center; position: absolute; top: calc(var(--toggle-offset) - 1.688em + 0.188em); left: var(--toggle-offset); transition: var(--transition); z-index: 2; }
|
||||
.bb8__head-container { position: relative; transition: var(--transition); z-index: 2; transform-origin: 1.25em 3.75em; }
|
||||
.bb8__head { overflow: hidden; margin-bottom: -0.188em; width: 2.5em; height: 1.688em; background: linear-gradient(transparent 0.063em, dimgray 0.063em 0.313em, transparent 0.313em 0.375em, var(--accent) 0.375em 0.5em, transparent 0.5em 1.313em, silver 1.313em 1.438em, transparent 1.438em), linear-gradient(45deg, transparent 0.188em, var(--bb8-bg) 0.188em 1.25em, transparent 1.25em), linear-gradient(-45deg, transparent 0.188em, var(--bb8-bg) 0.188em 1.25em, transparent 1.25em), linear-gradient(var(--bb8-bg) 1.25em, transparent 1.25em); border-radius: var(--radius) var(--radius) 0 0; position: relative; z-index: 1; filter: drop-shadow(0 0.063em 0.125em gray); }
|
||||
.bb8__head::before { content: ""; position: absolute; width: 0.563em; height: 0.563em; background: radial-gradient(0.125em circle at 0.25em 0.375em, red, transparent), radial-gradient(0.063em circle at 0.375em 0.188em, var(--bb8-bg) 50%, transparent 100%), linear-gradient(45deg, #000 0.188em, dimgray 0.313em 0.375em, #000 0.5em); border-radius: var(--radius); top: 0.413em; left: 50%; transform: translate(-50%); box-shadow: 0 0 0 0.089em lightgray, 0.563em 0.281em 0 -0.148em, 0.563em 0.281em 0 -0.1em var(--bb8-bg), 0.563em 0.281em 0 -0.063em; z-index: 1; transition: var(--transition); }
|
||||
.bb8__head::after { content: ""; position: absolute; bottom: 0.375em; left: 0; width: 100%; height: 0.188em; background: linear-gradient(to right, var(--accent) 0.125em, transparent 0.125em 0.188em, var(--accent) 0.188em 0.313em, transparent 0.313em 0.375em, var(--accent) 0.375em 0.938em, transparent 0.938em 1em, var(--accent) 1em 1.125em, transparent 1.125em 1.875em, var(--accent) 1.875em 2em, transparent 2em 2.063em, var(--accent) 2.063em 2.25em, transparent 2.25em 2.313em, var(--accent) 2.313em 2.375em, transparent 2.375em 2.438em, var(--accent) 2.438em); transition: var(--transition); }
|
||||
.bb8__antenna { position: absolute; transform: translateY(-90%); width: 0.059em; border-radius: var(--radius) var(--radius) 0 0; transition: var(--transition); }
|
||||
.bb8__antenna:nth-child(1) { height: 0.938em; right: 0.938em; background: linear-gradient(#000 0.188em, silver 0.188em); }
|
||||
.bb8__antenna:nth-child(2) { height: 0.375em; left: 50%; transform: translate(-50%, -90%); background: silver; }
|
||||
.bb8__body { width: 4.375em; height: 4.375em; background: var(--bb8-bg); border-radius: var(--radius); position: relative; overflow: hidden; transition: var(--transition); z-index: 1; transform: rotate(45deg); background: linear-gradient(-90deg, var(--bb8-bg) 4%, var(--accent) 4% 10%, transparent 10% 90%, var(--accent) 90% 96%, var(--bb8-bg) 96%), linear-gradient(var(--bb8-bg) 4%, var(--accent) 4% 10%, transparent 10% 90%, var(--accent) 90% 96%, var(--bb8-bg) 96%), linear-gradient(to right, transparent 2.156em, silver 2.156em 2.219em, transparent 2.188em), linear-gradient(transparent 2.156em, silver 2.156em 2.219em, transparent 2.188em); background-color: var(--bb8-bg); }
|
||||
.bb8__body::after { content: ""; bottom: 1.5em; left: 0.563em; position: absolute; width: 0.188em; height: 0.188em; background: rgb(236, 236, 236); color: rgb(236, 236, 236); border-radius: 50%; box-shadow: 0.875em 0.938em, 0 -1.25em, 0.875em -2.125em, 2.125em -2.125em, 3.063em -1.25em, 3.063em 0, 2.125em 0.938em; }
|
||||
.bb8__body::before { content: ""; width: 2.625em; height: 2.625em; position: absolute; border-radius: 50%; z-index: 0.1; overflow: hidden; top: 50%; left: 50%; transform: translate(-50%, -50%); border: 0.313em solid var(--accent); background: radial-gradient(1em circle at center, rgb(236, 236, 236) 50%, transparent 51%), radial-gradient(1.25em circle at center, var(--bb8-bg) 50%, transparent 51%), linear-gradient(-90deg, transparent 42%, var(--accent) 42% 58%, transparent 58%), linear-gradient(var(--bb8-bg) 42%, var(--accent) 42% 58%, var(--bb8-bg) 58%); }
|
||||
.artificial__hidden { position: absolute; border-radius: inherit; inset: 0; pointer-events: none; overflow: hidden; }
|
||||
.bb8__shadow { content: ""; width: var(--bb8-diameter); height: 20%; border-radius: 50%; background: #3a271c; box-shadow: 0.313em 0 3.125em #3a271c; opacity: 0.25; position: absolute; bottom: 0; left: calc(var(--toggle-offset) - 0.938em); transition: var(--transition); transform: skew(-70deg); z-index: 1; }
|
||||
.bb8-toggle__scenery { width: 100%; height: 100%; pointer-events: none; overflow: hidden; position: relative; border-radius: inherit; }
|
||||
.bb8-toggle__scenery::before { content: ""; position: absolute; width: 100%; height: 30%; bottom: 0; background: #b18d71; z-index: 1; }
|
||||
.bb8-toggle__cloud { z-index: 1; position: absolute; border-radius: 50%; }
|
||||
.bb8-toggle__cloud:nth-last-child(1) { width: 0.875em; height: 0.625em; filter: blur(0.125em) drop-shadow(0.313em 0.313em #ffffffae) drop-shadow(-0.625em 0 #fff) drop-shadow(-0.938em -0.125em #fff); right: 1.875em; top: 2.813em; background: linear-gradient(to top right, #ffffffae, #ffffffae); transition: var(--transition); }
|
||||
.bb8-toggle__cloud:nth-last-child(2) { top: 0.625em; right: 4.375em; width: 0.875em; height: 0.375em; background: #dfdedeae; filter: blur(0.125em) drop-shadow(-0.313em -0.188em #e0dfdfae) drop-shadow(-0.625em -0.188em #bbbbbbae) drop-shadow(-1em 0.063em #cfcfcfae); transition: 0.6s; }
|
||||
.bb8-toggle__cloud:nth-last-child(3) { top: 1.25em; right: 0.938em; width: 0.875em; height: 0.375em; background: #ffffffae; filter: blur(0.125em) drop-shadow(0.438em 0.188em #ffffffae) drop-shadow(-0.625em 0.313em #ffffffae); transition: 0.8s; }
|
||||
.gomrassen, .hermes, .chenini { position: absolute; border-radius: var(--radius); background: linear-gradient(#fff, #6e8ea2); top: 100%; }
|
||||
.gomrassen { left: 0.938em; width: 1.875em; height: 1.875em; box-shadow: 0 0 0.188em #ffffff52, 0 0 0.188em #6e8ea24b; transition: var(--transition); }
|
||||
.gomrassen::before, .gomrassen::after { content: ""; position: absolute; border-radius: inherit; box-shadow: inset 0 0 0.063em rgb(140, 162, 169); background: rgb(184, 196, 200); }
|
||||
.gomrassen::before { left: 0.313em; top: 0.313em; width: 0.438em; height: 0.438em; } .gomrassen::after { width: 0.25em; height: 0.25em; left: 1.25em; top: 0.75em; }
|
||||
.hermes { left: 3.438em; width: 0.625em; height: 0.625em; box-shadow: 0 0 0.125em #ffffff52, 0 0 0.125em #6e8ea24b; transition: 0.6s; }
|
||||
.chenini { left: 4.375em; width: 0.5em; height: 0.5em; box-shadow: 0 0 0.125em #ffffff52, 0 0 0.125em #6e8ea24b; transition: 0.8s; }
|
||||
.tatto-1, .tatto-2 { position: absolute; width: 1.25em; height: 1.25em; border-radius: var(--radius); }
|
||||
.tatto-1 { background: #fefefe; right: 3.125em; top: 0.625em; box-shadow: 0 0 0.438em #fdf4e1; transition: var(--transition); }
|
||||
.tatto-2 { background: linear-gradient(#e6ac5c, #d75449); right: 1.25em; top: 2.188em; box-shadow: 0 0 0.438em #e6ad5c3d, 0 0 0.438em #d755494f; transition: 0.7s; }
|
||||
.bb8-toggle__star { position: absolute; width: 0.063em; height: 0.063em; background: #fff; border-radius: var(--radius); filter: drop-shadow(0 0 0.063em #fff); color: #fff; top: 100%; }
|
||||
.bb8-toggle__star:nth-child(1) { left: 3.75em; box-shadow: 1.25em 0.938em, -1.25em 2.5em, 0 1.25em, 1.875em 0.625em, -3.125em 1.875em, 1.25em 2.813em; transition: 0.2s; }
|
||||
.bb8-toggle__star:nth-child(2) { left: 4.688em; box-shadow: 0.625em 0, 0 0.625em, -0.625em -0.625em, 0.625em 0.938em, -3.125em 1.25em, 1.25em -1.563em; transition: 0.3s; }
|
||||
.bb8-toggle__star:nth-child(3) { left: 5.313em; box-shadow: -0.625em -0.625em, -2.188em 1.25em, -2.188em 0, -3.75em -0.625em, -3.125em -0.625em, -2.5em -0.313em, 0.75em -0.625em; transition: var(--transition); }
|
||||
.bb8-toggle__star:nth-child(4) { left: 1.875em; width: 0.125em; height: 0.125em; transition: 0.5s; }
|
||||
.bb8-toggle__star:nth-child(5) { left: 5em; width: 0.125em; height: 0.125em; transition: 0.6s; }
|
||||
.bb8-toggle__star:nth-child(6) { left: 2.5em; width: 0.125em; height: 0.125em; transition: 0.7s; }
|
||||
.bb8-toggle__star:nth-child(7) { left: 3.438em; width: 0.125em; height: 0.125em; transition: 0.8s; }
|
||||
.bb8-toggle__checkbox:checked + .bb8-toggle__container .bb8-toggle__star:nth-child(1) { top: 0.625em; }
|
||||
.bb8-toggle__checkbox:checked + .bb8-toggle__container .bb8-toggle__star:nth-child(2) { top: 1.875em; }
|
||||
.bb8-toggle__checkbox:checked + .bb8-toggle__container .bb8-toggle__star:nth-child(3) { top: 1.25em; }
|
||||
.bb8-toggle__checkbox:checked + .bb8-toggle__container .bb8-toggle__star:nth-child(4) { top: 3.438em; }
|
||||
.bb8-toggle__checkbox:checked + .bb8-toggle__container .bb8-toggle__star:nth-child(5) { top: 3.438em; }
|
||||
.bb8-toggle__checkbox:checked + .bb8-toggle__container .bb8-toggle__star:nth-child(6) { top: 0.313em; }
|
||||
.bb8-toggle__checkbox:checked + .bb8-toggle__container .bb8-toggle__star:nth-child(7) { top: 1.875em; }
|
||||
.bb8-toggle__checkbox:checked + .bb8-toggle__container .bb8-toggle__cloud { right: -100%; }
|
||||
.bb8-toggle__checkbox:checked + .bb8-toggle__container .gomrassen { top: 0.938em; }
|
||||
.bb8-toggle__checkbox:checked + .bb8-toggle__container .hermes { top: 2.5em; }
|
||||
.bb8-toggle__checkbox:checked + .bb8-toggle__container .chenini { top: 2.75em; }
|
||||
.bb8-toggle__checkbox:checked + .bb8-toggle__container { background-position-y: 0; }
|
||||
.bb8-toggle__checkbox:checked + .bb8-toggle__container .tatto-1 { top: 100%; }
|
||||
.bb8-toggle__checkbox:checked + .bb8-toggle__container .tatto-2 { top: 100%; }
|
||||
.bb8-toggle__checkbox:checked + .bb8-toggle__container .bb8 { left: calc(100% - var(--bb8-diameter) - var(--toggle-offset)); }
|
||||
.bb8-toggle__checkbox:checked + .bb8-toggle__container .bb8__shadow { left: calc(100% - var(--bb8-diameter) - var(--toggle-offset) + 0.938em); transform: skew(70deg); }
|
||||
.bb8-toggle__checkbox:checked + .bb8-toggle__container .bb8__body { transform: rotate(225deg); }
|
||||
.bb8-toggle__checkbox:hover + .bb8-toggle__container .bb8__head::before { left: 100%; }
|
||||
.bb8-toggle__checkbox:not(:checked):hover + .bb8-toggle__container .bb8__antenna:nth-child(1) { right: 1.5em; }
|
||||
.bb8-toggle__checkbox:hover + .bb8-toggle__container .bb8__antenna:nth-child(2) { left: 0.938em; }
|
||||
.bb8-toggle__checkbox:hover + .bb8-toggle__container .bb8__head::after { background-position: 1.375em 0; }
|
||||
.bb8-toggle__checkbox:checked:hover + .bb8-toggle__container .bb8__head::before { left: 0; }
|
||||
.bb8-toggle__checkbox:checked:hover + .bb8-toggle__container .bb8__antenna:nth-child(2) { left: calc(100% - 0.938em); }
|
||||
.bb8-toggle__checkbox:checked:hover + .bb8-toggle__container .bb8__head::after { background-position: -1.375em 0; }
|
||||
.bb8-toggle__checkbox:active + .bb8-toggle__container .bb8__head-container { transform: rotate(25deg); }
|
||||
.bb8-toggle__checkbox:checked:active + .bb8-toggle__container .bb8__head-container { transform: rotate(-25deg); }
|
||||
`;
|
||||
|
||||
export default LightDark;
|
||||
+128
-25
@@ -1,40 +1,143 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import "tailwindcss";
|
||||
@config "../tailwind.config.js";
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap');
|
||||
@import "./styles/admin.css";
|
||||
@import "./styles/pages.css";
|
||||
|
||||
:root {
|
||||
--bg-color: #060713;
|
||||
--card-bg: #0b0c15;
|
||||
--text-color: #ffffff;
|
||||
--accent-color: #00f260; /* Фирменный зеленый неон */
|
||||
--glass-border: rgba(255, 255, 255, 0.08);
|
||||
--input-bg: rgba(0, 0, 0, 0.4);
|
||||
/* ТЕМНАЯ ТЕМА (Deep Space) */
|
||||
--bg-color: #0b0c15;
|
||||
--text-color: #f1f5f9;
|
||||
--glass-bg: rgba(20, 20, 30, 0.6);
|
||||
--glass-border: rgba(255, 255, 255, 0.05);
|
||||
--accent-color: #00f3ff;
|
||||
--card-bg: #13141f;
|
||||
--shadow-color: rgba(0, 243, 255, 0.1);
|
||||
--map-filter: grayscale(100%) invert(100%) contrast(1.2); /* Делаем карту темной */
|
||||
--input-bg: rgba(15, 23, 42, 0.6);
|
||||
}
|
||||
|
||||
body.light {
|
||||
/* СВЕТЛАЯ ТЕМА (Tech Lab) */
|
||||
--bg-color: #f0f2f5;
|
||||
--text-color: #1a1c23;
|
||||
--glass-bg: rgba(255, 255, 255, 0.8);
|
||||
--glass-border: rgba(0, 0, 0, 0.05);
|
||||
--accent-color: #2563eb;
|
||||
--card-bg: #ffffff;
|
||||
--input-bg: rgba(255, 255, 255, 0.9);
|
||||
--shadow-color: rgba(37, 99, 235, 0.15);
|
||||
--map-filter: grayscale(0%) invert(0%); /* Обычная карта */
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-family: 'Inter', sans-serif;
|
||||
transition: background 0.4s ease, color 0.4s ease;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.custom-scroll::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.custom-scroll::-webkit-scrollbar-thumb {
|
||||
background: var(--accent-color);
|
||||
border-radius: 10px;
|
||||
/* Скроллбар */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--accent-color); border-radius: 10px; }
|
||||
|
||||
/* Эффект стекла (Без рамок, только тень и блюр) */
|
||||
.glass {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: 0 8px 32px 0 var(--shadow-color);
|
||||
}
|
||||
|
||||
/* Кнопка */
|
||||
.btn-neon {
|
||||
background: rgba(0, 242, 96, 0.1);
|
||||
border: 1px solid var(--accent-color);
|
||||
color: var(--accent-color);
|
||||
box-shadow: 0 0 15px rgba(0, 242, 96, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
background: linear-gradient(135deg, var(--accent-color), #8b5cf6);
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 4px 15px var(--shadow-color);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.btn-neon:hover {
|
||||
background: var(--accent-color);
|
||||
color: #000;
|
||||
box-shadow: 0 0 25px rgba(0, 242, 96, 0.4);
|
||||
transform: translateY(-2px) scale(1.02);
|
||||
box-shadow: 0 8px 25px var(--shadow-color);
|
||||
}
|
||||
|
||||
/* --- СТИЛИ ДЛЯ КОМПОНЕНТОВ UIVERSE (Вставляем сюда, чтобы работали в Dashboard) --- */
|
||||
|
||||
/* 1. NEON CARD */
|
||||
.uiverse-card {
|
||||
width: 100%; height: 320px;
|
||||
background: var(--card-bg);
|
||||
position: relative; display: flex; place-content: center; place-items: center;
|
||||
overflow: hidden; border-radius: 20px;
|
||||
box-shadow: 0 10px 20px rgba(0,0,0,0.2);
|
||||
}
|
||||
.uiverse-card::before {
|
||||
content: ''; position: absolute; width: 100px;
|
||||
background-image: linear-gradient(180deg, var(--accent-color), #bc13fe);
|
||||
height: 150%; animation: rotBGimg 4s linear infinite; transition: all 0.2s linear;
|
||||
}
|
||||
@keyframes rotBGimg { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
.uiverse-card::after {
|
||||
content: ''; position: absolute; background: var(--card-bg); inset: 3px; border-radius: 18px;
|
||||
}
|
||||
.uiverse-card-content {
|
||||
position: absolute; z-index: 10; width: 92%; height: 92%;
|
||||
display: flex; flex-direction: column; justify-content: space-between;
|
||||
}
|
||||
|
||||
/* 2. CYBER SWITCH */
|
||||
.cyber-switch { font-size: 14px; position: relative; display: inline-block; width: 3.5em; height: 2em; }
|
||||
.cyber-switch input { opacity: 0; width: 0; height: 0; }
|
||||
.cyber-slider {
|
||||
position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background-color: #1a1a2e; transition: .4s; border-radius: 10px;
|
||||
border: 1px solid var(--accent-color);
|
||||
}
|
||||
.cyber-slider:before {
|
||||
position: absolute; content: ""; height: 1.4em; width: 1.4em; left: 0.3em; bottom: 0.25em;
|
||||
background-color: var(--accent-color); transition: .4s; border-radius: 50%;
|
||||
box-shadow: 0 0 10px var(--accent-color);
|
||||
}
|
||||
.cyber-switch input:checked + .cyber-slider { background-color: var(--accent-color); border-color: white; }
|
||||
.cyber-switch input:checked + .cyber-slider:before {
|
||||
transform: translateX(1.5em); background-color: white; box-shadow: none;
|
||||
}
|
||||
/* Обновленный класс для инпутов */
|
||||
.contact-input {
|
||||
width: 100%;
|
||||
background-color: var(--input-bg); /* Используем переменную */
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem;
|
||||
color: var(--text-color);
|
||||
outline: none;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.contact-input:focus {
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 4px rgba(var(--accent-color), 0.1); /* Свечение */
|
||||
background-color: var(--card-bg);
|
||||
}
|
||||
|
||||
/* Плейсхолдеры */
|
||||
.contact-input::placeholder {
|
||||
color: var(--text-color);
|
||||
opacity: 0.5;
|
||||
}
|
||||
@keyframes scan {
|
||||
0% { transform: translateY(-50%); }
|
||||
100% { transform: translateY(50%); }
|
||||
}
|
||||
|
||||
.animate-scan {
|
||||
animation: scan 2s linear infinite;
|
||||
}
|
||||
+3
-3
@@ -2,12 +2,12 @@ import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
import bridge from '@vkontakte/vk-bridge'
|
||||
import bridge from '@vkontakte/vk-bridge' // Импортируем мост
|
||||
|
||||
// Инициализируем VK Bridge до рендеринга приложения
|
||||
// Инициализируем мост ВК
|
||||
bridge.send('VKWebAppInit')
|
||||
.then(() => console.log('VK Bridge успешно инициализирован'))
|
||||
.catch((err) => console.error('Ошибка инициализации VK Bridge:', err));
|
||||
.catch((err) => console.error('Ошибка моста:', err));
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { Activity, Shield, Cpu, Wifi } from 'lucide-react';
|
||||
import Mesto from '../components/Mesto';
|
||||
import Pogoda from '../components/Pogoda';
|
||||
import Notebook from '../components/Notebook';
|
||||
import Server from '../components/Server';
|
||||
import Button from '../components/Record';
|
||||
const About = () => {
|
||||
return (
|
||||
<div className="pt-24 min-h-screen px-4 md:px-8 max-w-[1600px] mx-auto pb-20 overflow-x-hidden relative">
|
||||
<motion.div initial={{ opacity: 0, x: -100 }}animate={{ opacity: 1, x: 0 }}transition={{ delay: 1, type: "spring" }}className="fixed bottom-6 left-6 z-[90] md:bottom-10 md:left-10">
|
||||
<div className="transform scale-75 origin-bottom-left hover:scale-90 transition-transform duration-300 drop-shadow-2xl">
|
||||
<Pogoda />
|
||||
</div>
|
||||
</motion.div>
|
||||
<motion.div initial={{ opacity: 0, x: 100 }}animate={{ opacity: 1, x: 0 }}transition={{ delay: 1.2, type: "spring" }}className="fixed bottom-6 right-6 z-[100] md:bottom-10 md:right-10 flex flex-col items-end pointer-events-none">
|
||||
<div className="pointer-events-auto relative group">
|
||||
<div className="absolute -top-12 right-0 flex items-center gap-3 opacity-100 group-hover:opacity-0 transition-opacity duration-300 pointer-events-auto">
|
||||
<div className="scale-75 origin-right"><Button /></div>
|
||||
<div className="bg-black/70 text-white text-xs font-bold px-3 py-1.5 rounded-full backdrop-blur-md border border-white/10">
|
||||
LIVE MAP
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Карта */}
|
||||
<div className="w-[280px] h-[200px] md:w-[320px] md:h-[240px] bg-[var(--card-bg)] rounded-3xl overflow-visible border-2 border-[var(--accent-color)] shadow-2xl transform transition-all duration-500 origin-bottom-right group-hover:scale-[1.8] group-hover:-translate-x-10 group-hover:-translate-y-10 group-hover:rounded-[2rem]">
|
||||
<div className="absolute inset-0 bg-black/20 group-hover:bg-transparent z-10 transition-colors duration-300 pointer-events-none rounded-3xl" />
|
||||
<div className="w-full h-full flex items-center justify-center transform scale-[0.5] md:scale-[0.6] group-hover:scale-100 transition-transform duration-500">
|
||||
<Mesto />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
|
||||
{/* === ОСНОВНОЙ КОНТЕНТ === */}
|
||||
|
||||
{/* HEADER */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-[var(--accent-color)] tracking-[0.2em] text-sm font-bold mb-4 uppercase">О Проекте</h2>
|
||||
<h1 className="text-5xl md:text-7xl font-black mb-6 text-[var(--text-color)] uppercase tracking-tighter">
|
||||
Экосистема <br/>
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-[var(--accent-color)] to-purple-600">Smart Nexus</span>
|
||||
</h1>
|
||||
<p className="text-[var(--text-color)] opacity-70 text-lg max-w-3xl mx-auto border-b border-[var(--glass-border)] pb-8">
|
||||
Интеллектуальное управление пространством. Мы превращаем квадратные метры в думающий организм.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* TEXT & DASHBOARD BLOCK */}
|
||||
<div className="flex flex-col items-center gap-16 mb-24">
|
||||
|
||||
{/* Text */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="max-w-4xl text-center space-y-6"
|
||||
>
|
||||
<h3 className="text-3xl font-bold text-[var(--text-color)]">Центральный нейро-хаб</h3>
|
||||
<p className="text-[var(--text-color)] leading-relaxed opacity-80 text-lg">
|
||||
В основе системы лежит локальный сервер обработки данных. В отличие от облачных решений,
|
||||
<b> Smart Nexus</b> обрабатывает все сигналы внутри дома (Edge Computing), обеспечивая мгновенную реакцию
|
||||
и полную безопасность.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* === БОЛЬШАЯ ПАНЕЛЬ МОНИТОРИНГА === */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
whileInView={{ scale: 1, opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
className="glass p-8 md:p-10 rounded-[3rem] border border-[var(--glass-border)] w-full max-w-5xl grid grid-cols-1 md:grid-cols-2 gap-10 items-center relative overflow-hidden"
|
||||
>
|
||||
{/* Фон свечение */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[var(--accent-color)]/5 to-purple-500/5 pointer-events-none" />
|
||||
|
||||
{/* ЛЕВАЯ ЧАСТЬ: ВИЗУАЛИЗАЦИЯ (СЕРВЕР) */}
|
||||
<div className="flex flex-col items-center justify-center relative">
|
||||
<div className="absolute top-0 left-0 bg-white/5 px-3 py-1 rounded-full text-[10px] font-mono text-[var(--text-color)] border border-white/10">
|
||||
UNIT: ALPHA-01
|
||||
</div>
|
||||
<div className="transform scale-110 hover:scale-125 transition-transform duration-700 cursor-pointer">
|
||||
<Server />
|
||||
</div>
|
||||
<div className="mt-6 flex items-center gap-2 px-4 py-2 bg-[#0f172a] rounded-full border border-green-500/30 shadow-[0_0_15px_rgba(74,222,128,0.2)] z-10">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
<span className="text-xs font-mono text-green-400">SYSTEM ONLINE</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ПРАВАЯ ЧАСТЬ: МЕТРИКИ (НОВОЕ) */}
|
||||
<div className="flex flex-col gap-6 z-10">
|
||||
|
||||
<h4 className="text-[var(--text-color)] font-bold text-xl flex items-center gap-2">
|
||||
<Activity size={20} className="text-[var(--accent-color)]"/>
|
||||
Телеметрия Ядра
|
||||
</h4>
|
||||
|
||||
{/* Progress Bars */}
|
||||
<div className="space-y-4">
|
||||
{/* CPU */}
|
||||
<div className="group">
|
||||
<div className="flex justify-between text-xs font-mono text-[var(--text-color)] opacity-70 mb-1">
|
||||
<span className="flex items-center gap-1"><Cpu size={12}/> CPU LOAD</span>
|
||||
<span>12%</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-black/20 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }} whileInView={{ width: '12%' }}
|
||||
className="h-full bg-blue-500 rounded-full relative"
|
||||
>
|
||||
<div className="absolute right-0 top-0 h-full w-2 bg-white/50 blur-[2px]" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RAM */}
|
||||
<div className="group">
|
||||
<div className="flex justify-between text-xs font-mono text-[var(--text-color)] opacity-70 mb-1">
|
||||
<span className="flex items-center gap-1"><Shield size={12}/> SECURITY LAYER</span>
|
||||
<span className="text-green-400">ACTIVE</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-black/20 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }} whileInView={{ width: '100%' }}
|
||||
className="h-full bg-green-500 rounded-full relative"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NETWORK */}
|
||||
<div className="group">
|
||||
<div className="flex justify-between text-xs font-mono text-[var(--text-color)] opacity-70 mb-1">
|
||||
<span className="flex items-center gap-1"><Wifi size={12}/> UPLINK</span>
|
||||
<span>1.2 Gbps</span>
|
||||
</div>
|
||||
{/* График полосочками */}
|
||||
<div className="flex gap-1 h-4 items-end">
|
||||
{[40, 70, 30, 80, 50, 90, 60, 40, 70, 50, 80, 60].map((h, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ height: 0 }}
|
||||
whileInView={{ height: `${h}%` }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
className="w-1 bg-[var(--accent-color)]/50 rounded-t-sm"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Grid */}
|
||||
<div className="grid grid-cols-2 gap-4 mt-2">
|
||||
<div className="bg-[var(--bg-color)]/50 p-3 rounded-xl border border-[var(--glass-border)]">
|
||||
<p className="text-[10px] text-[var(--text-color)] opacity-50 uppercase">Requests</p>
|
||||
<p className="text-lg font-mono font-bold text-[var(--text-color)]">8,432<span className="text-[10px] opacity-50">/sec</span></p>
|
||||
</div>
|
||||
<div className="bg-[var(--bg-color)]/50 p-3 rounded-xl border border-[var(--glass-border)]">
|
||||
<p className="text-[10px] text-[var(--text-color)] opacity-50 uppercase">Ping</p>
|
||||
<p className="text-lg font-mono font-bold text-green-400">3ms</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* ENGINEER TERMINAL SECTION */}
|
||||
<motion.div
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
whileInView={{ y: 0, opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
className="max-w-5xl mx-auto grid grid-cols-1 md:grid-cols-2 gap-12 items-center glass p-8 md:p-12 rounded-[2.5rem] border border-[var(--glass-border)] mb-20"
|
||||
>
|
||||
<div className="order-2 md:order-1">
|
||||
<h3 className="text-2xl md:text-3xl font-bold text-[var(--text-color)] mb-4">Инженерный доступ</h3>
|
||||
<p className="text-[var(--text-color)] opacity-70 mb-6">
|
||||
Полный контроль над сценариями автоматизации. Доступ к логам системы, настройка чувствительности датчиков и обновление прошивок модулей.
|
||||
</p>
|
||||
<div className="bg-[#0b0c15] p-5 rounded-xl border border-white/10 font-mono text-xs text-green-400 shadow-inner overflow-hidden relative">
|
||||
<div className="absolute top-0 right-0 p-2 opacity-50 text-[10px] text-white">v.2.4.1</div>
|
||||
<p> connect --secure root@nexus</p>
|
||||
<p className="text-blue-400"> Authenticating...</p>
|
||||
<p> Access Granted.</p>
|
||||
<p className="animate-pulse"> _</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center order-1 md:order-2">
|
||||
<div className="transform scale-90 hover:scale-100 transition-transform duration-500">
|
||||
<Notebook />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default About;
|
||||
@@ -0,0 +1,171 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { MapPin, Phone, Mail, Send } from 'lucide-react';
|
||||
import Radar from '../components/Radar';
|
||||
import Social from '../components/Social';
|
||||
import Telephone from '../components/Telephone';
|
||||
|
||||
const Contact = () => {
|
||||
return (
|
||||
<div className="pt-24 min-h-screen px-4 md:px-8 max-w-[1600px] mx-auto pb-20 overflow-x-hidden relative">
|
||||
|
||||
{/* === ПЛАВАЮЩИЙ SOCIAL (FIXED) === */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 1, type: "spring" }}
|
||||
className="fixed bottom-6 left-6 z-[100] md:bottom-8 md:left-8 pointer-events-none"
|
||||
>
|
||||
{/* pointer-events-auto нужен, чтобы клики работали, но контейнер не мешал */}
|
||||
<div className="pointer-events-auto transform scale-75 origin-bottom-left hover:scale-90 transition-transform duration-300">
|
||||
<Social />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* ЗАГОЛОВОК */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="mb-12 max-w-2xl"
|
||||
>
|
||||
<h1 className="text-5xl md:text-7xl font-black mb-4 text-[var(--text-color)] uppercase tracking-tighter leading-none">
|
||||
Центр <br />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-[var(--accent-color)] to-purple-600">Связи</span>
|
||||
</h1>
|
||||
<p className="text-[var(--text-color)] opacity-60 text-lg border-l-4 border-[var(--accent-color)] pl-4 mt-6">
|
||||
Ангарский политехнический техникум. <br />
|
||||
Техническая поддержка систем Smart Nexus.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* ГЛАВНАЯ СЕТКА */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-stretch">
|
||||
|
||||
{/* === ЛЕВАЯ КОЛОНКА (Форма) === */}
|
||||
{/* Добавлен flex и h-full, чтобы колонка тянулась вниз */}
|
||||
<div className="lg:col-span-5 flex flex-col z-20">
|
||||
|
||||
<motion.div
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="glass p-8 rounded-[2rem] border border-[var(--glass-border)] h-full flex flex-col"
|
||||
>
|
||||
<h3 className="text-2xl font-bold mb-6 text-[var(--text-color)]">Входящий сигнал</h3>
|
||||
<form className="flex flex-col flex-grow gap-5">
|
||||
<div className="group">
|
||||
<label className="text-xs font-bold text-[var(--text-color)] opacity-50 ml-2 mb-1 block uppercase">Ваше Имя</label>
|
||||
<input type="text" className="contact-input" placeholder="Введите имя..." />
|
||||
</div>
|
||||
<div className="group">
|
||||
<label className="text-xs font-bold text-[var(--text-color)] opacity-50 ml-2 mb-1 block uppercase">Email</label>
|
||||
<input type="email" className="contact-input" placeholder="mail@example.com" />
|
||||
</div>
|
||||
{/* Textarea растягивается на всю доступную высоту (flex-grow) */}
|
||||
<div className="group flex-grow flex flex-col">
|
||||
<label className="text-xs font-bold text-[var(--text-color)] opacity-50 ml-2 mb-1 block uppercase">Сообщение</label>
|
||||
<textarea className="contact-input flex-grow resize-none min-h-[200px]" placeholder="Опишите задачу..." />
|
||||
</div>
|
||||
<button className="btn-neon w-full py-4 text-lg tracking-widest flex justify-center items-center gap-2 mt-auto">
|
||||
<Send size={18} /> ОТПРАВИТЬ
|
||||
</button>
|
||||
</form>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* === ПРАВАЯ КОЛОНКА (Карта + Контакты) === */}
|
||||
<div className="lg:col-span-7 flex flex-col gap-8 relative z-10">
|
||||
|
||||
{/* 1. КАРТА */}
|
||||
<motion.div
|
||||
initial={{ x: 50, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="glass p-2 rounded-[2.5rem] border border-[var(--glass-border)] relative group overflow-hidden h-[450px]"
|
||||
>
|
||||
{/* Плашка адреса */}
|
||||
<div className="absolute top-6 left-8 z-20 flex items-center gap-3 bg-[var(--card-bg)]/90 backdrop-blur-md px-5 py-3 rounded-2xl border border-[var(--glass-border)] shadow-xl">
|
||||
<div className="bg-red-500/20 p-2 rounded-full text-red-500 animate-pulse">
|
||||
<MapPin size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--text-color)] font-bold text-sm block leading-none mb-1">АПТ</span>
|
||||
<span className="text-[var(--text-color)] text-[10px] opacity-60 font-mono block">52.549955, 103.885752</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Радар */}
|
||||
<div className="absolute bottom-8 right-8 z-20 scale-75 drop-shadow-2xl pointer-events-none">
|
||||
<Radar />
|
||||
</div>
|
||||
|
||||
{/* Карта */}
|
||||
<div className="w-full h-full rounded-[2rem] overflow-hidden relative bg-[var(--bg-color)]">
|
||||
<iframe
|
||||
src="https://yandex.ru/map-widget/v1/?ll=103.885752%2C52.549955&mode=search&ol=geo&ouri=ymapsbm1%3A%2F%2Fgeo%3Fdata%3DCgg1NzM1NjAyNBJK0KDQvtGB0YHQuNGPLCDQmdGA0LrRg9GC0YHQutCw0Y8g0L7QsdC70LDRgdGC0YwsIDEsINCg0LDQsdC%2B0YfQuNC5INC60LLQsNGA0YLQsNC7LCDQkNC90LPQsNGA0YHQuiIKDS9uV0IV58lWQg%2C%2C&z=17"
|
||||
width="100%"
|
||||
height="100%"
|
||||
frameBorder="0"
|
||||
className="grayscale invert brightness-[0.85] contrast-[1.1] group-hover:grayscale-0 group-hover:invert-0 group-hover:brightness-100 transition-all duration-700"
|
||||
style={{ filter: 'var(--map-filter)' }}
|
||||
title="Yandex Map"
|
||||
></iframe>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 2. БЛОК КОНТАКТОВ + ТЕЛЕФОН */}
|
||||
<motion.div
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="glass px-8 py-6 rounded-[2rem] border border-[var(--glass-border)] flex flex-col xl:flex-row justify-between items-center gap-6 relative overflow-hidden"
|
||||
>
|
||||
|
||||
{/* Текстовые данные: В ОДНУ СТРОКУ (Flex Row) */}
|
||||
<div className="flex flex-col md:flex-row items-center gap-8 md:gap-16 z-20 w-full xl:w-auto text-center md:text-left">
|
||||
|
||||
{/* Телефон */}
|
||||
<div className="group">
|
||||
<div className="flex items-center justify-center md:justify-start gap-2 text-[var(--accent-color)] mb-1">
|
||||
<Phone size={16} />
|
||||
<p className="text-[10px] md:text-xs font-black uppercase tracking-widest opacity-70">ЭКСТРЕННАЯ СВЯЗЬ</p>
|
||||
</div>
|
||||
<p className="text-[var(--text-color)] font-black font-mono text-xl md:text-2xl group-hover:text-[var(--accent-color)] transition-colors cursor-pointer tracking-tight whitespace-nowrap">
|
||||
+7 (999) 000-NEXUS
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Вертикальный разделитель (только на десктопе) */}
|
||||
<div className="hidden md:block w-px h-10 bg-[var(--glass-border)]" />
|
||||
|
||||
{/* Почта */}
|
||||
<div className="group">
|
||||
<div className="flex items-center justify-center md:justify-start gap-2 text-[var(--accent-color)] mb-1">
|
||||
<Mail size={16} />
|
||||
<p className="text-[10px] md:text-xs font-black uppercase tracking-widest opacity-70">ЦИФРОВАЯ ПОЧТА</p>
|
||||
</div>
|
||||
<p className="text-[var(--text-color)] font-bold font-mono text-lg md:text-xl group-hover:text-[var(--accent-color)] transition-colors cursor-pointer">
|
||||
core@nexus.tech
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Анимация Телефона: УМЕНЬШЕНА И СДВИНУТА */}
|
||||
{/* pr-8 md:pr-12 создает отступ от правого края блока */}
|
||||
<div className="shrink-0 relative z-20 pr-0 md:pr-12 flex justify-center w-full md:w-auto">
|
||||
<div className="transform scale-75 md:scale-90">
|
||||
<Telephone />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Фон */}
|
||||
<div className="absolute right-0 top-0 w-3/4 h-full bg-gradient-to-l from-[var(--accent-color)]/10 to-transparent pointer-events-none z-0" />
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Contact;
|
||||
@@ -0,0 +1,255 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { ShoppingCart, History, Trash2, Plus, Minus, ArrowLeft, ArrowRight, Package, Cpu, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import Status from '../components/Status';
|
||||
|
||||
const API_URL = 'https://diplomnexus.aptcloud.ru';
|
||||
|
||||
const categoryMap = [
|
||||
{ label: 'ВСЕ', value: 'Все' },
|
||||
{ label: 'ДАТЧИКИ', value: 'sensors' },
|
||||
{ label: 'КАМЕРЫ', value: 'cameras' },
|
||||
{ label: 'ОСВЕЩЕНИЕ', value: 'lighting' },
|
||||
{ label: 'ХАБЫ', value: 'hubs' }
|
||||
];
|
||||
|
||||
const translateCategory = (cat) => {
|
||||
const map = {
|
||||
sensors: 'ДАТЧИКИ',
|
||||
cameras: 'КАМЕРЫ',
|
||||
lighting: 'ОСВЕЩЕНИЕ',
|
||||
hubs: 'ХАБЫ'
|
||||
};
|
||||
return map[cat] || cat.toUpperCase();
|
||||
};
|
||||
|
||||
const Dashboard = ({ user }) => {
|
||||
const [items, setItems] = useState([]);
|
||||
const [cart, setCart] = useState(() => JSON.parse(localStorage.getItem('cart') || '[]'));
|
||||
const [orders, setOrders] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedCat, setSelectedCat] = useState('Все');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [orderIndex, setOrderIndex] = useState(0);
|
||||
|
||||
const itemsPerPage = 9;
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const [prodRes, orderRes] = await Promise.all([
|
||||
axios.get(`${API_URL}/products`),
|
||||
axios.get(`${API_URL}/orders`, { headers: { Authorization: `Bearer ${token}` } })
|
||||
]);
|
||||
setItems(prodRes.data);
|
||||
setOrders(orderRes.data);
|
||||
} catch (e) { console.error(e); } finally { setLoading(false); }
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('cart', JSON.stringify(cart));
|
||||
}, [cart]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [selectedCat]);
|
||||
|
||||
const getPaginationGroup = (totalPages) => {
|
||||
let start = Math.max(1, currentPage - 5);
|
||||
let end = Math.min(totalPages, start + 11);
|
||||
if (end - start < 11) {
|
||||
start = Math.max(1, end - 11);
|
||||
}
|
||||
const pages = [];
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
return pages;
|
||||
};
|
||||
|
||||
const addToCart = (item) => {
|
||||
setCart(prev => {
|
||||
const exist = prev.find(i => i.id === item.id && i.category === item.category);
|
||||
if (exist) return prev.map(i => (i.id === item.id && i.category === item.category) ? {...i, qty: i.qty + 1} : i);
|
||||
return [...prev, {...item, qty: 1}];
|
||||
});
|
||||
};
|
||||
|
||||
const removeFromCart = (id, category) => setCart(prev => prev.filter(i => !(i.id === id && i.category === category)));
|
||||
const updateQty = (id, category, delta) => {
|
||||
setCart(prev => prev.map(i => (i.id === id && i.category === category) ? { ...i, qty: Math.max(1, i.qty + delta) } : i));
|
||||
};
|
||||
|
||||
const handlePayment = () => {
|
||||
if(cart.length === 0) return;
|
||||
localStorage.setItem('tempCart', JSON.stringify(cart));
|
||||
navigate('/payment');
|
||||
};
|
||||
|
||||
const filteredItems = selectedCat === 'Все' ? items : items.filter(i => i.category === selectedCat);
|
||||
const totalPages = Math.ceil(filteredItems.length / itemsPerPage);
|
||||
const currentItems = filteredItems.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage);
|
||||
|
||||
const visibleOrders = (() => {
|
||||
if (orders.length === 0) return [];
|
||||
if (orders.length < 5) return orders;
|
||||
const res = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
result.push(orders[(orderIndex + i) % orders.length]);
|
||||
}
|
||||
return res;
|
||||
})();
|
||||
|
||||
if (loading) return <div className="h-screen flex items-center justify-center bg-[var(--bg-color)] font-mono text-[var(--accent-color)]">LOADING_NEXUS_SYSTEM...</div>;
|
||||
|
||||
return (
|
||||
<div className="pt-28 pb-20 max-w-[1900px] mx-auto px-4 min-h-screen">
|
||||
|
||||
{/* HEADER */}
|
||||
<div className="flex flex-col xl:flex-row justify-between items-end border-b border-[var(--glass-border)] pb-8 mb-10 gap-6">
|
||||
<div>
|
||||
<h1 className="text-5xl font-black text-[var(--text-color)] mb-2 uppercase tracking-tighter">
|
||||
ТЕРМИНАЛ <span className="text-transparent bg-clip-text bg-gradient-to-r from-[var(--accent-color)] to-purple-600">NEXUS</span>
|
||||
</h1>
|
||||
<p className="text-[var(--text-color)] opacity-60 font-mono text-sm uppercase">:: OPERATOR: {user?.name} :: ONLINE</p>
|
||||
</div>
|
||||
|
||||
{/* ФИЛЬТРЫ КАТЕГОРИЙ (ИСПРАВЛЕНО: text-white) */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categoryMap.map(cat => (
|
||||
<button
|
||||
key={cat.value}
|
||||
onClick={() => setSelectedCat(cat.value)}
|
||||
className={`px-6 py-2.5 rounded-xl font-bold text-[11px] uppercase tracking-widest transition-all border ${
|
||||
selectedCat === cat.value
|
||||
? 'bg-[var(--accent-color)] text-white border-[var(--accent-color)] shadow-[0_0_20px_rgba(var(--accent-color),0.4)]'
|
||||
: 'glass text-[var(--text-color)] hover:bg-white/5 border-[var(--glass-border)]'
|
||||
}`}
|
||||
>
|
||||
{cat.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col xl:flex-row gap-8 items-start">
|
||||
<div className="flex-1 w-full min-w-0">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-3 gap-6">
|
||||
<AnimatePresence mode='wait'>
|
||||
{currentItems.map((item) => (
|
||||
<motion.div key={`${item.category}-${item.id}`} initial={{ opacity: 0, scale: 0.98 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0 }}
|
||||
className="uiverse-card group h-[400px] w-full border border-[var(--glass-border)] hover:border-[var(--accent-color)]/30 transition-colors"
|
||||
>
|
||||
<div className="uiverse-card-content p-5 flex flex-col h-full justify-between bg-[var(--card-bg)]">
|
||||
<div className="relative h-48 w-full rounded-2xl overflow-hidden border border-[var(--glass-border)] bg-white p-4">
|
||||
<img src={item.image.startsWith('http') ? item.image : `${API_URL}/${item.image}`} className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-500" alt={item.name} />
|
||||
<div className="absolute top-2 left-2 bg-black/80 px-2 py-1 rounded text-[9px] text-white font-bold uppercase border border-white/10">{translateCategory(item.category)}</div>
|
||||
<div className="absolute top-3 right-3 bg-black/80 px-3 py-1 rounded-lg text-[var(--accent-color)] font-bold font-mono border border-[var(--accent-color)]/30">{item.price} ₽</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<h3 className="font-black text-lg text-[var(--text-color)] uppercase leading-none truncate">{item.name}</h3>
|
||||
<p className="text-xs text-[var(--text-color)] opacity-50 mt-2 line-clamp-2 h-8">{item.description}</p>
|
||||
</div>
|
||||
<button onClick={() => addToCart(item)} className="mt-4 w-full py-3 rounded-xl bg-[var(--accent-color)]/10 text-[var(--accent-color)] font-bold text-xs tracking-[0.2em] border border-[var(--accent-color)]/20 hover:bg-[var(--accent-color)] hover:text-white transition-all">
|
||||
{cart.some(c => c.id === item.id && c.category === item.category) ? 'В ХРАНИЛИЩЕ' : 'ИНТЕГРИРОВАТЬ'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* ПАГИНАЦИЯ (ИСПРАВЛЕНО: text-white) */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center items-center gap-2 mt-10">
|
||||
<button onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1} className="p-3 glass rounded-xl hover:text-[var(--accent-color)] disabled:opacity-20"><ArrowLeft size={20}/></button>
|
||||
<div className="flex gap-1 overflow-hidden">
|
||||
{getPaginationGroup(totalPages).map((i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setCurrentPage(i)}
|
||||
className={`w-10 h-10 shrink-0 rounded-xl font-bold font-mono transition-all ${
|
||||
currentPage === i
|
||||
? 'bg-[var(--accent-color)] text-white shadow-[0_0_15px_var(--accent-color)]'
|
||||
: 'glass text-[var(--text-color)] opacity-50 hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
{i}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} disabled={currentPage === totalPages} className="p-3 glass rounded-xl hover:text-[var(--accent-color)] disabled:opacity-20"><ArrowRight size={20}/></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<aside className="w-full xl:w-[400px] shrink-0">
|
||||
<div className="glass p-6 rounded-[2rem] sticky top-28 border border-[var(--glass-border)] shadow-2xl">
|
||||
<div className="flex justify-between items-center mb-6 pb-4 border-b border-[var(--glass-border)]">
|
||||
<h3 className="text-xl font-black text-[var(--text-color)] flex items-center gap-3 tracking-wider uppercase"><ShoppingCart size={22} className="text-[var(--accent-color)]" /> КОРЗИНА</h3>
|
||||
<span className="text-[10px] font-mono bg-black/40 px-2 py-1 rounded text-[var(--accent-color)] border border-[var(--glass-border)]">{cart.length} UNITS</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 mb-6 max-h-[400px] overflow-y-auto pr-1 custom-scroll">
|
||||
{cart.length === 0 ? <div className="text-center py-16 opacity-20"><Package size={48} className="mx-auto mb-2" /><p className="font-mono text-[10px] uppercase">Хранилище пусто</p></div> : cart.map(c => (
|
||||
<div key={`${c.category}-${c.id}`} className="flex gap-3 items-center bg-black/20 p-3 rounded-2xl border border-[var(--glass-border)]">
|
||||
<img src={c.image.startsWith('http') ? c.image : `${API_URL}/${c.image}`} className="w-12 h-12 rounded bg-white object-contain p-1" />
|
||||
<div className="flex-1 min-w-0"><span className="text-[10px] font-bold block text-[var(--text-color)] truncate uppercase">{c.name}</span><span className="text-xs text-[var(--accent-color)] font-mono">{c.price * c.qty} ₽</span></div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<button onClick={() => removeFromCart(c.id, c.category)} className="text-gray-500 hover:text-red-500"><Trash2 size={14}/></button>
|
||||
<div className="flex items-center gap-1 bg-black/40 rounded-lg p-1 border border-[var(--glass-border)]">
|
||||
<button onClick={() => updateQty(c.id, c.category, -1)} className="hover:text-[var(--accent-color)]"><Minus size={10}/></button>
|
||||
<span className="font-mono text-[10px] font-bold w-4 text-center">{c.qty}</span>
|
||||
<button onClick={() => updateQty(c.id, c.category, 1)} className="hover:text-[var(--accent-color)]"><Plus size={10}/></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-[var(--glass-border)] pt-5">
|
||||
<div className="flex justify-between mb-6 items-end"><span className="text-[var(--text-color)] opacity-40 font-bold text-[10px] uppercase tracking-widest">Итого</span><span className="text-2xl font-black text-[var(--accent-color)] font-mono">{cart.reduce((a, c) => a + c.price * c.qty, 0).toLocaleString()} ₽</span></div>
|
||||
<button onClick={handlePayment} disabled={cart.length === 0} className="w-full py-5 bg-[var(--accent-color)] text-white font-black tracking-[0.3em] rounded-2xl uppercase text-[11px] shadow-[0_0_30px_rgba(var(--accent-color),0.4)] transition-all flex justify-center items-center gap-3 disabled:opacity-20"><Cpu size={18} /> ИНИЦИАЛИЗАЦИЯ</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* НИЖНЯЯ КАРУСЕЛЬ */}
|
||||
{orders.length > 0 && (
|
||||
<div className="mt-20 border-t border-[var(--glass-border)] pt-12" onMouseEnter={() => setIsPaused(true)} onMouseLeave={() => setIsPaused(false)}>
|
||||
<div className="flex justify-between items-center mb-8 px-4">
|
||||
<h3 className="text-3xl font-black text-[var(--text-color)] flex items-center gap-4 uppercase tracking-widest"><History className="text-[var(--accent-color)]" size={32} /> ЛОГ ОПЕРАЦИЙ</h3>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setOrderIndex(p => (p - 1 + orders.length) % orders.length)} className="p-3 rounded-xl glass hover:text-[var(--accent-color)]"><ChevronLeft size={24} /></button>
|
||||
<button onClick={() => setOrderIndex(p => (p + 1) % orders.length)} className="p-3 rounded-xl glass hover:text-[var(--accent-color)]"><ChevronRight size={24} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-5 gap-6 overflow-hidden px-4">
|
||||
<AnimatePresence mode='popLayout'>
|
||||
{visibleOrders.map((order, i) => (
|
||||
<motion.div key={`${order.id}-${orderIndex}-${i}`} initial={{ x: 100, opacity: 0 }} animate={{ x: 0, opacity: 1 }} exit={{ x: -100, opacity: 0 }} transition={{ duration: 0.4 }}
|
||||
className="glass p-6 rounded-[2rem] border border-[var(--glass-border)] relative flex flex-col justify-between min-h-[220px] bg-[var(--card-bg)]/50"
|
||||
>
|
||||
<div className={`absolute top-0 left-0 w-1.5 h-full ${order.status === 'completed' ? 'bg-green-500 shadow-[0_0_10px_#22c55e]' : 'bg-blue-500 shadow-[0_0_10px_#3b82f6]'}`} />
|
||||
<div className="flex justify-between items-start mb-4"><span className="font-mono text-[10px] px-2 py-1 rounded bg-white/5 border border-white/10 opacity-60">#{order.order_number}</span><span className="font-bold text-lg text-[var(--text-color)]">{order.total}₽</span></div>
|
||||
<p className="text-xs text-[var(--text-color)] opacity-70 font-bold line-clamp-3 mb-4">{order.content}</p>
|
||||
<div className="w-full mt-auto">
|
||||
<Status status={order.status} date={new Date(order.created_at).toLocaleDateString()} />
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
@@ -0,0 +1,249 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight, Shield, Zap, Smartphone, Users, Package, Activity, Cpu, Globe } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import ArcReactor from '../components/ArcReactor';
|
||||
import Iphone from '../components/Iphone';
|
||||
import Fingerprint from '../components/Fingerprint';
|
||||
import OsCore from '../components/OsCore'; // <--- НОВЫЙ ИМПОРТ
|
||||
|
||||
const Home = () => {
|
||||
const [stats, setStats] = useState({ users: 0, products: 0, orders: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const [u, p, o] = await Promise.all([
|
||||
axios.get('https://diplomnexus.aptcloud.ru/users', { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }}),
|
||||
axios.get('https://diplomnexus.aptcloud.ru/products'),
|
||||
axios.get('https://diplomnexus.aptcloud.ru/orders', { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }})
|
||||
]);
|
||||
setStats({ users: u.data.length, products: p.data.length, orders: o.data.length });
|
||||
} catch (e) {
|
||||
// Fallback данные для красоты, если бэк не отвечает
|
||||
setStats({ users: 1240, products: 48, orders: 8900 });
|
||||
}
|
||||
};
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
// Анимация для контейнеров
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1, transition: { staggerChildren: 0.2 } }
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { y: 20, opacity: 0 },
|
||||
visible: { y: 0, opacity: 1 }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative overflow-x-hidden">
|
||||
|
||||
{/* ФОНОВАЯ СЕТКА (Grid Background) */}
|
||||
<div className="fixed inset-0 pointer-events-none z-0">
|
||||
<div className="absolute inset-0 bg-[var(--bg-color)]" />
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]" />
|
||||
<div className="absolute left-0 right-0 top-0 -z-10 m-auto h-[310px] w-[310px] rounded-full bg-[var(--accent-color)] opacity-20 blur-[100px]" />
|
||||
</div>
|
||||
|
||||
{/* === HERO SECTION === */}
|
||||
<section className="relative z-10 min-h-screen flex items-center pt-20 px-6">
|
||||
<div className="max-w-[1600px] mx-auto grid lg:grid-cols-2 gap-12 items-center w-full">
|
||||
|
||||
{/* Левая часть: Текст */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center lg:text-left"
|
||||
>
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-[var(--accent-color)]/30 bg-[var(--accent-color)]/10 text-[var(--accent-color)] text-xs font-bold tracking-[0.2em] mb-8 shadow-[0_0_15px_rgba(0,243,255,0.2)]">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[var(--accent-color)] opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-[var(--accent-color)]"></span>
|
||||
</span>
|
||||
SYSTEM NEXUS V.5.0
|
||||
</div>
|
||||
|
||||
<h1 className="text-6xl md:text-8xl font-black mb-6 leading-[0.9] text-[var(--text-color)] tracking-tighter">
|
||||
УМНЫЙ <br/>
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-[var(--accent-color)] via-purple-500 to-pink-500 animate-gradient">ДОМ</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-[var(--text-color)] opacity-60 mb-10 max-w-xl mx-auto lg:mx-0 leading-relaxed">
|
||||
Мы не просто автоматизируем рутину. Мы создаем цифровую нервную систему вашего жилища, которая чувствует, думает и защищает.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-6 justify-center lg:justify-start">
|
||||
<Link to="/products" className="btn-neon px-10 py-5 text-lg flex items-center justify-center gap-3 group">
|
||||
ОТКРЫТЬ КАТАЛОГ
|
||||
<ArrowRight className="group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
<Link to="/about" className="px-10 py-5 text-lg font-bold text-[var(--text-color)] border border-[var(--glass-border)] rounded-xl hover:bg-white/5 transition-colors flex items-center justify-center gap-2">
|
||||
<Globe size={20} />
|
||||
О СИСТЕМЕ
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Правая часть: Реактор */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 1, delay: 0.2 }}
|
||||
className="flex justify-center relative"
|
||||
>
|
||||
{/* Декоративные круги */}
|
||||
<div className="absolute inset-0 bg-gradient-to-tr from-[var(--accent-color)]/20 to-transparent blur-3xl rounded-full opacity-30 animate-pulse" />
|
||||
<div className="relative z-10 scale-125 md:scale-150 drop-shadow-[0_0_60px_var(--accent-color)]">
|
||||
<ArcReactor />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* === STATS SECTION === */}
|
||||
<motion.section
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
className="py-20 relative z-10"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Card 1 */}
|
||||
<motion.div variants={itemVariants} className="glass p-8 rounded-[2rem] border border-[var(--glass-border)] flex items-center justify-between group hover:border-[var(--accent-color)]/50 transition-colors">
|
||||
<div>
|
||||
<h3 className="text-5xl font-black text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-cyan-300 mb-2">{stats.users}</h3>
|
||||
<p className="text-sm text-[var(--text-color)] opacity-60 font-bold uppercase tracking-widest">Пользователей</p>
|
||||
</div>
|
||||
<div className="p-4 bg-blue-500/10 rounded-2xl group-hover:scale-110 transition-transform">
|
||||
<Users size={32} className="text-blue-400" />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Card 2 */}
|
||||
<motion.div variants={itemVariants} className="glass p-8 rounded-[2rem] border border-[var(--glass-border)] flex items-center justify-between group hover:border-purple-500/50 transition-colors">
|
||||
<div>
|
||||
<h3 className="text-5xl font-black text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-pink-300 mb-2">{stats.products}</h3>
|
||||
<p className="text-sm text-[var(--text-color)] opacity-60 font-bold uppercase tracking-widest">Модулей</p>
|
||||
</div>
|
||||
<div className="p-4 bg-purple-500/10 rounded-2xl group-hover:scale-110 transition-transform">
|
||||
<Cpu size={32} className="text-purple-400" />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Card 3 */}
|
||||
<motion.div variants={itemVariants} className="glass p-8 rounded-[2rem] border border-[var(--glass-border)] flex items-center justify-between group hover:border-green-500/50 transition-colors">
|
||||
<div>
|
||||
<h3 className="text-5xl font-black text-transparent bg-clip-text bg-gradient-to-r from-green-400 to-emerald-300 mb-2">{stats.orders}+</h3>
|
||||
<p className="text-sm text-[var(--text-color)] opacity-60 font-bold uppercase tracking-widest">Установок</p>
|
||||
</div>
|
||||
<div className="p-4 bg-green-500/10 rounded-2xl group-hover:scale-110 transition-transform">
|
||||
<Activity size={32} className="text-green-400" />
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.section>
|
||||
|
||||
{/* === SHOWCASE SECTION (Компоненты) === */}
|
||||
<section className="py-32 relative z-10">
|
||||
<div className="max-w-[1600px] mx-auto px-6">
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-24"
|
||||
>
|
||||
<h2 className="text-4xl md:text-6xl font-black text-[var(--text-color)] mb-4">ТЕХНОЛОГИЧЕСКИЙ СТЕК</h2>
|
||||
<div className="w-24 h-1 bg-[var(--accent-color)] mx-auto rounded-full" />
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12 items-center">
|
||||
|
||||
{/* 1. MOBILE APP */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="glass p-8 rounded-[3rem] border border-[var(--glass-border)] flex flex-col items-center text-center group h-full relative overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-[var(--accent-color)] to-transparent opacity-50" />
|
||||
|
||||
<div className="h-[300px] flex items-center justify-center w-full mb-8">
|
||||
<div className="transform scale-75 group-hover:scale-90 transition-transform duration-500">
|
||||
<Iphone />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-2xl font-bold text-[var(--text-color)] mb-2 group-hover:text-[var(--accent-color)] transition-colors">Мобильный Контроль</h3>
|
||||
<p className="text-sm opacity-60 text-[var(--text-color)]">Управление домом из любой точки мира. Полная синхронизация в реальном времени.</p>
|
||||
</motion.div>
|
||||
|
||||
{/* 2. CORE SYSTEM (OS CORE) - CENTER */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="glass p-2 rounded-[3rem] border border-[var(--glass-border)] flex flex-col items-center text-center group h-full relative overflow-hidden shadow-[0_0_50px_rgba(0,0,0,0.3)]"
|
||||
>
|
||||
{/* Glowing bg */}
|
||||
<div className="absolute inset-0 bg-[var(--accent-color)]/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
|
||||
<div className="w-full flex items-center justify-center py-12 px-4 flex-grow">
|
||||
{/* НОВЫЙ КОМПОНЕНТ OS CORE */}
|
||||
<div className="transform scale-100 md:scale-125 transition-transform duration-500">
|
||||
<OsCore />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pb-8 px-8 relative z-10">
|
||||
<h3 className="text-2xl font-bold text-[var(--text-color)] mb-2 group-hover:text-purple-400 transition-colors">Ядро Системы (OS)</h3>
|
||||
<p className="text-sm opacity-60 text-[var(--text-color)]">Интеллектуальная операционная система, объединяющая все устройства в единый организм.</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 3. BIOMETRICS */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="glass p-8 rounded-[3rem] border border-[var(--glass-border)] flex flex-col items-center text-center group h-full relative overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-green-500 to-transparent opacity-50" />
|
||||
|
||||
<div className="h-[300px] flex items-center justify-center w-full mb-8">
|
||||
<div className="transform scale-100 group-hover:scale-110 transition-transform duration-500">
|
||||
<Fingerprint />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-2xl font-bold text-[var(--text-color)] mb-2 group-hover:text-green-400 transition-colors">Биометрия</h3>
|
||||
<p className="text-sm opacity-60 text-[var(--text-color)]">Бесключевой доступ и персональные сценарии на основе отпечатка пальца.</p>
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FOOTER CTA */}
|
||||
<section className="py-20 text-center relative z-10">
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-1/2 h-px bg-gradient-to-r from-transparent via-[var(--accent-color)] to-transparent" />
|
||||
<h2 className="text-3xl font-bold text-[var(--text-color)] mb-8">Готовы к будущему?</h2>
|
||||
<Link to="/contact" className="inline-flex items-center gap-2 px-8 py-3 rounded-full bg-[var(--text-color)] text-[var(--bg-color)] font-black hover:scale-105 transition-transform">
|
||||
СВЯЗАТЬСЯ С НАМИ <ArrowRight size={18} />
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
@@ -0,0 +1,240 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
User, Calendar, Shield, Key, Lock,
|
||||
Package, Clock, CheckCircle, Truck, XCircle, Send, ExternalLink
|
||||
} from 'lucide-react';
|
||||
import UserAvatar from '../components/UserAvatar';
|
||||
|
||||
const API_URL = 'https://diplomnexus.aptcloud.ru';
|
||||
|
||||
const Kabinet = ({ user }) => {
|
||||
const [orders, setOrders] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [passForm, setPassForm] = useState({ oldPassword: '', newPassword: '' });
|
||||
const [passMsg, setPassMsg] = useState('');
|
||||
const [passError, setPassError] = useState('');
|
||||
const [telegramInfo, setTelegramInfo] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
fetchUserOrders();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const fetchUserOrders = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
// Запрашиваем /orders. После исправления index.js придут только заказы этого юзера
|
||||
const res = await axios.get(`${API_URL}/orders`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
const userOrders = res.data;
|
||||
setOrders(userOrders);
|
||||
|
||||
// Ищем привязанный TG (проверяем, есть ли хоть в одном заказе telegram_chat_id)
|
||||
const linkedOrder = userOrders.find(o => o.telegram_chat_id);
|
||||
if (linkedOrder) {
|
||||
setTelegramInfo({
|
||||
id: linkedOrder.telegram_chat_id,
|
||||
username: linkedOrder.telegram_username || user.name,
|
||||
connected: true
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Ошибка загрузки заказов:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePassword = async (e) => {
|
||||
e.preventDefault();
|
||||
setPassMsg(''); setPassError('');
|
||||
if (passForm.newPassword.length < 6) return setPassError('Мин. 6 символов');
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
await axios.put(`${API_URL}/users/password`, passForm, { headers: { Authorization: `Bearer ${token}` } });
|
||||
setPassMsg('Пароль обновлен!'); setPassForm({ oldPassword: '', newPassword: '' });
|
||||
} catch (e) { setPassError(e.response?.data?.error || 'Ошибка смены пароля'); }
|
||||
};
|
||||
|
||||
const handleCancelOrder = async (orderId) => {
|
||||
if (!window.confirm('Вы уверены, что хотите отменить заказ?')) return;
|
||||
try {
|
||||
await axios.post(`${API_URL}/api/internal/orders/${orderId}/cancel-by-user`);
|
||||
// Обновляем список локально, чтобы не дергать сервер лишний раз (или можно вызвать fetchUserOrders)
|
||||
setOrders(orders.map(o => o.id === orderId ? { ...o, status: 'cancelled' } : o));
|
||||
alert('Заказ отменен.');
|
||||
} catch (e) { alert('Не удалось отменить.'); }
|
||||
};
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
switch (status) {
|
||||
case 'placed': return <span className="flex items-center gap-1 text-blue-400 bg-blue-400/10 px-2 py-1 rounded text-xs border border-blue-400/20"><Clock size={12}/> РАЗМЕЩЕН</span>;
|
||||
case 'processing': return <span className="flex items-center gap-1 text-yellow-400 bg-yellow-400/10 px-2 py-1 rounded text-xs border border-yellow-400/20"><Package size={12}/> В РАБОТЕ</span>;
|
||||
case 'shipped': return <span className="flex items-center gap-1 text-purple-400 bg-purple-400/10 px-2 py-1 rounded text-xs border border-purple-400/20"><Truck size={12}/> В ПУТИ</span>;
|
||||
case 'completed': return <span className="flex items-center gap-1 text-green-400 bg-green-400/10 px-2 py-1 rounded text-xs border border-green-400/20"><CheckCircle size={12}/> ВЫПОЛНЕН</span>;
|
||||
case 'cancelled': return <span className="flex items-center gap-1 text-red-400 bg-red-400/10 px-2 py-1 rounded text-xs border border-red-400/20"><XCircle size={12}/> ОТМЕНЕН</span>;
|
||||
default: return <span className="opacity-50">{status}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) return <div className="pt-32 text-center text-white">Загрузка профиля...</div>;
|
||||
|
||||
return (
|
||||
<div className="pt-28 pb-10 min-h-screen px-4 max-w-7xl mx-auto">
|
||||
<motion.h1 initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} className="text-4xl font-black text-[var(--accent-color)] mb-8 uppercase tracking-widest">
|
||||
Личный Кабинет
|
||||
</motion.h1>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
|
||||
{/* ЛЕВАЯ КОЛОНКА: ПРОФИЛЬ */}
|
||||
<div className="lg:col-span-1 space-y-6">
|
||||
|
||||
{/* КАРТОЧКА ПОЛЬЗОВАТЕЛЯ */}
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="glass p-6 rounded-2xl border border-[var(--glass-border)] shadow-xl relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-full h-20 bg-[var(--accent-color)]/10" />
|
||||
<div className="relative z-10 flex flex-col items-center">
|
||||
<div className="p-1 bg-[var(--bg-color)] rounded-full mb-4">
|
||||
<UserAvatar user={user} className="w-24 h-24 rounded-full border-2 border-[var(--accent-color)]" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-[var(--text-color)]">{user.name}</h2>
|
||||
<p className="text-sm opacity-50 mb-6 font-mono">{user.email}</p>
|
||||
|
||||
<div className="w-full space-y-3">
|
||||
<div className="flex items-center gap-3 p-3 bg-[var(--input-bg)] rounded-xl border border-[var(--glass-border)]">
|
||||
<Shield size={18} className="text-[var(--accent-color)]"/>
|
||||
<div className="text-xs">
|
||||
<p className="opacity-50">Роль</p>
|
||||
<p className="font-bold uppercase">{user.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 bg-[var(--input-bg)] rounded-xl border border-[var(--glass-border)]">
|
||||
<Calendar size={18} className="text-[var(--accent-color)]"/>
|
||||
<div className="text-xs">
|
||||
<p className="opacity-50">Дата регистрации</p>
|
||||
<p>{user.created_at ? new Date(user.created_at).toLocaleDateString() : '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* TELEGRAM */}
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.15 }} className="glass p-6 rounded-2xl border border-[var(--glass-border)] shadow-xl">
|
||||
<h3 className="text-lg font-bold text-[var(--text-color)] mb-4 flex items-center gap-2"><Send size={18} className="text-blue-400"/> TELEGRAM</h3>
|
||||
{telegramInfo ? (
|
||||
<div className="bg-blue-500/10 border border-blue-500/30 rounded-xl p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-blue-500 flex items-center justify-center text-white font-bold text-xl">
|
||||
{telegramInfo.username[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="overflow-hidden">
|
||||
<p className="text-sm font-bold text-white truncate">{telegramInfo.username}</p>
|
||||
<p className="text-[10px] text-blue-300 font-mono">ID: {telegramInfo.id}</p>
|
||||
<a href={`https://t.me/${telegramInfo.username}`} target="_blank" rel="noreferrer" className="text-[10px] text-blue-400 hover:text-white flex items-center gap-1 mt-1">@{telegramInfo.username} <ExternalLink size={8}/></a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-[10px] text-center bg-blue-500/20 py-1 rounded text-blue-200">✅ Аккаунт привязан</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center p-4 bg-[var(--input-bg)] rounded-xl border border-dashed border-gray-600">
|
||||
<p className="text-sm opacity-50 mb-2">Аккаунт не привязан</p>
|
||||
<p className="text-xs opacity-30">Совершите покупку через бота, чтобы привязать уведомления.</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* СМЕНА ПАРОЛЯ */}
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }} className="glass p-6 rounded-2xl border border-[var(--glass-border)] shadow-xl">
|
||||
<h3 className="text-lg font-bold text-[var(--text-color)] mb-4 flex items-center gap-2"><Key size={18} className="text-[var(--accent-color)]"/> ПАРОЛЬ</h3>
|
||||
<form onSubmit={handleChangePassword} className="space-y-3">
|
||||
<div className="relative">
|
||||
<Lock size={14} className="absolute left-3 top-3 opacity-30"/>
|
||||
<input type="password" placeholder="Старый пароль" className="w-full bg-[var(--input-bg)] border border-[var(--glass-border)] rounded-lg py-2 pl-9 pr-3 text-sm text-[var(--text-color)] outline-none focus:border-[var(--accent-color)]" value={passForm.oldPassword} onChange={e => setPassForm({...passForm, oldPassword: e.target.value})} required/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Key size={14} className="absolute left-3 top-3 opacity-30"/>
|
||||
<input type="password" placeholder="Новый пароль" className="w-full bg-[var(--input-bg)] border border-[var(--glass-border)] rounded-lg py-2 pl-9 pr-3 text-sm text-[var(--text-color)] outline-none focus:border-[var(--accent-color)]" value={passForm.newPassword} onChange={e => setPassForm({...passForm, newPassword: e.target.value})} required/>
|
||||
</div>
|
||||
{passMsg && <p className="text-xs text-green-500 text-center">{passMsg}</p>}
|
||||
{passError && <p className="text-xs text-red-500 text-center">{passError}</p>}
|
||||
<button className="w-full btn-neon py-2 rounded-lg text-xs font-bold mt-2 hover:scale-105 transition-transform">ОБНОВИТЬ</button>
|
||||
</form>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* ПРАВАЯ КОЛОНКА: ЗАКАЗЫ */}
|
||||
<motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.3 }} className="lg:col-span-2">
|
||||
<div className="glass p-6 rounded-2xl border border-[var(--glass-border)] shadow-xl min-h-[600px]">
|
||||
<h3 className="text-2xl font-bold text-[var(--text-color)] mb-6 flex items-center gap-3"><Package className="text-[var(--accent-color)]"/> МОИ ЗАКАЗЫ</h3>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center opacity-50 py-20 animate-pulse">Загрузка списка заказов...</div>
|
||||
) : orders.length === 0 ? (
|
||||
<div className="text-center py-20 flex flex-col items-center">
|
||||
<Package size={48} className="mx-auto opacity-20 mb-4"/>
|
||||
<p className="opacity-50">История заказов пуста</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{orders.map((o) => (
|
||||
<div key={o.id} className="p-5 bg-[var(--input-bg)]/30 border border-[var(--glass-border)] rounded-2xl hover:border-[var(--accent-color)]/30 transition-all">
|
||||
|
||||
{/* ХЕДЕР ЗАКАЗА */}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-4 border-b border-[var(--glass-border)] pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-[var(--bg-color)] flex items-center justify-center border border-[var(--glass-border)] text-[var(--accent-color)] font-bold">
|
||||
#{o.order_number || o.id}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">{getStatusBadge(o.status)}</div>
|
||||
<span className="text-[10px] opacity-40 font-mono block mt-1">{new Date(o.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(o.status === 'placed' || o.status === 'processing') && (
|
||||
<button onClick={() => handleCancelOrder(o.id)} className="flex items-center gap-1 text-[10px] text-red-400 bg-red-500/10 px-3 py-1.5 rounded-lg border border-red-500/20 hover:bg-red-500 hover:text-white transition-colors">
|
||||
<XCircle size={14}/> ОТМЕНИТЬ ЗАКАЗ
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ТЕЛО ЗАКАЗА */}
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
<div className="flex-1">
|
||||
<p className="text-[10px] uppercase opacity-40 mb-1 font-bold">Содержание</p>
|
||||
<p className="text-sm text-[var(--text-color)] leading-relaxed font-medium">{o.content}</p>
|
||||
|
||||
{o.delivery_address && (
|
||||
<div className="mt-3 flex gap-2">
|
||||
<div className="w-0.5 bg-[var(--glass-border)] self-stretch" />
|
||||
<p className="text-xs opacity-60 font-mono whitespace-pre-wrap">{o.delivery_address}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="md:w-40 shrink-0 flex flex-col gap-2">
|
||||
<div className="bg-[var(--bg-color)] p-3 rounded-xl border border-[var(--glass-border)]">
|
||||
<span className="text-[10px] opacity-50 block">Сумма</span>
|
||||
<span className="font-bold text-[var(--accent-color)] text-lg">{o.total} ₽</span>
|
||||
</div>
|
||||
<div className={`p-2 rounded-xl border text-center text-xs font-bold ${o.payment_status === 'paid' ? 'bg-green-500/10 text-green-500 border-green-500/20' : 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20'}`}>
|
||||
{o.payment_status === 'paid' ? 'ОПЛАЧЕНО' : 'НЕ ОПЛАЧЕНО'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default Kabinet;
|
||||
@@ -0,0 +1,293 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import { CreditCard, MapPin, Calendar, User, Phone, Navigation, Loader2 } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import Iphone from '../components/Iphone';
|
||||
|
||||
const Payment = () => {
|
||||
const [cart, setCart] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Состояние формы
|
||||
const [form, setForm] = useState({
|
||||
fio: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
date: '',
|
||||
coords: '52.5400, 103.8800'
|
||||
});
|
||||
|
||||
const mapInstance = useRef(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 1. ЗАГРУЗКА КОРЗИНЫ
|
||||
useEffect(() => {
|
||||
const savedCart = JSON.parse(localStorage.getItem('tempCart') || '[]');
|
||||
setCart(savedCart);
|
||||
if(savedCart.length === 0) navigate('/dashboard');
|
||||
}, []);
|
||||
|
||||
const total = cart.reduce((acc, item) => acc + item.price * item.qty, 0);
|
||||
|
||||
// 2. ИНИЦИАЛИЗАЦИЯ КАРТЫ
|
||||
useEffect(() => {
|
||||
if (!window.ymaps) {
|
||||
const script = document.createElement('script');
|
||||
script.src = "https://api-maps.yandex.ru/2.1/?apikey=794e6377-6202-426f-8706-930263f350df&lang=ru_RU";
|
||||
script.async = true;
|
||||
document.body.appendChild(script);
|
||||
script.onload = initMap;
|
||||
} else {
|
||||
initMap();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (mapInstance.current) {
|
||||
mapInstance.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const initMap = () => {
|
||||
window.ymaps.ready(() => {
|
||||
if (mapInstance.current) return;
|
||||
|
||||
const map = new window.ymaps.Map("yandex-map", {
|
||||
center: [52.5400, 103.8800], // Ангарск
|
||||
zoom: 13,
|
||||
controls: ['zoomControl']
|
||||
});
|
||||
|
||||
mapInstance.current = map;
|
||||
|
||||
const myPlacemark = new window.ymaps.Placemark([52.5400, 103.8800], {
|
||||
hintContent: 'Место установки'
|
||||
}, {
|
||||
preset: 'islands#redDotIcon'
|
||||
});
|
||||
|
||||
map.geoObjects.add(myPlacemark);
|
||||
|
||||
map.events.add('click', function (e) {
|
||||
const coords = e.get('coords');
|
||||
myPlacemark.geometry.setCoordinates(coords);
|
||||
|
||||
const lat = coords[0].toFixed(4);
|
||||
const lon = coords[1].toFixed(4);
|
||||
|
||||
const streets = ['ул. Карла Маркса', 'ул. Ленина', '12-й микрорайон', '85-й квартал', 'ул. Космонавтов', 'ул. Чайковского', 'ул. Горького', 'мкр. Китова'];
|
||||
const randomStreet = streets[Math.floor(Math.random() * streets.length)];
|
||||
const randomHouse = Math.floor(Math.random() * 80) + 1;
|
||||
const detectedAddress = `г. Ангарск, ${randomStreet}, д. ${randomHouse}`;
|
||||
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
coords: `${lat}, ${lon}`,
|
||||
address: detectedAddress
|
||||
}));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// --- ЛОГИКА ОПЛАТЫ ---
|
||||
const handlePay = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
// 1. СОЗДАЕМ ЗАКАЗ В БАЗЕ
|
||||
const res = await axios.post('https://diplomnexus.aptcloud.ru/orders', {
|
||||
cart: cart,
|
||||
delivery: form
|
||||
}, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
const orderId = res.data.orderId;
|
||||
|
||||
if (!orderId) {
|
||||
alert('Ошибка сервера: не вернулся номер заказа.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. ЧИСТИМ КОРЗИНУ
|
||||
localStorage.removeItem('cart');
|
||||
localStorage.removeItem('tempCart');
|
||||
|
||||
// 3. ЖЕСТКИЙ РЕДИРЕКТ В ТЕЛЕГРАМ
|
||||
// Окно браузера сразу перейдет в ТГ
|
||||
window.location.href = `https://t.me/oplata_umniydombot?start=${orderId}`;
|
||||
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setLoading(false);
|
||||
alert('Ошибка при создании заказа.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pt-32 min-h-screen flex justify-center items-start px-4 pb-20 overflow-x-hidden">
|
||||
<div className="max-w-[1600px] w-full grid grid-cols-1 xl:grid-cols-12 gap-12 items-start">
|
||||
|
||||
{/* ЛЕВАЯ КОЛОНКА */}
|
||||
<motion.div
|
||||
initial={{ x: -50, opacity: 0 }} animate={{ x: 0, opacity: 1 }}
|
||||
className="xl:col-span-8 flex flex-col lg:flex-row gap-8"
|
||||
>
|
||||
<div className="hidden lg:flex flex-col items-center gap-6 pt-10 sticky top-32 h-fit">
|
||||
<div className="scale-90 xl:scale-100 hover:scale-105 transition-transform duration-500">
|
||||
<Iphone />
|
||||
</div>
|
||||
<p className="text-[var(--text-color)] opacity-40 text-xs font-mono text-center max-w-[200px]">
|
||||
Управляйте установкой через приложение Nexus Home после оплаты
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 glass p-8 md:p-10 rounded-[2.5rem] border border-[var(--glass-border)]">
|
||||
<div className="flex items-center gap-4 mb-8 border-b border-[var(--glass-border)] pb-6">
|
||||
<div className="p-3 bg-[var(--accent-color)]/10 rounded-2xl text-[var(--accent-color)] border border-[var(--accent-color)]/20">
|
||||
<Navigation size={28} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-3xl font-black text-[var(--text-color)] uppercase tracking-tight">Настройка</h2>
|
||||
<p className="text-[var(--text-color)] opacity-50 text-sm">Данные для выезда инженера</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="pay-form" onSubmit={handlePay} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="group">
|
||||
<label className="text-[10px] font-bold text-[var(--text-color)] opacity-50 ml-4 mb-1 block uppercase tracking-widest">ФИО Клиента</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-5 top-4 text-gray-500" size={18}/>
|
||||
<input
|
||||
className="w-full bg-[var(--input-bg)] border border-[var(--glass-border)] rounded-2xl py-3.5 pl-12 pr-6 text-[var(--text-color)] outline-none focus:border-[var(--accent-color)] transition-all font-bold"
|
||||
placeholder="Иванов Иван" required
|
||||
value={form.fio} onChange={e=>setForm({...form, fio: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="group">
|
||||
<label className="text-[10px] font-bold text-[var(--text-color)] opacity-50 ml-4 mb-1 block uppercase tracking-widest">Телефон</label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-5 top-4 text-gray-500" size={18}/>
|
||||
<input
|
||||
className="w-full bg-[var(--input-bg)] border border-[var(--glass-border)] rounded-2xl py-3.5 pl-12 pr-6 text-[var(--text-color)] outline-none focus:border-[var(--accent-color)] transition-all font-mono"
|
||||
placeholder="+7 (999)..." required
|
||||
value={form.phone} onChange={e=>setForm({...form, phone: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="group">
|
||||
<label className="text-[10px] font-bold text-[var(--text-color)] opacity-50 ml-4 mb-1 block uppercase tracking-widest">Адрес (Кликните на карту)</label>
|
||||
<div className="relative">
|
||||
<MapPin className="absolute left-5 top-4 text-gray-500" size={18}/>
|
||||
<input
|
||||
className="w-full bg-[var(--input-bg)] border border-[var(--glass-border)] rounded-2xl py-3.5 pl-12 pr-6 text-[var(--text-color)] outline-none focus:border-[var(--accent-color)] transition-all font-bold text-[var(--accent-color)]"
|
||||
placeholder="Выберите точку на карте..." required
|
||||
value={form.address} onChange={e=>setForm({...form, address: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="group">
|
||||
<label className="text-[10px] font-bold text-[var(--text-color)] opacity-50 ml-4 mb-1 block uppercase tracking-widest">Дата монтажа</label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-5 top-4 text-gray-500" size={18}/>
|
||||
<input type="datetime-local" className="w-full bg-[var(--input-bg)] border border-[var(--glass-border)] rounded-2xl py-3.5 pl-12 pr-6 text-[var(--text-color)] outline-none focus:border-[var(--accent-color)] transition-all font-mono" required
|
||||
value={form.date} onChange={e=>setForm({...form, date: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="group pt-2">
|
||||
<div className="flex justify-between items-end mb-2 ml-2">
|
||||
<label className="text-[10px] font-bold text-[var(--text-color)] opacity-50 uppercase tracking-widest">Геолокация объекта</label>
|
||||
<span className="text-[10px] font-mono text-[var(--accent-color)] bg-[var(--accent-color)]/10 px-2 py-0.5 rounded border border-[var(--accent-color)]/20">
|
||||
{form.coords}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-80 w-full rounded-[2rem] overflow-hidden border border-[var(--glass-border)] shadow-inner relative bg-[var(--bg-color)]">
|
||||
<div
|
||||
id="yandex-map"
|
||||
style={{ width: '100%', height: '100%', filter: 'var(--map-filter)' }}
|
||||
className="transition-all duration-700"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* ПРАВАЯ КОЛОНКА: ЧЕК */}
|
||||
<motion.div
|
||||
initial={{ x: 50, opacity: 0 }} animate={{ x: 0, opacity: 1 }} transition={{ delay: 0.2 }}
|
||||
className="xl:col-span-4 h-fit sticky top-28"
|
||||
>
|
||||
<div className="glass p-8 rounded-[2.5rem] border border-[var(--glass-border)] shadow-2xl relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-[var(--accent-color)]/20 blur-[60px] rounded-full pointer-events-none" />
|
||||
|
||||
<h3 className="text-xl font-black text-[var(--text-color)] mb-6 flex items-center gap-2">
|
||||
<CreditCard className="text-[var(--accent-color)]"/> СВОДКА
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3 mb-8 max-h-[300px] overflow-y-auto pr-1 custom-scroll">
|
||||
{cart.map(item => (
|
||||
<div key={item.id} className="flex justify-between items-center text-sm p-3 rounded-xl bg-[var(--bg-color)]/50 border border-[var(--glass-border)]">
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<div className="w-10 h-10 rounded-lg bg-white p-1 shrink-0">
|
||||
<img src={item.image.startsWith('http') ? item.image : `https://diplomnexus.aptcloud.ru/${item.image}`} className="w-full h-full object-contain" />
|
||||
</div>
|
||||
<div className="flex flex-col truncate">
|
||||
<span className="font-bold text-[var(--text-color)] truncate">{item.name}</span>
|
||||
<span className="text-[10px] opacity-60">x{item.qty}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-mono font-bold text-[var(--accent-color)]">{item.price * item.qty}₽</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--glass-border)] pt-6 mb-8 space-y-2">
|
||||
<div className="flex justify-between text-sm opacity-60">
|
||||
<span>Оборудование:</span>
|
||||
<span className="font-mono">{total}₽</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm opacity-60">
|
||||
<span>Монтаж:</span>
|
||||
<span className="font-mono text-green-400">БЕСПЛАТНО</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-2xl font-black text-[var(--text-color)] mt-4">
|
||||
<span>ИТОГО:</span>
|
||||
<span className="text-[var(--accent-color)]">{total}₽</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
form="pay-form"
|
||||
disabled={loading}
|
||||
className="w-full py-5 btn-neon text-white font-black tracking-[0.15em] rounded-2xl flex items-center justify-center gap-3 shadow-[0_10px_30px_rgba(0,0,0,0.3)] hover:scale-[1.02] active:scale-[0.98] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? <><Loader2 className="animate-spin" /> ПЕРЕХОД...</> : 'ОПЛАТИТЬ ЧЕРЕЗ TELEGRAM'}
|
||||
</button>
|
||||
|
||||
<div className="mt-6 flex justify-center gap-4 opacity-30 grayscale">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/2/2a/Mastercard-logo.svg" className="h-6" alt="MC"/>
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/5/5e/Visa_Inc._logo.svg" className="h-6" alt="Visa"/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Payment;
|
||||
@@ -0,0 +1,261 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { Plus, X, Eye, ChevronLeft, ChevronRight, AlertCircle, Video } from 'lucide-react';
|
||||
import ActionMenu from '../../components/ActionMenu';
|
||||
|
||||
const API_URL = 'https://diplomnexus.aptcloud.ru';
|
||||
|
||||
const CamerasTable = ({ user }) => {
|
||||
const [cameras, setCameras] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [viewData, setViewData] = useState(null);
|
||||
const [formData, setFormData] = useState({ id: null, name: '', price: '', category: 'cameras', description: '', image: null });
|
||||
|
||||
// ПАГИНАЦИЯ
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 10;
|
||||
|
||||
// ОПРЕДЕЛЕНИЕ ПРАВ
|
||||
const isAdmin = user?.role === 'admin' || user?.email === 'admin@mail.ru';
|
||||
|
||||
useEffect(() => {
|
||||
fetchCameras();
|
||||
}, []);
|
||||
|
||||
const fetchCameras = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Запрос именно к таблице cameras
|
||||
const res = await axios.get(`${API_URL}/api/cameras`);
|
||||
setCameras(res.data);
|
||||
} catch(e) {
|
||||
console.error("Ошибка при загрузке камер:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!isAdmin) return;
|
||||
if(window.confirm('Удалить камеру из системы мониторинга?')) {
|
||||
try {
|
||||
await axios.delete(`${API_URL}/admin/cameras/${id}`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
fetchCameras();
|
||||
} catch(e) { alert("Ошибка удаления"); }
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!isAdmin) return;
|
||||
|
||||
const data = new FormData();
|
||||
Object.keys(formData).forEach(k => {
|
||||
if (formData[k] !== null) data.append(k, formData[k]);
|
||||
});
|
||||
|
||||
const cfg = { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } };
|
||||
try {
|
||||
if(formData.id) {
|
||||
await axios.put(`${API_URL}/admin/cameras/${formData.id}`, formData, cfg);
|
||||
} else {
|
||||
await axios.post(`${API_URL}/admin/cameras`, data, cfg);
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
fetchCameras();
|
||||
} catch(e) { alert("Ошибка сохранения"); }
|
||||
};
|
||||
|
||||
const getImageUrl = (img) => {
|
||||
if (!img) return null;
|
||||
if (img.startsWith('http')) return img;
|
||||
return `${API_URL}/${img}`;
|
||||
};
|
||||
|
||||
const indexOfLastItem = currentPage * itemsPerPage;
|
||||
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
|
||||
const currentItems = cameras.slice(indexOfFirstItem, indexOfLastItem);
|
||||
const totalPages = Math.ceil(cameras.length / itemsPerPage);
|
||||
|
||||
return (
|
||||
<div className="pt-28 pb-10 min-h-screen px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
|
||||
<div className="flex justify-between items-end mb-6 border-b border-[var(--glass-border)] pb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-[var(--accent-color)]/20 rounded-lg text-[var(--accent-color)]">
|
||||
<Video size={32}/>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-3xl font-black text-[var(--accent-color)] uppercase tracking-widest leading-none">
|
||||
ВИДЕОНАБЛЮДЕНИЕ
|
||||
</h2>
|
||||
<p className="text-[10px] opacity-50 mt-1 font-mono uppercase tracking-tighter">Системы безопасности и онлайн-мониторинга ({cameras.length} ед.)</p>
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => { setFormData({ id: null, name: '', price: '', category: 'cameras', description: '', image: null }); setIsModalOpen(true); }}
|
||||
className="btn-neon px-4 py-2 text-xs font-bold flex gap-2"
|
||||
>
|
||||
<Plus size={16}/> ДОБАВИТЬ КАМЕРУ
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="glass overflow-hidden rounded-lg shadow-xl min-h-[400px]">
|
||||
{loading ? (
|
||||
<div className="p-10 text-center opacity-50 font-mono">SCANNING DATABASE...</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="bg-[var(--accent-color)]/10 text-[var(--text-color)] uppercase text-[10px] font-bold tracking-wider">
|
||||
<tr>
|
||||
<th className="p-4">VIEW</th>
|
||||
<th className="p-4">Модель</th>
|
||||
<th className="p-4">SKU / ID</th>
|
||||
<th className="p-4">Тариф / Цена</th>
|
||||
<th className="p-4 text-right">Управление</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-sm text-[var(--text-color)]">
|
||||
{currentItems.map((p) => (
|
||||
<tr key={p.id} className="hover:bg-[var(--accent-color)]/5 border-b border-[var(--glass-border)] last:border-0 transition-colors">
|
||||
<td className="p-4">
|
||||
{p.image ? (
|
||||
<img src={getImageUrl(p.image)} className="w-12 h-10 object-cover rounded border border-[var(--glass-border)] bg-black/40" alt={p.name} />
|
||||
) : (
|
||||
<div className="w-12 h-10 bg-[var(--glass-border)] rounded flex items-center justify-center text-[8px] opacity-50">NO_SIGNAL</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4 font-bold">{p.name}</td>
|
||||
<td className="p-4 font-mono opacity-60 text-[11px]">{p.sku || `CAM-${p.id}`}</td>
|
||||
<td className="p-4 text-[var(--accent-color)] font-mono font-bold">{p.price} ₽</td>
|
||||
<td className="p-4 text-right flex justify-end gap-2 items-center">
|
||||
<button onClick={() => setViewData(p)} className="p-2 text-gray-400 hover:text-[var(--accent-color)] transition-colors">
|
||||
<Eye size={18}/>
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<ActionMenu
|
||||
onEdit={() => { setFormData({...p, category: 'cameras'}); setIsModalOpen(true); }}
|
||||
onDelete={() => handleDelete(p.id)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-between items-center p-4 border-t border-[var(--glass-border)] bg-[var(--input-bg)]">
|
||||
<button onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1} className="p-2 disabled:opacity-30 hover:text-[var(--accent-color)]"><ChevronLeft/></button>
|
||||
<span className="text-xs font-mono">PAGE {currentPage} / {totalPages}</span>
|
||||
<button onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} disabled={currentPage === totalPages} className="p-2 disabled:opacity-30 hover:text-[var(--accent-color)]"><ChevronRight/></button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MODAL EDIT/CREATE */}
|
||||
{isModalOpen && isAdmin && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
|
||||
<div className="glass p-6 w-full max-w-md relative border border-[var(--accent-color)] rounded-xl">
|
||||
<button onClick={() => setIsModalOpen(false)} className="absolute top-4 right-4 text-[var(--text-color)] hover:text-red-500"><X size={20}/></button>
|
||||
<h3 className="text-xl font-bold text-[var(--accent-color)] mb-4 uppercase tracking-tighter">
|
||||
{formData.id ? 'Обновление прошивки/данных' : 'Регистрация новой камеры'}
|
||||
</h3>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
<input
|
||||
className="bg-[var(--input-bg)] border border-[var(--glass-border)] p-2 rounded text-[var(--text-color)] focus:border-[var(--accent-color)] outline-none"
|
||||
placeholder="Модель камеры"
|
||||
value={formData.name}
|
||||
onChange={e=>setFormData({...formData, name:e.target.value})}
|
||||
required
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="bg-[var(--input-bg)] border border-[var(--glass-border)] p-2 rounded text-[var(--text-color)] w-full focus:border-[var(--accent-color)] outline-none"
|
||||
placeholder="Стоимость (₽)"
|
||||
type="number"
|
||||
value={formData.price}
|
||||
onChange={e=>setFormData({...formData, price:e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
className="bg-[var(--input-bg)] border border-[var(--glass-border)] p-2 rounded text-[var(--text-color)] h-24 focus:border-[var(--accent-color)] outline-none text-sm"
|
||||
placeholder="Описание характеристик (разрешение, ночная съемка и т.д.)..."
|
||||
value={formData.description}
|
||||
onChange={e=>setFormData({...formData, description:e.target.value})}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[10px] opacity-50 uppercase ml-1 font-bold">Фото объектива/вида</span>
|
||||
<input type="file" className="text-xs text-[var(--text-color)] file:bg-[var(--accent-color)] file:border-0 file:rounded file:px-2 file:py-1 file:mr-2 file:text-black file:font-bold cursor-pointer" onChange={e=>setFormData({...formData, image: e.target.files[0]})}/>
|
||||
</div>
|
||||
<button className="btn-neon py-3 mt-2 font-bold rounded uppercase tracking-widest text-sm">ВНЕСТИ В РЕЕСТР</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VIEW MODAL */}
|
||||
{viewData && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
|
||||
<div className="glass p-0 w-full max-w-2xl relative border border-[var(--glass-border)] text-[var(--text-color)] rounded-lg overflow-hidden flex flex-col md:flex-row shadow-2xl">
|
||||
<button onClick={() => setViewData(null)} className="absolute top-2 right-2 z-10 bg-black/50 p-1 rounded-full text-white hover:bg-[var(--accent-color)] hover:text-black transition-colors"><X size={20}/></button>
|
||||
|
||||
<div className="w-full md:w-1/2 h-64 md:h-auto bg-black relative flex items-center justify-center">
|
||||
{viewData.image ? (
|
||||
<img src={getImageUrl(viewData.image)} className="w-full h-full object-cover opacity-90 shadow-[inset_0_0_50px_rgba(0,0,0,0.8)]" alt={viewData.name} />
|
||||
) : (
|
||||
<div className="text-[var(--glass-border)]"><AlertCircle size={48}/></div>
|
||||
)}
|
||||
<div className="absolute top-4 left-4 flex gap-1">
|
||||
<div className="w-2 h-2 rounded-full bg-red-600 animate-pulse"></div>
|
||||
<span className="text-[10px] font-bold text-white uppercase drop-shadow-md">Live Stream</span>
|
||||
</div>
|
||||
<div className="absolute bottom-4 left-4">
|
||||
<span className="bg-[var(--accent-color)] text-black px-3 py-1 font-bold rounded text-sm shadow-lg">
|
||||
{viewData.price} ₽
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-1/2 p-6 flex flex-col gap-4 bg-[var(--bg-color)]/95">
|
||||
<div>
|
||||
<h3 className="text-2xl font-black text-[var(--accent-color)] uppercase leading-tight mb-1">{viewData.name}</h3>
|
||||
<p className="text-[10px] opacity-40 font-mono tracking-widest uppercase">ID_SOURCE: {viewData.id} / {viewData.sku || 'NEXUS_VISION'}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-[10px] uppercase font-bold">
|
||||
<div className="p-2 bg-[var(--input-bg)] rounded border border-[var(--glass-border)] flex flex-col">
|
||||
<span className="opacity-50 text-[8px]">Объектив</span>
|
||||
<span className="text-[var(--accent-color)]">4K Ultra HD</span>
|
||||
</div>
|
||||
<div className="p-2 bg-[var(--input-bg)] rounded border border-[var(--glass-border)] flex flex-col">
|
||||
<span className="opacity-50 text-[8px]">Запись</span>
|
||||
<span className="text-green-500">Облако / SD</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<span className="text-[9px] uppercase opacity-40 font-bold block mb-1">Техническая сводка:</span>
|
||||
<p className="text-sm opacity-80 bg-black/20 p-3 rounded border border-[var(--glass-border)] h-32 overflow-y-auto custom-scroll leading-relaxed">
|
||||
{viewData.description || 'Данные о характеристиках видеосенсора не заполнены.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CamerasTable;
|
||||
@@ -0,0 +1,252 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { Plus, X, Eye, ChevronLeft, ChevronRight, AlertCircle } from 'lucide-react';
|
||||
import ActionMenu from '../../components/ActionMenu';
|
||||
|
||||
const API_URL = 'https://diplomnexus.aptcloud.ru';
|
||||
|
||||
const HubsTable = ({ user }) => {
|
||||
const [hubs, setHubs] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [viewData, setViewData] = useState(null);
|
||||
const [formData, setFormData] = useState({ id: null, name: '', price: '', category: 'hubs', description: '', image: null });
|
||||
|
||||
// ПАГИНАЦИЯ
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 10;
|
||||
|
||||
// ОПРЕДЕЛЕНИЕ ПРАВ
|
||||
const isAdmin = user?.role === 'admin' || user?.email === 'admin@mail.ru';
|
||||
|
||||
useEffect(() => {
|
||||
fetchHubs();
|
||||
}, []);
|
||||
|
||||
const fetchHubs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Запрос именно к таблице hubs
|
||||
const res = await axios.get(`${API_URL}/api/hubs`);
|
||||
setHubs(res.data);
|
||||
} catch(e) {
|
||||
console.error("Ошибка при загрузке хабов:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!isAdmin) return;
|
||||
if(window.confirm('Удалить этот хаб из базы?')) {
|
||||
try {
|
||||
await axios.delete(`${API_URL}/admin/hubs/${id}`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
fetchHubs();
|
||||
} catch(e) { alert("Ошибка удаления"); }
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!isAdmin) return;
|
||||
|
||||
const data = new FormData();
|
||||
// Добавляем все поля в FormData
|
||||
Object.keys(formData).forEach(k => {
|
||||
if (formData[k] !== null) data.append(k, formData[k]);
|
||||
});
|
||||
|
||||
const cfg = { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } };
|
||||
try {
|
||||
if(formData.id) {
|
||||
// Обновление
|
||||
await axios.put(`${API_URL}/admin/hubs/${formData.id}`, formData, cfg);
|
||||
} else {
|
||||
// Создание нового
|
||||
await axios.post(`${API_URL}/admin/hubs`, data, cfg);
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
fetchHubs();
|
||||
} catch(e) { alert("Ошибка сохранения"); }
|
||||
};
|
||||
|
||||
const getImageUrl = (img) => {
|
||||
if (!img) return null;
|
||||
if (img.startsWith('http')) return img;
|
||||
return `${API_URL}/${img}`;
|
||||
};
|
||||
|
||||
const indexOfLastItem = currentPage * itemsPerPage;
|
||||
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
|
||||
const currentItems = hubs.slice(indexOfFirstItem, indexOfLastItem);
|
||||
const totalPages = Math.ceil(hubs.length / itemsPerPage);
|
||||
|
||||
return (
|
||||
<div className="pt-28 pb-10 min-h-screen px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
|
||||
<div className="flex justify-between items-end mb-6 border-b border-[var(--glass-border)] pb-4">
|
||||
<h2 className="text-3xl font-black text-[var(--accent-color)] uppercase tracking-widest">
|
||||
УМНЫЕ ХАБЫ ({hubs.length})
|
||||
</h2>
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => { setFormData({ id: null, name: '', price: '', category: 'hubs', description: '', image: null }); setIsModalOpen(true); }}
|
||||
className="btn-neon px-4 py-2 text-xs font-bold flex gap-2"
|
||||
>
|
||||
<Plus size={16}/> ДОБАВИТЬ ХАБ
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="glass overflow-hidden rounded-lg shadow-xl min-h-[400px]">
|
||||
{loading ? (
|
||||
<div className="p-10 text-center opacity-50">Загрузка данных из таблицы Hubs...</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="bg-[var(--accent-color)]/10 text-[var(--text-color)] uppercase text-[10px] font-bold tracking-wider">
|
||||
<tr>
|
||||
<th className="p-4">IMG</th>
|
||||
<th className="p-4">Название устройства</th>
|
||||
<th className="p-4">Артикул (SKU)</th>
|
||||
<th className="p-4">Цена</th>
|
||||
<th className="p-4 text-right">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-sm text-[var(--text-color)]">
|
||||
{currentItems.map((p) => (
|
||||
<tr key={p.id} className="hover:bg-[var(--accent-color)]/5 border-b border-[var(--glass-border)] last:border-0 transition-colors">
|
||||
<td className="p-4">
|
||||
{p.image ? (
|
||||
<img src={getImageUrl(p.image)} className="w-10 h-10 object-cover rounded bg-black/20" alt={p.name} />
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-[var(--glass-border)] rounded flex items-center justify-center text-xs opacity-50">НЕТ</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4 font-bold">{p.name}</td>
|
||||
<td className="p-4 font-mono opacity-60 text-xs">{p.sku || 'N/A'}</td>
|
||||
<td className="p-4 text-[var(--accent-color)] font-mono font-bold">{p.price} ₽</td>
|
||||
<td className="p-4 text-right flex justify-end gap-2 items-center">
|
||||
<button onClick={() => setViewData(p)} className="p-2 text-gray-400 hover:text-[var(--accent-color)] transition-colors">
|
||||
<Eye size={18}/>
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<ActionMenu
|
||||
onEdit={() => { setFormData({...p, category: 'hubs'}); setIsModalOpen(true); }}
|
||||
onDelete={() => handleDelete(p.id)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-between items-center p-4 border-t border-[var(--glass-border)] bg-[var(--input-bg)]">
|
||||
<button onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1} className="p-2 disabled:opacity-30 hover:text-[var(--accent-color)]"><ChevronLeft/></button>
|
||||
<span className="text-xs font-mono">СТР. {currentPage} ИЗ {totalPages}</span>
|
||||
<button onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} disabled={currentPage === totalPages} className="p-2 disabled:opacity-30 hover:text-[var(--accent-color)]"><ChevronRight/></button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MODAL EDIT/CREATE */}
|
||||
{isModalOpen && isAdmin && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
|
||||
<div className="glass p-6 w-full max-w-md relative border border-[var(--accent-color)] rounded-xl">
|
||||
<button onClick={() => setIsModalOpen(false)} className="absolute top-4 right-4 text-[var(--text-color)] hover:text-red-500"><X size={20}/></button>
|
||||
<h3 className="text-xl font-bold text-[var(--accent-color)] mb-4 uppercase tracking-tighter">
|
||||
{formData.id ? 'Настройка хаба' : 'Новый хаб'}
|
||||
</h3>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
<input
|
||||
className="bg-[var(--input-bg)] border border-[var(--glass-border)] p-2 rounded text-[var(--text-color)] focus:border-[var(--accent-color)] outline-none"
|
||||
placeholder="Название хаба"
|
||||
value={formData.name}
|
||||
onChange={e=>setFormData({...formData, name:e.target.value})}
|
||||
required
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="bg-[var(--input-bg)] border border-[var(--glass-border)] p-2 rounded text-[var(--text-color)] w-full focus:border-[var(--accent-color)] outline-none"
|
||||
placeholder="Цена (₽)"
|
||||
type="number"
|
||||
value={formData.price}
|
||||
onChange={e=>setFormData({...formData, price:e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
className="bg-[var(--input-bg)] border border-[var(--glass-border)] p-2 rounded text-[var(--text-color)] h-24 focus:border-[var(--accent-color)] outline-none text-sm"
|
||||
placeholder="Техническое описание хаба..."
|
||||
value={formData.description}
|
||||
onChange={e=>setFormData({...formData, description:e.target.value})}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[10px] opacity-50 uppercase ml-1">Изображение устройства</span>
|
||||
<input type="file" className="text-xs text-[var(--text-color)] file:bg-[var(--accent-color)] file:border-0 file:rounded file:px-2 file:py-1 file:mr-2 file:text-black file:font-bold cursor-pointer" onChange={e=>setFormData({...formData, image: e.target.files[0]})}/>
|
||||
</div>
|
||||
<button className="btn-neon py-3 mt-2 font-bold rounded uppercase tracking-widest text-sm">ПОДТВЕРДИТЬ</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VIEW MODAL */}
|
||||
{viewData && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
|
||||
<div className="glass p-0 w-full max-w-2xl relative border border-[var(--glass-border)] text-[var(--text-color)] rounded-lg overflow-hidden flex flex-col md:flex-row shadow-2xl">
|
||||
<button onClick={() => setViewData(null)} className="absolute top-2 right-2 z-10 bg-black/50 p-1 rounded-full text-white hover:bg-[var(--accent-color)] hover:text-black transition-colors"><X size={20}/></button>
|
||||
|
||||
<div className="w-full md:w-1/2 h-64 md:h-auto bg-black relative flex items-center justify-center">
|
||||
{viewData.image ? (
|
||||
<img src={getImageUrl(viewData.image)} className="w-full h-full object-cover opacity-90" alt={viewData.name} />
|
||||
) : (
|
||||
<div className="text-[var(--glass-border)]"><AlertCircle size={48}/></div>
|
||||
)}
|
||||
<div className="absolute bottom-4 left-4">
|
||||
<span className="bg-[var(--accent-color)] text-black px-3 py-1 font-bold rounded text-sm shadow-lg">
|
||||
{viewData.price} ₽
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-1/2 p-6 flex flex-col gap-4 bg-[var(--bg-color)]/95">
|
||||
<div>
|
||||
<h3 className="text-2xl font-black text-[var(--accent-color)] uppercase leading-tight mb-1">{viewData.name}</h3>
|
||||
<p className="text-[10px] opacity-40 font-mono italic">HUB_NODE_ID: {viewData.id} / {viewData.sku || 'NO_SKU'}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="p-2 bg-[var(--input-bg)] rounded border border-[var(--glass-border)]">
|
||||
<span className="opacity-50 block uppercase text-[8px]">Тип системы</span>
|
||||
<span className="font-bold">Центральный Хаб</span>
|
||||
</div>
|
||||
<div className="p-2 bg-[var(--input-bg)] rounded border border-[var(--glass-border)]">
|
||||
<span className="opacity-50 block uppercase text-[8px]">Склад</span>
|
||||
<span className="font-bold text-green-500">Доступно</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<span className="text-[9px] uppercase opacity-40 font-bold block mb-1">Спецификация:</span>
|
||||
<p className="text-sm opacity-80 bg-black/20 p-3 rounded border border-[var(--glass-border)] h-32 overflow-y-auto custom-scroll">
|
||||
{viewData.description || 'Технические характеристики не указаны.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HubsTable;
|
||||
@@ -0,0 +1,257 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { Plus, X, Eye, ChevronLeft, ChevronRight, AlertCircle, Lightbulb } from 'lucide-react';
|
||||
import ActionMenu from '../../components/ActionMenu';
|
||||
|
||||
const API_URL = 'https://diplomnexus.aptcloud.ru';
|
||||
|
||||
const LightingTable = ({ user }) => {
|
||||
const [lighting, setLighting] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [viewData, setViewData] = useState(null);
|
||||
const [formData, setFormData] = useState({ id: null, name: '', price: '', category: 'lighting', description: '', image: null });
|
||||
|
||||
// ПАГИНАЦИЯ
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 10;
|
||||
|
||||
// ОПРЕДЕЛЕНИЕ ПРАВ
|
||||
const isAdmin = user?.role === 'admin' || user?.email === 'admin@mail.ru';
|
||||
|
||||
useEffect(() => {
|
||||
fetchLighting();
|
||||
}, []);
|
||||
|
||||
const fetchLighting = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Запрос именно к таблице lighting
|
||||
const res = await axios.get(`${API_URL}/api/lighting`);
|
||||
setLighting(res.data);
|
||||
} catch(e) {
|
||||
console.error("Ошибка при загрузке освещения:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!isAdmin) return;
|
||||
if(window.confirm('Удалить осветительный прибор из базы?')) {
|
||||
try {
|
||||
await axios.delete(`${API_URL}/admin/lighting/${id}`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
fetchLighting();
|
||||
} catch(e) { alert("Ошибка удаления"); }
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!isAdmin) return;
|
||||
|
||||
const data = new FormData();
|
||||
Object.keys(formData).forEach(k => {
|
||||
if (formData[k] !== null) data.append(k, formData[k]);
|
||||
});
|
||||
|
||||
const cfg = { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } };
|
||||
try {
|
||||
if(formData.id) {
|
||||
await axios.put(`${API_URL}/admin/lighting/${formData.id}`, formData, cfg);
|
||||
} else {
|
||||
await axios.post(`${API_URL}/admin/lighting`, data, cfg);
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
fetchLighting();
|
||||
} catch(e) { alert("Ошибка сохранения"); }
|
||||
};
|
||||
|
||||
const getImageUrl = (img) => {
|
||||
if (!img) return null;
|
||||
if (img.startsWith('http')) return img;
|
||||
return `${API_URL}/${img}`;
|
||||
};
|
||||
|
||||
const indexOfLastItem = currentPage * itemsPerPage;
|
||||
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
|
||||
const currentItems = lighting.slice(indexOfFirstItem, indexOfLastItem);
|
||||
const totalPages = Math.ceil(lighting.length / itemsPerPage);
|
||||
|
||||
return (
|
||||
<div className="pt-28 pb-10 min-h-screen px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
|
||||
<div className="flex justify-between items-end mb-6 border-b border-[var(--glass-border)] pb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-yellow-500/20 rounded-lg text-yellow-500 shadow-[0_0_15px_rgba(234,179,8,0.3)]">
|
||||
<Lightbulb size={32}/>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-3xl font-black text-[var(--accent-color)] uppercase tracking-widest leading-none">
|
||||
УМНОЕ ОСВЕЩЕНИЕ
|
||||
</h2>
|
||||
<p className="text-[10px] opacity-50 mt-1 font-mono uppercase tracking-tighter">Управление светом и атмосферой ({lighting.length} поз.)</p>
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => { setFormData({ id: null, name: '', price: '', category: 'lighting', description: '', image: null }); setIsModalOpen(true); }}
|
||||
className="btn-neon px-4 py-2 text-xs font-bold flex gap-2"
|
||||
>
|
||||
<Plus size={16}/> ДОБАВИТЬ ЛАМПУ
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="glass overflow-hidden rounded-lg shadow-xl min-h-[400px]">
|
||||
{loading ? (
|
||||
<div className="p-10 text-center opacity-50 font-mono tracking-widest">LIGHT_INIT_DATABASE...</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="bg-[var(--accent-color)]/10 text-[var(--text-color)] uppercase text-[10px] font-bold tracking-wider">
|
||||
<tr>
|
||||
<th className="p-4">ФОТО</th>
|
||||
<th className="p-4">Наименование</th>
|
||||
<th className="p-4">SKU / Арт.</th>
|
||||
<th className="p-4">Стоимость</th>
|
||||
<th className="p-4 text-right">Инфо</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-sm text-[var(--text-color)]">
|
||||
{currentItems.map((p) => (
|
||||
<tr key={p.id} className="hover:bg-[var(--accent-color)]/5 border-b border-[var(--glass-border)] last:border-0 transition-colors">
|
||||
<td className="p-4">
|
||||
{p.image ? (
|
||||
<img src={getImageUrl(p.image)} className="w-10 h-10 object-cover rounded shadow-[0_0_10px_rgba(255,255,255,0.1)] bg-black/20" alt={p.name} />
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-[var(--glass-border)] rounded flex items-center justify-center text-[10px] opacity-50 italic">DARK</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4 font-bold">{p.name}</td>
|
||||
<td className="p-4 font-mono opacity-60 text-xs">{p.sku || `LUM-${p.id}`}</td>
|
||||
<td className="p-4 text-[var(--accent-color)] font-mono font-bold">{p.price} ₽</td>
|
||||
<td className="p-4 text-right flex justify-end gap-2 items-center">
|
||||
<button onClick={() => setViewData(p)} className="p-2 text-gray-400 hover:text-[var(--accent-color)] transition-colors">
|
||||
<Eye size={18}/>
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<ActionMenu
|
||||
onEdit={() => { setFormData({...p, category: 'lighting'}); setIsModalOpen(true); }}
|
||||
onDelete={() => handleDelete(p.id)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-between items-center p-4 border-t border-[var(--glass-border)] bg-[var(--input-bg)]">
|
||||
<button onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1} className="p-2 disabled:opacity-30 hover:text-[var(--accent-color)]"><ChevronLeft/></button>
|
||||
<span className="text-xs font-mono">STATION {currentPage} / {totalPages}</span>
|
||||
<button onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} disabled={currentPage === totalPages} className="p-2 disabled:opacity-30 hover:text-[var(--accent-color)]"><ChevronRight/></button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MODAL EDIT/CREATE */}
|
||||
{isModalOpen && isAdmin && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
|
||||
<div className="glass p-6 w-full max-w-md relative border border-[var(--accent-color)] rounded-xl">
|
||||
<button onClick={() => setIsModalOpen(false)} className="absolute top-4 right-4 text-[var(--text-color)] hover:text-red-500"><X size={20}/></button>
|
||||
<h3 className="text-xl font-bold text-[var(--accent-color)] mb-4 uppercase tracking-tighter">
|
||||
{formData.id ? 'Корректировка яркости/данных' : 'Добавление источника света'}
|
||||
</h3>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
<input
|
||||
className="bg-[var(--input-bg)] border border-[var(--glass-border)] p-2 rounded text-[var(--text-color)] focus:border-[var(--accent-color)] outline-none"
|
||||
placeholder="Название лампы/панели"
|
||||
value={formData.name}
|
||||
onChange={e=>setFormData({...formData, name:e.target.value})}
|
||||
required
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="bg-[var(--input-bg)] border border-[var(--glass-border)] p-2 rounded text-[var(--text-color)] w-full focus:border-[var(--accent-color)] outline-none"
|
||||
placeholder="Цена (₽)"
|
||||
type="number"
|
||||
value={formData.price}
|
||||
onChange={e=>setFormData({...formData, price:e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
className="bg-[var(--input-bg)] border border-[var(--glass-border)] p-2 rounded text-[var(--text-color)] h-24 focus:border-[var(--accent-color)] outline-none text-sm"
|
||||
placeholder="Описание световых характеристик (Люмены, RGB, цветовая температура)..."
|
||||
value={formData.description}
|
||||
onChange={e=>setFormData({...formData, description:e.target.value})}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[10px] opacity-50 uppercase ml-1 font-bold">Изображение товара</span>
|
||||
<input type="file" className="text-xs text-[var(--text-color)] file:bg-[var(--accent-color)] file:border-0 file:rounded file:px-2 file:py-1 file:mr-2 file:text-black file:font-bold cursor-pointer" onChange={e=>setFormData({...formData, image: e.target.files[0]})}/>
|
||||
</div>
|
||||
<button className="btn-neon py-3 mt-2 font-bold rounded uppercase tracking-widest text-sm">СОХРАНИТЬ ЛАМПУ</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VIEW MODAL */}
|
||||
{viewData && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
|
||||
<div className="glass p-0 w-full max-w-2xl relative border border-[var(--glass-border)] text-[var(--text-color)] rounded-lg overflow-hidden flex flex-col md:flex-row shadow-2xl">
|
||||
<button onClick={() => setViewData(null)} className="absolute top-2 right-2 z-10 bg-black/50 p-1 rounded-full text-white hover:bg-[var(--accent-color)] hover:text-black transition-colors"><X size={20}/></button>
|
||||
|
||||
<div className="w-full md:w-1/2 h-64 md:h-auto bg-black relative flex items-center justify-center">
|
||||
{viewData.image ? (
|
||||
<img src={getImageUrl(viewData.image)} className="w-full h-full object-cover opacity-90 shadow-[0_0_60px_rgba(255,255,255,0.05)]" alt={viewData.name} />
|
||||
) : (
|
||||
<div className="text-[var(--glass-border)]"><AlertCircle size={48}/></div>
|
||||
)}
|
||||
<div className="absolute bottom-4 left-4">
|
||||
<span className="bg-[var(--accent-color)] text-black px-3 py-1 font-bold rounded text-sm shadow-[0_0_15px_var(--accent-color)]">
|
||||
{viewData.price} ₽
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-1/2 p-6 flex flex-col gap-4 bg-[var(--bg-color)]/95">
|
||||
<div>
|
||||
<h3 className="text-2xl font-black text-[var(--accent-color)] uppercase leading-tight mb-1">{viewData.name}</h3>
|
||||
<p className="text-[10px] opacity-40 font-mono tracking-widest uppercase">ID_SOURCE: {viewData.id} / {viewData.sku || 'NEXUS_LIGHT'}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-[10px] uppercase font-bold">
|
||||
<div className="p-2 bg-[var(--input-bg)] rounded border border-[var(--glass-border)] flex flex-col">
|
||||
<span className="opacity-50 text-[8px]">Спектр</span>
|
||||
<span className="text-yellow-400">RGB + White</span>
|
||||
</div>
|
||||
<div className="p-2 bg-[var(--input-bg)] rounded border border-[var(--glass-border)] flex flex-col">
|
||||
<span className="opacity-50 text-[8px]">Ресурс</span>
|
||||
<span className="text-blue-400">50,000 Часов</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<span className="text-[9px] uppercase opacity-40 font-bold block mb-1">Световая схема и описание:</span>
|
||||
<p className="text-sm opacity-80 bg-black/20 p-3 rounded border border-[var(--glass-border)] h-32 overflow-y-auto custom-scroll leading-relaxed">
|
||||
{viewData.description || 'Сведения об интенсивности и цветопередаче отсутствуют.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LightingTable;
|
||||
@@ -0,0 +1,59 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { Eye, X, Terminal, AlertTriangle, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
const LogsTable = ({ user }) => {
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [viewData, setViewData] = useState(null);
|
||||
|
||||
// ПАГИНАЦИЯ
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 10;
|
||||
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdmin) axios.get('https://diplomnexus.aptcloud.ru/logs', { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } }).then(res => setLogs(res.data));
|
||||
}, [isAdmin]);
|
||||
|
||||
const indexOfLastItem = currentPage * itemsPerPage;
|
||||
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
|
||||
const currentItems = logs.slice(indexOfFirstItem, indexOfLastItem);
|
||||
const totalPages = Math.ceil(logs.length / itemsPerPage);
|
||||
|
||||
if (!isAdmin) return <div className="h-screen flex items-center justify-center text-red-500 gap-4"><AlertTriangle size={48}/><h1 className="text-3xl font-black">ACCESS DENIED</h1></div>;
|
||||
|
||||
return (
|
||||
<div className="pt-28 pb-10 min-h-screen px-4 font-mono">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<h2 className="text-3xl font-black text-red-500 mb-6 uppercase tracking-widest border-b border-red-900/30 pb-4 flex items-center gap-3"><Terminal/> SYSTEM LOGS ({logs.length})</h2>
|
||||
<div className="glass bg-black/90 overflow-hidden rounded-lg shadow-xl border-red-900/30">
|
||||
<table className="w-full text-left border-collapse text-xs text-gray-400">
|
||||
<thead className="bg-red-900/20 text-red-500 uppercase font-bold">
|
||||
<tr><th className="p-3">Time</th><th className="p-3">User</th><th className="p-3">Method</th><th className="p-3">Route</th><th className="p-3">Status</th><th className="p-3 text-right">View</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{currentItems.map((l) => (
|
||||
<tr key={l.id} className="hover:bg-red-900/10 border-b border-red-900/20">
|
||||
<td className="p-3 opacity-60">{new Date(l.timestamp).toLocaleTimeString()}</td>
|
||||
<td className="p-3 text-gray-200">{l.username}</td>
|
||||
<td className="p-3 font-bold">{l.method}</td>
|
||||
<td className="p-3">{l.url}</td>
|
||||
<td className={`p-3 font-bold ${l.status_code>=400?'text-red-500':'text-green-500'}`}>{l.status_code}</td>
|
||||
<td className="p-3 text-right"><button onClick={() => setViewData(l)}><Eye size={14} className="hover:text-white"/></button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex justify-between items-center p-4 border-t border-red-900/30 bg-black">
|
||||
<button onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1} className="p-2 disabled:opacity-30 hover:text-red-500"><ChevronLeft/></button>
|
||||
<span className="text-xs font-mono text-red-500">PAGE {currentPage} OF {totalPages}</span>
|
||||
<button onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} disabled={currentPage === totalPages} className="p-2 disabled:opacity-30 hover:text-red-500"><ChevronRight/></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{viewData && (<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-sm p-4 text-gray-300 font-mono"><div className="bg-[#111] p-6 w-full max-w-2xl relative border border-red-900"><button onClick={() => setViewData(null)} className="absolute top-4 right-4 text-red-500"><X size={20}/></button><h3 className="text-lg font-bold text-red-500 mb-4">LOG DETAIL #{viewData.id}</h3><pre className="text-xs bg-black p-4 overflow-auto max-h-96 border border-gray-800">{JSON.stringify(viewData, null, 2)}</pre></div></div>)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default LogsTable;
|
||||
@@ -0,0 +1,204 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { Eye, Mail, User, Shield, RefreshCw, AlertCircle, X, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import ActionMenu from '../../components/ActionMenu';
|
||||
const API_URL = 'https://diplomnexus.aptcloud.ru';
|
||||
const MessagesTable = ({ user }) => {
|
||||
const [msgs, setMsgs] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [viewData, setViewData] = useState(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 10;
|
||||
const isAdmin =
|
||||
user?.role === 'admin' ||
|
||||
user?.email === 'admin@mail.ru' ||
|
||||
user?.name === 'seth1nk';
|
||||
useEffect(() => {
|
||||
fetchMessages();
|
||||
const interval = setInterval(fetchMessages, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
const fetchMessages = async () => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await axios.get(`${API_URL}/admin/messages`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
const sorted = res.data.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
setMsgs(sorted);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
console.error("Fetch error:", e);
|
||||
if (e.response && e.response.status === 403) {
|
||||
setError("У вас нет прав администратора для просмотра всех сообщений.");
|
||||
} else if (e.response && e.response.status === 404) {
|
||||
setError("API '/admin/messages' не найден. Проверьте index.js.");
|
||||
} else {
|
||||
setError("Не удалось загрузить сообщения. Проверьте сервер.");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const handleDelete = async (id) => {
|
||||
if (!isAdmin) return alert("Доступ запрещен");
|
||||
if (window.confirm("Удалить сообщение?")) {
|
||||
|
||||
alert("Функция удаления пока не настроена на сервере.");
|
||||
}
|
||||
};
|
||||
const indexOfLastItem = currentPage * itemsPerPage;
|
||||
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
|
||||
const currentItems = msgs.slice(indexOfFirstItem, indexOfLastItem);
|
||||
const totalPages = Math.ceil(msgs.length / itemsPerPage);
|
||||
const renderStatus = (m) => {
|
||||
if (m.is_admin) return <span className="text-[10px] font-bold text-blue-400 bg-blue-400/10 px-2 py-1 rounded border border-blue-400/20">ОТВЕТ</span>;
|
||||
if (m.is_read) return <span className="text-[10px] font-bold text-green-500 bg-green-500/10 px-2 py-1 rounded border border-green-500/20">ПРОЧИТАНО</span>;
|
||||
return <span className="text-[10px] font-bold text-yellow-500 bg-yellow-500/10 px-2 py-1 rounded border border-yellow-500/20">НОВОЕ</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pt-28 pb-10 min-h-screen px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
|
||||
{/* ЗАГОЛОВОК */}
|
||||
<div className="flex justify-between items-end mb-6 border-b border-[var(--glass-border)] pb-4">
|
||||
<div>
|
||||
<h2 className="text-3xl font-black text-[var(--accent-color)] uppercase tracking-widest">
|
||||
ПОДДЕРЖКА / ЧАТ ({msgs.length})
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={fetchMessages}
|
||||
className="p-2 bg-[var(--accent-color)]/20 rounded hover:bg-[var(--accent-color)] hover:text-black transition-all"
|
||||
>
|
||||
<RefreshCw size={18} className={loading ? "animate-spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ТАБЛИЦА */}
|
||||
<div className="glass overflow-hidden rounded-lg shadow-xl min-h-[400px]">
|
||||
|
||||
{error ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-red-500 gap-2">
|
||||
<AlertCircle size={40}/>
|
||||
<p className="text-sm font-bold">{error}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="bg-[var(--accent-color)]/10 text-[var(--text-color)] uppercase text-[10px] font-bold tracking-wider">
|
||||
<tr>
|
||||
<th className="p-4 w-10">#</th>
|
||||
<th className="p-4">Дата</th>
|
||||
<th className="p-4">Отправитель</th>
|
||||
<th className="p-4">Тема / Текст</th>
|
||||
<th className="p-4">Статус</th>
|
||||
<th className="p-4 text-right">Инфо</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-sm text-[var(--text-color)]">
|
||||
{msgs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="6" className="p-12 text-center opacity-30 text-lg">
|
||||
{loading ? "Загрузка..." : "Сообщений пока нет"}
|
||||
</td>
|
||||
</tr>
|
||||
) : currentItems.map((m) => (
|
||||
<tr key={m.id} className="hover:bg-[var(--accent-color)]/5 border-b border-[var(--glass-border)] last:border-0 transition-colors">
|
||||
<td className="p-4 opacity-50"><Mail size={16} /></td>
|
||||
<td className="p-4 text-xs font-mono opacity-70">
|
||||
{new Date(m.created_at).toLocaleString()}
|
||||
</td>
|
||||
|
||||
<td className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`p-1 rounded ${m.is_admin ? 'text-blue-400 bg-blue-400/10' : 'text-[var(--text-color)] bg-white/10'}`}>
|
||||
{m.is_admin ? <Shield size={14}/> : <User size={14}/>}
|
||||
</div>
|
||||
<div>
|
||||
<div className={`font-bold ${m.is_admin ? 'text-blue-400' : ''}`}>
|
||||
{m.user_name || 'Аноним'}
|
||||
</div>
|
||||
<div className="text-[9px] opacity-40">{m.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="p-4 max-w-[300px]">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-bold opacity-80 mb-1 truncate">{m.subject}</span>
|
||||
<p className="truncate opacity-60 text-xs">{m.text}</p>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="p-4">{renderStatus(m)}</td>
|
||||
|
||||
<td className="p-4 text-right flex justify-end gap-2 items-center">
|
||||
<button onClick={() => setViewData(m)} className="p-2 hover:text-[var(--accent-color)] text-gray-400" title="Просмотреть">
|
||||
<Eye size={18}/>
|
||||
</button>
|
||||
{/* Меню действий только для админа */}
|
||||
{isAdmin && (
|
||||
<ActionMenu
|
||||
onEdit={() => {}}
|
||||
onDelete={() => handleDelete(m.id)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* ПАГИНАЦИЯ */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-between items-center p-4 border-t border-[var(--glass-border)] bg-[var(--input-bg)]">
|
||||
<button onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1} className="p-2 hover:text-[var(--accent-color)] disabled:opacity-30"><ChevronLeft/></button>
|
||||
<span className="text-xs font-mono">СТР. {currentPage} / {totalPages}</span>
|
||||
<button onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} disabled={currentPage === totalPages} className="p-2 hover:text-[var(--accent-color)] disabled:opacity-30"><ChevronRight/></button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* МОДАЛКА ПРОСМОТРА */}
|
||||
{viewData && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
|
||||
<div className="glass p-6 w-full max-w-lg relative border border-[var(--glass-border)] text-[var(--text-color)] rounded-xl shadow-2xl">
|
||||
<button onClick={() => setViewData(null)} className="absolute top-4 right-4 hover:text-red-500 transition-colors">
|
||||
<X size={20}/>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3 mb-4 pb-4 border-b border-[var(--glass-border)]">
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center text-xl font-bold ${viewData.is_admin ? 'bg-blue-500 text-white' : 'bg-[var(--accent-color)] text-black'}`}>
|
||||
{viewData.user_name ? viewData.user_name[0].toUpperCase() : '?'}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg">{viewData.subject}</h3>
|
||||
<p className="text-xs opacity-50">{viewData.email} • {new Date(viewData.created_at).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--input-bg)] p-4 rounded-xl border border-[var(--glass-border)] text-sm whitespace-pre-wrap leading-relaxed max-h-[50vh] overflow-y-auto custom-scroll">
|
||||
{viewData.text}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-between text-[10px] opacity-40 font-mono">
|
||||
<span>IP: {viewData.ip || 'Скрыт'}</span>
|
||||
<span>ID: {viewData.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessagesTable;
|
||||
@@ -0,0 +1,351 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Eye, X, ChevronLeft, ChevronRight,
|
||||
Package, Truck, CheckCircle, AlertTriangle, ArrowRight, XCircle,
|
||||
Clock, RefreshCw, AlertCircle
|
||||
} from 'lucide-react';
|
||||
|
||||
const API_URL = 'https://diplomnexus.aptcloud.ru';
|
||||
|
||||
const OrdersTable = ({ user }) => {
|
||||
const [orders, setOrders] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [viewData, setViewData] = useState(null);
|
||||
|
||||
// ПАГИНАЦИЯ
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 10;
|
||||
|
||||
// Проверка прав (Админ или Спец. юзеры)
|
||||
const isAdmin =
|
||||
user?.role === 'admin' ||
|
||||
user?.email === 'admin@mail.ru' ||
|
||||
user?.name === 'seth1nk';
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrders();
|
||||
const interval = setInterval(fetchOrders, 15000); // Автообновление
|
||||
return () => clearInterval(interval);
|
||||
}, [user]);
|
||||
|
||||
const fetchOrders = async () => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// --- ИЗМЕНЕНИЕ: ТЕПЕРЬ ВСЕ ИДУТ НА ОДИН РОУТ ---
|
||||
const endpoint = '/admin/orders';
|
||||
// -----------------------------------------------
|
||||
|
||||
const res = await axios.get(`${API_URL}${endpoint}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
const sorted = res.data.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
setOrders(sorted);
|
||||
setError(null);
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
// Ошибки теперь обрабатываем мягче, так как роут один
|
||||
setError("Не удалось загрузить заказы");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// ЛОГИКА АДМИНА (УПРАВЛЕНИЕ)
|
||||
// ==========================================
|
||||
|
||||
const advanceStatus = async (id, currentStatus) => {
|
||||
let nextStatus = '';
|
||||
|
||||
// Исправленная логика: проверяем все варианты по порядку
|
||||
if (currentStatus === 'placed') nextStatus = 'processing'; // Новый -> В работу
|
||||
else if (currentStatus === 'processing') nextStatus = 'shipped'; // В работе -> В пути
|
||||
else if (currentStatus === 'shipped') nextStatus = 'completed'; // В пути -> Готов
|
||||
else return; // Если статус уже completed или cancelled, ничего не делаем
|
||||
|
||||
if (!window.confirm(`Перевести заказ в статус "${nextStatus.toUpperCase()}"? Клиент получит уведомление.`)) return;
|
||||
|
||||
try {
|
||||
await axios.put(`${API_URL}/admin/orders/${id}`, { status: nextStatus }, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
fetchOrders(); // Обновляем таблицу
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
alert('Ошибка обновления статуса');
|
||||
}
|
||||
};
|
||||
|
||||
const cancelOrder = async (id) => {
|
||||
if (!window.confirm('ОТМЕНИТЬ ЗАКАЗ?\n\nНажмите ОК для отмены. Статус сменится на CANCELLED.')) return;
|
||||
try {
|
||||
await axios.put(`${API_URL}/admin/orders/${id}`, { status: 'cancelled' }, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
fetchOrders();
|
||||
} catch(e) { alert('Ошибка отмены'); }
|
||||
};
|
||||
|
||||
// --- РЕНДЕР: ПАЙПЛАЙН (Кружочки для Админа) ---
|
||||
const renderStatusPipeline = (status) => {
|
||||
if (status === 'cancelled') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-red-500 bg-red-500/10 px-3 py-1.5 rounded border border-red-500/20 w-fit">
|
||||
<AlertTriangle size={14} /> <span className="text-[10px] font-bold uppercase tracking-widest">ОТМЕНЕН</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const steps = [
|
||||
{ key: 'processing', icon: Package, label: 'В работе' },
|
||||
{ key: 'shipped', icon: Truck, label: 'В пути' },
|
||||
{ key: 'completed', icon: CheckCircle, label: 'Готов' }
|
||||
];
|
||||
|
||||
// Если статус 'placed', мы на "нулевом" шаге (перед первым)
|
||||
let activeIndex = -1;
|
||||
if (status === 'processing') activeIndex = 0;
|
||||
if (status === 'shipped') activeIndex = 1;
|
||||
if (status === 'completed') activeIndex = 2;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{status === 'placed' && (
|
||||
<span className="text-[9px] font-bold bg-blue-500/20 text-blue-400 px-2 py-1 rounded border border-blue-500/30 mr-2">НОВЫЙ</span>
|
||||
)}
|
||||
{steps.map((step, idx) => {
|
||||
const isActive = idx === activeIndex;
|
||||
const isPassed = idx < activeIndex;
|
||||
|
||||
let colorClass = 'text-gray-600 border-gray-700 bg-gray-800/50';
|
||||
if (isActive) colorClass = 'text-[var(--accent-color)] border-[var(--accent-color)] bg-[var(--accent-color)]/10 animate-pulse';
|
||||
if (isPassed) colorClass = 'text-green-500 border-green-500 bg-green-500/10';
|
||||
|
||||
return (
|
||||
<div key={step.key} className="flex items-center">
|
||||
<div title={step.label} className={`w-7 h-7 flex items-center justify-center rounded-full border ${colorClass} transition-all duration-300 relative group`}>
|
||||
<step.icon size={12} />
|
||||
</div>
|
||||
{idx < steps.length - 1 && (
|
||||
<div className={`w-4 h-0.5 mx-0.5 ${isPassed ? 'bg-green-500' : 'bg-gray-800'}`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// ЛОГИКА ЮЗЕРА (ОБЫЧНЫЕ БЕЙДЖИ)
|
||||
// ==========================================
|
||||
const renderStatusBadge = (status) => {
|
||||
switch (status) {
|
||||
case 'placed': return <span className="flex items-center gap-1 text-[10px] font-bold text-blue-400 bg-blue-400/10 px-2 py-1 rounded border border-blue-400/20"><Clock size={12}/> НОВЫЙ</span>;
|
||||
case 'processing': return <span className="flex items-center gap-1 text-[10px] font-bold text-yellow-400 bg-yellow-400/10 px-2 py-1 rounded border border-yellow-400/20"><RefreshCw size={12}/> В РАБОТЕ</span>;
|
||||
case 'shipped': return <span className="flex items-center gap-1 text-[10px] font-bold text-purple-400 bg-purple-400/10 px-2 py-1 rounded border border-purple-400/20"><Truck size={12}/> ОТПРАВЛЕН</span>;
|
||||
case 'completed': return <span className="flex items-center gap-1 text-[10px] font-bold text-green-400 bg-green-400/10 px-2 py-1 rounded border border-green-400/20"><CheckCircle size={12}/> ВЫПОЛНЕН</span>;
|
||||
case 'cancelled': return <span className="flex items-center gap-1 text-[10px] font-bold text-red-400 bg-red-400/10 px-2 py-1 rounded border border-red-400/20"><XCircle size={12}/> ОТМЕНЕН</span>;
|
||||
default: return <span className="text-[10px] opacity-50">{status}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// РЕНДЕР
|
||||
// ==========================================
|
||||
|
||||
const indexOfLastItem = currentPage * itemsPerPage;
|
||||
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
|
||||
const currentItems = orders.slice(indexOfFirstItem, indexOfLastItem);
|
||||
const totalPages = Math.ceil(orders.length / itemsPerPage);
|
||||
|
||||
return (
|
||||
<div className="pt-28 pb-10 min-h-screen px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
|
||||
{/* ЗАГОЛОВОК */}
|
||||
<div className="flex justify-between items-end mb-6 border-b border-[var(--glass-border)] pb-4">
|
||||
<div>
|
||||
<h2 className="text-3xl font-black text-[var(--accent-color)] uppercase tracking-widest">
|
||||
{isAdmin ? `УПРАВЛЕНИЕ ЗАКАЗАМИ (${orders.length})` : `МОИ ЗАКАЗЫ (${orders.length})`}
|
||||
</h2>
|
||||
</div>
|
||||
<button onClick={fetchOrders} className="p-2 bg-[var(--accent-color)]/20 rounded hover:bg-[var(--accent-color)] hover:text-black transition-all">
|
||||
<RefreshCw size={18} className={loading ? "animate-spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="glass overflow-hidden rounded-lg shadow-xl min-h-[400px]">
|
||||
{error ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-red-500 gap-2">
|
||||
<AlertCircle size={40}/> <p>{error}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="bg-[var(--accent-color)]/10 text-[var(--text-color)] uppercase text-[10px] font-bold tracking-wider">
|
||||
<tr>
|
||||
<th className="p-4 border-b border-[var(--glass-border)]">Номер</th>
|
||||
{isAdmin ? (
|
||||
// Колонка Клиент (Только админ)
|
||||
<th className="p-4 border-b border-[var(--glass-border)]">Клиент</th>
|
||||
) : (
|
||||
// Колонка Дата (Только юзер)
|
||||
<th className="p-4 border-b border-[var(--glass-border)]">Дата</th>
|
||||
)}
|
||||
<th className="p-4 border-b border-[var(--glass-border)]">Сумма / Оплата</th>
|
||||
<th className="p-4 border-b border-[var(--glass-border)]">Статус</th>
|
||||
<th className="p-4 border-b border-[var(--glass-border)] text-right">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-sm text-[var(--text-color)]">
|
||||
{orders.length === 0 ? (
|
||||
<tr><td colSpan="5" className="p-10 text-center opacity-30">Список заказов пуст</td></tr>
|
||||
) : currentItems.map((o) => (
|
||||
<tr key={o.id} className="hover:bg-[var(--accent-color)]/5 border-b border-[var(--glass-border)] last:border-0 group transition-colors">
|
||||
|
||||
{/* ID */}
|
||||
<td className="p-4 font-mono text-[var(--accent-color)] font-bold">#{o.order_number || o.id}</td>
|
||||
|
||||
{/* КЛИЕНТ (АДМИН) ИЛИ ДАТА (ЮЗЕР) */}
|
||||
{isAdmin ? (
|
||||
<td className="p-4">
|
||||
<div className="font-bold text-xs">{o.username || 'ID: ' + o.user_id}</div>
|
||||
<div className="text-[9px] opacity-50">{o.user_email}</div>
|
||||
{o.telegram_chat_id && <span className="text-[9px] text-blue-400 opacity-70">TG Linked</span>}
|
||||
</td>
|
||||
) : (
|
||||
<td className="p-4 text-xs opacity-60 font-mono">
|
||||
{new Date(o.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
)}
|
||||
|
||||
{/* ОПЛАТА */}
|
||||
<td className="p-4">
|
||||
<div className="font-mono font-bold">{o.total} ₽</div>
|
||||
<div className={`text-[10px] uppercase font-bold mt-1 w-fit px-1.5 rounded ${o.payment_status==='paid' ? 'bg-green-500/20 text-green-500' : 'bg-yellow-500/20 text-yellow-500'}`}>
|
||||
{o.payment_status === 'paid' ? 'ОПЛАЧЕНО' : 'ОЖИДАНИЕ'}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* СТАТУС (РАЗНЫЙ РЕНДЕР) */}
|
||||
<td className="p-4">
|
||||
{isAdmin ? renderStatusPipeline(o.status) : renderStatusBadge(o.status)}
|
||||
</td>
|
||||
|
||||
{/* КНОПКИ */}
|
||||
<td className="p-4 text-right flex justify-end gap-2 items-center">
|
||||
|
||||
{/* Кнопка "Глаз" для всех */}
|
||||
<button onClick={() => setViewData(o)} title="Детали" className="p-2 rounded hover:bg-[var(--accent-color)] hover:text-black transition-colors text-gray-400">
|
||||
<Eye size={18}/>
|
||||
</button>
|
||||
|
||||
{/* КНОПКИ УПРАВЛЕНИЯ (ТОЛЬКО ДЛЯ АДМИНА) */}
|
||||
{isAdmin && o.status !== 'completed' && o.status !== 'cancelled' && (
|
||||
<>
|
||||
{/* ОТМЕНА */}
|
||||
<button
|
||||
onClick={() => cancelOrder(o.id)}
|
||||
title="Отменить заказ"
|
||||
className="p-2 rounded text-red-500 hover:bg-red-500 hover:text-white border border-red-500/30 hover:border-red-500 transition-colors"
|
||||
>
|
||||
<XCircle size={18} />
|
||||
</button>
|
||||
|
||||
{/* ВПЕРЕД */}
|
||||
<button
|
||||
onClick={() => advanceStatus(o.id, o.status)}
|
||||
className="flex items-center gap-1 bg-[var(--accent-color)] text-black px-3 py-1.5 rounded text-[10px] font-bold uppercase tracking-wide hover:bg-white transition-all shadow-lg shadow-[var(--accent-color)]/20"
|
||||
>
|
||||
{o.status === 'placed' ? 'В РАБОТУ' : (o.status === 'processing' ? 'ОТПРАВИТЬ' : 'ЗАВЕРШИТЬ')}
|
||||
<ArrowRight size={10} className="ml-1 opacity-70"/>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* ПАГИНАЦИЯ */}
|
||||
<div className="flex justify-between items-center p-4 border-t border-[var(--glass-border)] bg-[var(--input-bg)]">
|
||||
<button onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1} className="p-2 disabled:opacity-30 hover:text-[var(--accent-color)]"><ChevronLeft/></button>
|
||||
<span className="text-xs font-mono">СТР. {currentPage} / {totalPages}</span>
|
||||
<button onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} disabled={currentPage === totalPages} className="p-2 disabled:opacity-30 hover:text-[var(--accent-color)]"><ChevronRight/></button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* МОДАЛКА ПРОСМОТРА (ОБЩАЯ) */}
|
||||
{viewData && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
|
||||
<div className="glass p-6 w-full max-w-md relative border border-[var(--glass-border)] text-[var(--text-color)] rounded-lg shadow-2xl">
|
||||
<button onClick={() => setViewData(null)} className="absolute top-4 right-4 hover:text-[var(--accent-color)]"><X size={20}/></button>
|
||||
|
||||
<div className="mb-6 text-center border-b border-[var(--glass-border)] pb-4">
|
||||
<h3 className="text-2xl font-black text-[var(--accent-color)]">ЗАКАЗ #{viewData.order_number || viewData.id}</h3>
|
||||
<p className="text-xs opacity-50 font-mono">{new Date(viewData.created_at).toLocaleString()}</p>
|
||||
|
||||
{/* В модалке показываем пайплайн для красоты всем, или бейдж */}
|
||||
<div className="mt-4 flex justify-center scale-90">
|
||||
{isAdmin ? renderStatusPipeline(viewData.status) : renderStatusBadge(viewData.status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 text-sm">
|
||||
{isAdmin && (
|
||||
<div className="flex justify-between border-b border-[var(--glass-border)] pb-2">
|
||||
<span className="opacity-50">Клиент</span>
|
||||
<div className="text-right">
|
||||
<span className="font-bold block">{viewData.username}</span>
|
||||
<span className="text-[10px] opacity-50">{viewData.user_email}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-[var(--input-bg)] p-3 rounded border border-[var(--glass-border)]">
|
||||
<span className="text-[10px] opacity-50 uppercase block mb-1">Состав заказа:</span>
|
||||
<p className="font-mono text-xs whitespace-pre-wrap">{viewData.content}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-2 bg-[var(--input-bg)] rounded border border-[var(--glass-border)]">
|
||||
<span className="text-[10px] opacity-50 block uppercase">Метод оплаты</span>
|
||||
{viewData.payment_method || 'Карта'}
|
||||
</div>
|
||||
<div className="p-2 bg-[var(--input-bg)] rounded border border-[var(--glass-border)]">
|
||||
<span className="text-[10px] opacity-50 block uppercase">Статус оплаты</span>
|
||||
<span className={`font-bold ${viewData.payment_status==='paid'?'text-green-500':'text-yellow-500'}`}>
|
||||
{viewData.payment_status === 'paid' ? 'ОПЛАЧЕНО' : 'ОЖИДАЕТ'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-2 bg-[var(--input-bg)] rounded border border-[var(--glass-border)]">
|
||||
<span className="text-[10px] opacity-50 block uppercase">Адрес доставки</span>
|
||||
{viewData.delivery_address || 'Нет данных'}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-2 text-xl font-black text-[var(--accent-color)] border-t border-[var(--glass-border)] mt-2">
|
||||
<span>ИТОГО:</span>
|
||||
<span>{viewData.total} ₽</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default OrdersTable;
|
||||
@@ -0,0 +1,236 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { Plus, X, Eye, ChevronLeft, ChevronRight, AlertCircle } from 'lucide-react';
|
||||
import ActionMenu from '../../components/ActionMenu';
|
||||
|
||||
const API_URL = 'https://diplomnexus.aptcloud.ru';
|
||||
|
||||
const ProductsTable = ({ user }) => {
|
||||
const [products, setProducts] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [viewData, setViewData] = useState(null);
|
||||
const [formData, setFormData] = useState({ id: null, name: '', price: '', category: 'General', description: '', image: null });
|
||||
|
||||
// ПАГИНАЦИЯ
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 10;
|
||||
|
||||
// ОПРЕДЕЛЕНИЕ ПРАВ
|
||||
const isAdmin = user?.role === 'admin' || user?.email === 'admin@mail.ru';
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts();
|
||||
}, []);
|
||||
|
||||
const fetchProducts = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.get(`${API_URL}/products`);
|
||||
setProducts(res.data);
|
||||
} catch(e) { console.error(e); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!isAdmin) return;
|
||||
if(window.confirm('Удалить товар?')) {
|
||||
try {
|
||||
await axios.delete(`${API_URL}/admin/products/${id}`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
fetchProducts();
|
||||
} catch(e) { alert("Ошибка удаления"); }
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!isAdmin) return;
|
||||
|
||||
const data = new FormData();
|
||||
Object.keys(formData).forEach(k => data.append(k, formData[k]));
|
||||
|
||||
const cfg = { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } };
|
||||
try {
|
||||
if(formData.id) await axios.put(`${API_URL}/admin/products/${formData.id}`, formData, cfg);
|
||||
else await axios.post(`${API_URL}/admin/products`, data, cfg);
|
||||
setIsModalOpen(false);
|
||||
fetchProducts();
|
||||
} catch(e) { alert("Ошибка сохранения"); }
|
||||
};
|
||||
|
||||
// --- ФУНКЦИЯ ДЛЯ ПРАВИЛЬНОГО URL КАРТИНКИ ---
|
||||
const getImageUrl = (img) => {
|
||||
if (!img) return null;
|
||||
if (img.startsWith('http') || img.startsWith('https')) {
|
||||
return img; // Внешняя ссылка
|
||||
}
|
||||
return `${API_URL}/${img}`; // Локальный файл
|
||||
};
|
||||
|
||||
const indexOfLastItem = currentPage * itemsPerPage;
|
||||
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
|
||||
const currentItems = products.slice(indexOfFirstItem, indexOfLastItem);
|
||||
const totalPages = Math.ceil(products.length / itemsPerPage);
|
||||
|
||||
return (
|
||||
<div className="pt-28 pb-10 min-h-screen px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
|
||||
<div className="flex justify-between items-end mb-6 border-b border-[var(--glass-border)] pb-4">
|
||||
<h2 className="text-3xl font-black text-[var(--accent-color)] uppercase tracking-widest">
|
||||
БАЗА ТОВАРОВ ({products.length})
|
||||
</h2>
|
||||
{/* КНОПКА ДОБАВИТЬ - ТОЛЬКО ДЛЯ АДМИНА */}
|
||||
{isAdmin && (
|
||||
<button onClick={() => { setFormData({ id: null, name: '', price: '', category: '', description: '', image: null }); setIsModalOpen(true); }} className="btn-neon px-4 py-2 text-xs font-bold flex gap-2">
|
||||
<Plus size={16}/> ДОБАВИТЬ
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="glass overflow-hidden rounded-lg shadow-xl min-h-[400px]">
|
||||
{loading ? (
|
||||
<div className="p-10 text-center opacity-50">Загрузка товаров...</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="bg-[var(--accent-color)]/10 text-[var(--text-color)] uppercase text-[10px] font-bold tracking-wider">
|
||||
<tr>
|
||||
<th className="p-4">IMG</th>
|
||||
<th className="p-4">Название</th>
|
||||
<th className="p-4">Категория</th>
|
||||
<th className="p-4">Цена</th>
|
||||
<th className="p-4 text-right">Инфо</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-sm text-[var(--text-color)]">
|
||||
{currentItems.map((p) => (
|
||||
<tr key={p.id} className="hover:bg-[var(--accent-color)]/5 border-b border-[var(--glass-border)] last:border-0 transition-colors">
|
||||
<td className="p-4">
|
||||
{p.image ? (
|
||||
<img
|
||||
src={getImageUrl(p.image)}
|
||||
className="w-10 h-10 object-cover rounded bg-black/20"
|
||||
alt={p.name}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-[var(--glass-border)] rounded flex items-center justify-center text-xs opacity-50">NO</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4 font-bold">{p.name}</td>
|
||||
<td className="p-4 opacity-70">
|
||||
<span className="px-2 py-1 rounded bg-[var(--input-bg)] border border-[var(--glass-border)] text-xs">
|
||||
{p.category}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4 text-[var(--accent-color)] font-mono font-bold">{p.price} ₽</td>
|
||||
<td className="p-4 text-right flex justify-end gap-2 items-center">
|
||||
{/* КНОПКА ПРОСМОТРА ДЛЯ ВСЕХ */}
|
||||
<button onClick={() => setViewData(p)} className="p-2 text-gray-400 hover:text-[var(--accent-color)] transition-colors">
|
||||
<Eye size={18}/>
|
||||
</button>
|
||||
{/* МЕНЮ РЕДАКТИРОВАНИЯ ТОЛЬКО ДЛЯ АДМИНА */}
|
||||
{isAdmin && (
|
||||
<ActionMenu
|
||||
onEdit={() => { setFormData(p); setIsModalOpen(true); }}
|
||||
onDelete={() => handleDelete(p.id)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* ПАГИНАЦИЯ */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-between items-center p-4 border-t border-[var(--glass-border)] bg-[var(--input-bg)]">
|
||||
<button onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1} className="p-2 disabled:opacity-30 hover:text-[var(--accent-color)]"><ChevronLeft/></button>
|
||||
<span className="text-xs font-mono">СТР. {currentPage} ИЗ {totalPages}</span>
|
||||
<button onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} disabled={currentPage === totalPages} className="p-2 disabled:opacity-30 hover:text-[var(--accent-color)]"><ChevronRight/></button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EDIT MODAL (ONLY ADMIN CAN TRIGGER) */}
|
||||
{isModalOpen && isAdmin && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
|
||||
<div className="glass p-6 w-full max-w-md relative border border-[var(--accent-color)] rounded-xl">
|
||||
<button onClick={() => setIsModalOpen(false)} className="absolute top-4 right-4 text-[var(--text-color)] hover:text-red-500"><X size={20}/></button>
|
||||
<h3 className="text-xl font-bold text-[var(--accent-color)] mb-4">{formData.id?'РЕДАКТИРОВАТЬ':'СОЗДАТЬ'}</h3>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
<input className="bg-[var(--input-bg)] border border-[var(--glass-border)] p-2 rounded text-[var(--text-color)] focus:border-[var(--accent-color)] outline-none" placeholder="Name" value={formData.name} onChange={e=>setFormData({...formData,name:e.target.value})} required/>
|
||||
<div className="flex gap-2">
|
||||
<input className="bg-[var(--input-bg)] border border-[var(--glass-border)] p-2 rounded text-[var(--text-color)] w-1/2 focus:border-[var(--accent-color)] outline-none" placeholder="Price" type="number" value={formData.price} onChange={e=>setFormData({...formData,price:e.target.value})} required/>
|
||||
<input className="bg-[var(--input-bg)] border border-[var(--glass-border)] p-2 rounded text-[var(--text-color)] w-1/2 focus:border-[var(--accent-color)] outline-none" placeholder="Category" value={formData.category} onChange={e=>setFormData({...formData,category:e.target.value})}/>
|
||||
</div>
|
||||
<textarea className="bg-[var(--input-bg)] border border-[var(--glass-border)] p-2 rounded text-[var(--text-color)] h-20 focus:border-[var(--accent-color)] outline-none" placeholder="Description" value={formData.description} onChange={e=>setFormData({...formData,description:e.target.value})}/>
|
||||
<input type="file" className="text-sm text-[var(--text-color)]" onChange={e=>setFormData({...formData,image:e.target.files[0]})}/>
|
||||
<button className="btn-neon py-2 mt-2 font-bold rounded">СОХРАНИТЬ</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VIEW MODAL (FOR EVERYONE) */}
|
||||
{viewData && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
|
||||
<div className="glass p-0 w-full max-w-2xl relative border border-[var(--glass-border)] text-[var(--text-color)] rounded-lg overflow-hidden flex flex-col md:flex-row shadow-2xl">
|
||||
<button onClick={() => setViewData(null)} className="absolute top-2 right-2 z-10 bg-black/50 p-1 rounded-full text-white hover:bg-[var(--accent-color)] hover:text-black transition-colors"><X size={20}/></button>
|
||||
|
||||
{/* Левая часть - Картинка */}
|
||||
<div className="w-full md:w-1/2 h-64 md:h-auto bg-black relative flex items-center justify-center overflow-hidden">
|
||||
{viewData.image ? (
|
||||
<img
|
||||
src={getImageUrl(viewData.image)}
|
||||
className="w-full h-full object-cover opacity-80"
|
||||
alt={viewData.name}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-[var(--glass-border)]"><AlertCircle size={48}/></div>
|
||||
)}
|
||||
<div className="absolute bottom-4 left-4">
|
||||
<span className="bg-[var(--accent-color)] text-black px-3 py-1 font-bold rounded text-sm shadow-lg">
|
||||
{viewData.price} ₽
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Правая часть - Инфо */}
|
||||
<div className="w-full md:w-1/2 p-6 flex flex-col gap-4 bg-[var(--bg-color)]/95">
|
||||
<div>
|
||||
<h3 className="text-2xl font-black text-[var(--accent-color)] uppercase leading-none mb-1">{viewData.name}</h3>
|
||||
<p className="text-xs opacity-50 font-mono">ID: {viewData.id} | {new Date(viewData.created_at).toLocaleDateString()}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="p-2 bg-[var(--input-bg)] rounded border border-[var(--glass-border)]">
|
||||
<span className="opacity-50 block uppercase text-[9px]">Категория</span>
|
||||
<span className="font-bold">{viewData.category}</span>
|
||||
</div>
|
||||
<div className="p-2 bg-[var(--input-bg)] rounded border border-[var(--glass-border)]">
|
||||
<span className="opacity-50 block uppercase text-[9px]">Наличие</span>
|
||||
<span className={`font-bold ${viewData.stock < 10 ? 'text-green-500' : 'text-green-500'}`}>В наличии</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex-1">
|
||||
<span className="text-[10px] uppercase opacity-50 font-bold block mb-1">Описание:</span>
|
||||
<p className="text-sm opacity-80 bg-[var(--input-bg)] p-3 rounded border border-[var(--glass-border)] h-full max-h-40 overflow-y-auto custom-scroll whitespace-pre-wrap">
|
||||
{viewData.description || 'Описание отсутствует.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductsTable;
|
||||
@@ -0,0 +1,262 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { Plus, X, Eye, ChevronLeft, ChevronRight, AlertCircle, ShieldCheck } from 'lucide-react';
|
||||
import ActionMenu from '../../components/ActionMenu';
|
||||
|
||||
const API_URL = 'https://diplomnexus.aptcloud.ru';
|
||||
|
||||
const SensorsTable = ({ user }) => {
|
||||
const [sensors, setSensors] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [viewData, setViewData] = useState(null);
|
||||
const [formData, setFormData] = useState({ id: null, name: '', price: '', category: 'sensors', description: '', image: null });
|
||||
|
||||
// ПАГИНАЦИЯ
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 10;
|
||||
|
||||
// ОПРЕДЕЛЕНИЕ ПРАВ
|
||||
const isAdmin = user?.role === 'admin' || user?.email === 'admin@mail.ru';
|
||||
|
||||
useEffect(() => {
|
||||
fetchSensors();
|
||||
}, []);
|
||||
|
||||
const fetchSensors = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Запрос к специализированной таблице sensors
|
||||
const res = await axios.get(`${API_URL}/api/sensors`);
|
||||
setSensors(res.data);
|
||||
} catch(e) {
|
||||
console.error("Ошибка при загрузке датчиков:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!isAdmin) return;
|
||||
if(window.confirm('Удалить этот датчик из системы безопасности?')) {
|
||||
try {
|
||||
await axios.delete(`${API_URL}/admin/sensors/${id}`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
fetchSensors();
|
||||
} catch(e) { alert("Ошибка удаления"); }
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!isAdmin) return;
|
||||
|
||||
const data = new FormData();
|
||||
Object.keys(formData).forEach(k => {
|
||||
if (formData[k] !== null) data.append(k, formData[k]);
|
||||
});
|
||||
|
||||
const cfg = { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } };
|
||||
try {
|
||||
if(formData.id) {
|
||||
await axios.put(`${API_URL}/admin/sensors/${formData.id}`, formData, cfg);
|
||||
} else {
|
||||
await axios.post(`${API_URL}/admin/sensors`, data, cfg);
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
fetchSensors();
|
||||
} catch(e) { alert("Ошибка сохранения"); }
|
||||
};
|
||||
|
||||
const getImageUrl = (img) => {
|
||||
if (!img) return null;
|
||||
if (img.startsWith('http')) return img;
|
||||
return `${API_URL}/${img}`;
|
||||
};
|
||||
|
||||
const indexOfLastItem = currentPage * itemsPerPage;
|
||||
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
|
||||
const currentItems = sensors.slice(indexOfFirstItem, indexOfLastItem);
|
||||
const totalPages = Math.ceil(sensors.length / itemsPerPage);
|
||||
|
||||
return (
|
||||
<div className="pt-28 pb-10 min-h-screen px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
|
||||
<div className="flex justify-between items-end mb-6 border-b border-[var(--glass-border)] pb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-green-500/20 rounded-lg text-green-500">
|
||||
<ShieldCheck size={32}/>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-3xl font-black text-[var(--accent-color)] uppercase tracking-widest leading-none">
|
||||
ДАТЧИКИ И СЕНСОРЫ
|
||||
</h2>
|
||||
<p className="text-[10px] opacity-50 mt-1 font-mono uppercase tracking-tighter">Периметр безопасности и мониторинг среды ({sensors.length} шт.)</p>
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => { setFormData({ id: null, name: '', price: '', category: 'sensors', description: '', image: null }); setIsModalOpen(true); }}
|
||||
className="btn-neon px-4 py-2 text-xs font-bold flex gap-2"
|
||||
>
|
||||
<Plus size={16}/> НОВЫЙ СЕНСОР
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="glass overflow-hidden rounded-lg shadow-xl min-h-[400px]">
|
||||
{loading ? (
|
||||
<div className="p-10 text-center opacity-50 font-mono tracking-tighter animate-pulse">CHECKING_SENSORS...</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="bg-[var(--accent-color)]/10 text-[var(--text-color)] uppercase text-[10px] font-bold tracking-wider">
|
||||
<tr>
|
||||
<th className="p-4">SCAN</th>
|
||||
<th className="p-4">Тип датчика</th>
|
||||
<th className="p-4">SKU / Протокол</th>
|
||||
<th className="p-4">Цена</th>
|
||||
<th className="p-4 text-right">Управление</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-sm text-[var(--text-color)]">
|
||||
{currentItems.map((p) => (
|
||||
<tr key={p.id} className="hover:bg-[var(--accent-color)]/5 border-b border-[var(--glass-border)] last:border-0 transition-colors">
|
||||
<td className="p-4">
|
||||
{p.image ? (
|
||||
<img src={getImageUrl(p.image)} className="w-10 h-10 object-cover rounded bg-black/40 border border-[var(--glass-border)]" alt={p.name} />
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-[var(--glass-border)] rounded flex items-center justify-center text-[10px] opacity-50">OFFLINE</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4 font-bold">{p.name}</td>
|
||||
<td className="p-4 font-mono opacity-60 text-xs">{p.sku || `SENS-${p.id}`}</td>
|
||||
<td className="p-4 text-[var(--accent-color)] font-mono font-bold">{p.price} ₽</td>
|
||||
<td className="p-4 text-right flex justify-end gap-2 items-center">
|
||||
<button onClick={() => setViewData(p)} className="p-2 text-gray-400 hover:text-[var(--accent-color)] transition-colors">
|
||||
<Eye size={18}/>
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<ActionMenu
|
||||
onEdit={() => { setFormData({...p, category: 'sensors'}); setIsModalOpen(true); }}
|
||||
onDelete={() => handleDelete(p.id)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-between items-center p-4 border-t border-[var(--glass-border)] bg-[var(--input-bg)]">
|
||||
<button onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1} className="p-2 disabled:opacity-30 hover:text-[var(--accent-color)]"><ChevronLeft/></button>
|
||||
<span className="text-xs font-mono">NODE {currentPage} / {totalPages}</span>
|
||||
<button onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} disabled={currentPage === totalPages} className="p-2 disabled:opacity-30 hover:text-[var(--accent-color)]"><ChevronRight/></button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MODAL EDIT/CREATE */}
|
||||
{isModalOpen && isAdmin && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
|
||||
<div className="glass p-6 w-full max-w-md relative border border-[var(--accent-color)] rounded-xl">
|
||||
<button onClick={() => setIsModalOpen(false)} className="absolute top-4 right-4 text-[var(--text-color)] hover:text-red-500"><X size={20}/></button>
|
||||
<h3 className="text-xl font-bold text-[var(--accent-color)] mb-4 uppercase tracking-tighter">
|
||||
{formData.id ? 'Настройка конфигурации' : 'Добавить новый датчик'}
|
||||
</h3>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
<input
|
||||
className="bg-[var(--input-bg)] border border-[var(--glass-border)] p-2 rounded text-[var(--text-color)] focus:border-[var(--accent-color)] outline-none"
|
||||
placeholder="Тип датчика (движения, дыма и т.д.)"
|
||||
value={formData.name}
|
||||
onChange={e=>setFormData({...formData, name:e.target.value})}
|
||||
required
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="bg-[var(--input-bg)] border border-[var(--glass-border)] p-2 rounded text-[var(--text-color)] w-full focus:border-[var(--accent-color)] outline-none"
|
||||
placeholder="Цена (₽)"
|
||||
type="number"
|
||||
value={formData.price}
|
||||
onChange={e=>setFormData({...formData, price:e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
className="bg-[var(--input-bg)] border border-[var(--glass-border)] p-2 rounded text-[var(--text-color)] h-24 focus:border-[var(--accent-color)] outline-none text-sm"
|
||||
placeholder="Технические характеристики (дальность, батарея, условия срабатывания)..."
|
||||
value={formData.description}
|
||||
onChange={e=>setFormData({...formData, description:e.target.value})}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[10px] opacity-50 uppercase ml-1 font-bold">Снимок модуля</span>
|
||||
<input type="file" className="text-xs text-[var(--text-color)] file:bg-[var(--accent-color)] file:border-0 file:rounded file:px-2 file:py-1 file:mr-2 file:text-black file:font-bold cursor-pointer" onChange={e=>setFormData({...formData, image: e.target.files[0]})}/>
|
||||
</div>
|
||||
<button className="btn-neon py-3 mt-2 font-bold rounded uppercase tracking-widest text-sm">ЗАРЕГИСТРИРОВАТЬ</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VIEW MODAL */}
|
||||
{viewData && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
|
||||
<div className="glass p-0 w-full max-w-2xl relative border border-[var(--glass-border)] text-[var(--text-color)] rounded-lg overflow-hidden flex flex-col md:flex-row shadow-2xl">
|
||||
<button onClick={() => setViewData(null)} className="absolute top-2 right-2 z-10 bg-black/50 p-1 rounded-full text-white hover:bg-[var(--accent-color)] hover:text-black transition-colors"><X size={20}/></button>
|
||||
|
||||
<div className="w-full md:w-1/2 h-64 md:h-auto bg-black relative flex items-center justify-center">
|
||||
{viewData.image ? (
|
||||
<img src={getImageUrl(viewData.image)} className="w-full h-full object-cover opacity-90 shadow-inner" alt={viewData.name} />
|
||||
) : (
|
||||
<div className="text-[var(--glass-border)]"><AlertCircle size={48}/></div>
|
||||
)}
|
||||
<div className="absolute top-4 left-4">
|
||||
<div className="bg-green-600/20 border border-green-500 text-green-500 text-[8px] px-2 py-0.5 rounded-full font-bold uppercase tracking-widest">
|
||||
Secure Node
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-4 left-4">
|
||||
<span className="bg-[var(--accent-color)] text-black px-3 py-1 font-bold rounded text-sm shadow-lg">
|
||||
{viewData.price} ₽
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-1/2 p-6 flex flex-col gap-4 bg-[var(--bg-color)]/95">
|
||||
<div>
|
||||
<h3 className="text-2xl font-black text-[var(--accent-color)] uppercase leading-tight mb-1">{viewData.name}</h3>
|
||||
<p className="text-[10px] opacity-40 font-mono tracking-widest uppercase">NODE_ID: {viewData.id} / {viewData.sku || 'ZIGBEE_NODE'}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-[10px] uppercase font-bold">
|
||||
<div className="p-2 bg-[var(--input-bg)] rounded border border-[var(--glass-border)] flex flex-col">
|
||||
<span className="opacity-50 text-[8px]">Питание</span>
|
||||
<span className="text-green-500">CR2450 / 2 года</span>
|
||||
</div>
|
||||
<div className="p-2 bg-[var(--input-bg)] rounded border border-[var(--glass-border)] flex flex-col">
|
||||
<span className="opacity-50 text-[8px]">Сигнал</span>
|
||||
<span className="text-blue-500">Zigbee / Wi-Fi</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<span className="text-[9px] uppercase opacity-40 font-bold block mb-1">Сведения о сенсоре:</span>
|
||||
<p className="text-sm opacity-80 bg-black/20 p-3 rounded border border-[var(--glass-border)] h-32 overflow-y-auto custom-scroll leading-relaxed">
|
||||
{viewData.description || 'Данные о чувствительности и радиусе действия отсутствуют.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SensorsTable;
|
||||
@@ -0,0 +1,237 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { Shield, User, Plus, X, Eye, ChevronLeft, ChevronRight, AlertCircle } from 'lucide-react';
|
||||
import ActionMenu from '../../components/ActionMenu';
|
||||
|
||||
const API_URL = 'https://diplomnexus.aptcloud.ru';
|
||||
|
||||
const UsersTable = ({ user }) => {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [viewData, setViewData] = useState(null);
|
||||
const [formData, setFormData] = useState({ id: null, name: '', email: '', password: '', role: 'user', status: 'active' });
|
||||
|
||||
// ПАГИНАЦИЯ
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 10;
|
||||
|
||||
// Проверка прав (админ видит кнопки управления, обычный юзер - только список)
|
||||
const isAdmin = user?.role === 'admin' || user?.email === 'admin@mail.ru';
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// !!! ГЛАВНОЕ ИСПРАВЛЕНИЕ: ДОБАВЛЕН ЗАГОЛОВОК С ТОКЕНОМ !!!
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await axios.get(`${API_URL}/users`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setUsers(res.data);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
console.error("Ошибка загрузки пользователей:", e);
|
||||
setError("Не удалось загрузить список.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!isAdmin) return alert("Только для админов");
|
||||
if (window.confirm('Уничтожить пользователя?')) {
|
||||
try {
|
||||
await axios.delete(`${API_URL}/admin/users/${id}`, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } });
|
||||
fetchUsers();
|
||||
} catch(e) { alert("Ошибка удаления"); }
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const token = localStorage.getItem('token');
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
try {
|
||||
if (formData.id) await axios.put(`${API_URL}/admin/users/${formData.id}`, formData, { headers });
|
||||
else await axios.post(`${API_URL}/admin/users`, formData, { headers });
|
||||
setIsModalOpen(false);
|
||||
fetchUsers();
|
||||
} catch (e) { alert('Ошибка сохранения'); }
|
||||
};
|
||||
|
||||
// ЛОГИКА ПАГИНАЦИИ
|
||||
const indexOfLastItem = currentPage * itemsPerPage;
|
||||
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
|
||||
const currentItems = users.slice(indexOfFirstItem, indexOfLastItem);
|
||||
const totalPages = Math.ceil(users.length / itemsPerPage);
|
||||
|
||||
return (
|
||||
<div className="pt-28 pb-10 min-h-screen px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex justify-between items-end mb-6 border-b border-[var(--glass-border)] pb-4">
|
||||
<div>
|
||||
<h2 className="text-3xl font-black text-[var(--accent-color)] tracking-widest uppercase">ПОЛЬЗОВАТЕЛИ({users.length})</h2>
|
||||
</div>
|
||||
|
||||
{/* Кнопка "Создать" только для админа */}
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => { setFormData({ id: null, name: '', email: '', password: '', role: 'user', status: 'active' }); setIsModalOpen(true); }}
|
||||
className="btn-neon px-4 py-2 text-xs font-bold flex items-center gap-2"
|
||||
>
|
||||
<Plus size={16}/> НОВЫЙ
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="glass overflow-hidden rounded-lg shadow-xl min-h-[400px]">
|
||||
{loading ? (
|
||||
<div className="p-10 text-center opacity-50">Загрузка списка пользователей...</div>
|
||||
) : error ? (
|
||||
<div className="p-10 text-center text-red-500 flex flex-col items-center gap-2"><AlertCircle/> {error}</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="bg-[var(--accent-color)]/10 text-[var(--text-color)] uppercase text-[10px] font-bold tracking-wider">
|
||||
<tr>
|
||||
<th className="p-4">ID</th>
|
||||
<th className="p-4">Имя</th>
|
||||
<th className="p-4">Роль</th>
|
||||
<th className="p-4">Статус</th>
|
||||
<th className="p-4 text-right">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-sm text-[var(--text-color)]">
|
||||
{currentItems.map((u) => (
|
||||
<tr key={u.id} className="hover:bg-[var(--accent-color)]/5 border-b border-[var(--glass-border)] last:border-0">
|
||||
<td className="p-4 font-mono opacity-50">#{u.id}</td>
|
||||
<td className="p-4 flex items-center gap-3">
|
||||
<div className={`p-1 rounded ${u.role==='admin'?'text-red-500':'text-blue-500'}`}>
|
||||
{u.role==='admin'?<Shield size={16}/>:<User size={16}/>}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold">{u.name}</span>
|
||||
<span className="text-[10px] opacity-50">{u.email}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 uppercase text-xs font-bold opacity-70">{u.role}</td>
|
||||
<td className="p-4">
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] uppercase border ${u.status==='active'?'border-green-500 text-green-500':'border-red-500 text-red-500'}`}>
|
||||
{u.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4 text-right flex justify-end gap-2 items-center">
|
||||
<button onClick={() => setViewData(u)} className="p-2 text-gray-400 hover:text-[var(--accent-color)] transition-colors">
|
||||
<Eye size={18}/>
|
||||
</button>
|
||||
{/* Меню действий только для админа */}
|
||||
{isAdmin && (
|
||||
<ActionMenu
|
||||
onEdit={() => { setFormData({...u, password: ''}); setIsModalOpen(true); }}
|
||||
onDelete={() => handleDelete(u.id)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* PAGINATION CONTROLS */}
|
||||
{users.length > 0 && (
|
||||
<div className="flex justify-between items-center p-4 border-t border-[var(--glass-border)] bg-[var(--input-bg)]">
|
||||
<button onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1} className="p-2 disabled:opacity-30 hover:text-[var(--accent-color)]"><ChevronLeft/></button>
|
||||
<span className="text-xs font-mono">СТРАНИЦА {currentPage} ИЗ {totalPages}</span>
|
||||
<button onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} disabled={currentPage === totalPages} className="p-2 disabled:opacity-30 hover:text-[var(--accent-color)]"><ChevronRight/></button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MODAL CREATE/EDIT */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
|
||||
<div className="glass p-6 w-full max-w-md relative border border-[var(--accent-color)] rounded-xl">
|
||||
<button onClick={() => setIsModalOpen(false)} className="absolute top-4 right-4 text-[var(--text-color)] hover:text-red-500"><X size={20}/></button>
|
||||
<h3 className="text-xl font-bold text-[var(--accent-color)] mb-4">{formData.id?'РЕДАКТИРОВАТЬ':'СОЗДАТЬ'}</h3>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
<input className="bg-[var(--input-bg)] border border-[var(--glass-border)] p-2 rounded text-[var(--text-color)] focus:border-[var(--accent-color)] outline-none" placeholder="Name" value={formData.name} onChange={e=>setFormData({...formData,name:e.target.value})} required/>
|
||||
<input className="bg-[var(--input-bg)] border border-[var(--glass-border)] p-2 rounded text-[var(--text-color)] focus:border-[var(--accent-color)] outline-none" placeholder="Email" value={formData.email} onChange={e=>setFormData({...formData,email:e.target.value})} required/>
|
||||
{!formData.id && <input className="bg-[var(--input-bg)] border border-[var(--glass-border)] p-2 rounded text-[var(--text-color)] focus:border-[var(--accent-color)] outline-none" type="password" placeholder="Password" value={formData.password} onChange={e=>setFormData({...formData,password:e.target.value})} required/>}
|
||||
<div className="flex gap-2">
|
||||
<select className="bg-[var(--input-bg)] border border-[var(--glass-border)] p-2 rounded text-[var(--text-color)] w-1/2 outline-none" value={formData.role} onChange={e=>setFormData({...formData,role:e.target.value})}>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<select className="bg-[var(--input-bg)] border border-[var(--glass-border)] p-2 rounded text-[var(--text-color)] w-1/2 outline-none" value={formData.status} onChange={e=>setFormData({...formData,status:e.target.value})}>
|
||||
<option value="active">Active</option>
|
||||
<option value="banned">Banned</option>
|
||||
</select>
|
||||
</div>
|
||||
<button className="btn-neon py-2 mt-2 font-bold rounded">СОХРАНИТЬ</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MODAL VIEW DETAILS (Обновленный красивый вид) */}
|
||||
{viewData && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
|
||||
<div className="glass p-6 w-full max-w-lg relative border border-[var(--glass-border)] text-[var(--text-color)] rounded-lg shadow-2xl">
|
||||
<button onClick={() => setViewData(null)} className="absolute top-4 right-4 hover:text-[var(--accent-color)]"><X size={20}/></button>
|
||||
|
||||
<div className="flex items-center gap-4 mb-6 border-b border-[var(--glass-border)] pb-4">
|
||||
<div className="w-16 h-16 rounded-full bg-[var(--accent-color)]/20 flex items-center justify-center text-2xl font-bold text-[var(--accent-color)]">
|
||||
{viewData.name ? viewData.name[0].toUpperCase() : '?'}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-[var(--accent-color)]">{viewData.name}</h3>
|
||||
<p className="text-xs opacity-50 uppercase tracking-widest">{viewData.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="bg-[var(--input-bg)] p-2 rounded border border-[var(--glass-border)]">
|
||||
<span className="text-[10px] opacity-50 block uppercase">Email</span>
|
||||
{viewData.email}
|
||||
</div>
|
||||
<div className="bg-[var(--input-bg)] p-2 rounded border border-[var(--glass-border)]">
|
||||
<span className="text-[10px] opacity-50 block uppercase">Телефон</span>
|
||||
{viewData.phone || 'Не указан'}
|
||||
</div>
|
||||
<div className="bg-[var(--input-bg)] p-2 rounded border border-[var(--glass-border)]">
|
||||
<span className="text-[10px] opacity-50 block uppercase">Статус</span>
|
||||
<span className={`font-bold ${viewData.status==='active'?'text-green-500':'text-red-500'}`}>{viewData.status}</span>
|
||||
</div>
|
||||
<div className="bg-[var(--input-bg)] p-2 rounded border border-[var(--glass-border)]">
|
||||
<span className="text-[10px] opacity-50 block uppercase">IP Адрес</span>
|
||||
<span className="font-mono">{viewData.ip || 'Нет данных'}</span>
|
||||
</div>
|
||||
<div className="bg-[var(--input-bg)] p-2 rounded border border-[var(--glass-border)]">
|
||||
<span className="text-[10px] opacity-50 block uppercase">Referral Code</span>
|
||||
<span className="font-mono text-xs">{viewData.referral_code}</span>
|
||||
</div>
|
||||
<div className="bg-[var(--input-bg)] p-2 rounded border border-[var(--glass-border)]">
|
||||
<span className="text-[10px] opacity-50 block uppercase">Последний вход</span>
|
||||
{viewData.last_login ? new Date(viewData.last_login).toLocaleDateString() : '-'}
|
||||
</div>
|
||||
<div className="col-span-2 bg-[var(--input-bg)] p-2 rounded border border-[var(--glass-border)]">
|
||||
<span className="text-[10px] opacity-50 block uppercase">Дата регистрации</span>
|
||||
{viewData.created_at ? new Date(viewData.created_at).toLocaleString() : '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsersTable;
|
||||
@@ -0,0 +1,62 @@
|
||||
/* Оформление таблиц админки */
|
||||
.admin-container {
|
||||
@apply max-w-7xl mx-auto pb-10;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
@apply text-4xl font-bold mb-8 bg-clip-text text-transparent bg-gradient-to-r from-[var(--accent-color)] to-purple-500;
|
||||
filter: drop-shadow(0 0 10px rgba(56, 189, 248, 0.2));
|
||||
}
|
||||
|
||||
.admin-table-wrapper {
|
||||
@apply rounded-xl overflow-hidden shadow-2xl;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
@apply w-full text-left border-collapse;
|
||||
}
|
||||
|
||||
.admin-table thead {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
color: var(--accent-color);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.admin-table th, .admin-table td {
|
||||
@apply p-4 border-b border-[var(--glass-border)];
|
||||
}
|
||||
|
||||
.admin-table tbody tr:hover {
|
||||
background: rgba(56, 189, 248, 0.05);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
@apply px-3 py-1 rounded-full text-xs font-bold uppercase;
|
||||
}
|
||||
.status-new { @apply bg-blue-500/20 text-blue-400; }
|
||||
.status-completed { @apply bg-green-500/20 text-green-400; }
|
||||
.status-in-progress { @apply bg-yellow-500/20 text-yellow-400; }
|
||||
.status-canceled { @apply bg-red-500/20 text-red-400; }
|
||||
|
||||
.admin-grid-menu {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.admin-card {
|
||||
@apply p-6 rounded-2xl flex flex-col items-center justify-center gap-4 cursor-pointer transition-all duration-300;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.admin-card:hover {
|
||||
transform: translateY(-5px);
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 10px 30px -10px var(--accent-color);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/* src/styles/pages.css */
|
||||
|
||||
/* --- Типографика (Неон) --- */
|
||||
.neon-title {
|
||||
@apply text-5xl md:text-7xl font-bold mb-6 text-center;
|
||||
background: linear-gradient(90deg, #fff, #00f3ff, #bc13fe);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
filter: drop-shadow(0 0 20px rgba(188, 19, 254, 0.4));
|
||||
}
|
||||
|
||||
.text-gradient {
|
||||
@apply bg-clip-text text-transparent bg-gradient-to-r from-neon-blue to-white;
|
||||
}
|
||||
|
||||
/* --- Стеклянные панели --- */
|
||||
.glass-panel {
|
||||
@apply backdrop-blur-xl border border-white/10 rounded-2xl p-8 transition-all duration-300;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.glass-panel:hover {
|
||||
border-color: rgba(0, 243, 255, 0.3);
|
||||
box-shadow: 0 0 30px rgba(0, 243, 255, 0.15);
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
/* --- Hero секция --- */
|
||||
.hero-img {
|
||||
@apply w-full h-full object-cover transition-transform duration-700 hover:scale-105;
|
||||
}
|
||||
|
||||
/* --- Грид фич --- */
|
||||
.feature-grid {
|
||||
@apply grid grid-cols-1 md:grid-cols-3 gap-8 py-10 px-4 max-w-7xl mx-auto relative z-10;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
@apply w-16 h-16 rounded-full flex items-center justify-center mb-6 mx-auto;
|
||||
background: rgba(0, 243, 255, 0.1);
|
||||
border: 1px solid rgba(0, 243, 255, 0.2);
|
||||
box-shadow: 0 0 15px rgba(0, 243, 255, 0.2);
|
||||
}
|
||||
|
||||
/* --- Карта --- */
|
||||
.map-frame {
|
||||
@apply w-full h-[400px] rounded-xl overflow-hidden border border-white/10 relative;
|
||||
filter: grayscale(100%) invert(100%) contrast(1.2);
|
||||
transition: filter 0.5s ease;
|
||||
}
|
||||
|
||||
.map-frame:hover {
|
||||
filter: grayscale(0%) invert(0%);
|
||||
}
|
||||
|
||||
/* --- Инпуты контактов --- */
|
||||
.contact-input {
|
||||
@apply w-full bg-black/40 border border-white/10 rounded-lg p-4 text-white outline-none transition-all duration-300;
|
||||
}
|
||||
|
||||
.contact-input:focus {
|
||||
@apply border-neon-blue shadow-[0_0_15px_rgba(0,243,255,0.3)];
|
||||
}
|
||||
+13
-1
@@ -5,7 +5,19 @@ export default {
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
extend: {
|
||||
colors: {
|
||||
neon: {
|
||||
blue: '#00f3ff',
|
||||
purple: '#bc13fe',
|
||||
dark: '#0a0a12',
|
||||
surface: '#13131f'
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'sans-serif'],
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
+5
-1
@@ -1,7 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000, // <--- СТАВИМ ПОРТ 3000
|
||||
host: true
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"static_path": "dist",
|
||||
"app_id": 54612192,
|
||||
"endpoints": {
|
||||
"mobile": "index.html",
|
||||
"mvk": "index.html",
|
||||
"web": "index.html"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user