Update frontend dependencies, enhance Vite configuration, and refactor components
Publish Docker image / build-and-push (push) Successful in 2m24s
Publish Docker image / build-and-push (push) Successful in 2m24s
- Added new dependencies including @base-ui/react, @fontsource-variable/geist, and tailwindcss for improved UI and styling. - Updated Vite configuration to include path aliasing for easier imports. - Refactored App.jsx to implement a theme context and improved routing structure. - Enhanced various components (e.g., ConfirmDialog, DataTable) to utilize new UI components and improve user experience. - Cleaned up CSS imports and ensured proper styling integration. Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"plugins": {
|
||||||
|
"shadcn": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$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": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# 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.
|
||||||
Generated
+4410
-125
File diff suppressed because it is too large
Load Diff
+16
-1
@@ -12,31 +12,46 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
+170
-571
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, createContext, useContext, useRef } from 'react';
|
import { useContext, useEffect, useMemo, useState, createContext } 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,10 +6,9 @@ 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,
|
||||||
@@ -19,17 +18,32 @@ 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';
|
||||||
@@ -64,64 +78,174 @@ 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: {
|
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: 'Тёмная' },
|
||||||
home: 'Главная', data: 'Данные', management: 'Управление', tools: 'Инструменты',
|
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' },
|
||||||
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 (
|
return <LanguageContext.Provider value={{ lang, setLang, t }}>{children}</LanguageContext.Provider>;
|
||||||
<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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 30_000 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const appRoutes = [
|
||||||
|
{ 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 (
|
return (
|
||||||
<ThemeContext.Provider value={{ theme, setTheme }}>
|
<Routes>
|
||||||
{children}
|
{appRoutes.map((entry) => <Route key={entry.path} path={entry.path} element={entry.element} />)}
|
||||||
</ThemeContext.Provider>
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
</Routes>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Единственный экземпляр QueryClient для всего приложения
|
|
||||||
const queryClient = new QueryClient({
|
function MainLayout() {
|
||||||
defaultOptions: {
|
const { lang, setLang, t } = useContext(LanguageContext);
|
||||||
queries: {
|
const { theme, setTheme } = useContext(ThemeContext);
|
||||||
refetchOnWindowFocus: false,
|
const location = useLocation();
|
||||||
staleTime: 30_000,
|
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>
|
||||||
|
<header className="sticky top-0 z-10 flex h-14 items-center gap-2 border-b bg-background px-4">
|
||||||
|
<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="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 (
|
||||||
@@ -148,529 +272,4 @@ 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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ 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' },
|
||||||
@@ -744,7 +746,7 @@ function ServerManager() {
|
|||||||
{activeTab === 'servers' && (
|
{activeTab === 'servers' && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="offcanvas offcanvas-start"
|
className={`offcanvas offcanvas-start${showFiltersOffcanvas ? ' show' : ''}`}
|
||||||
id="server-filters-offcanvas"
|
id="server-filters-offcanvas"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
aria-labelledby="server-filters-offcanvas-label"
|
aria-labelledby="server-filters-offcanvas-label"
|
||||||
@@ -774,7 +776,7 @@ function ServerManager() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-close"
|
className="btn-close"
|
||||||
data-bs-dismiss="offcanvas"
|
onClick={() => setShowFiltersOffcanvas(false)}
|
||||||
aria-label="Закрыть"
|
aria-label="Закрыть"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -807,8 +809,7 @@ 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"
|
||||||
data-bs-toggle="offcanvas"
|
onClick={() => setShowFiltersOffcanvas(true)}
|
||||||
data-bs-target="#server-filters-offcanvas"
|
|
||||||
aria-controls="server-filters-offcanvas"
|
aria-controls="server-filters-offcanvas"
|
||||||
title="Фильтры"
|
title="Фильтры"
|
||||||
>
|
>
|
||||||
@@ -835,12 +836,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" data-bs-toggle="dropdown">
|
<button className="btn btn-outline-secondary btn-sm dropdown-toggle" type="button" onClick={() => setShowSortMenu((v) => !v)}>
|
||||||
Сортировка: {sortField}
|
Сортировка: {sortField}
|
||||||
</button>
|
</button>
|
||||||
<div className="dropdown-menu">
|
<div className={`dropdown-menu${showSortMenu ? ' show' : ''}`}>
|
||||||
{['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)}>
|
<button key={field} className="dropdown-item" onClick={() => { handleSort(field); setShowSortMenu(false); }}>
|
||||||
{field} {sortField === field ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
{field} {sortField === field ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -202,8 +202,7 @@ 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"
|
||||||
data-bs-toggle="dropdown"
|
onClick={(e) => { e.preventDefault(); setOpen((v) => !v); }}
|
||||||
onClick={(e) => e.preventDefault()}
|
|
||||||
aria-label={`Оповещения: ${total} активных`}
|
aria-label={`Оповещения: ${total} активных`}
|
||||||
title="Оповещения о проблемах"
|
title="Оповещения о проблемах"
|
||||||
>
|
>
|
||||||
@@ -216,7 +215,7 @@ export default function AlertsBell({ dropup = false }) {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<div className="dropdown-menu dropdown-menu-end dropdown-menu-card overflow-hidden" style={{ width: `${PANEL_WIDTH}px`, maxWidth: '95vw' }}>
|
<div className={`dropdown-menu dropdown-menu-end dropdown-menu-card overflow-hidden${open ? ' show' : ''}`} 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>
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
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 - упрощённая версия для быстрых подтверждений
|
||||||
@@ -16,90 +26,29 @@ 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?.()}>
|
||||||
{/* Modal Backdrop */}
|
<AlertDialogContent className={size === 'lg' ? 'sm:max-w-2xl' : 'sm:max-w-md'}>
|
||||||
<div className="modal-backdrop show" onClick={handleBackdropClick} />
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||||
{/* Modal */}
|
<AlertDialogDescription asChild>
|
||||||
<div
|
<div className="flex items-start gap-3 pt-2">
|
||||||
className="modal show d-block"
|
{destructive && <IconAlertTriangle className="text-destructive" size={24} />}
|
||||||
role="dialog"
|
<p className="m-0">{message}</p>
|
||||||
aria-modal="true"
|
</div>
|
||||||
tabIndex={-1}
|
</AlertDialogDescription>
|
||||||
onKeyDown={(e) => { if (e.key === 'Escape') onCancel?.(); }}
|
</AlertDialogHeader>
|
||||||
>
|
<AlertDialogFooter>
|
||||||
<div className={`modal-dialog ${size === 'sm' ? 'modal-sm' : size === 'lg' ? 'modal-lg' : ''} modal-dialog-centered`} role="document">
|
<AlertDialogCancel disabled={loading}>{cancelText}</AlertDialogCancel>
|
||||||
<div
|
<AlertDialogAction
|
||||||
className="modal-content"
|
className={destructive ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' : ''}
|
||||||
tabIndex={-1}
|
onClick={onConfirm}
|
||||||
onKeyDown={(e) => {
|
disabled={loading}
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="modal-header">
|
{confirmText}
|
||||||
<h5 className="modal-title">{title}</h5>
|
</AlertDialogAction>
|
||||||
<button type="button" className="btn-close" onClick={onCancel} aria-label="Закрыть" />
|
</AlertDialogFooter>
|
||||||
</div>
|
</AlertDialogContent>
|
||||||
|
</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>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,14 +106,13 @@ function ConfirmDiffModal({ show, diff, onConfirm, onClose, loading = false }) {
|
|||||||
<button
|
<button
|
||||||
className="accordion-button collapsed"
|
className="accordion-button collapsed"
|
||||||
type="button"
|
type="button"
|
||||||
data-bs-toggle="collapse"
|
onClick={(e) => e.preventDefault()}
|
||||||
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" data-bs-parent="#diffAccordion">
|
<div id="collapseAdded" className="accordion-collapse collapse show" 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) => (
|
||||||
@@ -139,14 +138,13 @@ function ConfirmDiffModal({ show, diff, onConfirm, onClose, loading = false }) {
|
|||||||
<button
|
<button
|
||||||
className="accordion-button collapsed"
|
className="accordion-button collapsed"
|
||||||
type="button"
|
type="button"
|
||||||
data-bs-toggle="collapse"
|
onClick={(e) => e.preventDefault()}
|
||||||
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" data-bs-parent="#diffAccordion">
|
<div id="collapseRemoved" className="accordion-collapse collapse show" 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) => (
|
||||||
@@ -172,14 +170,13 @@ function ConfirmDiffModal({ show, diff, onConfirm, onClose, loading = false }) {
|
|||||||
<button
|
<button
|
||||||
className="accordion-button collapsed"
|
className="accordion-button collapsed"
|
||||||
type="button"
|
type="button"
|
||||||
data-bs-toggle="collapse"
|
onClick={(e) => e.preventDefault()}
|
||||||
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" data-bs-parent="#diffAccordion">
|
<div id="collapseChanged" className="accordion-collapse collapse show" 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) => (
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
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 - модальное окно подтверждения действия
|
||||||
@@ -17,93 +27,34 @@ 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?.()}>
|
||||||
{/* Modal Backdrop */}
|
<AlertDialogContent>
|
||||||
<div className="modal-backdrop show" onClick={handleBackdropClick} />
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||||
{/* Modal */}
|
<AlertDialogDescription asChild>
|
||||||
<div
|
<div className="flex items-start gap-3 pt-2">
|
||||||
className="modal show d-block"
|
{showIcon && Icon && <Icon className={variant === 'danger' ? 'text-destructive' : 'text-muted-foreground'} size={24} />}
|
||||||
role="dialog"
|
<div>{typeof message === 'string' ? <p className="m-0">{message}</p> : message}</div>
|
||||||
aria-modal="true"
|
</div>
|
||||||
tabIndex={-1}
|
</AlertDialogDescription>
|
||||||
onKeyDown={(e) => { if (e.key === 'Escape') onClose?.(); }}
|
</AlertDialogHeader>
|
||||||
>
|
<AlertDialogFooter>
|
||||||
<div className="modal-dialog modal-sm modal-dialog-centered" role="document">
|
<AlertDialogCancel disabled={loading}>{cancelLabel}</AlertDialogCancel>
|
||||||
<div
|
<AlertDialogAction
|
||||||
className="modal-content"
|
className={variant === 'danger' ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' : ''}
|
||||||
tabIndex={-1}
|
onClick={handleConfirm}
|
||||||
onKeyDown={(e) => {
|
disabled={loading}
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="modal-header">
|
{confirmLabel}
|
||||||
<h5 className="modal-title">{title}</h5>
|
</AlertDialogAction>
|
||||||
<button type="button" className="btn-close" onClick={onClose} aria-label="Закрыть" />
|
</AlertDialogFooter>
|
||||||
</div>
|
</AlertDialogContent>
|
||||||
|
</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>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,40 +4,10 @@ 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 = [],
|
||||||
@@ -61,234 +31,129 @@ 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;
|
||||||
|
|
||||||
// Рендер заголовка колонки (col.title — подпись, col.tooltip — подсказка для UX)
|
const renderHeaderContent = (col) => {
|
||||||
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="d-flex align-items-center">
|
<div className="flex items-center gap-1">
|
||||||
{col.icon && <col.icon size={16} className="me-1 text-muted" />}
|
{col.icon && <col.icon size={16} className="text-muted-foreground" />}
|
||||||
{col.title}
|
<span>{col.title}</span>
|
||||||
{isSortable && isActive && (
|
{isSortable && isActive && (sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />)}
|
||||||
<span className="ms-1">
|
|
||||||
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
if (!isSortable) return content;
|
||||||
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 (
|
return (
|
||||||
<th {...thProps}>
|
<button type="button" className="inline-flex cursor-pointer items-center bg-transparent p-0 text-left" onClick={() => onSort(col.key)}>
|
||||||
{col.tooltip ? <Tooltip content={col.tooltip} position="top">{content}</Tooltip> : content}
|
{content}
|
||||||
</th>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Рендер ячейки
|
const renderCell = (col, item) => (col.render ? col.render(item[col.key], item) : item[col.key]);
|
||||||
const renderCell = (col, item) => {
|
|
||||||
const value = item[col.key];
|
const ActionIconButton = ({ children, label, onClick, variant = 'outline' }) => (
|
||||||
|
<Tooltip content={label} position="top">
|
||||||
if (col.render) {
|
<Button type="button" variant={variant} size="icon" className="h-8 w-8" onClick={onClick} aria-label={label}>
|
||||||
return col.render(value, item);
|
{children}
|
||||||
}
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
return value;
|
);
|
||||||
};
|
|
||||||
|
|
||||||
// Рендер действий
|
|
||||||
const renderActions = (item) => {
|
const renderActions = (item) => {
|
||||||
if (isEditing(item)) {
|
if (isEditing(item)) {
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex items-center justify-end gap-1">
|
||||||
{inlineEdit.renderEditor && inlineEdit.renderEditor(item)}
|
{inlineEdit.renderEditor && inlineEdit.renderEditor(item)}
|
||||||
<Tooltip content="Сохранить (Enter)">
|
<ActionIconButton label="Сохранить (Enter)" onClick={inlineEdit.onSave}>
|
||||||
<button
|
<IconCheck size={16} />
|
||||||
className="btn btn-success btn-icon btn-sm me-1"
|
</ActionIconButton>
|
||||||
onClick={inlineEdit.onSave}
|
<ActionIconButton label="Отмена (Esc)" onClick={inlineEdit.onCancel}>
|
||||||
aria-label="Сохранить"
|
<IconX size={16} />
|
||||||
>
|
</ActionIconButton>
|
||||||
<IconCheck size={16} />
|
</div>
|
||||||
</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 (
|
return (
|
||||||
<>
|
<div className="flex items-center justify-end gap-1">
|
||||||
{onEdit && (
|
{onEdit && <ActionIconButton label="Редактировать" onClick={() => onEdit(item)}><IconEdit size={16} /></ActionIconButton>}
|
||||||
<Tooltip content="Редактировать" position="top">
|
{onCopy && <ActionIconButton label="Копировать" onClick={() => onCopy(item)}><IconCopy size={16} /></ActionIconButton>}
|
||||||
<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) => (
|
{actions.map((action, idx) => (
|
||||||
<Tooltip key={idx} content={action.label} position="top">
|
<ActionIconButton key={idx} label={action.label} onClick={() => action.onClick(item)}>
|
||||||
<button
|
<action.icon size={16} />
|
||||||
className={`btn btn-outline-${action.variant || 'secondary'} btn-icon btn-sm me-1`}
|
</ActionIconButton>
|
||||||
onClick={() => action.onClick(item)}
|
|
||||||
aria-label={action.label}
|
|
||||||
>
|
|
||||||
<action.icon size={16} />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
))}
|
||||||
{onDelete && (
|
{onDelete && (
|
||||||
<Tooltip content="Удалить" position="top">
|
<ActionIconButton label="Удалить" onClick={() => onDelete(item)} variant="destructive">
|
||||||
<button
|
<IconTrash size={16} />
|
||||||
className="btn btn-outline-danger btn-icon btn-sm"
|
</ActionIconButton>
|
||||||
onClick={() => onDelete(item)}
|
|
||||||
aria-label="Удалить"
|
|
||||||
>
|
|
||||||
<IconTrash size={16} />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Загрузка
|
if (loading) return <TableSkeleton rows={skeletonRows} cols={columns.length + (hasActions ? 1 : 0)} hasCheckbox={selectable} />;
|
||||||
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
|
<EmptyState title={emptyState.title} description={emptyState.description} action={emptyState.action} secondaryAction={emptyState.secondaryAction} />
|
||||||
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="table-responsive">
|
<div className="overflow-x-auto rounded-md border">
|
||||||
<table className={tableClass} role="grid">
|
<Table className={`${compact ? 'text-sm' : ''} ${striped ? '[&_tbody_tr:nth-child(odd)]:bg-muted/30' : ''}`}>
|
||||||
<thead>
|
<TableHeader>
|
||||||
<tr>
|
<TableRow>
|
||||||
{selectable && (
|
{selectable && (
|
||||||
<th scope="col" style={{ width: '40px' }}>
|
<TableHead style={{ width: '40px' }}>
|
||||||
<input
|
<Checkbox checked={allSelected} onCheckedChange={(checked) => checked ? onSelectAll?.() : onDeselectAll?.()} />
|
||||||
type="checkbox"
|
</TableHead>
|
||||||
className="form-check-input"
|
|
||||||
checked={allSelected}
|
|
||||||
onChange={(e) => e.target.checked ? onSelectAll?.() : onDeselectAll?.()}
|
|
||||||
title="Выбрать все на странице"
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
)}
|
)}
|
||||||
{columns.map(renderColumnHeader)}
|
{columns.map((col) => (
|
||||||
{hasActions && <th scope="col" className="text-end td-actions">Действия</th>}
|
<TableHead key={col.key} style={col.width ? { width: col.width } : undefined}>
|
||||||
</tr>
|
{col.tooltip ? <Tooltip content={col.tooltip} position="top">{renderHeaderContent(col)}</Tooltip> : renderHeaderContent(col)}
|
||||||
</thead>
|
</TableHead>
|
||||||
<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 (
|
||||||
<tr
|
<TableRow key={id} data-state={isSelected ? 'selected' : undefined} className={editing ? 'bg-muted' : ''} style={{ animation: `fadeIn 0.3s ease ${idx * 0.02}s both` }}>
|
||||||
key={id}
|
|
||||||
className={editing ? 'table-active' : (isSelected ? 'table-selected' : '')}
|
|
||||||
style={{ animation: `fadeIn 0.3s ease ${idx * 0.02}s both` }}
|
|
||||||
>
|
|
||||||
{selectable && (
|
{selectable && (
|
||||||
<td>
|
<TableCell>
|
||||||
<input
|
<Checkbox checked={isSelected} onCheckedChange={() => onSelectItem?.(id)} onClick={(e) => e.stopPropagation()} />
|
||||||
type="checkbox"
|
</TableCell>
|
||||||
className="form-check-input"
|
|
||||||
checked={isSelected}
|
|
||||||
onChange={() => onSelectItem?.(id)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
)}
|
)}
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<td key={col.key} className={col.className}>
|
<TableCell key={col.key} className={col.className}>
|
||||||
{renderCell(col, item)}
|
{renderCell(col, item)}
|
||||||
</td>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
{hasActions && (
|
{hasActions && <TableCell>{renderActions(item)}</TableCell>}
|
||||||
<td className="text-end td-actions">
|
</TableRow>
|
||||||
{renderActions(item)}
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
</tr>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</TableBody>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pagination && (
|
{pagination && (
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={pagination.currentPage}
|
currentPage={pagination.currentPage}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { IconX } from '@tabler/icons-react';
|
import { IconX } from '@tabler/icons-react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -10,6 +11,7 @@ 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 (
|
||||||
@@ -62,13 +64,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"
|
||||||
data-bs-toggle="dropdown"
|
onClick={() => setOpenDropdown((v) => (v === idx ? null : idx))}
|
||||||
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">
|
<div className={`dropdown-menu dropdown-menu-dark${openDropdown === idx ? ' show' : ''}`}>
|
||||||
{action.items?.map((item, i) => (
|
{action.items?.map((item, i) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
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 - универсальное поле формы с валидацией
|
||||||
@@ -72,7 +82,7 @@ function FormField({
|
|||||||
};
|
};
|
||||||
}, [localValue, isDirty, onValidate, debounceMs]);
|
}, [localValue, isDirty, onValidate, debounceMs]);
|
||||||
|
|
||||||
const inputClasses = `form-control ${hasError ? 'is-invalid' : ''} ${hasSuccess ? 'is-valid' : ''} ${inputClassName}`;
|
const inputClasses = `${hasError ? 'border-destructive' : ''} ${hasSuccess ? 'border-green-600' : ''} ${inputClassName}`;
|
||||||
|
|
||||||
const handleChange = (e) => {
|
const handleChange = (e) => {
|
||||||
const newValue = e.target.value;
|
const newValue = e.target.value;
|
||||||
@@ -89,7 +99,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}
|
||||||
@@ -110,31 +120,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}
|
||||||
onChange={handleChange}
|
onValueChange={(newValue) => {
|
||||||
onBlur={handleBlur}
|
setLocalValue(newValue);
|
||||||
required={required}
|
setIsDirty(true);
|
||||||
|
onChange?.(newValue);
|
||||||
|
}}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
autoFocus={autoFocus}
|
|
||||||
aria-invalid={hasError}
|
|
||||||
aria-describedby={error ? `${inputId}-error` : helpText ? `${inputId}-help` : undefined}
|
|
||||||
{...inputProps}
|
|
||||||
>
|
>
|
||||||
{options?.map((option) => (
|
<SelectTrigger id={inputId} className={inputClasses}>
|
||||||
<option key={option.value} value={option.value} disabled={option.disabled}>
|
<SelectValue placeholder={placeholder} />
|
||||||
{option.label}
|
</SelectTrigger>
|
||||||
</option>
|
<SelectContent>
|
||||||
))}
|
{options?.map((option) => (
|
||||||
</select>
|
<SelectItem key={option.value} value={String(option.value)} disabled={option.disabled}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<input
|
<Input
|
||||||
id={inputId}
|
id={inputId}
|
||||||
name={name}
|
name={name}
|
||||||
type={type}
|
type={type}
|
||||||
@@ -203,29 +213,29 @@ function FormField({
|
|||||||
return (
|
return (
|
||||||
<div className={`mb-3 ${className}`}>
|
<div className={`mb-3 ${className}`}>
|
||||||
{label && (
|
{label && (
|
||||||
<label htmlFor={inputId} className="form-label">
|
<Label htmlFor={inputId}>
|
||||||
{label}
|
{label}
|
||||||
{required && <span className="text-danger ms-1" aria-label="обязательное поле">*</span>}
|
{required && <span className="text-destructive ml-1" aria-label="обязательное поле">*</span>}
|
||||||
</label>
|
</Label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{renderInputWithIcon()}
|
{renderInputWithIcon()}
|
||||||
|
|
||||||
{displayError && (
|
{displayError && (
|
||||||
<div id={`${inputId}-error`} className="invalid-feedback d-block" role="alert">
|
<div id={`${inputId}-error`} className="mt-1 text-sm text-destructive" role="alert">
|
||||||
{displayError}
|
{displayError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{displaySuccess && !displayError && (
|
{displaySuccess && !displayError && (
|
||||||
<div className="valid-feedback d-block">
|
<div className="mt-1 text-sm text-green-600">
|
||||||
{displaySuccess}
|
{displaySuccess}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{helpText && !displayError && !displaySuccess && (
|
{helpText && !displayError && !displaySuccess && (
|
||||||
<div id={`${inputId}-help`} className="form-text">
|
<div id={`${inputId}-help`} className="mt-1 flex items-center text-xs text-muted-foreground">
|
||||||
<IconAlertCircle size={14} className="me-1" />
|
<IconAlertCircle size={14} className="mr-1" />
|
||||||
{helpText}
|
{helpText}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
/**
|
/**
|
||||||
* FormModal - модальное окно с формой
|
* FormModal - модальное окно с формой
|
||||||
* Простой подход со встроенным backdrop (как в HistoryModal)
|
* Простой подход со встроенным backdrop (как в HistoryModal)
|
||||||
@@ -18,8 +26,6 @@ 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);
|
||||||
@@ -36,67 +42,34 @@ function FormModal({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBackdropClick = (e) => {
|
const sizeClass = size === 'sm' ? 'sm:max-w-md' : size === 'lg' ? 'sm:max-w-2xl' : size === 'xl' ? 'sm:max-w-4xl' : 'sm:max-w-lg';
|
||||||
if (e.target === e.currentTarget) {
|
|
||||||
onClose?.();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Dialog open={show} onOpenChange={(next) => !next && onClose?.()}>
|
||||||
{/* Modal Backdrop */}
|
<DialogContent className={sizeClass} onEscapeKeyDown={handleKeyDown}>
|
||||||
<div className="modal-backdrop show" onClick={handleBackdropClick} />
|
<form onSubmit={handleSubmit}>
|
||||||
|
<DialogHeader>
|
||||||
{/* Modal */}
|
<DialogTitle>{title}</DialogTitle>
|
||||||
<div
|
</DialogHeader>
|
||||||
className="modal show d-block"
|
<div className="py-4">{children}</div>
|
||||||
role="dialog"
|
<DialogFooter className={centered ? 'justify-center' : ''}>
|
||||||
aria-modal="true"
|
<Button type="button" variant="outline" onClick={onClose} disabled={loading}>
|
||||||
tabIndex={-1}
|
{CancelIcon && <CancelIcon className="mr-2 size-4" />}
|
||||||
onKeyDown={handleKeyDown}
|
{cancelLabel}
|
||||||
>
|
</Button>
|
||||||
<div
|
<Button
|
||||||
className={`modal-dialog${size !== 'md' ? ` modal-${size}` : ''}${centered ? ' modal-dialog-centered' : ''}`}
|
type="submit"
|
||||||
role="document"
|
variant={submitVariant === 'danger' ? 'destructive' : 'default'}
|
||||||
>
|
disabled={loading || disabled}
|
||||||
<div className="modal-content" tabIndex={-1}>
|
>
|
||||||
<form onSubmit={handleSubmit}>
|
{loading && <span className="mr-2 size-4 animate-spin rounded-full border-2 border-current border-t-transparent" />}
|
||||||
<div className="modal-header">
|
{!loading && SubmitIcon && <SubmitIcon className="mr-2 size-4" />}
|
||||||
<h5 className="modal-title">{title}</h5>
|
{submitLabel}
|
||||||
<button type="button" className="btn-close" onClick={onClose} aria-label="Закрыть" />
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
<div className="modal-body">
|
</DialogContent>
|
||||||
{children}
|
</Dialog>
|
||||||
</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>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Универсальный Modal компонент
|
* Универсальный Modal компонент
|
||||||
@@ -18,107 +26,35 @@ function Modal({
|
|||||||
centered = false,
|
centered = false,
|
||||||
className = ''
|
className = ''
|
||||||
}) {
|
}) {
|
||||||
const modalRef = useRef(null);
|
|
||||||
|
|
||||||
// Управление классом modal-open на body
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (show) {
|
if (show) onOpen?.();
|
||||||
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?.()}>
|
||||||
{/* Modal Backdrop */}
|
<DialogContent
|
||||||
<div className="modal-backdrop show" onClick={handleBackdropClick} />
|
className={`${sizeClass} ${className}`.trim()}
|
||||||
|
onEscapeKeyDown={(e) => {
|
||||||
{/* Modal */}
|
if (!keyboard) e.preventDefault();
|
||||||
<div
|
}}
|
||||||
className={`modal show d-block ${className}`}
|
onPointerDownOutside={(e) => {
|
||||||
role="dialog"
|
if (!backdrop) e.preventDefault();
|
||||||
aria-modal="true"
|
}}
|
||||||
tabIndex={-1}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Escape') onClose?.(); }}
|
|
||||||
>
|
>
|
||||||
<div
|
{title && (
|
||||||
className={`modal-dialog${size !== 'md' ? ` modal-${size}` : ''}${scrollable ? ' modal-dialog-scrollable' : ''}${centered ? ' modal-dialog-centered' : ''}`}
|
<DialogHeader>
|
||||||
role="document"
|
<DialogTitle>{title}</DialogTitle>
|
||||||
>
|
</DialogHeader>
|
||||||
<div
|
)}
|
||||||
className="modal-content"
|
<DialogDescription asChild>
|
||||||
ref={modalRef}
|
<div className={scrollable ? 'max-h-[70vh] overflow-y-auto' : ''}>
|
||||||
tabIndex={-1}
|
{children}
|
||||||
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>
|
||||||
</div>
|
</DialogDescription>
|
||||||
</div>
|
{footer && <DialogFooter className={centered ? 'justify-center' : ''}>{footer}</DialogFooter>}
|
||||||
</>
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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, useState } from 'react';
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef } from 'react';
|
||||||
import { IconCheck, IconAlertTriangle, IconInfoCircle, IconX } from '@tabler/icons-react';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
const NotifyContext = createContext({
|
const NotifyContext = createContext({
|
||||||
add: () => {},
|
add: () => {},
|
||||||
@@ -20,45 +20,36 @@ const DEFAULT_TIMEOUT = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function NotifyProvider({ children }) {
|
export function NotifyProvider({ children }) {
|
||||||
const [items, setItems] = useState([]); // { id, type, message, count, createdAt }
|
const items = useRef(new Map());
|
||||||
const timeouts = useRef(new Map());
|
|
||||||
const idSeq = useRef(1);
|
const idSeq = useRef(1);
|
||||||
|
|
||||||
const remove = useCallback((id) => {
|
const remove = useCallback((id) => {
|
||||||
setItems((prev) => prev.filter((n) => n.id !== id));
|
const toastId = items.current.get(id);
|
||||||
const t = timeouts.current.get(id);
|
if (toastId) {
|
||||||
if (t) { clearTimeout(t); timeouts.current.delete(id); }
|
toast.dismiss(toastId);
|
||||||
|
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;
|
||||||
setItems((prev) => {
|
const id = idSeq.current++;
|
||||||
const dup = prev.find((n) => n.type === type && n.message === text);
|
const dur = DEFAULT_TIMEOUT[type] ?? 4000;
|
||||||
if (dup) {
|
const description = details
|
||||||
const updated = prev.map((n) => n.id === dup.id ? { ...n, count: (n.count || 1) + 1, createdAt: Date.now(), details: details ?? n.details } : n);
|
? (typeof details === 'string' ? details : JSON.stringify(details, null, 2))
|
||||||
schedule(dup.id, type);
|
: undefined;
|
||||||
return updated;
|
const toastId = toast[type === 'warning' ? 'warning' : type](text, {
|
||||||
}
|
duration: dur,
|
||||||
const id = idSeq.current++;
|
description,
|
||||||
const next = [...prev, { id, type, message: text, details, count: 1, createdAt: Date.now() }];
|
|
||||||
schedule(id, type);
|
|
||||||
return next;
|
|
||||||
});
|
});
|
||||||
}, [schedule]);
|
items.current.set(id, toastId);
|
||||||
|
return id;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const clear = useCallback(() => {
|
const clear = useCallback(() => {
|
||||||
setItems([]);
|
for (const toastId of items.current.values()) toast.dismiss(toastId);
|
||||||
for (const t of timeouts.current.values()) clearTimeout(t);
|
items.current.clear();
|
||||||
timeouts.current.clear();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const api = useMemo(() => ({
|
const api = useMemo(() => ({
|
||||||
@@ -80,7 +71,6 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -102,72 +92,3 @@ 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
data-bs-toggle="dropdown"
|
onClick={() => setShowList(v => !v)}
|
||||||
aria-expanded="false"
|
aria-expanded={showList}
|
||||||
>
|
>
|
||||||
<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" style={{ minWidth: '300px' }}>
|
<div className={`dropdown-menu dropdown-menu-end${showList ? ' show' : ''}`} style={{ minWidth: '300px' }}>
|
||||||
<div className="dropdown-header">Сохраненные фильтры</div>
|
<div className="dropdown-header">Сохраненные фильтры</div>
|
||||||
|
|
||||||
{/* Список сохраненных фильтров */}
|
{/* Список сохраненных фильтров */}
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
/* eslint-disable react-refresh/only-export-components */
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import { useState, useEffect, createContext, useContext, useCallback, useMemo } from 'react'
|
import { createContext, useContext, useEffect, useMemo } from 'react'
|
||||||
import {
|
import { toast } from 'sonner'
|
||||||
IconCheck,
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
IconX,
|
|
||||||
IconAlertTriangle,
|
|
||||||
IconInfoCircle
|
|
||||||
} from '@tabler/icons-react'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Контекст для глобального управления toast уведомлениями.
|
* Контекст для глобального управления toast уведомлениями.
|
||||||
@@ -20,213 +16,25 @@ const ToastContext = createContext({
|
|||||||
|
|
||||||
export const useToast = () => useContext(ToastContext)
|
export const useToast = () => useContext(ToastContext)
|
||||||
|
|
||||||
const DEFAULT_TITLES = {
|
|
||||||
success: 'Выполнено успешно',
|
|
||||||
error: 'Произошла ошибка',
|
|
||||||
warning: 'Внимание',
|
|
||||||
info: 'Информация',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Один 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(() => {
|
|
||||||
if (toast.duration && toast.duration > 0) {
|
|
||||||
const timer = setTimeout(() => handleClose(), toast.duration)
|
|
||||||
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 }) {
|
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(() => ({
|
const contextValue = useMemo(() => ({
|
||||||
success: (message, options) => addToast('success', message, options),
|
success: (message, options) => toast.success(message, options),
|
||||||
error: (message, options) => addToast('error', message, options),
|
error: (message, options) => toast.error(message, options),
|
||||||
warning: (message, options) => addToast('warning', message, options),
|
warning: (message, options) => toast.warning(message, options),
|
||||||
info: (message, options) => addToast('info', message, options),
|
info: (message, options) => toast.info(message, options),
|
||||||
}), [addToast])
|
}), [])
|
||||||
|
|
||||||
// Глобальный доступ через window.toast (для обратной совместимости)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.toast = contextValue
|
window.toast = contextValue
|
||||||
|
return () => {
|
||||||
|
if (window.toast === contextValue) delete window.toast
|
||||||
|
}
|
||||||
}, [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,4 +1,3 @@
|
|||||||
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';
|
||||||
|
|
||||||
@@ -6,24 +5,7 @@ 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 }) {
|
||||||
const modalRef = useRef(null);
|
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 handleChange = (field, value) => {
|
const handleChange = (field, value) => {
|
||||||
onNewServerChange({ ...newServer, [field]: value });
|
onNewServerChange({ ...newServer, [field]: value });
|
||||||
@@ -35,7 +17,9 @@ 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">
|
||||||
@@ -43,7 +27,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" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" className="btn-close" onClick={onClose} aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-body">
|
<div className="modal-body">
|
||||||
{error && (
|
{error && (
|
||||||
@@ -165,7 +149,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" data-bs-dismiss="modal">
|
<button type="button" className="btn btn-secondary d-flex align-items-center" onClick={onClose}>
|
||||||
<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}>
|
||||||
@@ -175,7 +159,8 @@ function AddServerModal({ show, newServer, customProvider, onNewServerChange, on
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,45 +1,29 @@
|
|||||||
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 }) {
|
||||||
const modalRef = useRef(null);
|
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]);
|
|
||||||
|
|
||||||
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" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" className="btn-close" onClick={onClose} 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" data-bs-dismiss="modal">
|
<button type="button" className="btn btn-secondary d-flex align-items-center" onClick={onClose}>
|
||||||
<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}>
|
||||||
@@ -48,7 +32,8 @@ function DeleteServerModal({ show, server, onDelete, onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ 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({});
|
||||||
|
|
||||||
@@ -39,22 +38,7 @@ function EditServerModal({ show, server, onChange, onSave, onClose }) {
|
|||||||
}
|
}
|
||||||
}, [show, server]);
|
}, [show, server]);
|
||||||
|
|
||||||
useEffect(() => {
|
if (!show) return null;
|
||||||
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 => {
|
||||||
@@ -121,12 +105,14 @@ 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" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" className="btn-close" onClick={onClose} 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); }}>
|
||||||
@@ -249,7 +235,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" data-bs-dismiss="modal">
|
<button type="button" className="btn btn-secondary d-flex align-items-center" onClick={onClose}>
|
||||||
<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)}>
|
||||||
@@ -258,7 +244,8 @@ function EditServerModal({ show, server, onChange, onSave, onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +1,17 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect } 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]);
|
||||||
|
|
||||||
useEffect(() => {
|
if (!show) return null;
|
||||||
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 };
|
||||||
@@ -43,7 +27,9 @@ 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">
|
||||||
@@ -51,7 +37,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" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" className="btn-close" onClick={onClose} aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-body">
|
<div className="modal-body">
|
||||||
<div className="row g-2">
|
<div className="row g-2">
|
||||||
@@ -165,7 +151,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" data-bs-dismiss="modal">
|
<button type="button" className="btn btn-secondary d-flex align-items-center" onClick={onClose}>
|
||||||
<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}>
|
||||||
@@ -174,7 +160,8 @@ function LinkGeneratorModal({ show, server, urlSettings, onUrlSettingsChange, on
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ 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('');
|
||||||
@@ -114,25 +113,7 @@ 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]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обработчик изменения поля
|
* Обработчик изменения поля
|
||||||
@@ -196,7 +177,9 @@ 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">
|
||||||
@@ -213,7 +196,7 @@ function ServerModal({ show, mode = 'add', server, onSave, onClose, error: exter
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</h5>
|
</h5>
|
||||||
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" className="btn-close" onClick={onClose} aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
@@ -512,7 +495,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" data-bs-dismiss="modal">
|
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||||
Отмена
|
Отмена
|
||||||
</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">
|
||||||
@@ -532,7 +515,8 @@ function ServerModal({ show, mode = 'add', server, onSave, onClose, error: exter
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
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 }
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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 }
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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 }
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
"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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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 }
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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 }
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
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 }
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,721 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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 }
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"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 }
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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 }
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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 }
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
+31
-13
@@ -1,3 +1,10 @@
|
|||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.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 */
|
||||||
@@ -18,20 +25,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;
|
||||||
@@ -39,7 +46,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);
|
||||||
@@ -176,14 +183,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;
|
||||||
@@ -447,12 +454,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;
|
||||||
@@ -465,19 +472,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; }
|
||||||
@@ -491,4 +498,15 @@ 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; } }
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -4,11 +4,6 @@ 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 />
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import tailwindcssAnimate from "tailwindcss-animate"
|
||||||
|
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
darkMode: ["class"],
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,jsx,ts,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [tailwindcssAnimate],
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,9 +1,18 @@
|
|||||||
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,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user