Rollback frontend and config changes to commit a9a7e98 state.
Publish Docker image / build-and-push (push) Successful in 2m12s

Restore project files to match the requested baseline before subsequent updates.

Made-with: Cursor
This commit is contained in:
2026-04-26 14:05:50 +07:00
parent d86434e9c9
commit fe7acd655e
57 changed files with 1805 additions and 7665 deletions
-7
View File
@@ -1,7 +0,0 @@
{
"plugins": {
"shadcn": {
"enabled": true
}
}
}
Binary file not shown.
Binary file not shown.
-25
View File
@@ -1,25 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
@@ -1,28 +0,0 @@
# Tabler to shadcn/ui baseline
## Purpose
This file freezes the legacy UI baseline before migration to native-first shadcn/ui.
All new UI work should follow these constraints.
## Confirmed legacy entry points
- `frontend/src/main.jsx` imports Tabler CSS and Tabler JS runtime.
- `frontend/src/App.jsx` and `frontend/src/App.css` contain the primary legacy shell and Tabler-oriented layout classes.
## Legacy patterns to block in new code
- `window.Tabler.*`
- `data-bs-*` attributes for interactive behavior
- Bootstrap/Tabler JS-driven collapse, dropdown, modal, offcanvas behavior
- New custom wrappers for components that already exist in shadcn/ui primitives
## Migration-first rules
- Use shadcn/ui primitives directly for new UI: `Button`, `Input`, `Dialog`, `AlertDialog`, `DropdownMenu`, `Table`, `Tooltip`, `Sheet`, `Select`, `Checkbox`, `Sonner`.
- Use shadcn sidebar primitives for application shell work:
- `SidebarProvider`
- `Sidebar`
- `SidebarInset`
- `SidebarTrigger`
- Keep custom abstractions thin and only where there is repeated business logic.
- Do not introduce new Tabler classes in migrated files.
## Current impact snapshot
Tabler-related usage appears across most frontend screens and shared components. The migration must proceed by shared primitives first, then screen waves.
+122 -4407
View File
File diff suppressed because it is too large Load Diff
+1 -16
View File
@@ -12,46 +12,31 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.4.1",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/geist": "^5.2.8",
"@radix-ui/react-slot": "^1.2.4",
"@tabler/core": "^1.3.2", "@tabler/core": "^1.3.2",
"@tabler/icons-react": "^3.34.0", "@tabler/icons-react": "^3.34.0",
"@tanstack/react-query": "^5.56.2", "@tanstack/react-query": "^5.56.2",
"@tanstack/react-virtual": "^3.10.8", "@tanstack/react-virtual": "^3.10.8",
"@xyflow/react": "^12.3.4", "@xyflow/react": "^12.3.4",
"axios": "^1.10.0", "axios": "^1.10.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"diff": "^8.0.3", "diff": "^8.0.3",
"lucide-react": "^1.11.0",
"next-themes": "^0.4.6",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-router-dom": "^6.30.1", "react-router-dom": "^6.30.1",
"recharts": "^3.7.0", "recharts": "^3.7.0"
"shadcn": "^4.5.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.29.0", "@eslint/js": "^9.29.0",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.5.2", "@vitejs/plugin-react": "^4.5.2",
"autoprefixer": "^10.5.0",
"eslint": "^9.29.0", "eslint": "^9.29.0",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.2.0", "globals": "^16.2.0",
"postcss": "^8.5.10",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^7.0.0" "vite": "^7.0.0"
}, },
-6
View File
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+572 -171
View File
@@ -1,4 +1,4 @@
import { useContext, useEffect, useMemo, useState, createContext } from 'react'; import { useState, useEffect, createContext, useContext, useRef } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { import {
BrowserRouter as Router, BrowserRouter as Router,
@@ -6,9 +6,10 @@ import {
Route, Route,
Link, Link,
useLocation, useLocation,
Navigate, Navigate
} from 'react-router-dom'; } from 'react-router-dom';
import { import {
IconBrandTabler,
IconWorld, IconWorld,
IconNetwork, IconNetwork,
IconSettings, IconSettings,
@@ -18,32 +19,17 @@ import {
IconFilter, IconFilter,
IconDownload, IconDownload,
IconCreditCard, IconCreditCard,
IconMenu2,
IconArrowsExchange, IconArrowsExchange,
IconLayoutSidebarLeftExpand,
IconLayoutNavbar,
IconChartPie, IconChartPie,
IconChartBar, IconChartBar,
IconClock, IconClock,
IconClockPlay, IconClockPlay,
IconShield, IconShield,
IconRoute2, IconRoute2
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
} from '@/components/ui/sidebar';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Separator } from '@/components/ui/separator';
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
import { Button } from '@/components/ui/button';
import ServerManager from './ServerManager'; import ServerManager from './ServerManager';
import FilterManager from './FilterManager'; import FilterManager from './FilterManager';
import EasySwitchManager from './EasySwitchManager'; import EasySwitchManager from './EasySwitchManager';
@@ -78,174 +64,64 @@ import { PingProvider } from './contexts/PingContext.jsx';
import { AlertsProvider } from './contexts/AlertsContext.jsx'; import { AlertsProvider } from './contexts/AlertsContext.jsx';
import AlertsBell from './components/AlertsBell.jsx'; import AlertsBell from './components/AlertsBell.jsx';
// --- Simple i18n (RU/EN) ---
const LanguageContext = createContext({ lang: 'ru', setLang: () => {}, t: (k) => k }); const LanguageContext = createContext({ lang: 'ru', setLang: () => {}, t: (k) => k });
const ThemeContext = createContext({ theme: 'light', setTheme: () => {} });
function LanguageProvider({ children }) { function LanguageProvider({ children }) {
const [lang, setLang] = useState(() => localStorage.getItem('lang') || 'ru'); const [lang, setLang] = useState(() => localStorage.getItem('lang') || 'ru');
useEffect(() => localStorage.setItem('lang', lang), [lang]); useEffect(() => { localStorage.setItem('lang', lang); }, [lang]);
const dict = { const dict = {
ru: { home: 'Главная', data: 'Данные', management: 'Управление', tools: 'Инструменты', dashboard: 'Панель', trafficTraffic: 'Расход трафика', resourceStats: 'Статистика ресурсов', networkMap: 'Карта сети', uptimeMonitor: 'Uptime Monitor', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS', routeOptimizer: 'Оптимизация маршрутов ИИ', communities: 'Community', servers: 'Серверы', filters: 'Фильтры', billing: 'Биллинг', autoUrls: 'Авто URL', easySwitch: 'Easy Switch', networkConfig: 'Сетевые настройки', mikrotikBackups: 'MikroTik Бэкапы', pingServices: 'Пинг сервисов', firewall: 'Firewall', ospfTools: 'Инструменты OSPF', light: 'Светлая', dark: 'Тёмная' }, ru: {
en: { home: 'Home', data: 'Data', management: 'Management', tools: 'Tools', dashboard: 'Dashboard', trafficTraffic: 'Traffic Usage', resourceStats: 'Resource Stats', networkMap: 'Network Map', uptimeMonitor: 'Uptime Monitor', domains: 'Domains', ipRanges: 'IP Ranges', asns: 'ASNs', routeOptimizer: 'AI Route Optimizer', communities: 'Communities', servers: 'Servers', filters: 'Filters', billing: 'Billing', autoUrls: 'Auto URLs', easySwitch: 'Easy Switch', networkConfig: 'Network Config', mikrotikBackups: 'MikroTik Backups', pingServices: 'Ping Services', firewall: 'Firewall', ospfTools: 'OSPF Tools', light: 'Light', dark: 'Dark' }, home: 'Главная', data: 'Данные', management: 'Управление', tools: 'Инструменты',
dashboard: 'Панель', trafficTraffic: 'Расход трафика', resourceStats: 'Статистика ресурсов', networkMap: 'Карта сети', uptimeMonitor: 'Uptime Monitor', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS',
routeOptimizer: 'Оптимизация маршрутов ИИ',
communities: 'Community', servers: 'Серверы', filters: 'Фильтры', billing: 'Биллинг', autoUrls: 'Авто URL',
easySwitch: 'Easy Switch', networkConfig: 'Сетевые настройки', mikrotikBackups: 'MikroTik Бэкапы', pingServices: 'Пинг сервисов', firewall: 'Firewall', ospfTools: 'Инструменты OSPF',
light: 'Светлая', dark: 'Тёмная',
layoutSidebar: 'Сайдбар', layoutHorizontal: 'Верхнее меню'
},
en: {
home: 'Home', data: 'Data', management: 'Management', tools: 'Tools',
dashboard: 'Dashboard', trafficTraffic: 'Traffic Usage', resourceStats: 'Resource Stats', networkMap: 'Network Map', uptimeMonitor: 'Uptime Monitor', domains: 'Domains', ipRanges: 'IP Ranges', asns: 'ASNs',
routeOptimizer: 'AI Route Optimizer',
communities: 'Communities', servers: 'Servers', filters: 'Filters', billing: 'Billing', autoUrls: 'Auto URLs',
easySwitch: 'Easy Switch', networkConfig: 'Network Config', mikrotikBackups: 'MikroTik Backups', pingServices: 'Ping Services', firewall: 'Firewall', ospfTools: 'OSPF Tools',
light: 'Light', dark: 'Dark',
layoutSidebar: 'Sidebar', layoutHorizontal: 'Top menu'
}
}; };
const t = (key) => (dict[lang] && key in dict[lang] ? dict[lang][key] : key); const t = (key) => (dict[lang] && key in dict[lang] ? dict[lang][key] : key);
return <LanguageContext.Provider value={{ lang, setLang, t }}>{children}</LanguageContext.Provider>; return (
<LanguageContext.Provider value={{ lang, setLang, t }}>
{children}
</LanguageContext.Provider>
);
} }
// --- Theme provider (light/dark) ---
const ThemeContext = createContext({ theme: 'light', setTheme: () => {} });
function ThemeProvider({ children }) { function ThemeProvider({ children }) {
const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'light'); const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'light');
useEffect(() => { useEffect(() => {
localStorage.setItem('theme', theme); localStorage.setItem('theme', theme);
document.documentElement.classList.toggle('dark', theme === 'dark');
document.documentElement.setAttribute('data-bs-theme', theme); document.documentElement.setAttribute('data-bs-theme', theme);
}, [theme]); }, [theme]);
return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>; return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
} }
// Единственный экземпляр QueryClient для всего приложения
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 30_000 } }, defaultOptions: {
}); queries: {
refetchOnWindowFocus: false,
const appRoutes = [ staleTime: 30_000,
{ path: '/dashboard', element: <Dashboard /> }, }
{ path: '/traffic', element: <TrafficDashboard /> },
{ path: '/resource-stats', element: <ResourceStatsPage /> },
{ path: '/network-map', element: <NetworkMapDashboard /> },
{ path: '/route-optimizer', element: <RouteOptimizerPage /> },
{ path: '/uptime-monitor', element: <UptimeMonitorPage /> },
{ path: '/domains', element: <DomainsNewManager /> },
{ path: '/ip-ranges', element: <IPRangesManager /> },
{ path: '/asns', element: <ASNsNewManager /> },
{ path: '/communities', element: <CommunitiesManager /> },
{ path: '/auto-urls', element: <AutoUrlManager /> },
{ path: '/servers', element: <ServerManager /> },
{ path: '/billing', element: <BillingManager /> },
{ path: '/filters', element: <FilterManager /> },
{ path: '/network-config', element: <NetworkConfigManager /> },
{ path: '/easy-switch', element: <EasySwitchManager /> },
{ path: '/mikrotik-backups', element: <MikrotikBackupsManager /> },
{ path: '/ospf-tools', element: <OspfToolsPage /> },
{ path: '/mikrotik-tools', element: <MikrotikTools /> },
{ path: '/firewall', element: <FirewallPage /> },
{ path: '/interface-speed', element: <InterfaceSpeedTest /> },
{ path: '/ping-services', element: <PingServicesManager /> },
{ path: '/scheduler', element: <NetworkMapSchedulerPage /> },
{ path: '/settings', element: <SettingsPage /> },
];
function AppRoutes() {
return (
<Routes>
{appRoutes.map((entry) => <Route key={entry.path} path={entry.path} element={entry.element} />)}
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
);
}
function MainLayout() {
const { lang, setLang, t } = useContext(LanguageContext);
const { theme, setTheme } = useContext(ThemeContext);
const location = useLocation();
const useNewUiShell = import.meta.env.VITE_NEW_UI_SHELL !== '0';
const navCategories = useMemo(() => ([
{ id: 'home', title: t('home'), icon: IconHome, items: [{ id: 'dashboard', title: t('dashboard'), path: '/dashboard', icon: IconHome }, { id: 'traffic', title: t('trafficTraffic'), path: '/traffic', icon: IconChartPie }, { id: 'resource-stats', title: t('resourceStats'), path: '/resource-stats', icon: IconChartBar }, { id: 'network-map', title: t('networkMap'), path: '/network-map', icon: IconNetwork }, { id: 'route-optimizer', title: t('routeOptimizer'), path: '/route-optimizer', icon: IconRoute2 }, { id: 'uptime-monitor', title: t('uptimeMonitor'), path: '/uptime-monitor', icon: IconClock }] },
{ id: 'data', title: t('data'), icon: IconDatabase, items: [{ id: 'domains', title: t('domains'), path: '/domains', icon: IconWorld }, { id: 'ip-ranges', title: t('ipRanges'), path: '/ip-ranges', icon: IconNetwork }, { id: 'asns', title: t('asns'), path: '/asns', icon: IconNetwork }, { id: 'communities', title: t('communities'), path: '/communities', icon: IconFilter }] },
{ id: 'management', title: t('management'), icon: IconServer, items: [{ id: 'servers', title: t('servers'), path: '/servers', icon: IconServer }, { id: 'filters', title: t('filters'), path: '/filters', icon: IconFilter }, { id: 'network-config', title: t('networkConfig'), path: '/network-config', icon: IconNetwork }, { id: 'easy-switch', title: t('easySwitch'), path: '/easy-switch', icon: IconArrowsExchange }, { id: 'billing', title: t('billing'), path: '/billing', icon: IconCreditCard }] },
{ id: 'tools', title: t('tools'), icon: IconSettings, items: [{ id: 'auto-urls', title: t('autoUrls'), path: '/auto-urls', icon: IconDownload }, { id: 'mikrotik-backups', title: t('mikrotikBackups'), path: '/mikrotik-backups', icon: IconDatabase }, { id: 'ospf-tools', title: t('ospfTools'), path: '/ospf-tools', icon: IconRoute2 }, { id: 'mikrotik-tools', title: 'MikroTik Инструменты', path: '/mikrotik-tools', icon: IconNetwork }, { id: 'firewall', title: t('firewall'), path: '/firewall', icon: IconShield }, { id: 'interface-speed', title: 'Скорость интерфейсов', path: '/interface-speed', icon: IconNetwork }, { id: 'ping-services', title: t('pingServices'), path: '/ping-services', icon: IconNetwork }, { id: 'scheduler', title: 'Планировщик карты сети', path: '/scheduler', icon: IconClockPlay }] },
{ id: 'settings', title: 'Настройки', icon: IconSettings, single: true, path: '/settings' },
]), [t]);
const currentTitle = navCategories.flatMap((c) => c.items || []).find((item) => location.pathname.startsWith(item.path))?.title || 'RouterOS Manager';
if (!useNewUiShell) {
return (
<div className="min-h-screen">
<main id="main-content" className="container-xl py-4">
<AppRoutes />
</main>
</div>
);
} }
});
return (
<SidebarProvider>
<Sidebar collapsible="icon">
<SidebarContent>
{navCategories.map((category) => (
<SidebarGroup key={category.id}>
<SidebarGroupLabel>{category.title}</SidebarGroupLabel>
{category.single ? (
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={location.pathname === category.path} tooltip={category.title}>
<Link to={category.path}><category.icon /><span>{category.title}</span></Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
) : (
<SidebarMenu>
<Collapsible defaultOpen className="group/collapsible">
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={category.title}><category.icon /><span>{category.title}</span></SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
{category.items.map((item) => (
<SidebarMenuButton key={item.id} asChild isActive={location.pathname.startsWith(item.path)} className="pl-8">
<Link to={item.path}><item.icon /><span>{item.title}</span></Link>
</SidebarMenuButton>
))}
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
</SidebarMenu>
)}
</SidebarGroup>
))}
</SidebarContent>
<SidebarFooter>
<div className="flex items-center gap-2 p-2">
<AlertsBell />
<KeyboardShortcutsButton className="h-8 px-2" />
<Link to="/settings"><Button variant="ghost" size="icon"><IconSettings size={18} /></Button></Link>
</div>
</SidebarFooter>
</Sidebar>
<SidebarInset className="bg-muted/20">
<header className="sticky top-0 z-10 flex h-14 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur">
<SidebarTrigger />
<Separator orientation="vertical" className="h-4" />
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem><BreadcrumbPage>{currentTitle}</BreadcrumbPage></BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild><Button variant="outline" size="sm">{lang === 'ru' ? 'RU' : 'EN'}</Button></DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setLang('ru')}>Русский</DropdownMenuItem>
<DropdownMenuItem onClick={() => setLang('en')}>English</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild><Button variant="outline" size="sm">{theme === 'dark' ? 'Dark' : 'Light'}</Button></DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme('light')}>{t('light')}</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>{t('dark')}</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<main id="main-content" className="mx-auto w-full max-w-[1600px] p-4">
<AppRoutes />
</main>
<footer className="border-t p-3 text-sm text-muted-foreground">Версия UI: {import.meta?.env?.VITE_APP_VERSION || 'dev'}</footer>
</SidebarInset>
<CommandPalette />
</SidebarProvider>
);
}
function App() { function App() {
return ( return (
@@ -272,4 +148,529 @@ function App() {
); );
} }
const LAYOUT_FLUID = 'fluid';
const LAYOUT_HORIZONTAL = 'horizontal';
const SIDEBAR_COLLAPSE_ID = 'sidebar-menu';
const MOBILE_BREAKPOINT = 768;
function MainLayout() {
const location = useLocation();
const { lang, setLang, t } = useContext(LanguageContext);
const { theme, setTheme } = useContext(ThemeContext);
const [layout, setLayout] = useState(() => localStorage.getItem('layout') || LAYOUT_FLUID);
const [sidebarBackdrop, setSidebarBackdrop] = useState(false);
const sidebarTogglerRef = useRef(null);
useEffect(() => {
localStorage.setItem('layout', layout);
}, [layout]);
// Мобильный сайдбар: бэкдроп при открытом меню и закрытие при навигации
useEffect(() => {
if (layout !== LAYOUT_FLUID) return;
const collapseEl = document.getElementById(SIDEBAR_COLLAPSE_ID);
const onShow = () => {
if (window.innerWidth < MOBILE_BREAKPOINT) {
setSidebarBackdrop(true);
document.body.classList.add('sidebar-overlay-open');
}
};
const onHide = () => {
setSidebarBackdrop(false);
document.body.classList.remove('sidebar-overlay-open');
};
if (collapseEl) {
collapseEl.addEventListener('show.bs.collapse', onShow);
collapseEl.addEventListener('hide.bs.collapse', onHide);
return () => {
collapseEl.removeEventListener('show.bs.collapse', onShow);
collapseEl.removeEventListener('hide.bs.collapse', onHide);
};
}
}, [layout]);
useEffect(() => {
if (layout !== LAYOUT_FLUID || window.innerWidth >= MOBILE_BREAKPOINT) return;
setSidebarBackdrop(false);
document.body.classList.remove('sidebar-overlay-open');
const collapseEl = document.getElementById(SIDEBAR_COLLAPSE_ID);
if (collapseEl?.classList.contains('show')) {
sidebarTogglerRef.current?.click?.();
}
}, [location.pathname, layout]);
useEffect(() => {
}, [location.pathname, layout]);
// Состояние для управления выпадающими меню
const [dropdownStates, setDropdownStates] = useState({
data: false,
management: false,
tools: false
});
// Закрытие выпадающих меню только при клике в сайдбаре вне открытого меню (клики по странице не закрывают)
useEffect(() => {
const handleClickOutside = (event) => {
const inSidebar = event.target.closest('.navbar-vertical');
const inDropdown = event.target.closest('.nav-item.dropdown');
if (inSidebar && !inDropdown) {
setDropdownStates({
data: false,
management: false,
tools: false
});
}
};
document.addEventListener('click', handleClickOutside);
return () => {
document.removeEventListener('click', handleClickOutside);
};
}, []);
const toggleDropdown = (dropdown) => {
setDropdownStates(prev => {
// Закрываем все выпадающие меню
const newState = {
data: false,
management: false,
tools: false
};
// Открываем только выбранное меню, если оно было закрыто
if (!prev[dropdown]) {
newState[dropdown] = true;
}
return newState;
});
};
const navCategories = [
{
id: 'home',
title: t('home'),
icon: IconHome,
items: [
{ id: 'dashboard', title: t('dashboard'), path: '/dashboard', icon: IconHome },
{ id: 'traffic', title: t('trafficTraffic'), path: '/traffic', icon: IconChartPie },
{ id: 'resource-stats', title: t('resourceStats'), path: '/resource-stats', icon: IconChartBar },
{ id: 'network-map', title: t('networkMap'), path: '/network-map', icon: IconNetwork },
{ id: 'route-optimizer', title: t('routeOptimizer'), path: '/route-optimizer', icon: IconRoute2 },
{ id: 'uptime-monitor', title: t('uptimeMonitor'), path: '/uptime-monitor', icon: IconClock }
]
},
{
id: 'data',
title: t('data'),
icon: IconDatabase,
items: [
{ id: 'domains', title: t('domains'), path: '/domains', icon: IconWorld },
{ id: 'ip-ranges', title: t('ipRanges'), path: '/ip-ranges', icon: IconNetwork },
{ id: 'asns', title: t('asns'), path: '/asns', icon: IconNetwork },
{ id: 'communities', title: t('communities'), path: '/communities', icon: IconFilter }
]
},
{
id: 'management',
title: t('management'),
icon: IconServer,
items: [
{ id: 'servers', title: t('servers'), path: '/servers', icon: IconServer },
{ id: 'filters', title: t('filters'), path: '/filters', icon: IconFilter },
{ id: 'network-config', title: t('networkConfig'), path: '/network-config', icon: IconNetwork },
{ id: 'easy-switch', title: t('easySwitch'), path: '/easy-switch', icon: IconArrowsExchange },
{ id: 'billing', title: t('billing'), path: '/billing', icon: IconCreditCard }
]
},
{
id: 'tools',
title: t('tools'),
icon: IconSettings,
items: [
{ id: 'auto-urls', title: t('autoUrls'), path: '/auto-urls', icon: IconDownload },
{ id: 'mikrotik-backups', title: t('mikrotikBackups'), path: '/mikrotik-backups', icon: IconDatabase },
{ id: 'ospf-tools', title: t('ospfTools'), path: '/ospf-tools', icon: IconRoute2 },
{ id: 'mikrotik-tools', title: 'MikroTik Инструменты', path: '/mikrotik-tools', icon: IconNetwork },
{ id: 'firewall', title: t('firewall'), path: '/firewall', icon: IconShield },
{ id: 'interface-speed', title: 'Скорость интерфейсов', path: '/interface-speed', icon: IconNetwork },
{ id: 'ping-services', title: t('pingServices'), path: '/ping-services', icon: IconNetwork },
{ id: 'uptime-monitor', title: t('uptimeMonitor'), path: '/uptime-monitor', icon: IconClock },
{ id: 'scheduler', title: 'Планировщик карты сети', path: '/scheduler', icon: IconClockPlay }
]
},
{
id: 'settings',
title: 'Настройки',
icon: IconSettings,
single: true,
path: '/settings'
}
];
// Определяем активную вкладку по адресу
const getActiveTab = () => {
for (const category of navCategories) {
if (category.single && location.pathname === category.path) {
return category.id;
}
if (category.items) {
for (const item of category.items) {
if (location.pathname.startsWith(item.path)) {
return item.id;
}
}
}
}
return 'home';
};
const activeTab = getActiveTab();
// Старый вид: один верхний навбар с горизонтальным меню
if (layout === LAYOUT_HORIZONTAL) {
return (
<div className="page d-flex flex-column min-vh-100">
<a href="#main-content" className="skip-link">Перейти к основному содержанию</a>
<header className="navbar navbar-expand-md navbar-light d-print-none" style={{ boxShadow: '0 1px 0 0 #e9ecef' }}>
<div className="container-xl">
<div className="navbar-brand navbar-brand-autodark d-none-navbar-horizontal pe-0 pe-md-3">
<Link to="/" className="navbar-brand d-flex align-items-center">
<IconBrandTabler className="navbar-brand-icon me-2" />
<span className="fw-bold">RouterOS Manager</span>
</Link>
</div>
<button
className="navbar-toggler d-md-none"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbar-menu-horizontal"
aria-controls="navbar-menu-horizontal"
aria-expanded="false"
aria-label="Toggle navigation"
>
<IconMenu2 />
</button>
<div className="collapse navbar-collapse" id="navbar-menu-horizontal">
<div className="d-flex flex-column flex-md-row flex-fill align-items-stretch align-items-md-center">
<ul className="navbar-nav">
{navCategories.map(category => (
<li key={category.id} className="nav-item dropdown">
{category.single ? (
<Link
to={category.path}
className={`nav-link${activeTab === category.id ? ' active' : ''}`}
>
<category.icon className="icon icon-tabler" />
<span className="nav-link-title">{category.title}</span>
</Link>
) : (
<>
<a
className={`nav-link dropdown-toggle${activeTab.startsWith(category.id) ? ' active' : ''}`}
href="#"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleDropdown(category.id);
}}
>
<category.icon className="icon icon-tabler" />
<span className="nav-link-title">{category.title}</span>
</a>
{dropdownStates[category.id] && (
<div className="dropdown-menu show" onClick={(e) => e.stopPropagation()}>
<div className="dropdown-menu-columns">
<div className="dropdown-menu-column">
{category.items.map(item => (
<Link
key={item.id}
to={item.path}
className={`dropdown-item${activeTab === item.id ? ' active' : ''}`}
>
<item.icon className="icon icon-tabler" />
{item.title}
</Link>
))}
</div>
</div>
</div>
)}
</>
)}
</li>
))}
</ul>
</div>
</div>
<div className="navbar-nav flex-row order-md-last">
<div className="nav-item dropdown me-1">
<a href="#" className="nav-link px-2 dropdown-toggle" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()}>
{layout === LAYOUT_FLUID ? <IconLayoutSidebarLeftExpand size={20} /> : <IconLayoutNavbar size={20} />}
</a>
<div className="dropdown-menu dropdown-menu-end">
<a href="#" className={`dropdown-item d-flex align-items-center ${layout === LAYOUT_FLUID ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLayout(LAYOUT_FLUID); }}>
<IconLayoutSidebarLeftExpand className="me-2" size={18} /> {t('layoutSidebar')}
</a>
<a href="#" className={`dropdown-item d-flex align-items-center ${layout === LAYOUT_HORIZONTAL ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLayout(LAYOUT_HORIZONTAL); }}>
<IconLayoutNavbar className="me-2" size={18} /> {t('layoutHorizontal')}
</a>
</div>
</div>
<div className="nav-item dropdown me-1">
<a href="#" className="nav-link px-2 dropdown-toggle d-flex align-items-center" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()} title="Язык">
<span className="fs-5" aria-hidden>{lang === 'ru' ? '🇷🇺' : '🇺🇸'}</span>
</a>
<div className="dropdown-menu dropdown-menu-end">
<a href="#" className={`dropdown-item d-flex align-items-center ${lang === 'ru' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLang('ru'); }}><span className="me-2" aria-hidden>🇷🇺</span>Русский</a>
<a href="#" className={`dropdown-item d-flex align-items-center ${lang === 'en' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLang('en'); }}><span className="me-2" aria-hidden>🇺🇸</span>English</a>
</div>
</div>
<div className="nav-item dropdown me-1">
<a href="#" className="nav-link px-2 dropdown-toggle" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()}>
{theme === 'dark' ? '🌙' : '☀️'}
</a>
<div className="dropdown-menu dropdown-menu-end">
<a href="#" className={`dropdown-item ${theme === 'light' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setTheme('light'); }}> {t('light')}</a>
<a href="#" className={`dropdown-item ${theme === 'dark' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setTheme('dark'); }}>🌙 {t('dark')}</a>
</div>
</div>
<div className="nav-item me-1">
<AlertsBell />
</div>
<div className="nav-item me-1">
<KeyboardShortcutsButton className="nav-link px-2" />
</div>
<div className="nav-item">
<Link to="/settings" className="nav-link px-2" title="Настройки">
<IconSettings size={20} />
</Link>
</div>
</div>
</div>
</header>
<main id="main-content" className="container-xl mt-4 flex-grow-1" tabIndex="-1">
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/traffic" element={<TrafficDashboard />} />
<Route path="/resource-stats" element={<ResourceStatsPage />} />
<Route path="/network-map" element={<NetworkMapDashboard />} />
<Route path="/route-optimizer" element={<RouteOptimizerPage />} />
<Route path="/uptime-monitor" element={<UptimeMonitorPage />} />
<Route path="/domains" element={<DomainsNewManager />} />
<Route path="/ip-ranges" element={<IPRangesManager />} />
<Route path="/asns" element={<ASNsNewManager />} />
<Route path="/communities" element={<CommunitiesManager />} />
<Route path="/auto-urls" element={<AutoUrlManager />} />
<Route path="/servers" element={<ServerManager />} />
<Route path="/billing" element={<BillingManager />} />
<Route path="/filters" element={<FilterManager />} />
<Route path="/network-config" element={<NetworkConfigManager />} />
<Route path="/easy-switch" element={<EasySwitchManager />} />
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
<Route path="/ospf-tools" element={<OspfToolsPage />} />
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
<Route path="/firewall" element={<FirewallPage />} />
<Route path="/interface-speed" element={<InterfaceSpeedTest />} />
<Route path="/ping-services" element={<PingServicesManager />} />
<Route path="/scheduler" element={<NetworkMapSchedulerPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
</main>
<footer className="footer mt-auto py-2">
<div className="container-xl text-muted small">Версия UI: {import.meta?.env?.VITE_APP_VERSION || 'dev'}</div>
</footer>
<CommandPalette />
</div>
);
}
// Fluid Vertical: вертикальный сайдбар + верхний навбар + page-wrapper
return (
<div className="page">
{/* Skip link для доступности */}
<a href="#main-content" className="skip-link">
Перейти к основному содержанию
</a>
{/* Левая вертикальная навигация — всегда тёмная (Tabler navbar-vertical) */}
<header className="navbar navbar-vertical navbar-expand-md d-print-none" data-bs-theme="dark">
<div className="container-fluid">
<Link to="/" className="navbar-brand navbar-brand-autodark">
<IconBrandTabler className="navbar-brand-icon" />
<span className="fw-bold">RouterOS Manager</span>
</Link>
<div className="navbar-nav flex-row d-md-none">
<button
ref={sidebarTogglerRef}
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target={`#${SIDEBAR_COLLAPSE_ID}`}
aria-controls={SIDEBAR_COLLAPSE_ID}
aria-expanded="false"
aria-label="Открыть меню"
>
<IconMenu2 />
</button>
</div>
<div className="collapse navbar-collapse" id={SIDEBAR_COLLAPSE_ID}>
<ul className="navbar-nav pt-lg-3">
{navCategories.map(category => (
<li key={category.id} className={`nav-item${category.items ? ' dropdown' : ''}${(category.single && activeTab === category.id) || (category.items && category.items.some(i => activeTab === i.id)) ? ' active' : ''}`}>
{category.single ? (
<Link
to={category.path}
className="nav-link"
>
<span className="nav-link-icon">
<category.icon className="icon" />
</span>
<span className="nav-link-title">{category.title}</span>
</Link>
) : (
<>
<a
className="nav-link dropdown-toggle"
href="#"
onClick={(e) => {
e.preventDefault();
toggleDropdown(category.id);
}}
aria-expanded={dropdownStates[category.id]}
>
<span className="nav-link-icon">
<category.icon className="icon" />
</span>
<span className="nav-link-title">{category.title}</span>
</a>
<div className={`dropdown-menu${dropdownStates[category.id] ? ' show' : ''}`}>
{category.items.map(item => (
<Link
key={item.id}
to={item.path}
className={`dropdown-item d-flex align-items-center${activeTab === item.id ? ' active' : ''}`}
>
<span className="nav-link-icon me-2">
<item.icon className="icon" />
</span>
{item.title}
</Link>
))}
</div>
</>
)}
</li>
))}
</ul>
{/* Горизонтальный ряд значков в самом низу сайдбара */}
<div className="navbar-sidebar-footer border-top mt-auto">
<div className="d-flex align-items-center justify-content-center gap-1 py-2 px-2">
<div className="nav-item dropdown dropup">
<a href="#" className="nav-link btn-icon rounded d-inline-flex align-items-center justify-content-center" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()} title="Язык">
<span className="fs-5" aria-hidden>{lang === 'ru' ? '🇷🇺' : '🇺🇸'}</span>
</a>
<div className="dropdown-menu dropdown-menu-start">
<a href="#" className={`dropdown-item d-flex align-items-center ${lang === 'ru' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLang('ru'); }}><span className="me-2" aria-hidden>🇷🇺</span>Русский</a>
<a href="#" className={`dropdown-item d-flex align-items-center ${lang === 'en' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLang('en'); }}><span className="me-2" aria-hidden>🇺🇸</span>English</a>
</div>
</div>
<div className="nav-item dropdown dropup">
<a href="#" className="nav-link btn-icon rounded" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()} title={t('layoutSidebar')}>
{layout === LAYOUT_FLUID ? <IconLayoutSidebarLeftExpand size={20} /> : <IconLayoutNavbar size={20} />}
</a>
<div className="dropdown-menu dropdown-menu-start">
<a href="#" className={`dropdown-item d-flex align-items-center ${layout === LAYOUT_FLUID ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLayout(LAYOUT_FLUID); }}>
<IconLayoutSidebarLeftExpand className="me-2" size={18} /> {t('layoutSidebar')}
</a>
<a href="#" className={`dropdown-item d-flex align-items-center ${layout === LAYOUT_HORIZONTAL ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setLayout(LAYOUT_HORIZONTAL); }}>
<IconLayoutNavbar className="me-2" size={18} /> {t('layoutHorizontal')}
</a>
</div>
</div>
<div className="nav-item dropdown dropup">
<a href="#" className="nav-link btn-icon rounded" data-bs-toggle="dropdown" onClick={(e) => e.preventDefault()} title={t('light') + ' / ' + t('dark')}>
{theme === 'dark' ? '🌙' : '☀️'}
</a>
<div className="dropdown-menu dropdown-menu-start">
<a href="#" className={`dropdown-item ${theme === 'light' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setTheme('light'); }}> {t('light')}</a>
<a href="#" className={`dropdown-item ${theme === 'dark' ? 'active' : ''}`} onClick={(e) => { e.preventDefault(); setTheme('dark'); }}>🌙 {t('dark')}</a>
</div>
</div>
<div className="nav-item">
<AlertsBell dropup />
</div>
<div className="nav-item">
<KeyboardShortcutsButton className="nav-link btn-icon rounded" title="Горячие клавиши" />
</div>
<div className="nav-item">
<Link to="/settings" className="nav-link btn-icon rounded" title="Настройки">
<IconSettings size={20} />
</Link>
</div>
</div>
</div>
</div>
</div>
</header>
{/* Бэкдроп мобильного меню: закрытие по клику */}
{sidebarBackdrop && (
<div
className="sidebar-backdrop d-md-none"
aria-hidden="true"
onClick={() => sidebarTogglerRef.current?.click?.()}
/>
)}
<div className="page-wrapper">
<div className="page-body">
<div className="container-fluid">
<main id="main-content" tabIndex="-1">
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/traffic" element={<TrafficDashboard />} />
<Route path="/resource-stats" element={<ResourceStatsPage />} />
<Route path="/network-map" element={<NetworkMapDashboard />} />
<Route path="/route-optimizer" element={<RouteOptimizerPage />} />
<Route path="/uptime-monitor" element={<UptimeMonitorPage />} />
<Route path="/domains" element={<DomainsNewManager />} />
<Route path="/ip-ranges" element={<IPRangesManager />} />
<Route path="/asns" element={<ASNsNewManager />} />
<Route path="/communities" element={<CommunitiesManager />} />
<Route path="/auto-urls" element={<AutoUrlManager />} />
<Route path="/servers" element={<ServerManager />} />
<Route path="/billing" element={<BillingManager />} />
<Route path="/filters" element={<FilterManager />} />
<Route path="/network-config" element={<NetworkConfigManager />} />
<Route path="/easy-switch" element={<EasySwitchManager />} />
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
<Route path="/ospf-tools" element={<OspfToolsPage />} />
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
<Route path="/firewall" element={<FirewallPage />} />
<Route path="/interface-speed" element={<InterfaceSpeedTest />} />
<Route path="/ping-services" element={<PingServicesManager />} />
<Route path="/scheduler" element={<NetworkMapSchedulerPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
</main>
</div>
</div>
<footer className="footer footer-transparent d-print-none">
<div className="container-fluid">
<ul className="list-inline list-inline-dots mb-0 text-muted small">
<li className="list-inline-item">Версия UI: {import.meta?.env?.VITE_APP_VERSION || 'dev'}</li>
</ul>
</div>
</footer>
</div>
<CommandPalette />
</div>
);
}
export default App; export default App;
+27 -34
View File
@@ -32,14 +32,11 @@ import LastSaved from './components/LastSaved.jsx';
import Tooltip from './components/Tooltip.jsx'; import Tooltip from './components/Tooltip.jsx';
import Sparkline from './components/Sparkline.jsx'; import Sparkline from './components/Sparkline.jsx';
import { useAlerts } from './contexts/AlertsContext.jsx'; import { useAlerts } from './contexts/AlertsContext.jsx';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
function StatCard({ icon: _Icon, color, value, title, subtitle, to, trend, previousValue }) { function StatCard({ icon: _Icon, color, value, title, subtitle, to, trend, previousValue }) {
return ( return (
<Card className="h-100 position-relative card-hover"> <div className="card h-100 position-relative card-hover">
<CardContent className="d-flex align-items-center p-3"> <div className="card-body d-flex align-items-center">
<span className={`avatar avatar-lg me-3 bg-${color}-lt text-${color} border-0`}> <span className={`avatar avatar-lg me-3 bg-${color}-lt text-${color} border-0`}>
<_Icon size={32} /> <_Icon size={32} />
</span> </span>
@@ -59,18 +56,18 @@ function StatCard({ icon: _Icon, color, value, title, subtitle, to, trend, previ
<div className="text-muted small opacity-75">{subtitle}</div> <div className="text-muted small opacity-75">{subtitle}</div>
)} )}
</div> </div>
</CardContent> </div>
{to && ( {to && (
<Link to={to} className="stretched-link" aria-label={`Перейти к ${title}`}></Link> <Link to={to} className="stretched-link" aria-label={`Перейти к ${title}`}></Link>
)} )}
</Card> </div>
); );
} }
function MetricCard({ title, value, icon: _Icon, color, description }) { function MetricCard({ title, value, icon: _Icon, color, description }) {
return ( return (
<Card className="h-100 position-relative"> <div className="card h-100 position-relative">
<CardContent className="d-flex align-items-center p-3"> <div className="card-body d-flex align-items-center">
<span className={`avatar avatar-lg me-3 bg-${color}-lt text-${color} border-0`}> <span className={`avatar avatar-lg me-3 bg-${color}-lt text-${color} border-0`}>
<_Icon size={32} /> <_Icon size={32} />
</span> </span>
@@ -82,8 +79,8 @@ function MetricCard({ title, value, icon: _Icon, color, description }) {
<div className="text-muted lh-1">{description}</div> <div className="text-muted lh-1">{description}</div>
)} )}
</div> </div>
</CardContent> </div>
</Card> </div>
); );
} }
@@ -360,12 +357,10 @@ function Dashboard() {
if (error) { if (error) {
return ( return (
<Card className="border-destructive"> <div className="alert alert-danger" role="alert">
<CardContent className="p-3 text-destructive"> <IconAlertTriangle className="me-2" />
<IconAlertTriangle className="me-2" /> {error}
{error} </div>
</CardContent>
</Card>
); );
} }
@@ -382,9 +377,9 @@ function Dashboard() {
<LastSaved timestamp={lastFetchTime} variant="compact" /> <LastSaved timestamp={lastFetchTime} variant="compact" />
)} )}
<Tooltip content="Обновить данные" shortcut="⌘R"> <Tooltip content="Обновить данные" shortcut="⌘R">
<Button variant="outline" size="sm" onClick={() => window.location.reload()}> <button className="btn btn-outline-primary btn-sm" onClick={() => window.location.reload()}>
<IconRefresh className="me-2" /> Обновить <IconRefresh className="me-2" /> Обновить
</Button> </button>
</Tooltip> </Tooltip>
</div> </div>
)} )}
@@ -394,16 +389,15 @@ function Dashboard() {
<div className="d-flex flex-column flex-sm-row align-items-start align-items-sm-center justify-content-between gap-2 mb-2"> <div className="d-flex flex-column flex-sm-row align-items-start align-items-sm-center justify-content-between gap-2 mb-2">
<h3 className="mb-0">Пинг сервисов</h3> <h3 className="mb-0">Пинг сервисов</h3>
<Tooltip content="Принудительно обновить пинг (без учёта кэша), история и тренд накапливаются"> <Tooltip content="Принудительно обновить пинг (без учёта кэша), история и тренд накапливаются">
<Button <button
type="button" type="button"
variant="outline" className="btn btn-outline-primary btn-sm w-100 w-sm-auto"
size="sm"
onClick={() => fetchPingServices(true)} onClick={() => fetchPingServices(true)}
disabled={pingLoading} disabled={pingLoading}
> >
<IconRefresh className={pingLoading ? 'spin me-1' : 'me-1'} size={16} /> <IconRefresh className={pingLoading ? 'spin me-1' : 'me-1'} size={16} />
Обновить пинг Обновить пинг
</Button> </button>
</Tooltip> </Tooltip>
</div> </div>
<div className="row g-3 mb-4"> <div className="row g-3 mb-4">
@@ -435,29 +429,28 @@ function Dashboard() {
<IconBell size={24} /> <IconBell size={24} />
Активные оповещения Активные оповещения
{alerts.length > 0 && ( {alerts.length > 0 && (
<Badge variant="destructive">{alerts.length}</Badge> <span className="badge bg-danger rounded-pill">{alerts.length}</span>
)} )}
</h3> </h3>
<Button <button
type="button" type="button"
variant="outline" className="btn btn-outline-primary btn-sm"
size="sm"
onClick={() => refreshAlerts()} onClick={() => refreshAlerts()}
disabled={alertsLoading} disabled={alertsLoading}
> >
<IconRefresh className={alertsLoading ? 'spin me-1' : 'me-1'} size={16} /> <IconRefresh className={alertsLoading ? 'spin me-1' : 'me-1'} size={16} />
Обновить Обновить
</Button> </button>
</div> </div>
{alerts.length === 0 && !alertsLoading && ( {alerts.length === 0 && !alertsLoading && (
<Card> <div className="card">
<CardContent className="text-center text-muted py-4"> <div className="card-body text-center text-muted py-4">
Нет активных оповещений Нет активных оповещений
</CardContent> </div>
</Card> </div>
)} )}
{alerts.length > 0 && ( {alerts.length > 0 && (
<Card> <div className="card">
<div className="list-group list-group-flush"> <div className="list-group list-group-flush">
{alerts.map((alert) => { {alerts.map((alert) => {
const Icon = ALERT_ICONS[alert.type] || IconAlertTriangle; const Icon = ALERT_ICONS[alert.type] || IconAlertTriangle;
@@ -478,7 +471,7 @@ function Dashboard() {
); );
})} })}
</div> </div>
</Card> </div>
)} )}
</div> </div>
+1 -1
View File
@@ -915,7 +915,7 @@ function FilterManager() {
const rows = await listCommunities(); const rows = await listCommunities();
if (signal.aborted) return; if (signal.aborted) return;
setCommunities(Array.isArray(rows) ? rows : []); setCommunities(Array.isArray(rows) ? rows : []);
} catch (_e) { } catch (e) {
if (signal.aborted) return; if (signal.aborted) return;
} }
})(); })();
+7 -8
View File
@@ -113,8 +113,6 @@ function ServerManager() {
const [graphTunnelFilters, setGraphTunnelFilters] = useState({ GRE: true, IPSec: true, WireGuard: true, OpenVPN: true }); const [graphTunnelFilters, setGraphTunnelFilters] = useState({ GRE: true, IPSec: true, WireGuard: true, OpenVPN: true });
const [graphCountryFilter, setGraphCountryFilter] = useState(''); const [graphCountryFilter, setGraphCountryFilter] = useState('');
const [graphProviderFilter, setGraphProviderFilter] = useState(''); const [graphProviderFilter, setGraphProviderFilter] = useState('');
const [showFiltersOffcanvas, setShowFiltersOffcanvas] = useState(false);
const [showSortMenu, setShowSortMenu] = useState(false);
const presets = { const presets = {
default: { baseUrl: 'https://functions.yandexcloud.net/d4eno3im0qgsr4tj5hdo', cloudflare_gateway: 'SWE-IHOR', bunny_gateway: 'SWE-IHOR', fastly_gateway: 'SWE-IHOR', telegram_gateway: '94.142.140.1', hetzner_gateway: '94.142.140.1', type: 'routes', version: 'v4.rsc' }, default: { baseUrl: 'https://functions.yandexcloud.net/d4eno3im0qgsr4tj5hdo', cloudflare_gateway: 'SWE-IHOR', bunny_gateway: 'SWE-IHOR', fastly_gateway: 'SWE-IHOR', telegram_gateway: '94.142.140.1', hetzner_gateway: '94.142.140.1', type: 'routes', version: 'v4.rsc' },
@@ -746,7 +744,7 @@ function ServerManager() {
{activeTab === 'servers' && ( {activeTab === 'servers' && (
<> <>
<div <div
className={`offcanvas offcanvas-start${showFiltersOffcanvas ? ' show' : ''}`} className="offcanvas offcanvas-start"
id="server-filters-offcanvas" id="server-filters-offcanvas"
tabIndex={-1} tabIndex={-1}
aria-labelledby="server-filters-offcanvas-label" aria-labelledby="server-filters-offcanvas-label"
@@ -776,7 +774,7 @@ function ServerManager() {
<button <button
type="button" type="button"
className="btn-close" className="btn-close"
onClick={() => setShowFiltersOffcanvas(false)} data-bs-dismiss="offcanvas"
aria-label="Закрыть" aria-label="Закрыть"
/> />
</div> </div>
@@ -809,7 +807,8 @@ function ServerManager() {
<button <button
type="button" type="button"
className="btn btn-outline-secondary btn-sm d-flex align-items-center" className="btn btn-outline-secondary btn-sm d-flex align-items-center"
onClick={() => setShowFiltersOffcanvas(true)} data-bs-toggle="offcanvas"
data-bs-target="#server-filters-offcanvas"
aria-controls="server-filters-offcanvas" aria-controls="server-filters-offcanvas"
title="Фильтры" title="Фильтры"
> >
@@ -836,12 +835,12 @@ function ServerManager() {
{/* Sort dropdown */} {/* Sort dropdown */}
<div className="dropdown"> <div className="dropdown">
<button className="btn btn-outline-secondary btn-sm dropdown-toggle" type="button" onClick={() => setShowSortMenu((v) => !v)}> <button className="btn btn-outline-secondary btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown">
Сортировка: {sortField} Сортировка: {sortField}
</button> </button>
<div className={`dropdown-menu${showSortMenu ? ' show' : ''}`}> <div className="dropdown-menu">
{['ip', 'dns', 'country', 'provider', 'type', 'tunnel'].map(field => ( {['ip', 'dns', 'country', 'provider', 'type', 'tunnel'].map(field => (
<button key={field} className="dropdown-item" onClick={() => { handleSort(field); setShowSortMenu(false); }}> <button key={field} className="dropdown-item" onClick={() => handleSort(field)}>
{field} {sortField === field ? (sortOrder === 'asc' ? '▲' : '▼') : ''} {field} {sortField === field ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</button> </button>
))} ))}
+3 -2
View File
@@ -202,7 +202,8 @@ export default function AlertsBell({ dropup = false }) {
<a <a
href="#" href="#"
className="nav-link px-2 dropdown-toggle position-relative d-inline-flex align-items-center justify-content-center" className="nav-link px-2 dropdown-toggle position-relative d-inline-flex align-items-center justify-content-center"
onClick={(e) => { e.preventDefault(); setOpen((v) => !v); }} data-bs-toggle="dropdown"
onClick={(e) => e.preventDefault()}
aria-label={`Оповещения: ${total} активных`} aria-label={`Оповещения: ${total} активных`}
title="Оповещения о проблемах" title="Оповещения о проблемах"
> >
@@ -215,7 +216,7 @@ export default function AlertsBell({ dropup = false }) {
)} )}
</span> </span>
</a> </a>
<div className={`dropdown-menu dropdown-menu-end dropdown-menu-card overflow-hidden${open ? ' show' : ''}`} style={{ width: `${PANEL_WIDTH}px`, maxWidth: '95vw' }}> <div className="dropdown-menu dropdown-menu-end dropdown-menu-card overflow-hidden" style={{ width: `${PANEL_WIDTH}px`, maxWidth: '95vw' }}>
<AlertsPanelContent loading={loading} refresh={refresh} alerts={alerts} onClose={() => {}} /> <AlertsPanelContent loading={loading} refresh={refresh} alerts={alerts} onClose={() => {}} />
</div> </div>
</div> </div>
+83 -32
View File
@@ -1,14 +1,4 @@
import { IconAlertTriangle } from '@tabler/icons-react'; import { IconAlertTriangle } from '@tabler/icons-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
/** /**
* ConfirmDialog - упрощённая версия для быстрых подтверждений * ConfirmDialog - упрощённая версия для быстрых подтверждений
@@ -26,29 +16,90 @@ export default function ConfirmDialog({
size = 'sm', size = 'sm',
loading = false loading = false
}) { }) {
if (!open) return null;
const handleBackdropClick = (e) => {
if (e.target === e.currentTarget) {
onCancel?.();
}
};
return ( return (
<AlertDialog open={open} onOpenChange={(next) => !next && onCancel?.()}> <>
<AlertDialogContent className={size === 'lg' ? 'sm:max-w-2xl' : 'sm:max-w-md'}> {/* Modal Backdrop */}
<AlertDialogHeader> <div className="modal-backdrop show" onClick={handleBackdropClick} />
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription asChild> {/* Modal */}
<div className="flex items-start gap-3 pt-2"> <div
{destructive && <IconAlertTriangle className="text-destructive" size={24} />} className="modal show d-block"
<p className="m-0">{message}</p> role="dialog"
</div> aria-modal="true"
</AlertDialogDescription> tabIndex={-1}
</AlertDialogHeader> onKeyDown={(e) => { if (e.key === 'Escape') onCancel?.(); }}
<AlertDialogFooter> >
<AlertDialogCancel disabled={loading}>{cancelText}</AlertDialogCancel> <div className={`modal-dialog ${size === 'sm' ? 'modal-sm' : size === 'lg' ? 'modal-lg' : ''} modal-dialog-centered`} role="document">
<AlertDialogAction <div
className={destructive ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' : ''} className="modal-content"
onClick={onConfirm} tabIndex={-1}
disabled={loading} onKeyDown={(e) => {
if (e.key === 'Tab') {
const c = e.currentTarget;
const focusable = c.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
if (!focusable || focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}}
> >
{confirmText} <div className="modal-header">
</AlertDialogAction> <h5 className="modal-title">{title}</h5>
</AlertDialogFooter> <button type="button" className="btn-close" onClick={onCancel} aria-label="Закрыть" />
</AlertDialogContent> </div>
</AlertDialog>
<div className="modal-body">
<div className="d-flex align-items-start">
{destructive && (
<div className="flex-shrink-0 me-3">
<IconAlertTriangle className="text-danger" size={32} />
</div>
)}
<div className="flex-grow-1">
<p className="mb-0">{message}</p>
</div>
</div>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-secondary"
onClick={onCancel}
disabled={loading}
>
{cancelText}
</button>
<button
type="button"
className={`btn ${destructive ? 'btn-danger' : 'btn-primary'}`}
onClick={onConfirm}
disabled={loading}
>
{loading && (
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" />
)}
{confirmText}
</button>
</div>
</div>
</div>
</div>
</>
); );
} }
+9 -6
View File
@@ -106,13 +106,14 @@ function ConfirmDiffModal({ show, diff, onConfirm, onClose, loading = false }) {
<button <button
className="accordion-button collapsed" className="accordion-button collapsed"
type="button" type="button"
onClick={(e) => e.preventDefault()} data-bs-toggle="collapse"
data-bs-target="#collapseAdded"
> >
<IconPlus className="text-success me-2" size={20} /> <IconPlus className="text-success me-2" size={20} />
Добавлено ({added.length}) Добавлено ({added.length})
</button> </button>
</h2> </h2>
<div id="collapseAdded" className="accordion-collapse collapse show" data-bs-parent="#diffAccordion"> <div id="collapseAdded" className="accordion-collapse collapse" data-bs-parent="#diffAccordion">
<div className="accordion-body"> <div className="accordion-body">
<ul className="list-unstyled mb-0" style={{ maxHeight: '200px', overflowY: 'auto' }}> <ul className="list-unstyled mb-0" style={{ maxHeight: '200px', overflowY: 'auto' }}>
{addedPreview.map((item, idx) => ( {addedPreview.map((item, idx) => (
@@ -138,13 +139,14 @@ function ConfirmDiffModal({ show, diff, onConfirm, onClose, loading = false }) {
<button <button
className="accordion-button collapsed" className="accordion-button collapsed"
type="button" type="button"
onClick={(e) => e.preventDefault()} data-bs-toggle="collapse"
data-bs-target="#collapseRemoved"
> >
<IconMinus className="text-danger me-2" size={20} /> <IconMinus className="text-danger me-2" size={20} />
Удалено ({removed.length}) Удалено ({removed.length})
</button> </button>
</h2> </h2>
<div id="collapseRemoved" className="accordion-collapse collapse show" data-bs-parent="#diffAccordion"> <div id="collapseRemoved" className="accordion-collapse collapse" data-bs-parent="#diffAccordion">
<div className="accordion-body"> <div className="accordion-body">
<ul className="list-unstyled mb-0" style={{ maxHeight: '200px', overflowY: 'auto' }}> <ul className="list-unstyled mb-0" style={{ maxHeight: '200px', overflowY: 'auto' }}>
{removedPreview.map((item, idx) => ( {removedPreview.map((item, idx) => (
@@ -170,13 +172,14 @@ function ConfirmDiffModal({ show, diff, onConfirm, onClose, loading = false }) {
<button <button
className="accordion-button collapsed" className="accordion-button collapsed"
type="button" type="button"
onClick={(e) => e.preventDefault()} data-bs-toggle="collapse"
data-bs-target="#collapseChanged"
> >
<IconEdit className="text-info me-2" size={20} /> <IconEdit className="text-info me-2" size={20} />
Изменено ({changed.length}) Изменено ({changed.length})
</button> </button>
</h2> </h2>
<div id="collapseChanged" className="accordion-collapse collapse show" data-bs-parent="#diffAccordion"> <div id="collapseChanged" className="accordion-collapse collapse" data-bs-parent="#diffAccordion">
<div className="accordion-body"> <div className="accordion-body">
<ul className="list-unstyled mb-0" style={{ maxHeight: '200px', overflowY: 'auto' }}> <ul className="list-unstyled mb-0" style={{ maxHeight: '200px', overflowY: 'auto' }}>
{changedPreview.map((item, idx) => ( {changedPreview.map((item, idx) => (
+81 -32
View File
@@ -1,14 +1,4 @@
import { IconAlertTriangle } from '@tabler/icons-react'; import { IconAlertTriangle } from '@tabler/icons-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
/** /**
* ConfirmModal - модальное окно подтверждения действия * ConfirmModal - модальное окно подтверждения действия
@@ -27,34 +17,93 @@ function ConfirmModal({
loading = false, loading = false,
showIcon = true showIcon = true
}) { }) {
if (!show) return null;
const handleConfirm = () => { const handleConfirm = () => {
onConfirm?.(); onConfirm?.();
}; };
const handleBackdropClick = (e) => {
if (e.target === e.currentTarget) {
onClose?.();
}
};
return ( return (
<AlertDialog open={show} onOpenChange={(next) => !next && onClose?.()}> <>
<AlertDialogContent> {/* Modal Backdrop */}
<AlertDialogHeader> <div className="modal-backdrop show" onClick={handleBackdropClick} />
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription asChild> {/* Modal */}
<div className="flex items-start gap-3 pt-2"> <div
{showIcon && Icon && <Icon className={variant === 'danger' ? 'text-destructive' : 'text-muted-foreground'} size={24} />} className="modal show d-block"
<div>{typeof message === 'string' ? <p className="m-0">{message}</p> : message}</div> role="dialog"
</div> aria-modal="true"
</AlertDialogDescription> tabIndex={-1}
</AlertDialogHeader> onKeyDown={(e) => { if (e.key === 'Escape') onClose?.(); }}
<AlertDialogFooter> >
<AlertDialogCancel disabled={loading}>{cancelLabel}</AlertDialogCancel> <div className="modal-dialog modal-sm modal-dialog-centered" role="document">
<AlertDialogAction <div
className={variant === 'danger' ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' : ''} className="modal-content"
onClick={handleConfirm} tabIndex={-1}
disabled={loading} onKeyDown={(e) => {
if (e.key === 'Tab') {
const c = e.currentTarget;
const focusable = c.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
if (!focusable || focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}}
> >
{confirmLabel} <div className="modal-header">
</AlertDialogAction> <h5 className="modal-title">{title}</h5>
</AlertDialogFooter> <button type="button" className="btn-close" onClick={onClose} aria-label="Закрыть" />
</AlertDialogContent> </div>
</AlertDialog>
<div className="modal-body">
<div className="d-flex align-items-start">
{showIcon && Icon && (
<div className="flex-shrink-0 me-3">
<Icon className={`text-${variant}`} size={32} />
</div>
)}
<div className="flex-grow-1">
{typeof message === 'string' ? <p className="mb-0">{message}</p> : message}
</div>
</div>
</div>
<div className="modal-footer">
<button
className="btn btn-secondary"
onClick={onClose}
disabled={loading}
>
{cancelLabel}
</button>
<button
className={`btn btn-${variant}`}
onClick={handleConfirm}
disabled={loading}
>
{loading && (
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" />
)}
{confirmLabel}
</button>
</div>
</div>
</div>
</div>
</>
); );
} }
+223 -88
View File
@@ -4,10 +4,40 @@ import TableSkeleton, { TableEmpty } from './TableSkeleton.jsx';
import EmptyState from './EmptyState.jsx'; import EmptyState from './EmptyState.jsx';
import Pagination from './Pagination.jsx'; import Pagination from './Pagination.jsx';
import Tooltip from './Tooltip.jsx'; import Tooltip from './Tooltip.jsx';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
/**
* Универсальный компонент таблицы данных
*
* @param {Object} props
* @param {Array} props.columns - Массив описаний колонок
* - { key: string, title: string, sortable?: boolean, icon?: Component, render?: (value, item) => ReactNode }
* @param {Array} props.items - Массив данных для отображения
* @param {string} props.itemKey - Ключ уникального идентификатора элемента
* @param {boolean} props.loading - Состояние загрузки
* @param {string} props.sortField - Текущее поле сортировки
* @param {string} props.sortOrder - Направление сортировки ('asc' | 'desc')
* @param {function} props.onSort - Обработчик изменения сортировки
* @param {Set} props.selectedItems - Набор выбранных элементов
* @param {function} props.onSelectItem - Обработчик выбора элемента
* @param {function} props.onSelectAll - Обработчик выбора всех
* @param {function} props.onDeselectAll - Обработчик снятия выбора
* @param {function} props.onEdit - Обработчик редактирования (item) => void
* @param {function} props.onDelete - Обработчик удаления (item) => void
* @param {function} props.onCopy - Обработчик копирования (item) => void
* @param {Object} props.inlineEdit - Настройки инлайн-редактирования
* - { editingKey: string, editingValue: string, onSave: () => void, onCancel: () => void, onChange: (value) => void, renderEditor?: () => ReactNode }
* @param {Object} props.pagination - Настройки пагинации
* - { currentPage, totalPages, totalItems, pageSize, onPageChange }
* @param {Object} props.emptyState - Настройки пустого состояния
* - { title, description, action, secondaryAction }
* @param {Array} props.actions - Дополнительные действия в строке
* - [{ icon: Component, label: string, onClick: (item) => void, variant?: string }]
* @param {boolean} props.selectable - Включить чекбоксы выбора (default: true)
* @param {number} props.skeletonRows - Количество строк скелетона (default: 10)
* @param {boolean} props.striped - Чередование фона строк (Tabler UX, default: true)
* @param {boolean} props.compact - Уменьшенные отступы ячеек (default: false)
* @param {boolean} props.flat - Таблица без карточной подложки: только горизонтальные разделители (default: false)
*/
function DataTable({ function DataTable({
columns = [], columns = [],
items = [], items = [],
@@ -31,129 +61,234 @@ function DataTable({
skeletonRows = 10, skeletonRows = 10,
striped = true, striped = true,
compact = false, compact = false,
flat = false,
}) { }) {
const hasActions = onEdit || onDelete || onCopy || actions.length > 0; const hasActions = onEdit || onDelete || onCopy || actions.length > 0;
const isEditing = (item) => inlineEdit && item[itemKey] === inlineEdit.editingKey; const isEditing = (item) => inlineEdit && item[itemKey] === inlineEdit.editingKey;
const renderHeaderContent = (col) => { // Рендер заголовка колонки (col.title — подпись, col.tooltip — подсказка для UX)
const renderColumnHeader = (col) => {
const isSortable = col.sortable !== false && onSort; const isSortable = col.sortable !== false && onSort;
const isActive = sortField === col.key; const isActive = sortField === col.key;
const content = ( const content = (
<div className="flex items-center gap-1"> <div className="d-flex align-items-center">
{col.icon && <col.icon size={16} className="text-muted-foreground" />} {col.icon && <col.icon size={16} className="me-1 text-muted" />}
<span>{col.title}</span> {col.title}
{isSortable && isActive && (sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />)} {isSortable && isActive && (
</div> <span className="ms-1">
); {sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
if (!isSortable) return content; </span>
return (
<button type="button" className="inline-flex cursor-pointer items-center bg-transparent p-0 text-left" onClick={() => onSort(col.key)}>
{content}
</button>
);
};
const renderCell = (col, item) => (col.render ? col.render(item[col.key], item) : item[col.key]);
const ActionIconButton = ({ children, label, onClick, variant = 'outline' }) => (
<Tooltip content={label} position="top">
<Button type="button" variant={variant} size="icon" className="h-8 w-8" onClick={onClick} aria-label={label}>
{children}
</Button>
</Tooltip>
);
const renderActions = (item) => {
if (isEditing(item)) {
return (
<div className="flex items-center justify-end gap-1">
{inlineEdit.renderEditor && inlineEdit.renderEditor(item)}
<ActionIconButton label="Сохранить (Enter)" onClick={inlineEdit.onSave}>
<IconCheck size={16} />
</ActionIconButton>
<ActionIconButton label="Отмена (Esc)" onClick={inlineEdit.onCancel}>
<IconX size={16} />
</ActionIconButton>
</div>
);
}
return (
<div className="flex items-center justify-end gap-1">
{onEdit && <ActionIconButton label="Редактировать" onClick={() => onEdit(item)}><IconEdit size={16} /></ActionIconButton>}
{onCopy && <ActionIconButton label="Копировать" onClick={() => onCopy(item)}><IconCopy size={16} /></ActionIconButton>}
{actions.map((action, idx) => (
<ActionIconButton key={idx} label={action.label} onClick={() => action.onClick(item)}>
<action.icon size={16} />
</ActionIconButton>
))}
{onDelete && (
<ActionIconButton label="Удалить" onClick={() => onDelete(item)} variant="destructive">
<IconTrash size={16} />
</ActionIconButton>
)} )}
</div> </div>
); );
const thProps = {
key: col.key,
scope: 'col',
style: col.width ? { width: col.width } : undefined,
};
if (isSortable) {
return (
<th
{...thProps}
className="cursor-pointer user-select-none"
onClick={() => onSort(col.key)}
>
{col.tooltip ? <Tooltip content={col.tooltip} position="top">{content}</Tooltip> : content}
</th>
);
}
return (
<th {...thProps}>
{col.tooltip ? <Tooltip content={col.tooltip} position="top">{content}</Tooltip> : content}
</th>
);
}; };
if (loading) return <TableSkeleton rows={skeletonRows} cols={columns.length + (hasActions ? 1 : 0)} hasCheckbox={selectable} />; // Рендер ячейки
const renderCell = (col, item) => {
const value = item[col.key];
if (col.render) {
return col.render(value, item);
}
return value;
};
// Рендер действий
const renderActions = (item) => {
if (isEditing(item)) {
return (
<>
{inlineEdit.renderEditor && inlineEdit.renderEditor(item)}
<Tooltip content="Сохранить (Enter)">
<button
className="btn btn-success btn-icon btn-sm me-1"
onClick={inlineEdit.onSave}
aria-label="Сохранить"
>
<IconCheck size={16} />
</button>
</Tooltip>
<Tooltip content="Отмена (Esc)">
<button
className="btn btn-secondary btn-icon btn-sm"
onClick={inlineEdit.onCancel}
aria-label="Отмена"
>
<IconX size={16} />
</button>
</Tooltip>
</>
);
}
return (
<>
{onEdit && (
<Tooltip content="Редактировать" position="top">
<button
className="btn btn-outline-primary btn-icon btn-sm me-1"
onClick={() => onEdit(item)}
aria-label="Редактировать"
>
<IconEdit size={16} />
</button>
</Tooltip>
)}
{onCopy && (
<Tooltip content="Копировать" position="top">
<button
className="btn btn-outline-secondary btn-icon btn-sm me-1"
onClick={() => onCopy(item)}
aria-label="Копировать"
>
<IconCopy size={16} />
</button>
</Tooltip>
)}
{actions.map((action, idx) => (
<Tooltip key={idx} content={action.label} position="top">
<button
className={`btn btn-outline-${action.variant || 'secondary'} btn-icon btn-sm me-1`}
onClick={() => action.onClick(item)}
aria-label={action.label}
>
<action.icon size={16} />
</button>
</Tooltip>
))}
{onDelete && (
<Tooltip content="Удалить" position="top">
<button
className="btn btn-outline-danger btn-icon btn-sm"
onClick={() => onDelete(item)}
aria-label="Удалить"
>
<IconTrash size={16} />
</button>
</Tooltip>
)}
</>
);
};
// Загрузка
if (loading) {
return <TableSkeleton rows={skeletonRows} cols={columns.length + (hasActions ? 1 : 0)} hasCheckbox={selectable} />;
}
// Пустое состояние
if (items.length === 0) { if (items.length === 0) {
return ( return (
<TableEmpty cols={columns.length + (hasActions ? 1 : 0) + (selectable ? 1 : 0)}> <TableEmpty cols={columns.length + (hasActions ? 1 : 0) + (selectable ? 1 : 0)}>
{emptyState ? ( {emptyState ? (
<EmptyState title={emptyState.title} description={emptyState.description} action={emptyState.action} secondaryAction={emptyState.secondaryAction} /> <EmptyState
title={emptyState.title}
description={emptyState.description}
action={emptyState.action}
secondaryAction={emptyState.secondaryAction}
/>
) : ( ) : (
<EmptyState title="Нет данных" description="Добавьте записи, чтобы начать." /> <EmptyState title="Нет данных" description="Добавьте записи, чтобы начать." />
)} )}
</TableEmpty> </TableEmpty>
); );
} }
const allSelected = items.length > 0 && items.every((i) => selectedItems.has(i[itemKey])); const allSelected = items.length > 0 && items.every(i => selectedItems.has(i[itemKey]));
const tableClass = [
'table table-vcenter table-nowrap mb-0',
flat ? 'table-flat' : 'card-table',
!flat && striped && 'table-striped',
compact && 'table-sm',
].filter(Boolean).join(' ');
return ( return (
<> <>
<div className="overflow-x-auto rounded-md border"> <div className="table-responsive">
<Table className={`${compact ? 'text-sm' : ''} ${striped ? '[&_tbody_tr:nth-child(odd)]:bg-muted/30' : ''}`}> <table className={tableClass} role="grid">
<TableHeader> <thead>
<TableRow> <tr>
{selectable && ( {selectable && (
<TableHead style={{ width: '40px' }}> <th scope="col" style={{ width: '40px' }}>
<Checkbox checked={allSelected} onCheckedChange={(checked) => checked ? onSelectAll?.() : onDeselectAll?.()} /> <input
</TableHead> type="checkbox"
className="form-check-input"
checked={allSelected}
onChange={(e) => e.target.checked ? onSelectAll?.() : onDeselectAll?.()}
title="Выбрать все на странице"
/>
</th>
)} )}
{columns.map((col) => ( {columns.map(renderColumnHeader)}
<TableHead key={col.key} style={col.width ? { width: col.width } : undefined}> {hasActions && <th scope="col" className="text-end td-actions">Действия</th>}
{col.tooltip ? <Tooltip content={col.tooltip} position="top">{renderHeaderContent(col)}</Tooltip> : renderHeaderContent(col)} </tr>
</TableHead> </thead>
))} <tbody>
{hasActions && <TableHead className="text-right">Действия</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, idx) => { {items.map((item, idx) => {
const id = item[itemKey]; const id = item[itemKey];
const isSelected = selectedItems.has(id); const isSelected = selectedItems.has(id);
const editing = isEditing(item); const editing = isEditing(item);
return ( return (
<TableRow key={id} data-state={isSelected ? 'selected' : undefined} className={editing ? 'bg-muted' : ''} style={{ animation: `fadeIn 0.3s ease ${idx * 0.02}s both` }}> <tr
key={id}
className={editing ? 'table-active' : (isSelected ? 'table-selected' : '')}
style={{ animation: `fadeIn 0.3s ease ${idx * 0.02}s both` }}
>
{selectable && ( {selectable && (
<TableCell> <td>
<Checkbox checked={isSelected} onCheckedChange={() => onSelectItem?.(id)} onClick={(e) => e.stopPropagation()} /> <input
</TableCell> type="checkbox"
className="form-check-input"
checked={isSelected}
onChange={() => onSelectItem?.(id)}
onClick={(e) => e.stopPropagation()}
/>
</td>
)} )}
{columns.map((col) => ( {columns.map((col) => (
<TableCell key={col.key} className={col.className}> <td key={col.key} className={col.className}>
{renderCell(col, item)} {renderCell(col, item)}
</TableCell> </td>
))} ))}
{hasActions && <TableCell>{renderActions(item)}</TableCell>} {hasActions && (
</TableRow> <td className="text-end td-actions">
{renderActions(item)}
</td>
)}
</tr>
); );
})} })}
</TableBody> </tbody>
</Table> </table>
</div> </div>
{pagination && ( {pagination && (
<Pagination <Pagination
currentPage={pagination.currentPage} currentPage={pagination.currentPage}
@@ -1,4 +1,3 @@
import { useState } from 'react';
import { IconX } from '@tabler/icons-react'; import { IconX } from '@tabler/icons-react';
/** /**
@@ -11,7 +10,6 @@ import { IconX } from '@tabler/icons-react';
* @param {Array<{ type: 'button', label: string, icon: React.ComponentType, onClick: () => void, variant?: 'danger', title?: string } | { type: 'dropdown', label: string, icon: React.ComponentType, items: Array<{ label: string, onClick: () => void }>, title?: string }>} actions - кнопки и выпадающие меню * @param {Array<{ type: 'button', label: string, icon: React.ComponentType, onClick: () => void, variant?: 'danger', title?: string } | { type: 'dropdown', label: string, icon: React.ComponentType, items: Array<{ label: string, onClick: () => void }>, title?: string }>} actions - кнопки и выпадающие меню
*/ */
function FloatingBulkActionsBar({ selectedCount = 0, onClearSelection, actions = [] }) { function FloatingBulkActionsBar({ selectedCount = 0, onClearSelection, actions = [] }) {
const [openDropdown, setOpenDropdown] = useState(null);
if (selectedCount === 0) return null; if (selectedCount === 0) return null;
return ( return (
@@ -64,13 +62,13 @@ function FloatingBulkActionsBar({ selectedCount = 0, onClearSelection, actions =
<button <button
type="button" type="button"
className="floating-bulk-btn dropdown-toggle" className="floating-bulk-btn dropdown-toggle"
onClick={() => setOpenDropdown((v) => (v === idx ? null : idx))} data-bs-toggle="dropdown"
title={action.title} title={action.title}
> >
{Icon && <Icon size={15} />} {Icon && <Icon size={15} />}
<span>{action.label}</span> <span>{action.label}</span>
</button> </button>
<div className={`dropdown-menu dropdown-menu-dark${openDropdown === idx ? ' show' : ''}`}> <div className="dropdown-menu dropdown-menu-dark">
{action.items?.map((item, i) => ( {action.items?.map((item, i) => (
<button <button
key={i} key={i}
+27 -37
View File
@@ -1,15 +1,5 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { IconCheck, IconX, IconAlertCircle } from '@tabler/icons-react'; import { IconCheck, IconX, IconAlertCircle } from '@tabler/icons-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
/** /**
* FormField - универсальное поле формы с валидацией * FormField - универсальное поле формы с валидацией
@@ -82,7 +72,7 @@ function FormField({
}; };
}, [localValue, isDirty, onValidate, debounceMs]); }, [localValue, isDirty, onValidate, debounceMs]);
const inputClasses = `${hasError ? 'border-destructive' : ''} ${hasSuccess ? 'border-green-600' : ''} ${inputClassName}`; const inputClasses = `form-control ${hasError ? 'is-invalid' : ''} ${hasSuccess ? 'is-valid' : ''} ${inputClassName}`;
const handleChange = (e) => { const handleChange = (e) => {
const newValue = e.target.value; const newValue = e.target.value;
@@ -99,7 +89,7 @@ function FormField({
const renderInput = () => { const renderInput = () => {
if (isTextarea) { if (isTextarea) {
return ( return (
<Textarea <textarea
id={inputId} id={inputId}
name={name} name={name}
className={inputClasses} className={inputClasses}
@@ -120,31 +110,31 @@ function FormField({
if (isSelect) { if (isSelect) {
return ( return (
<Select <select
id={inputId}
name={name}
className={inputClasses.replace('form-control', 'form-select')}
value={localValue} value={localValue}
onValueChange={(newValue) => { onChange={handleChange}
setLocalValue(newValue); onBlur={handleBlur}
setIsDirty(true); required={required}
onChange?.(newValue);
}}
disabled={disabled} disabled={disabled}
autoFocus={autoFocus}
aria-invalid={hasError}
aria-describedby={error ? `${inputId}-error` : helpText ? `${inputId}-help` : undefined}
{...inputProps}
> >
<SelectTrigger id={inputId} className={inputClasses}> {options?.map((option) => (
<SelectValue placeholder={placeholder} /> <option key={option.value} value={option.value} disabled={option.disabled}>
</SelectTrigger> {option.label}
<SelectContent> </option>
{options?.map((option) => ( ))}
<SelectItem key={option.value} value={String(option.value)} disabled={option.disabled}> </select>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
); );
} }
return ( return (
<Input <input
id={inputId} id={inputId}
name={name} name={name}
type={type} type={type}
@@ -213,29 +203,29 @@ function FormField({
return ( return (
<div className={`mb-3 ${className}`}> <div className={`mb-3 ${className}`}>
{label && ( {label && (
<Label htmlFor={inputId}> <label htmlFor={inputId} className="form-label">
{label} {label}
{required && <span className="text-destructive ml-1" aria-label="обязательное поле">*</span>} {required && <span className="text-danger ms-1" aria-label="обязательное поле">*</span>}
</Label> </label>
)} )}
{renderInputWithIcon()} {renderInputWithIcon()}
{displayError && ( {displayError && (
<div id={`${inputId}-error`} className="mt-1 text-sm text-destructive" role="alert"> <div id={`${inputId}-error`} className="invalid-feedback d-block" role="alert">
{displayError} {displayError}
</div> </div>
)} )}
{displaySuccess && !displayError && ( {displaySuccess && !displayError && (
<div className="mt-1 text-sm text-green-600"> <div className="valid-feedback d-block">
{displaySuccess} {displaySuccess}
</div> </div>
)} )}
{helpText && !displayError && !displaySuccess && ( {helpText && !displayError && !displaySuccess && (
<div id={`${inputId}-help`} className="mt-1 flex items-center text-xs text-muted-foreground"> <div id={`${inputId}-help`} className="form-text">
<IconAlertCircle size={14} className="mr-1" /> <IconAlertCircle size={14} className="me-1" />
{helpText} {helpText}
</div> </div>
)} )}
+61 -34
View File
@@ -1,11 +1,3 @@
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
/** /**
* FormModal - модальное окно с формой * FormModal - модальное окно с формой
* Простой подход со встроенным backdrop (как в HistoryModal) * Простой подход со встроенным backdrop (как в HistoryModal)
@@ -26,6 +18,8 @@ function FormModal({
size = 'md', size = 'md',
centered = false centered = false
}) { }) {
if (!show) return null;
const handleSubmit = (e) => { const handleSubmit = (e) => {
e.preventDefault(); e.preventDefault();
onSubmit?.(e); onSubmit?.(e);
@@ -42,34 +36,67 @@ function FormModal({
} }
}; };
const sizeClass = size === 'sm' ? 'sm:max-w-md' : size === 'lg' ? 'sm:max-w-2xl' : size === 'xl' ? 'sm:max-w-4xl' : 'sm:max-w-lg'; const handleBackdropClick = (e) => {
if (e.target === e.currentTarget) {
onClose?.();
}
};
return ( return (
<Dialog open={show} onOpenChange={(next) => !next && onClose?.()}> <>
<DialogContent className={sizeClass} onEscapeKeyDown={handleKeyDown}> {/* Modal Backdrop */}
<form onSubmit={handleSubmit}> <div className="modal-backdrop show" onClick={handleBackdropClick} />
<DialogHeader>
<DialogTitle>{title}</DialogTitle> {/* Modal */}
</DialogHeader> <div
<div className="py-4">{children}</div> className="modal show d-block"
<DialogFooter className={centered ? 'justify-center' : ''}> role="dialog"
<Button type="button" variant="outline" onClick={onClose} disabled={loading}> aria-modal="true"
{CancelIcon && <CancelIcon className="mr-2 size-4" />} tabIndex={-1}
{cancelLabel} onKeyDown={handleKeyDown}
</Button> >
<Button <div
type="submit" className={`modal-dialog${size !== 'md' ? ` modal-${size}` : ''}${centered ? ' modal-dialog-centered' : ''}`}
variant={submitVariant === 'danger' ? 'destructive' : 'default'} role="document"
disabled={loading || disabled} >
> <div className="modal-content" tabIndex={-1}>
{loading && <span className="mr-2 size-4 animate-spin rounded-full border-2 border-current border-t-transparent" />} <form onSubmit={handleSubmit}>
{!loading && SubmitIcon && <SubmitIcon className="mr-2 size-4" />} <div className="modal-header">
{submitLabel} <h5 className="modal-title">{title}</h5>
</Button> <button type="button" className="btn-close" onClick={onClose} aria-label="Закрыть" />
</DialogFooter> </div>
</form>
</DialogContent> <div className="modal-body">
</Dialog> {children}
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-secondary"
onClick={onClose}
disabled={loading}
>
{CancelIcon && <CancelIcon className="me-2" size={16} />}
{cancelLabel}
</button>
<button
type="submit"
className={`btn btn-${submitVariant}`}
disabled={loading || disabled}
>
{loading && (
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" />
)}
{!loading && SubmitIcon && <SubmitIcon className="me-2" size={16} />}
{submitLabel}
</button>
</div>
</form>
</div>
</div>
</div>
</>
); );
} }
+96 -32
View File
@@ -1,12 +1,4 @@
import { import { useEffect, useRef } from 'react';
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useEffect } from 'react';
/** /**
* Универсальный Modal компонент * Универсальный Modal компонент
@@ -26,35 +18,107 @@ function Modal({
centered = false, centered = false,
className = '' className = ''
}) { }) {
const modalRef = useRef(null);
// Управление классом modal-open на body
useEffect(() => { useEffect(() => {
if (show) onOpen?.(); if (show) {
document.body.classList.add('modal-open');
setTimeout(() => modalRef.current?.focus(), 100);
onOpen?.();
} else {
document.body.classList.remove('modal-open');
}
return () => {
document.body.classList.remove('modal-open');
};
}, [show, onOpen]); }, [show, onOpen]);
const sizeClass = size === 'sm' ? 'sm:max-w-md' : size === 'lg' ? 'sm:max-w-2xl' : size === 'xl' ? 'sm:max-w-4xl' : 'sm:max-w-lg';
// Обработка ESC
useEffect(() => {
if (keyboard && show) {
const handleEsc = (e) => {
if (e.key === 'Escape') {
onClose?.();
}
};
document.addEventListener('keydown', handleEsc);
return () => document.removeEventListener('keydown', handleEsc);
}
}, [keyboard, show, onClose]);
if (!show) return null;
const handleBackdropClick = (e) => {
if (backdrop && e.target === e.currentTarget) {
onClose?.();
}
};
return ( return (
<Dialog open={show} onOpenChange={(next) => !next && onClose?.()}> <>
<DialogContent {/* Modal Backdrop */}
className={`${sizeClass} ${className}`.trim()} <div className="modal-backdrop show" onClick={handleBackdropClick} />
onEscapeKeyDown={(e) => {
if (!keyboard) e.preventDefault(); {/* Modal */}
}} <div
onPointerDownOutside={(e) => { className={`modal show d-block ${className}`}
if (!backdrop) e.preventDefault(); role="dialog"
}} aria-modal="true"
tabIndex={-1}
onKeyDown={(e) => { if (e.key === 'Escape') onClose?.(); }}
> >
{title && ( <div
<DialogHeader> className={`modal-dialog${size !== 'md' ? ` modal-${size}` : ''}${scrollable ? ' modal-dialog-scrollable' : ''}${centered ? ' modal-dialog-centered' : ''}`}
<DialogTitle>{title}</DialogTitle> role="document"
</DialogHeader> >
)} <div
<DialogDescription asChild> className="modal-content"
<div className={scrollable ? 'max-h-[70vh] overflow-y-auto' : ''}> ref={modalRef}
{children} tabIndex={-1}
onKeyDown={(e) => {
if (e.key === 'Tab') {
const c = e.currentTarget;
const focusable = c.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
if (!focusable || focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}}
>
{title && (
<div className="modal-header">
<h5 className="modal-title">{title}</h5>
<button
type="button"
className="btn-close"
onClick={onClose}
aria-label="Закрыть"
/>
</div>
)}
<div className="modal-body">
{children}
</div>
{footer && (
<div className="modal-footer">
{footer}
</div>
)}
</div> </div>
</DialogDescription> </div>
{footer && <DialogFooter className={centered ? 'justify-center' : ''}>{footer}</DialogFooter>} </div>
</DialogContent> </>
</Dialog>
); );
} }
+100 -21
View File
@@ -1,6 +1,6 @@
/* eslint-disable react-refresh/only-export-components */ /* eslint-disable react-refresh/only-export-components */
import { createContext, useCallback, useContext, useEffect, useMemo, useRef } from 'react'; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'sonner'; import { IconCheck, IconAlertTriangle, IconInfoCircle, IconX } from '@tabler/icons-react';
const NotifyContext = createContext({ const NotifyContext = createContext({
add: () => {}, add: () => {},
@@ -20,36 +20,45 @@ const DEFAULT_TIMEOUT = {
}; };
export function NotifyProvider({ children }) { export function NotifyProvider({ children }) {
const items = useRef(new Map()); const [items, setItems] = useState([]); // { id, type, message, count, createdAt }
const timeouts = useRef(new Map());
const idSeq = useRef(1); const idSeq = useRef(1);
const remove = useCallback((id) => { const remove = useCallback((id) => {
const toastId = items.current.get(id); setItems((prev) => prev.filter((n) => n.id !== id));
if (toastId) { const t = timeouts.current.get(id);
toast.dismiss(toastId); if (t) { clearTimeout(t); timeouts.current.delete(id); }
items.current.delete(id);
}
}, []); }, []);
const schedule = useCallback((id, type) => {
const dur = DEFAULT_TIMEOUT[type] ?? 4000;
const old = timeouts.current.get(id);
if (old) clearTimeout(old);
const t = setTimeout(() => remove(id), dur);
timeouts.current.set(id, t);
}, [remove]);
const add = useCallback((type, message, details) => { const add = useCallback((type, message, details) => {
const text = String(message || '').trim(); const text = String(message || '').trim();
if (!text) return; if (!text) return;
const id = idSeq.current++; setItems((prev) => {
const dur = DEFAULT_TIMEOUT[type] ?? 4000; const dup = prev.find((n) => n.type === type && n.message === text);
const description = details if (dup) {
? (typeof details === 'string' ? details : JSON.stringify(details, null, 2)) const updated = prev.map((n) => n.id === dup.id ? { ...n, count: (n.count || 1) + 1, createdAt: Date.now(), details: details ?? n.details } : n);
: undefined; schedule(dup.id, type);
const toastId = toast[type === 'warning' ? 'warning' : type](text, { return updated;
duration: dur, }
description, const id = idSeq.current++;
const next = [...prev, { id, type, message: text, details, count: 1, createdAt: Date.now() }];
schedule(id, type);
return next;
}); });
items.current.set(id, toastId); }, [schedule]);
return id;
}, []);
const clear = useCallback(() => { const clear = useCallback(() => {
for (const toastId of items.current.values()) toast.dismiss(toastId); setItems([]);
items.current.clear(); for (const t of timeouts.current.values()) clearTimeout(t);
timeouts.current.clear();
}, []); }, []);
const api = useMemo(() => ({ const api = useMemo(() => ({
@@ -71,6 +80,7 @@ export function NotifyProvider({ children }) {
return ( return (
<NotifyContext.Provider value={api}> <NotifyContext.Provider value={api}>
{children} {children}
<NotifyViewport items={items} onClose={remove} />
</NotifyContext.Provider> </NotifyContext.Provider>
); );
} }
@@ -92,3 +102,72 @@ export function notifyMutationSuccess(message, details) {
} catch { /* no-op */ } } catch { /* no-op */ }
} }
function colorByType(type) {
switch (type) {
case 'success': return 'success';
case 'error': return 'danger';
case 'warning': return 'warning';
default: return 'info';
}
}
function iconByType(type) {
switch (type) {
case 'success': return <IconCheck className="me-2" />;
case 'warning': return <IconAlertTriangle className="me-2" />;
case 'error': return <IconAlertTriangle className="me-2" />;
default: return <IconInfoCircle className="me-2" />;
}
}
function NotifyViewport({ items, onClose }) {
return (
<div className="toast-container toast-container-end notify-viewport" style={{ zIndex: 1079 }}>
{items.map((n) => (
<div
key={n.id}
className="toast show toast-alert"
role="alert"
aria-live="assertive"
aria-atomic="true"
style={{
minWidth: '320px',
maxWidth: '420px',
overflow: 'hidden',
borderRadius: 'var(--tblr-border-radius, 0.25rem)',
animation: 'slideInRight 0.3s ease-out forwards',
}}
>
<div className={`alert alert-${colorByType(n.type)} toast-alert-inner mb-0`}>
<div className="toast-header toast-alert-header">
<span className="toast-alert-icon me-2">{iconByType(n.type)}</span>
<strong className="me-auto">
{n.type === 'success' && 'Успех'}
{n.type === 'error' && 'Ошибка'}
{n.type === 'warning' && 'Внимание'}
{n.type === 'info' && 'Информация'}
</strong>
<button type="button" className="btn-close" aria-label="Закрыть" onClick={() => onClose(n.id)} />
</div>
<div className="toast-body">
<div className="alert-description">
{n.message}
{n.count > 1 && <span className="badge bg-white text-body border ms-2">×{n.count}</span>}
</div>
{n.details && (
<details className="small mt-1">
<summary>Подробнее</summary>
<pre className="mb-0 mt-1" style={{ whiteSpace: 'pre-wrap' }}>
{typeof n.details === 'string' ? n.details : JSON.stringify(n.details, null, 2)}
</pre>
</details>
)}
</div>
</div>
</div>
))}
</div>
);
}
+4 -4
View File
@@ -14,7 +14,7 @@ function SavedFilters({
const [savedFilters, setSavedFilters] = useState([]) const [savedFilters, setSavedFilters] = useState([])
const [showSaveDialog, setShowSaveDialog] = useState(false) const [showSaveDialog, setShowSaveDialog] = useState(false)
const [filterName, setFilterName] = useState('') const [filterName, setFilterName] = useState('')
const [showList, setShowList] = useState(false) const [_showList, setShowList] = useState(false)
const storageKey = `savedFilters_${pageKey}` const storageKey = `savedFilters_${pageKey}`
@@ -83,8 +83,8 @@ function SavedFilters({
<button <button
className="btn btn-outline-primary dropdown-toggle" className="btn btn-outline-primary dropdown-toggle"
type="button" type="button"
onClick={() => setShowList(v => !v)} data-bs-toggle="dropdown"
aria-expanded={showList} aria-expanded="false"
> >
<IconBookmark size={18} className="me-1" /> <IconBookmark size={18} className="me-1" />
Фильтры Фильтры
@@ -93,7 +93,7 @@ function SavedFilters({
)} )}
</button> </button>
<div className={`dropdown-menu dropdown-menu-end${showList ? ' show' : ''}`} style={{ minWidth: '300px' }}> <div className="dropdown-menu dropdown-menu-end" style={{ minWidth: '300px' }}>
<div className="dropdown-header">Сохраненные фильтры</div> <div className="dropdown-header">Сохраненные фильтры</div>
{/* Список сохраненных фильтров */} {/* Список сохраненных фильтров */}
+206 -14
View File
@@ -1,7 +1,11 @@
/* eslint-disable react-refresh/only-export-components */ /* eslint-disable react-refresh/only-export-components */
import { createContext, useContext, useEffect, useMemo } from 'react' import { useState, useEffect, createContext, useContext, useCallback, useMemo } from 'react'
import { toast } from 'sonner' import {
import { Toaster } from '@/components/ui/sonner' IconCheck,
IconX,
IconAlertTriangle,
IconInfoCircle
} from '@tabler/icons-react'
/** /**
* Контекст для глобального управления toast уведомлениями. * Контекст для глобального управления toast уведомлениями.
@@ -16,25 +20,213 @@ const ToastContext = createContext({
export const useToast = () => useContext(ToastContext) export const useToast = () => useContext(ToastContext)
export function ToastContainer({ children }) { const DEFAULT_TITLES = {
const contextValue = useMemo(() => ({ success: 'Выполнено успешно',
success: (message, options) => toast.success(message, options), error: 'Произошла ошибка',
error: (message, options) => toast.error(message, options), warning: 'Внимание',
warning: (message, options) => toast.warning(message, options), info: 'Информация',
info: (message, options) => toast.info(message, options), }
}), [])
/**
* Один toast: разметка Tabler (toast + toast-header + toast-body), стиль Tabler Alerts.
*/
function Toast({ toast, onClose }) {
const [isLeaving, setIsLeaving] = useState(false)
const handleClose = useCallback(() => {
setIsLeaving(true)
setTimeout(() => onClose(toast.id), 300)
}, [onClose, toast.id])
useEffect(() => { useEffect(() => {
window.toast = contextValue if (toast.duration && toast.duration > 0) {
return () => { const timer = setTimeout(() => handleClose(), toast.duration)
if (window.toast === contextValue) delete window.toast return () => clearTimeout(timer)
} }
}, [toast.duration, handleClose])
const getIcon = () => {
switch (toast.type) {
case 'success': return <IconCheck size={20} />
case 'error': return <IconX size={20} />
case 'warning': return <IconAlertTriangle size={20} />
case 'info': return <IconInfoCircle size={20} />
default: return <IconInfoCircle size={20} />
}
}
const alertClass = {
success: 'alert-success',
error: 'alert-danger',
warning: 'alert-warning',
info: 'alert-info',
}[toast.type] || 'alert-info'
const title = toast.title ?? DEFAULT_TITLES[toast.type]
return (
<div
className={`toast show toast-alert ${isLeaving ? 'toast-leaving' : ''}`}
role="alert"
aria-live="assertive"
aria-atomic="true"
data-toast-type={toast.type}
style={{
minWidth: '320px',
maxWidth: '420px',
overflow: 'hidden',
borderRadius: 'var(--tblr-border-radius, 0.25rem)',
animation: isLeaving ? 'slideOutRight 0.3s ease-out forwards' : 'slideInRight 0.3s ease-out forwards',
}}
>
<div className={`alert ${alertClass} toast-alert-inner mb-0`}>
<div className="toast-header toast-alert-header">
<span className="toast-alert-icon me-2">{getIcon()}</span>
<strong className="me-auto">{title}</strong>
<button
type="button"
className="btn-close"
onClick={handleClose}
aria-label="Закрыть"
/>
</div>
<div className="toast-body">
<div className="alert-description">{toast.message}</div>
{toast.actions && toast.actions.length > 0 && (
<div className="btn-list mt-2">
{toast.actions.map((action, idx) => (
<button
key={idx}
type="button"
className={`btn btn-sm ${action.variant || 'btn-outline-primary'}`}
onClick={() => {
action.onClick?.()
handleClose()
}}
>
{action.icon && <action.icon size={14} className="me-1" />}
{action.label}
</button>
))}
</div>
)}
</div>
</div>
</div>
)
}
/**
* Контейнер для всех toast уведомлений
*/
export function ToastContainer({ children }) {
const [toasts, setToasts] = useState([])
const addToast = useCallback((type, message, options = {}) => {
const id = Date.now()
const toast = {
id,
type,
message,
title: options.title,
duration: options.duration !== undefined ? options.duration : (type === 'error' ? 0 :
type === 'warning' ? 5000 :
type === 'info' ? 4000 :
3000),
actions: options.actions
}
setToasts(prev => [...prev, toast])
return id
}, [])
const removeToast = useCallback((id) => {
setToasts(prev => prev.filter(t => t.id !== id))
}, [])
const contextValue = useMemo(() => ({
success: (message, options) => addToast('success', message, options),
error: (message, options) => addToast('error', message, options),
warning: (message, options) => addToast('warning', message, options),
info: (message, options) => addToast('info', message, options),
}), [addToast])
// Глобальный доступ через window.toast (для обратной совместимости)
useEffect(() => {
window.toast = contextValue
}, [contextValue]) }, [contextValue])
return ( return (
<ToastContext.Provider value={contextValue}> <ToastContext.Provider value={contextValue}>
{children} {children}
<Toaster richColors position="top-right" closeButton />
{/* Контейнер тостов справа (как в Tabler) */}
<div className="toast-container toast-container-end">
{toasts.map((t) => (
<Toast key={t.id} toast={t} onClose={removeToast} />
))}
</div>
{/* Стили: тосты в стиле Tabler Alerts, всплывают справа */}
<style>{`
.toast-container-end {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 0.5rem;
pointer-events: none;
}
.toast-container-end .toast {
pointer-events: auto;
}
/* NotifyProvider тосты ниже, чтобы не перекрывать useToast */
.toast-container-end.notify-viewport {
top: 5rem;
}
/* Toast в стиле Tabler Alert: колоночный layout, без дублирования отступов */
.toast-alert .toast-alert-inner {
display: block;
flex-direction: column;
padding: 0;
gap: 0;
border-radius: var(--tblr-border-radius, 0.25rem);
box-shadow: var(--tblr-shadow, 0 0.5rem 1rem rgba(0, 0, 0, 0.15));
}
.toast-alert .toast-alert-header {
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
background: transparent;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
font-weight: 500;
}
.toast-alert .toast-alert-icon {
color: inherit;
flex-shrink: 0;
}
.toast-alert .toast-body {
padding: 0.75rem;
}
.toast-alert .toast-body .alert-description {
color: var(--tblr-secondary);
}
.toast-alert.alert-danger .toast-alert-header { border-bottom-color: rgba(0, 0, 0, 0.1); }
.toast-alert .btn-close {
margin-left: auto;
}
@keyframes slideInRight {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideOutRight {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
`}</style>
</ToastContext.Provider> </ToastContext.Provider>
) )
} }
@@ -1,3 +1,4 @@
import { useEffect, useRef } from 'react';
import { IconPlus } from '@tabler/icons-react'; import { IconPlus } from '@tabler/icons-react';
import { SERVER_TYPE_OPTIONS } from '../../utils/serverUtils.js'; import { SERVER_TYPE_OPTIONS } from '../../utils/serverUtils.js';
@@ -5,7 +6,24 @@ import { SERVER_TYPE_OPTIONS } from '../../utils/serverUtils.js';
* Модальное окно для добавления нового сервера * Модальное окно для добавления нового сервера
*/ */
function AddServerModal({ show, newServer, customProvider, onNewServerChange, onCustomProviderChange, onAddServer, onClose, error }) { function AddServerModal({ show, newServer, customProvider, onNewServerChange, onCustomProviderChange, onAddServer, onClose, error }) {
if (!show) return null; const modalRef = useRef(null);
useEffect(() => {
const modalEl = modalRef.current;
if (window.Tabler && window.Tabler.Modal && modalEl) {
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalEl);
if (show) {
modalInstance.show();
} else {
modalInstance.hide();
}
const handler = () => onClose && onClose();
modalEl.addEventListener('hide.bs.modal', handler);
return () => {
modalEl.removeEventListener('hide.bs.modal', handler);
};
}
}, [show, onClose]);
const handleChange = (field, value) => { const handleChange = (field, value) => {
onNewServerChange({ ...newServer, [field]: value }); onNewServerChange({ ...newServer, [field]: value });
@@ -17,9 +35,7 @@ function AddServerModal({ show, newServer, customProvider, onNewServerChange, on
}; };
return ( return (
<> <div className="modal" tabIndex="-1" ref={modalRef}>
<div className="modal-backdrop show" onClick={onClose} />
<div className="modal show d-block" tabIndex="-1">
<div className="modal-dialog"> <div className="modal-dialog">
<div className="modal-content"> <div className="modal-content">
<div className="modal-header"> <div className="modal-header">
@@ -27,7 +43,7 @@ function AddServerModal({ show, newServer, customProvider, onNewServerChange, on
<IconPlus className="icon me-2" /> <IconPlus className="icon me-2" />
Добавить новый сервер Добавить новый сервер
</h5> </h5>
<button type="button" className="btn-close" onClick={onClose} aria-label="Close"></button> <button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div className="modal-body"> <div className="modal-body">
{error && ( {error && (
@@ -149,7 +165,7 @@ function AddServerModal({ show, newServer, customProvider, onNewServerChange, on
</form> </form>
</div> </div>
<div className="modal-footer"> <div className="modal-footer">
<button type="button" className="btn btn-secondary d-flex align-items-center" onClick={onClose}> <button type="button" className="btn btn-secondary d-flex align-items-center" data-bs-dismiss="modal">
<span className="fw-bold">Отмена</span> <span className="fw-bold">Отмена</span>
</button> </button>
<button type="button" className="btn btn-primary d-flex align-items-center" onClick={onAddServer}> <button type="button" className="btn btn-primary d-flex align-items-center" onClick={onAddServer}>
@@ -159,8 +175,7 @@ function AddServerModal({ show, newServer, customProvider, onNewServerChange, on
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</>
); );
} }
@@ -1,29 +1,45 @@
import { useEffect, useRef } from 'react';
import { IconAlertTriangle } from '@tabler/icons-react'; import { IconAlertTriangle } from '@tabler/icons-react';
/** /**
* Модальное окно подтверждения удаления сервера * Модальное окно подтверждения удаления сервера
*/ */
function DeleteServerModal({ show, server, onDelete, onClose }) { function DeleteServerModal({ show, server, onDelete, onClose }) {
if (!show) return null; const modalRef = useRef(null);
useEffect(() => {
const modalEl = modalRef.current;
if (window.Tabler && window.Tabler.Modal && modalEl) {
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalEl);
if (show) {
modalInstance.show();
} else {
modalInstance.hide();
}
const handler = () => onClose && onClose();
modalEl.addEventListener('hide.bs.modal', handler);
return () => {
modalEl.removeEventListener('hide.bs.modal', handler);
};
}
}, [show, onClose]);
return ( return (
<> <div className="modal" tabIndex="-1" ref={modalRef}>
<div className="modal-backdrop show" onClick={onClose} />
<div className="modal show d-block" tabIndex="-1">
<div className="modal-dialog"> <div className="modal-dialog">
<div className="modal-content"> <div className="modal-content">
<div className="modal-status bg-danger"></div> <div className="modal-status bg-danger"></div>
<div className="modal-header"> <div className="modal-header">
<IconAlertTriangle className="icon text-danger me-2" size={28} /> <IconAlertTriangle className="icon text-danger me-2" size={28} />
<h5 className="modal-title">Подтверждение удаления</h5> <h5 className="modal-title">Подтверждение удаления</h5>
<button type="button" className="btn-close" onClick={onClose} aria-label="Close"></button> <button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div className="modal-body"> <div className="modal-body">
<p>Вы уверены, что хотите удалить сервер <strong>{server?.ip}</strong>?</p> <p>Вы уверены, что хотите удалить сервер <strong>{server?.ip}</strong>?</p>
<p className="text-muted">Это действие нельзя отменить.</p> <p className="text-muted">Это действие нельзя отменить.</p>
</div> </div>
<div className="modal-footer"> <div className="modal-footer">
<button type="button" className="btn btn-secondary d-flex align-items-center" onClick={onClose}> <button type="button" className="btn btn-secondary d-flex align-items-center" data-bs-dismiss="modal">
<span className="fw-bold">Отмена</span> <span className="fw-bold">Отмена</span>
</button> </button>
<button type="button" className="btn btn-danger d-flex align-items-center" onClick={onDelete}> <button type="button" className="btn btn-danger d-flex align-items-center" onClick={onDelete}>
@@ -32,8 +48,7 @@ function DeleteServerModal({ show, server, onDelete, onClose }) {
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</>
); );
} }
@@ -11,6 +11,7 @@ import {
* Модальное окно для редактирования сервера * Модальное окно для редактирования сервера
*/ */
function EditServerModal({ show, server, onChange, onSave, onClose }) { function EditServerModal({ show, server, onChange, onSave, onClose }) {
const modalRef = useRef(null);
const initializedRef = useRef(false); const initializedRef = useRef(false);
const [localServer, setLocalServer] = useState({}); const [localServer, setLocalServer] = useState({});
@@ -38,7 +39,22 @@ function EditServerModal({ show, server, onChange, onSave, onClose }) {
} }
}, [show, server]); }, [show, server]);
if (!show) return null; useEffect(() => {
const modalEl = modalRef.current;
if (window.Tabler && window.Tabler.Modal && modalEl) {
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalEl);
if (show) {
modalInstance.show();
} else {
modalInstance.hide();
}
const handler = () => onClose && onClose();
modalEl.addEventListener('hide.bs.modal', handler);
return () => {
modalEl.removeEventListener('hide.bs.modal', handler);
};
}
}, [show, onClose]);
const handleFieldChange = (field, value) => { const handleFieldChange = (field, value) => {
setLocalServer(prev => { setLocalServer(prev => {
@@ -105,14 +121,12 @@ function EditServerModal({ show, server, onChange, onSave, onClose }) {
const showGateways = NEEDS_GATEWAYS(localServer.type); const showGateways = NEEDS_GATEWAYS(localServer.type);
return ( return (
<> <div className="modal" tabIndex="-1" ref={modalRef}>
<div className="modal-backdrop show" onClick={onClose} />
<div className="modal show d-block" tabIndex="-1">
<div className="modal-dialog"> <div className="modal-dialog">
<div className="modal-content"> <div className="modal-content">
<div className="modal-header"> <div className="modal-header">
<h5 className="modal-title">Редактировать сервер</h5> <h5 className="modal-title">Редактировать сервер</h5>
<button type="button" className="btn-close" onClick={onClose} aria-label="Close"></button> <button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div className="modal-body"> <div className="modal-body">
<form onSubmit={e => { e.preventDefault(); onSave && onSave(localServer); }}> <form onSubmit={e => { e.preventDefault(); onSave && onSave(localServer); }}>
@@ -235,7 +249,7 @@ function EditServerModal({ show, server, onChange, onSave, onClose }) {
</form> </form>
</div> </div>
<div className="modal-footer"> <div className="modal-footer">
<button type="button" className="btn btn-secondary d-flex align-items-center" onClick={onClose}> <button type="button" className="btn btn-secondary d-flex align-items-center" data-bs-dismiss="modal">
<span className="fw-bold">Отмена</span> <span className="fw-bold">Отмена</span>
</button> </button>
<button type="button" className="btn btn-primary d-flex align-items-center" onClick={() => onSave && onSave(localServer)}> <button type="button" className="btn btn-primary d-flex align-items-center" onClick={() => onSave && onSave(localServer)}>
@@ -244,8 +258,7 @@ function EditServerModal({ show, server, onChange, onSave, onClose }) {
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</>
); );
} }
@@ -1,17 +1,33 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { IconLink, IconDownload } from '@tabler/icons-react'; import { IconLink, IconDownload } from '@tabler/icons-react';
/** /**
* Модальное окно генератора ссылок для сервера * Модальное окно генератора ссылок для сервера
*/ */
function LinkGeneratorModal({ show, server, urlSettings, onUrlSettingsChange, onGenerateUrl, onCopyToClipboard, onClose }) { function LinkGeneratorModal({ show, server, urlSettings, onUrlSettingsChange, onGenerateUrl, onCopyToClipboard, onClose }) {
const modalRef = useRef(null);
const [localUrlSettings, setLocalUrlSettings] = useState(urlSettings); const [localUrlSettings, setLocalUrlSettings] = useState(urlSettings);
useEffect(() => { useEffect(() => {
setLocalUrlSettings(urlSettings); setLocalUrlSettings(urlSettings);
}, [urlSettings]); }, [urlSettings]);
if (!show) return null; useEffect(() => {
const modalEl = modalRef.current;
if (window.Tabler && window.Tabler.Modal && modalEl) {
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalEl);
if (show) {
modalInstance.show();
} else {
modalInstance.hide();
}
const handler = () => onClose && onClose();
modalEl.addEventListener('hide.bs.modal', handler);
return () => {
modalEl.removeEventListener('hide.bs.modal', handler);
};
}
}, [show, onClose]);
const handleSettingChange = (key, value) => { const handleSettingChange = (key, value) => {
const updated = { ...localUrlSettings, [key]: value }; const updated = { ...localUrlSettings, [key]: value };
@@ -27,9 +43,7 @@ function LinkGeneratorModal({ show, server, urlSettings, onUrlSettingsChange, on
const generatedUrl = server ? onGenerateUrl(server) : ''; const generatedUrl = server ? onGenerateUrl(server) : '';
return ( return (
<> <div className="modal modal-lg" tabIndex="-1" ref={modalRef}>
<div className="modal-backdrop show" onClick={onClose} />
<div className="modal modal-lg show d-block" tabIndex="-1">
<div className="modal-dialog"> <div className="modal-dialog">
<div className="modal-content"> <div className="modal-content">
<div className="modal-header"> <div className="modal-header">
@@ -37,7 +51,7 @@ function LinkGeneratorModal({ show, server, urlSettings, onUrlSettingsChange, on
<IconLink className="icon me-2" /> <IconLink className="icon me-2" />
Генератор ссылок для {server?.ip} Генератор ссылок для {server?.ip}
</h5> </h5>
<button type="button" className="btn-close" onClick={onClose} aria-label="Close"></button> <button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div className="modal-body"> <div className="modal-body">
<div className="row g-2"> <div className="row g-2">
@@ -151,7 +165,7 @@ function LinkGeneratorModal({ show, server, urlSettings, onUrlSettingsChange, on
</div> </div>
</div> </div>
<div className="modal-footer"> <div className="modal-footer">
<button type="button" className="btn btn-secondary d-flex align-items-center" onClick={onClose}> <button type="button" className="btn btn-secondary d-flex align-items-center" data-bs-dismiss="modal">
<span className="fw-bold">Закрыть</span> <span className="fw-bold">Закрыть</span>
</button> </button>
<button type="button" className="btn btn-primary d-flex align-items-center" onClick={handleSaveSettings}> <button type="button" className="btn btn-primary d-flex align-items-center" onClick={handleSaveSettings}>
@@ -160,8 +174,7 @@ function LinkGeneratorModal({ show, server, urlSettings, onUrlSettingsChange, on
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</>
); );
} }
+24 -8
View File
@@ -60,6 +60,7 @@ const getEmptyServer = () => ({
* @param {string} props.error - Текст ошибки для отображения * @param {string} props.error - Текст ошибки для отображения
*/ */
function ServerModal({ show, mode = 'add', server, onSave, onClose, error: externalError }) { function ServerModal({ show, mode = 'add', server, onSave, onClose, error: externalError }) {
const modalRef = useRef(null);
const initializedRef = useRef(false); const initializedRef = useRef(false);
const [localServer, setLocalServer] = useState(getEmptyServer()); const [localServer, setLocalServer] = useState(getEmptyServer());
const [customProvider, setCustomProvider] = useState(''); const [customProvider, setCustomProvider] = useState('');
@@ -113,7 +114,25 @@ function ServerModal({ show, mode = 'add', server, onSave, onClose, error: exter
} }
}, [show, server, isEditMode]); }, [show, server, isEditMode]);
if (!show) return null; /**
* Управление Bootstrap modal
*/
useEffect(() => {
const modalEl = modalRef.current;
if (window.Tabler && window.Tabler.Modal && modalEl) {
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalEl);
if (show) {
modalInstance.show();
} else {
modalInstance.hide();
}
const handler = () => onClose && onClose();
modalEl.addEventListener('hide.bs.modal', handler);
return () => {
modalEl.removeEventListener('hide.bs.modal', handler);
};
}
}, [show, onClose]);
/** /**
* Обработчик изменения поля * Обработчик изменения поля
@@ -177,9 +196,7 @@ function ServerModal({ show, mode = 'add', server, onSave, onClose, error: exter
}; };
return ( return (
<> <div className="modal" tabIndex="-1" ref={modalRef}>
<div className="modal-backdrop show" onClick={onClose} />
<div className="modal show d-block" tabIndex="-1">
<div className="modal-dialog modal-lg"> <div className="modal-dialog modal-lg">
<div className="modal-content"> <div className="modal-content">
<div className="modal-header"> <div className="modal-header">
@@ -196,7 +213,7 @@ function ServerModal({ show, mode = 'add', server, onSave, onClose, error: exter
</> </>
)} )}
</h5> </h5>
<button type="button" className="btn-close" onClick={onClose} aria-label="Close"></button> <button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
@@ -495,7 +512,7 @@ function ServerModal({ show, mode = 'add', server, onSave, onClose, error: exter
</div> </div>
<div className="modal-footer"> <div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={onClose}> <button type="button" className="btn btn-secondary" data-bs-dismiss="modal">
Отмена Отмена
</button> </button>
<button type="submit" className="btn btn-primary d-flex align-items-center"> <button type="submit" className="btn btn-primary d-flex align-items-center">
@@ -515,8 +532,7 @@ function ServerModal({ show, mode = 'add', server, onSave, onClose, error: exter
</form> </form>
</div> </div>
</div> </div>
</div> </div>
</>
); );
} }
-185
View File
@@ -1,185 +0,0 @@
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: AlertDialogPrimitive.Backdrop.Props) {
return (
<AlertDialogPrimitive.Backdrop
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: AlertDialogPrimitive.Popup.Props & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Popup
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: AlertDialogPrimitive.Close.Props &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<AlertDialogPrimitive.Close
data-slot="alert-dialog-cancel"
className={cn(className)}
render={<Button variant={variant} size={size} />}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}
-109
View File
@@ -1,109 +0,0 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}
-52
View File
@@ -1,52 +0,0 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }
-125
View File
@@ -1,125 +0,0 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
className={cn(className)}
{...props}
/>
)
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
function BreadcrumbLink({
className,
render,
...props
}: useRender.ComponentProps<"a">) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn("transition-colors hover:text-foreground", className),
},
props
),
render,
state: {
slot: "breadcrumb-link",
},
})
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? (
<ChevronRightIcon />
)}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn(
"flex size-5 items-center justify-center [&>svg]:size-4",
className
)}
{...props}
>
<MoreHorizontalIcon
/>
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
-58
View File
@@ -1,58 +0,0 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
-103
View File
@@ -1,103 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
-27
View File
@@ -1,27 +0,0 @@
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { cn } from "@/lib/utils"
import { CheckIcon } from "lucide-react"
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
@@ -1,19 +0,0 @@
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
)
}
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
return (
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
-160
View File
@@ -1,160 +0,0 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
@@ -1,268 +0,0 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
-20
View File
@@ -1,20 +0,0 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
-18
View File
@@ -1,18 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
-201
View File
@@ -1,201 +0,0 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
-23
View File
@@ -1,23 +0,0 @@
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
-138
View File
@@ -1,138 +0,0 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
-721
View File
@@ -1,721 +0,0 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { PanelLeftIcon } from "lucide-react"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className
)}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
dir,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
dir={dir}
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
className={cn(className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("h-8 w-full bg-background shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
render,
...props
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-group-label",
sidebar: "group-label",
},
})
}
function SidebarGroupAction({
className,
render,
...props
}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-group-action",
sidebar: "group-action",
},
})
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
render,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const { isMobile, state } = useSidebar()
const comp = useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
},
props
),
render: !tooltip ? render : <TooltipTrigger render={render} />,
state: {
slot: "sidebar-menu-button",
sidebar: "menu-button",
size,
active: isActive,
},
})
if (!tooltip) {
return comp
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
{comp}
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
render,
showOnHover = false,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
showOnHover?: boolean
}) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-menu-action",
sidebar: "menu-action",
},
})
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
})
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
render,
size = "md",
isActive = false,
className,
...props
}: useRender.ComponentProps<"a"> &
React.ComponentProps<"a"> & {
size?: "sm" | "md"
isActive?: boolean
}) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
className
),
},
props
),
render,
state: {
slot: "sidebar-menu-sub-button",
sidebar: "menu-sub-button",
size,
active: isActive,
},
})
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
-13
View File
@@ -1,13 +0,0 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
-46
View File
@@ -1,46 +0,0 @@
"use client"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
theme={"system"}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }
-114
View File
@@ -1,114 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
-18
View File
@@ -1,18 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
-64
View File
@@ -1,64 +0,0 @@
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
-19
View File
@@ -1,19 +0,0 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}
+13 -30
View File
@@ -1,9 +1,3 @@
@import "tw-animate-css";
@import "@fontsource-variable/geist";
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Using Bootstrap styles, this file can be empty or used for custom global styles. */ /* Using Bootstrap styles, this file can be empty or used for custom global styles. */
/* Sticky footer helpers */ /* Sticky footer helpers */
@@ -24,20 +18,20 @@
--app-gap-sm: .5rem; --app-gap-sm: .5rem;
--app-gap-md: .75rem; --app-gap-md: .75rem;
--app-gap-lg: 1rem; --app-gap-lg: 1rem;
/* Semantic colors for statuses */ /* Semantic colors for statuses */
--status-online: #2fb344; --status-online: #2fb344;
--status-offline: #d63939; --status-offline: #d63939;
--status-warning: #f59f00; --status-warning: #f59f00;
--status-unknown: #868e96; --status-unknown: #868e96;
/* Accent colors for important actions */ /* Accent colors for important actions */
--accent-primary: #206bc4; --accent-primary: #206bc4;
--accent-danger: #d63939; --accent-danger: #d63939;
--accent-success: #2fb344; --accent-success: #2fb344;
--accent-info: #4299e1; --accent-info: #4299e1;
--accent-warning: #f59f00; --accent-warning: #f59f00;
/* Enhanced spacing scale */ /* Enhanced spacing scale */
--space-xs: 0.25rem; --space-xs: 0.25rem;
--space-sm: 0.5rem; --space-sm: 0.5rem;
@@ -45,7 +39,7 @@
--space-lg: 1.5rem; --space-lg: 1.5rem;
--space-xl: 2rem; --space-xl: 2rem;
--space-xxl: 3rem; --space-xxl: 3rem;
/* Shadow scale */ /* Shadow scale */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.1); --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.1);
@@ -182,14 +176,14 @@ button:focus-visible,
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.btn-icon { .btn-icon {
min-width: 44px; min-width: 44px;
min-height: 44px; min-height: 44px;
width: 44px; width: 44px;
height: 44px; height: 44px;
} }
.navbar-toggler { .navbar-toggler {
min-width: 44px; min-width: 44px;
min-height: 44px; min-height: 44px;
@@ -453,12 +447,12 @@ button:focus-visible,
width: 2.5rem; width: 2.5rem;
height: 2.5rem; height: 2.5rem;
} }
.card-sm .h2, .card-sm .h2,
.card .h3.mb-0 { .card .h3.mb-0 {
font-size: 1.25rem; font-size: 1.25rem;
} }
.table-responsive { .table-responsive {
font-size: 0.875rem; font-size: 0.875rem;
overflow-x: auto; overflow-x: auto;
@@ -471,19 +465,19 @@ button:focus-visible,
.table-responsive .table td { .table-responsive .table td {
white-space: nowrap; white-space: nowrap;
} }
.btn-group { .btn-group {
flex-wrap: wrap; flex-wrap: wrap;
} }
.page-title { .page-title {
font-size: 1.35rem; font-size: 1.35rem;
} }
.page-header .header-actions { .page-header .header-actions {
width: 100%; width: 100%;
} }
/* Карточки и ряды: компактные отступы на мобильных */ /* Карточки и ряды: компактные отступы на мобильных */
.row.g-3 { --bs-gutter-x: 0.5rem; --bs-gutter-y: 0.5rem; } .row.g-3 { --bs-gutter-x: 0.5rem; --bs-gutter-y: 0.5rem; }
.card-title { font-size: 1rem; } .card-title { font-size: 1rem; }
@@ -497,15 +491,4 @@ button:focus-visible,
flex: 0 0 100%; flex: 0 0 100%;
max-width: 100%; max-width: 100%;
} }
} }
@layer base {
.theme { --font-heading: var(--font-sans); --font-sans: 'Geist Variable', sans-serif; }
:root { --background: oklch(1 0 0); --foreground: oklch(0.145 0 0); --card: oklch(1 0 0); --card-foreground: oklch(0.145 0 0); --popover: oklch(1 0 0); --popover-foreground: oklch(0.145 0 0); --primary: oklch(0.205 0 0); --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.97 0 0); --secondary-foreground: oklch(0.205 0 0); --muted: oklch(0.97 0 0); --muted-foreground: oklch(0.556 0 0); --accent: oklch(0.97 0 0); --accent-foreground: oklch(0.205 0 0); --destructive: oklch(0.577 0.245 27.325); --border: oklch(0.922 0 0); --input: oklch(0.922 0 0); --ring: oklch(0.708 0 0); --chart-1: oklch(0.87 0 0); --chart-2: oklch(0.556 0 0); --chart-3: oklch(0.439 0 0); --chart-4: oklch(0.371 0 0); --chart-5: oklch(0.269 0 0); --radius: 0.625rem; --sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.145 0 0); --sidebar-primary: oklch(0.205 0 0); --sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-accent: oklch(0.97 0 0); --sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-border: oklch(0.922 0 0); --sidebar-ring: oklch(0.708 0 0); }
.dark { --background: oklch(0.145 0 0); --foreground: oklch(0.985 0 0); --card: oklch(0.205 0 0); --card-foreground: oklch(0.985 0 0); --popover: oklch(0.205 0 0); --popover-foreground: oklch(0.985 0 0); --primary: oklch(0.922 0 0); --primary-foreground: oklch(0.205 0 0); --secondary: oklch(0.269 0 0); --secondary-foreground: oklch(0.985 0 0); --muted: oklch(0.269 0 0); --muted-foreground: oklch(0.708 0 0); --accent: oklch(0.269 0 0); --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); --ring: oklch(0.556 0 0); --chart-1: oklch(0.87 0 0); --chart-2: oklch(0.556 0 0); --chart-3: oklch(0.439 0 0); --chart-4: oklch(0.371 0 0); --chart-5: oklch(0.269 0 0); --sidebar: oklch(0.205 0 0); --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-accent: oklch(0.269 0 0); --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.556 0 0); }
* {
@apply border-border outline-ring/50; }
body {
@apply bg-background text-foreground; }
html {
@apply font-sans; } }
-6
View File
@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+5
View File
@@ -4,6 +4,11 @@ import App from './App.jsx'
import './index.css' import './index.css'
import '@tabler/core/dist/css/tabler.min.css' import '@tabler/core/dist/css/tabler.min.css'
// Динамический импорт Tabler JS и присваивание в window
import('@tabler/core/dist/js/tabler.min.js').then((mod) => {
window.Tabler = window.Tabler || window.globalThis.Tabler || mod;
});
ReactDOM.createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById('root')).render(
<App /> <App />
) )
-69
View File
@@ -1,69 +0,0 @@
import tailwindcssAnimate from "tailwindcss-animate"
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: [
"./index.html",
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground, 0 0% 98%))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
sidebar: {
DEFAULT: "hsl(var(--sidebar))",
foreground: "hsl(var(--sidebar-foreground))",
primary: "hsl(var(--sidebar-primary))",
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
accent: "hsl(var(--sidebar-accent))",
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
border: "hsl(var(--sidebar-border))",
ring: "hsl(var(--sidebar-ring))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
fontFamily: {
sans: ["Geist Variable", "ui-sans-serif", "system-ui", "sans-serif"],
},
},
},
plugins: [tailwindcssAnimate],
}
-9
View File
@@ -1,18 +1,9 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
build: { build: {
chunkSizeWarningLimit: 2000, chunkSizeWarningLimit: 2000,
}, },
+49
View File
@@ -8,10 +8,40 @@
"name": "router-lists-ui", "name": "router-lists-ui",
"version": "1.0.0", "version": "1.0.0",
"license": "ISC", "license": "ISC",
"dependencies": {
"@tabler/core": "^1.3.2"
},
"devDependencies": { "devDependencies": {
"concurrently": "^9.2.0" "concurrently": "^9.2.0"
} }
}, },
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@tabler/core": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@tabler/core/-/core-1.3.2.tgz",
"integrity": "sha512-QDVJbv48YJrahBLdxYkLi6NutQv4jGbkUWyzxE2NcNJ3s3GGpRx98JmbAoN92NZKNmf26vZdW6k2Q5haVKlS4A==",
"license": "MIT",
"dependencies": {
"@popperjs/core": "^2.11.8",
"bootstrap": "5.3.6"
},
"engines": {
"node": ">=20"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
}
},
"node_modules/ansi-regex": { "node_modules/ansi-regex": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -38,6 +68,25 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "url": "https://github.com/chalk/ansi-styles?sponsor=1"
} }
}, },
"node_modules/bootstrap": {
"version": "5.3.6",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.6.tgz",
"integrity": "sha512-jX0GAcRzvdwISuvArXn3m7KZscWWFAf1MKBcnzaN02qWMb3jpMoUX4/qgeiGzqyIb4ojulRzs89UCUmGcFSzTA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/twbs"
},
{
"type": "opencollective",
"url": "https://opencollective.com/bootstrap"
}
],
"license": "MIT",
"peerDependencies": {
"@popperjs/core": "^2.11.8"
}
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",