Initial commit: TopSysOps site + admin panel

Multi-page public site (Next.js), Node/Express backend with Postgres,
and Vite admin/account app, restructured from a single-page layout
into dedicated service, promotions, cases, about, FAQ, and blog pages
for SEO.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013otXCiTZsxdZ4SJKZ9wUky
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-08-25 23:12:16 +03:00
commit 130c5382cd
108 files changed
+7146

No files matched your search

+98
View File
@@ -0,0 +1,98 @@
import { useEffect, useState } from 'react';
import { NavLink, Navigate, Outlet, useNavigate } from 'react-router-dom';
import { LayoutDashboard, MessageSquare, Tag, Terminal, Settings as SettingsIcon, LogOut, Loader2, Briefcase, HelpCircle, Bot, Users, MapPin, Newspaper } from 'lucide-react';
import { api } from '../lib/api.js';
function cx(...args) {
return args.filter(Boolean).join(' ');
}
const NAV = [
{ to: '/admin/dashboard', label: 'Дашборд', icon: LayoutDashboard },
{ to: '/admin/reviews', label: 'Отзывы', icon: MessageSquare },
{ to: '/admin/customers', label: 'Клиенты', icon: Users },
{ to: '/admin/services', label: 'Прайс-лист', icon: Terminal },
{ to: '/admin/promotions', label: 'Акции', icon: Tag },
{ to: '/admin/cases', label: 'Кейсы', icon: Briefcase },
{ to: '/admin/faqs', label: 'FAQ', icon: HelpCircle },
{ to: '/admin/blog', label: 'Блог', icon: Newspaper },
{ to: '/admin/contact', label: 'Контакты', icon: MapPin },
{ to: '/admin/ai', label: 'ИИ-ассистент', icon: Bot },
{ to: '/admin/settings', label: 'Настройки', icon: SettingsIcon },
];
export default function AdminLayout() {
const navigate = useNavigate();
const [status, setStatus] = useState('checking'); // checking | authed | anon
useEffect(() => {
let cancelled = false;
api
.me()
.then(() => !cancelled && setStatus('authed'))
.catch(() => !cancelled && setStatus('anon'));
return () => {
cancelled = true;
};
}, []);
async function handleLogout() {
try {
await api.logout();
} finally {
navigate('/admin');
}
}
if (status === 'checking') {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950">
<Loader2 className="h-6 w-6 animate-spin text-emerald-500" />
</div>
);
}
if (status === 'anon') {
return <Navigate to="/admin" replace />;
}
return (
<div className="min-h-screen flex flex-col md:flex-row bg-slate-50 dark:bg-slate-950">
<aside className="md:w-56 border-b md:border-b-0 md:border-r p-4 flex md:flex-col gap-2 overflow-x-auto md:overflow-x-visible border-slate-200 dark:border-slate-900">
<a href="/" className="hidden md:flex items-center gap-2 mb-6 px-2">
<Terminal className="h-4 w-4 text-emerald-500" />
<span className="text-sm font-bold font-display">TopSysOps</span>
<span className="text-xs text-slate-400 dark:text-slate-500 font-mono">admin</span>
</a>
{NAV.map((item) => {
const Icon = item.icon;
return (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
cx(
'flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors flex-shrink-0 whitespace-nowrap',
isActive
? 'bg-emerald-500 text-white'
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-900'
)
}
>
<Icon className="h-4 w-4" /> {item.label}
</NavLink>
);
})}
<button
onClick={handleLogout}
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium flex-shrink-0 whitespace-nowrap md:mt-auto text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-900"
>
<LogOut className="h-4 w-4" /> Выйти
</button>
</aside>
<main className="flex-1 p-5 sm:p-8 max-w-5xl">
<Outlet />
</main>
</div>
);
}