@pattern-stack/frontend-patterns 0.0.3 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.es.js +1 -1
- package/dist/index.js +1 -0
- package/package.json +5 -3
- package/src/App.css +42 -0
- package/src/App.tsx +54 -0
- package/src/__tests__/README.md +221 -0
- package/src/__tests__/atoms/hooks/simple-hooks.test.ts +44 -0
- package/src/__tests__/atoms/ui/button.test.tsx +68 -0
- package/src/__tests__/atoms/utils/simple.test.ts +18 -0
- package/src/__tests__/atoms/utils/utils.test.ts +77 -0
- package/src/__tests__/features/auth/simple-auth.test.tsx +40 -0
- package/src/__tests__/molecules/layout/simple-layout.test.tsx +81 -0
- package/src/__tests__/organisms/showcase/simple-showcase.test.tsx +167 -0
- package/src/__tests__/setup.ts +51 -0
- package/src/__tests__/utils.tsx +123 -0
- package/src/atoms/composed/Accordion/Accordion.tsx +271 -0
- package/src/atoms/composed/Accordion/index.ts +1 -0
- package/src/atoms/composed/Alert/Alert.tsx +132 -0
- package/src/atoms/composed/Alert/index.ts +1 -0
- package/src/atoms/composed/Breadcrumb/Breadcrumb.tsx +83 -0
- package/src/atoms/composed/Breadcrumb/index.ts +1 -0
- package/src/atoms/composed/Chart/Chart.tsx +425 -0
- package/src/atoms/composed/Chart/index.ts +2 -0
- package/src/atoms/composed/ColorSwatch/ColorSwatch.tsx +72 -0
- package/src/atoms/composed/ColorSwatch/index.ts +1 -0
- package/src/atoms/composed/DarkModeToggle.tsx +66 -0
- package/src/atoms/composed/DataBadge/DataBadge.tsx +81 -0
- package/src/atoms/composed/DataBadge/index.ts +1 -0
- package/src/atoms/composed/DataTable/DataTable.tsx +394 -0
- package/src/atoms/composed/DataTable/TableCellWithTooltip.tsx +41 -0
- package/src/atoms/composed/DataTable/index.ts +2 -0
- package/src/atoms/composed/DateTimePicker/DateTimePicker.tsx +611 -0
- package/src/atoms/composed/DateTimePicker/index.ts +2 -0
- package/src/atoms/composed/DetailedCard/DetailedCard.tsx +181 -0
- package/src/atoms/composed/DetailedCard/index.ts +2 -0
- package/src/atoms/composed/EmptyState/EmptyState.tsx +90 -0
- package/src/atoms/composed/EmptyState/index.ts +1 -0
- package/src/atoms/composed/FileUpload/FileUpload.tsx +477 -0
- package/src/atoms/composed/FileUpload/index.ts +2 -0
- package/src/atoms/composed/FormField/FormField.tsx +92 -0
- package/src/atoms/composed/FormField/index.ts +1 -0
- package/src/atoms/composed/GlobalSearch/GlobalSearch.tsx +37 -0
- package/src/atoms/composed/GlobalSearch/index.ts +1 -0
- package/src/atoms/composed/IconBadge/IconBadge.tsx +95 -0
- package/src/atoms/composed/IconBadge/index.ts +2 -0
- package/src/atoms/composed/Modal/Modal.tsx +223 -0
- package/src/atoms/composed/Modal/index.ts +2 -0
- package/src/atoms/composed/PaletteSwitcher.tsx +386 -0
- package/src/atoms/composed/ProgressBar/ProgressBar.tsx +116 -0
- package/src/atoms/composed/ProgressBar/index.ts +1 -0
- package/src/atoms/composed/StatCard/StatCard.tsx +219 -0
- package/src/atoms/composed/StatCard/index.ts +1 -0
- package/src/atoms/composed/StyleGuide.tsx +717 -0
- package/src/atoms/composed/Toast/Toast.tsx +219 -0
- package/src/atoms/composed/Toast/index.ts +1 -0
- package/src/atoms/composed/Tooltip/Tooltip.tsx +213 -0
- package/src/atoms/composed/Tooltip/index.ts +1 -0
- package/src/atoms/composed/UserAvatar/UserAvatar.tsx +139 -0
- package/src/atoms/composed/UserAvatar/index.ts +1 -0
- package/src/atoms/composed/UserMenu/UserMenu.tsx +16 -0
- package/src/atoms/composed/UserMenu/index.ts +1 -0
- package/src/atoms/composed/index.ts +29 -0
- package/src/atoms/hooks/useApi.ts +80 -0
- package/src/atoms/hooks/useHealth.ts +17 -0
- package/src/atoms/index.ts +13 -0
- package/src/atoms/services/api/client.ts +134 -0
- package/src/atoms/services/auth-service.ts +248 -0
- package/src/atoms/services/health.ts +15 -0
- package/src/atoms/services/index.ts +3 -0
- package/src/atoms/shared/config/constants.ts +17 -0
- package/src/atoms/shared/config/dashboard-sizes.ts +111 -0
- package/src/atoms/shared/config/environment.ts +10 -0
- package/src/atoms/shared/index.ts +4 -0
- package/src/atoms/shared/styles/color-palettes.css +566 -0
- package/src/atoms/types/auth.ts +62 -0
- package/src/atoms/types/generated.ts +1469 -0
- package/src/atoms/types/index.ts +4 -0
- package/src/atoms/types/loading.ts +28 -0
- package/src/atoms/ui/Badge.tsx +30 -0
- package/src/atoms/ui/ErrorBoundary.tsx +59 -0
- package/src/atoms/ui/Select.tsx +53 -0
- package/src/atoms/ui/Switch.tsx +42 -0
- package/src/atoms/ui/Tabs.tsx +118 -0
- package/src/atoms/ui/avatar.tsx +48 -0
- package/src/atoms/ui/button.tsx +70 -0
- package/src/atoms/ui/card.tsx +76 -0
- package/src/atoms/ui/dropdown-menu.tsx +199 -0
- package/src/atoms/ui/index.ts +39 -0
- package/src/atoms/ui/input.tsx +23 -0
- package/src/atoms/ui/label.tsx +23 -0
- package/src/atoms/ui/skeleton.tsx +13 -0
- package/src/atoms/ui/spinner.tsx +49 -0
- package/src/atoms/ui/table.tsx +116 -0
- package/src/atoms/utils/animations.ts +135 -0
- package/src/atoms/utils/tooltip-helpers.ts +140 -0
- package/src/atoms/utils/utils.ts +9 -0
- package/src/features/auth/components/LoginForm.tsx +168 -0
- package/src/features/auth/components/LogoutButton.tsx +19 -0
- package/src/features/auth/components/ProtectedRoute.tsx +60 -0
- package/src/features/auth/components/index.ts +4 -0
- package/src/features/auth/hooks/index.ts +2 -0
- package/src/features/auth/hooks/useAuth.tsx +205 -0
- package/src/features/auth/hooks/usePermissions.ts +35 -0
- package/src/features/auth/index.ts +2 -0
- package/src/features/index.ts +2 -0
- package/src/index.css +704 -0
- package/src/index.ts +13 -0
- package/src/main.tsx +48 -0
- package/src/molecules/.gitkeep +0 -0
- package/src/molecules/forms/FormGroup.tsx +75 -0
- package/src/molecules/forms/SearchInput.tsx +259 -0
- package/src/molecules/forms/index.ts +4 -0
- package/src/molecules/index.ts +4 -0
- package/src/molecules/layout/AppHeader/AppHeader.tsx +42 -0
- package/src/molecules/layout/AppHeader/index.ts +1 -0
- package/src/molecules/layout/AppLayout.tsx +29 -0
- package/src/molecules/layout/PageTemplate.tsx +87 -0
- package/src/molecules/layout/SectionHeader/SectionHeader.tsx +87 -0
- package/src/molecules/layout/SectionHeader/index.ts +1 -0
- package/src/molecules/layout/ShowcaseSection.tsx +57 -0
- package/src/molecules/layout/Sidebar.tsx +144 -0
- package/src/molecules/layout/SidebarButton/SidebarButton.tsx +99 -0
- package/src/molecules/layout/SidebarButton/index.ts +1 -0
- package/src/molecules/layout/SidebarContext.tsx +31 -0
- package/src/molecules/layout/index.ts +7 -0
- package/src/molecules/navigation/NavMenu.tsx +188 -0
- package/src/molecules/navigation/Pagination.tsx +172 -0
- package/src/molecules/navigation/index.ts +4 -0
- package/src/organisms/index.ts +5 -0
- package/src/organisms/showcase/ComponentShowcasePage.tsx +2496 -0
- package/src/organisms/showcase/index.ts +1 -0
- package/src/pages/AdminShowcase/AdminCRUDShowcase.tsx +242 -0
- package/src/pages/AdminShowcase/AdminDashboardShowcase.tsx +171 -0
- package/src/pages/AdminShowcase/AdminDetailShowcase.tsx +385 -0
- package/src/pages/AdminShowcase/index.tsx +3 -0
- package/src/pages/ComponentShowcase/BadgesShowcase.tsx +188 -0
- package/src/pages/ComponentShowcase/CardsShowcase.tsx +392 -0
- package/src/pages/ComponentShowcase/PalettesShowcase.tsx +207 -0
- package/src/pages/ComponentShowcase/StatesShowcase.tsx +485 -0
- package/src/pages/ComponentShowcase/TablesShowcase.tsx +134 -0
- package/src/pages/ComponentShowcase/TypographyShowcase.tsx +255 -0
- package/src/pages/ComponentShowcase/index.tsx +188 -0
- package/src/pages/index.ts +2 -0
- package/src/templates/AuthTemplate.tsx +216 -0
- package/src/templates/ComponentShowcaseTemplate.tsx +173 -0
- package/src/templates/DashboardTemplate.tsx +232 -0
- package/src/templates/DataTemplate.tsx +319 -0
- package/src/templates/admin/AdminCRUDTemplate.tsx +630 -0
- package/src/templates/admin/AdminDashboardTemplate.tsx +351 -0
- package/src/templates/admin/AdminDetailTemplate.tsx +563 -0
- package/src/templates/admin/index.ts +29 -0
- package/src/templates/factory.tsx +169 -0
- package/src/templates/index.ts +37 -0
- package/src/vite-env.d.ts +1 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { DataBadge } from '../../atoms/composed/DataBadge';
|
|
3
|
+
import { cn } from '../../atoms/utils/utils';
|
|
4
|
+
|
|
5
|
+
interface ShowcaseSectionProps {
|
|
6
|
+
/** Section title */
|
|
7
|
+
title: string;
|
|
8
|
+
/** Section description */
|
|
9
|
+
description: string;
|
|
10
|
+
/** Badge configuration */
|
|
11
|
+
badge: {
|
|
12
|
+
text: string;
|
|
13
|
+
variant?: 'status' | 'category';
|
|
14
|
+
status?: 'success' | 'warning' | 'error' | 'info' | 'neutral';
|
|
15
|
+
category?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
|
|
16
|
+
size?: 'sm' | 'md' | 'lg';
|
|
17
|
+
};
|
|
18
|
+
/** Section content */
|
|
19
|
+
children: React.ReactNode;
|
|
20
|
+
/** Additional CSS classes */
|
|
21
|
+
className?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const ShowcaseSection: React.FC<ShowcaseSectionProps> = ({
|
|
25
|
+
title,
|
|
26
|
+
description,
|
|
27
|
+
badge,
|
|
28
|
+
children,
|
|
29
|
+
className
|
|
30
|
+
}) => {
|
|
31
|
+
return (
|
|
32
|
+
<div className={cn('space-y-6', className)} data-component-name="ShowcaseSection">
|
|
33
|
+
{/* Title with inline badge */}
|
|
34
|
+
<div className="space-y-4">
|
|
35
|
+
<h3 className="text-lg font-medium flex items-center gap-2">
|
|
36
|
+
{title}
|
|
37
|
+
<DataBadge
|
|
38
|
+
variant={badge.variant || 'status'}
|
|
39
|
+
status={badge.status || 'neutral'}
|
|
40
|
+
category={badge.category}
|
|
41
|
+
size={badge.size || 'sm'}
|
|
42
|
+
>
|
|
43
|
+
{badge.text}
|
|
44
|
+
</DataBadge>
|
|
45
|
+
</h3>
|
|
46
|
+
<p className="text-sm text-muted-foreground">
|
|
47
|
+
{description}
|
|
48
|
+
</p>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
{/* Content */}
|
|
52
|
+
<div>
|
|
53
|
+
{children}
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
|
3
|
+
import { cn } from '../../atoms/utils/utils';
|
|
4
|
+
import { useSidebar } from './SidebarContext';
|
|
5
|
+
import { SidebarButton } from './SidebarButton';
|
|
6
|
+
import {
|
|
7
|
+
Palette,
|
|
8
|
+
Menu,
|
|
9
|
+
X,
|
|
10
|
+
Shield,
|
|
11
|
+
Users
|
|
12
|
+
} from 'lucide-react';
|
|
13
|
+
|
|
14
|
+
interface SidebarItem {
|
|
15
|
+
value: string;
|
|
16
|
+
label: string;
|
|
17
|
+
icon: React.ReactNode;
|
|
18
|
+
path: string;
|
|
19
|
+
category?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface SidebarProps {
|
|
23
|
+
className?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const Sidebar = ({ className }: SidebarProps) => {
|
|
27
|
+
const { isExpanded, toggleSidebar } = useSidebar();
|
|
28
|
+
const location = useLocation();
|
|
29
|
+
const navigate = useNavigate();
|
|
30
|
+
const [searchParams] = useSearchParams();
|
|
31
|
+
|
|
32
|
+
const items: SidebarItem[] = [
|
|
33
|
+
{ value: 'showcase', label: 'Showcase', icon: <Palette className="w-5 h-5" />, path: '/showcase', category: 5 },
|
|
34
|
+
{ value: 'admin-dashboard', label: 'Admin Dashboard', icon: <Shield className="w-5 h-5" />, path: '/admin/dashboard', category: 2 },
|
|
35
|
+
{ value: 'admin-users', label: 'User Management', icon: <Users className="w-5 h-5" />, path: '/admin/users', category: 3 }
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const handleNavigation = (path: string) => {
|
|
39
|
+
if (path.includes('?')) {
|
|
40
|
+
// Handle query parameters by parsing the URL
|
|
41
|
+
const [basePath, query] = path.split('?');
|
|
42
|
+
const searchParams = new URLSearchParams(query);
|
|
43
|
+
navigate({ pathname: basePath, search: searchParams.toString() });
|
|
44
|
+
} else {
|
|
45
|
+
navigate(path);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<aside
|
|
51
|
+
className={cn(
|
|
52
|
+
"fixed left-0 top-16 flex flex-col bg-background border-r border-border z-40",
|
|
53
|
+
"transition-all duration-300 ease-in-out",
|
|
54
|
+
"h-[calc(100vh-4rem)]", // Full height minus header (4rem = 64px)
|
|
55
|
+
!isExpanded ? "w-16" : "w-64",
|
|
56
|
+
className
|
|
57
|
+
)}
|
|
58
|
+
data-component-name="Sidebar"
|
|
59
|
+
data-collapsed={!isExpanded}
|
|
60
|
+
>
|
|
61
|
+
{/* Toggle Button */}
|
|
62
|
+
<div className="flex justify-center p-3 pt-4">
|
|
63
|
+
<button
|
|
64
|
+
onClick={toggleSidebar}
|
|
65
|
+
className={cn(
|
|
66
|
+
"w-7 h-7 rounded-md",
|
|
67
|
+
"bg-muted/50 text-muted-foreground",
|
|
68
|
+
"flex items-center justify-center",
|
|
69
|
+
"hover:bg-muted hover:text-foreground",
|
|
70
|
+
"active:scale-95",
|
|
71
|
+
"transition-all duration-150 ease-out"
|
|
72
|
+
)}
|
|
73
|
+
data-component-name="SidebarToggle"
|
|
74
|
+
title={!isExpanded ? "Expand sidebar" : "Collapse sidebar"}
|
|
75
|
+
>
|
|
76
|
+
{!isExpanded ? <Menu className="w-3.5 h-3.5" /> : <X className="w-3.5 h-3.5" />}
|
|
77
|
+
</button>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
{/* Navigation Items */}
|
|
82
|
+
<nav className="flex-1 p-3 space-y-2" data-component-name="SidebarNav">
|
|
83
|
+
{items.map((item) => {
|
|
84
|
+
// Check if item path has query parameters
|
|
85
|
+
const [itemBasePath, itemQuery] = item.path.split('?');
|
|
86
|
+
const currentPath = location.pathname;
|
|
87
|
+
|
|
88
|
+
let isActive;
|
|
89
|
+
if (itemQuery) {
|
|
90
|
+
// For items with query parameters, check both path and query
|
|
91
|
+
const itemParams = new URLSearchParams(itemQuery);
|
|
92
|
+
const tabParam = itemParams.get('tab');
|
|
93
|
+
const currentTab = searchParams.get('tab');
|
|
94
|
+
isActive = currentPath === itemBasePath && currentTab === tabParam;
|
|
95
|
+
} else {
|
|
96
|
+
// For simple paths, check exact match
|
|
97
|
+
isActive = currentPath === item.path;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<SidebarButton
|
|
102
|
+
key={item.value}
|
|
103
|
+
icon={item.icon}
|
|
104
|
+
label={item.label}
|
|
105
|
+
active={isActive}
|
|
106
|
+
category={item.category}
|
|
107
|
+
expanded={isExpanded}
|
|
108
|
+
onClick={() => handleNavigation(item.path)}
|
|
109
|
+
/>
|
|
110
|
+
);
|
|
111
|
+
})}
|
|
112
|
+
</nav>
|
|
113
|
+
|
|
114
|
+
{/* Footer */}
|
|
115
|
+
<div
|
|
116
|
+
className={cn(
|
|
117
|
+
"p-4 border-t border-border",
|
|
118
|
+
!isExpanded && "px-3"
|
|
119
|
+
)}
|
|
120
|
+
data-component-name="SidebarFooter"
|
|
121
|
+
>
|
|
122
|
+
<div className="relative">
|
|
123
|
+
{/* Collapsed footer */}
|
|
124
|
+
<div className={cn(
|
|
125
|
+
"text-xs text-muted-foreground text-center",
|
|
126
|
+
"absolute inset-0 transition-opacity duration-200 ease-out",
|
|
127
|
+
!isExpanded ? "opacity-100" : "opacity-0"
|
|
128
|
+
)}>
|
|
129
|
+
v1.0
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
{/* Expanded footer */}
|
|
133
|
+
<div className={cn(
|
|
134
|
+
"text-xs text-muted-foreground whitespace-nowrap",
|
|
135
|
+
"transition-opacity duration-200 ease-out",
|
|
136
|
+
isExpanded ? "opacity-100" : "opacity-0"
|
|
137
|
+
)}>
|
|
138
|
+
AloeVera v1.0
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
</aside>
|
|
143
|
+
);
|
|
144
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { cn } from '../../../atoms/utils/utils';
|
|
3
|
+
import { Button } from '../../../atoms/ui/button';
|
|
4
|
+
|
|
5
|
+
interface SidebarButtonProps {
|
|
6
|
+
icon: React.ReactNode;
|
|
7
|
+
label: string;
|
|
8
|
+
active?: boolean;
|
|
9
|
+
category?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
|
|
10
|
+
expanded?: boolean;
|
|
11
|
+
onClick?: () => void;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const SidebarButton: React.FC<SidebarButtonProps> = ({
|
|
16
|
+
icon,
|
|
17
|
+
label,
|
|
18
|
+
active = false,
|
|
19
|
+
category = 1,
|
|
20
|
+
expanded = false,
|
|
21
|
+
onClick,
|
|
22
|
+
className
|
|
23
|
+
}) => {
|
|
24
|
+
return (
|
|
25
|
+
<Button
|
|
26
|
+
variant={active ? "secondary" : "ghost"}
|
|
27
|
+
onClick={onClick}
|
|
28
|
+
tooltip={!expanded ? label : undefined}
|
|
29
|
+
className={cn(
|
|
30
|
+
"relative w-full justify-start gap-3 h-12",
|
|
31
|
+
"transition-all duration-200 ease-in-out",
|
|
32
|
+
|
|
33
|
+
// Active state with category colors
|
|
34
|
+
active && [
|
|
35
|
+
`bg-category-${category}/15 hover:bg-category-${category}/25`,
|
|
36
|
+
`border-category-${category}/30`,
|
|
37
|
+
`text-category-${category}`,
|
|
38
|
+
"shadow-sm"
|
|
39
|
+
],
|
|
40
|
+
|
|
41
|
+
// Non-active hover effects
|
|
42
|
+
!active && [
|
|
43
|
+
"hover:bg-muted/80",
|
|
44
|
+
`hover:text-category-${category}`,
|
|
45
|
+
"hover:shadow-sm"
|
|
46
|
+
],
|
|
47
|
+
|
|
48
|
+
// Collapsed state - ensure proper centering
|
|
49
|
+
!expanded && "justify-center px-2",
|
|
50
|
+
expanded && "px-3",
|
|
51
|
+
|
|
52
|
+
className
|
|
53
|
+
)}
|
|
54
|
+
>
|
|
55
|
+
{/* Icon Container - Always square */}
|
|
56
|
+
<div className={cn(
|
|
57
|
+
"flex items-center justify-center w-6 h-6 flex-shrink-0",
|
|
58
|
+
"transition-colors duration-200",
|
|
59
|
+
active ? `text-category-${category}` : "text-muted-foreground group-hover:text-foreground"
|
|
60
|
+
)}>
|
|
61
|
+
{React.isValidElement(icon)
|
|
62
|
+
? React.cloneElement(icon as React.ReactElement<{ className?: string }>, { className: "w-5 h-5" })
|
|
63
|
+
: icon
|
|
64
|
+
}
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
{/* Label - only show when expanded */}
|
|
68
|
+
{expanded && (
|
|
69
|
+
<>
|
|
70
|
+
<span className={cn(
|
|
71
|
+
"text-sm font-medium flex-1 text-left",
|
|
72
|
+
active ? `text-category-${category}` : "text-foreground"
|
|
73
|
+
)}>
|
|
74
|
+
{label}
|
|
75
|
+
</span>
|
|
76
|
+
|
|
77
|
+
{/* Active indicator dot */}
|
|
78
|
+
{active && (
|
|
79
|
+
<div className={cn(
|
|
80
|
+
"w-2 h-2 rounded-full flex-shrink-0",
|
|
81
|
+
`bg-category-${category}`
|
|
82
|
+
)} />
|
|
83
|
+
)}
|
|
84
|
+
</>
|
|
85
|
+
)}
|
|
86
|
+
|
|
87
|
+
{/* Collapsed active indicator */}
|
|
88
|
+
{!expanded && active && (
|
|
89
|
+
<div className="absolute -top-1 -right-1">
|
|
90
|
+
<div className={cn(
|
|
91
|
+
"w-2.5 h-2.5 rounded-full",
|
|
92
|
+
`bg-category-${category}`,
|
|
93
|
+
"ring-2 ring-background"
|
|
94
|
+
)} />
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
</Button>
|
|
98
|
+
);
|
|
99
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { SidebarButton } from './SidebarButton';
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import React, { createContext, useContext, useState, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
interface SidebarContextType {
|
|
4
|
+
isExpanded: boolean;
|
|
5
|
+
setIsExpanded: (expanded: boolean) => void;
|
|
6
|
+
toggleSidebar: () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
|
|
10
|
+
|
|
11
|
+
export const SidebarProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
|
12
|
+
const [isExpanded, setIsExpanded] = useState(false);
|
|
13
|
+
|
|
14
|
+
const toggleSidebar = () => {
|
|
15
|
+
setIsExpanded(prev => !prev);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<SidebarContext.Provider value={{ isExpanded, setIsExpanded, toggleSidebar }}>
|
|
20
|
+
{children}
|
|
21
|
+
</SidebarContext.Provider>
|
|
22
|
+
);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const useSidebar = () => {
|
|
26
|
+
const context = useContext(SidebarContext);
|
|
27
|
+
if (!context) {
|
|
28
|
+
throw new Error('useSidebar must be used within a SidebarProvider');
|
|
29
|
+
}
|
|
30
|
+
return context;
|
|
31
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { PageTemplate, type PageTemplateProps } from './PageTemplate';
|
|
2
|
+
export { ShowcaseSection } from './ShowcaseSection';
|
|
3
|
+
export { AppLayout } from './AppLayout';
|
|
4
|
+
export { SectionHeader } from './SectionHeader';
|
|
5
|
+
export { SidebarButton } from './SidebarButton';
|
|
6
|
+
export { AppHeader } from './AppHeader';
|
|
7
|
+
export { SidebarProvider, useSidebar } from './SidebarContext';
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import React, { useState, useRef, useEffect } from 'react';
|
|
2
|
+
import { ChevronDown, ChevronRight } from 'lucide-react';
|
|
3
|
+
import { cn } from '../../atoms/utils/utils';
|
|
4
|
+
import { getAnimationClasses, animationPresets } from '../../atoms/utils/animations';
|
|
5
|
+
|
|
6
|
+
export interface NavMenuItem {
|
|
7
|
+
id: string;
|
|
8
|
+
label: string;
|
|
9
|
+
href?: string;
|
|
10
|
+
icon?: React.ReactNode;
|
|
11
|
+
category?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
|
|
12
|
+
children?: NavMenuItem[];
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface NavMenuProps {
|
|
17
|
+
items: NavMenuItem[];
|
|
18
|
+
orientation?: 'horizontal' | 'vertical';
|
|
19
|
+
className?: string;
|
|
20
|
+
defaultCategory?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
|
|
21
|
+
onItemClick?: (item: NavMenuItem) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const NavMenu: React.FC<NavMenuProps> = ({
|
|
25
|
+
items,
|
|
26
|
+
orientation = 'horizontal',
|
|
27
|
+
className,
|
|
28
|
+
defaultCategory = 1,
|
|
29
|
+
onItemClick
|
|
30
|
+
}) => {
|
|
31
|
+
const [openMenus, setOpenMenus] = useState<Set<string>>(new Set());
|
|
32
|
+
const timeoutRef = useRef<Record<string, NodeJS.Timeout>>({});
|
|
33
|
+
|
|
34
|
+
const toggleMenu = (itemId: string) => {
|
|
35
|
+
setOpenMenus(prev => {
|
|
36
|
+
const newSet = new Set(prev);
|
|
37
|
+
if (newSet.has(itemId)) {
|
|
38
|
+
newSet.delete(itemId);
|
|
39
|
+
} else {
|
|
40
|
+
newSet.add(itemId);
|
|
41
|
+
}
|
|
42
|
+
return newSet;
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const handleMouseEnter = (itemId: string) => {
|
|
47
|
+
if (orientation === 'horizontal') {
|
|
48
|
+
// Clear any pending close timeout
|
|
49
|
+
if (timeoutRef.current[itemId]) {
|
|
50
|
+
clearTimeout(timeoutRef.current[itemId]);
|
|
51
|
+
delete timeoutRef.current[itemId];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
setOpenMenus(prev => new Set(prev).add(itemId));
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const handleMouseLeave = (itemId: string) => {
|
|
59
|
+
if (orientation === 'horizontal') {
|
|
60
|
+
// Delay closing to allow for mouse movement to submenu
|
|
61
|
+
timeoutRef.current[itemId] = setTimeout(() => {
|
|
62
|
+
setOpenMenus(prev => {
|
|
63
|
+
const newSet = new Set(prev);
|
|
64
|
+
newSet.delete(itemId);
|
|
65
|
+
return newSet;
|
|
66
|
+
});
|
|
67
|
+
delete timeoutRef.current[itemId];
|
|
68
|
+
}, 150);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const handleItemClick = (item: NavMenuItem, event?: React.MouseEvent) => {
|
|
73
|
+
if (item.disabled) {
|
|
74
|
+
event?.preventDefault();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (item.children && item.children.length > 0) {
|
|
79
|
+
event?.preventDefault();
|
|
80
|
+
toggleMenu(item.id);
|
|
81
|
+
} else {
|
|
82
|
+
onItemClick?.(item);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
const timeouts = timeoutRef.current;
|
|
88
|
+
return () => {
|
|
89
|
+
// Cleanup timeouts
|
|
90
|
+
Object.values(timeouts).forEach(timeout => clearTimeout(timeout));
|
|
91
|
+
};
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
94
|
+
const renderMenuItem = (item: NavMenuItem, level = 0) => {
|
|
95
|
+
const hasChildren = item.children && item.children.length > 0;
|
|
96
|
+
const isOpen = openMenus.has(item.id);
|
|
97
|
+
const category = item.category || defaultCategory;
|
|
98
|
+
|
|
99
|
+
const itemContent = (
|
|
100
|
+
<div
|
|
101
|
+
className={cn(
|
|
102
|
+
'flex items-center gap-2 px-3 py-2 rounded-md transition-all duration-200',
|
|
103
|
+
'hover:bg-muted/50 cursor-pointer select-none',
|
|
104
|
+
item.disabled && 'opacity-50 cursor-not-allowed',
|
|
105
|
+
!item.disabled && [
|
|
106
|
+
`hover:text-category-${category}`,
|
|
107
|
+
`hover:bg-category-${category}/5`,
|
|
108
|
+
getAnimationClasses(animationPresets.subtle)
|
|
109
|
+
],
|
|
110
|
+
orientation === 'horizontal' && level === 0 && 'px-4 py-3'
|
|
111
|
+
)}
|
|
112
|
+
onClick={(e) => handleItemClick(item, e)}
|
|
113
|
+
>
|
|
114
|
+
{item.icon && (
|
|
115
|
+
<span className="flex-shrink-0">
|
|
116
|
+
{item.icon}
|
|
117
|
+
</span>
|
|
118
|
+
)}
|
|
119
|
+
|
|
120
|
+
<span className="flex-1 text-sm font-medium">
|
|
121
|
+
{item.label}
|
|
122
|
+
</span>
|
|
123
|
+
|
|
124
|
+
{hasChildren && (
|
|
125
|
+
<span className="flex-shrink-0 transition-transform duration-200">
|
|
126
|
+
{orientation === 'horizontal' ? (
|
|
127
|
+
<ChevronDown className={cn(
|
|
128
|
+
'w-4 h-4 transition-transform duration-200',
|
|
129
|
+
isOpen && 'rotate-180'
|
|
130
|
+
)} />
|
|
131
|
+
) : (
|
|
132
|
+
<ChevronRight className={cn(
|
|
133
|
+
'w-4 h-4 transition-transform duration-200',
|
|
134
|
+
isOpen && 'rotate-90'
|
|
135
|
+
)} />
|
|
136
|
+
)}
|
|
137
|
+
</span>
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<div
|
|
144
|
+
key={item.id}
|
|
145
|
+
className="relative"
|
|
146
|
+
onMouseEnter={() => handleMouseEnter(item.id)}
|
|
147
|
+
onMouseLeave={() => handleMouseLeave(item.id)}
|
|
148
|
+
>
|
|
149
|
+
{item.href && !hasChildren ? (
|
|
150
|
+
<a href={item.href} className="block">
|
|
151
|
+
{itemContent}
|
|
152
|
+
</a>
|
|
153
|
+
) : (
|
|
154
|
+
itemContent
|
|
155
|
+
)}
|
|
156
|
+
|
|
157
|
+
{/* Submenu */}
|
|
158
|
+
{hasChildren && isOpen && (
|
|
159
|
+
<div
|
|
160
|
+
className={cn(
|
|
161
|
+
'bg-popover border border-border rounded-md shadow-lg py-1 min-w-48',
|
|
162
|
+
'z-50',
|
|
163
|
+
orientation === 'horizontal'
|
|
164
|
+
? 'absolute top-full left-0 mt-1'
|
|
165
|
+
: 'ml-4 mt-1',
|
|
166
|
+
getAnimationClasses(animationPresets.card)
|
|
167
|
+
)}
|
|
168
|
+
>
|
|
169
|
+
{item.children!.map(child => renderMenuItem(child, level + 1))}
|
|
170
|
+
</div>
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<nav
|
|
178
|
+
className={cn(
|
|
179
|
+
'flex',
|
|
180
|
+
orientation === 'horizontal' ? 'flex-row space-x-1' : 'flex-col space-y-1',
|
|
181
|
+
className
|
|
182
|
+
)}
|
|
183
|
+
data-component-name="NavMenu"
|
|
184
|
+
>
|
|
185
|
+
{items.map(item => renderMenuItem(item))}
|
|
186
|
+
</nav>
|
|
187
|
+
);
|
|
188
|
+
};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
|
|
3
|
+
import { Button } from '../../atoms/ui/button';
|
|
4
|
+
import { cn } from '../../atoms/utils/utils';
|
|
5
|
+
|
|
6
|
+
interface PaginationProps {
|
|
7
|
+
currentPage: number;
|
|
8
|
+
totalPages: number;
|
|
9
|
+
onPageChange: (page: number) => void;
|
|
10
|
+
showFirstLast?: boolean;
|
|
11
|
+
maxVisiblePages?: number;
|
|
12
|
+
className?: string;
|
|
13
|
+
category?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
|
|
14
|
+
size?: 'sm' | 'md' | 'lg';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const Pagination: React.FC<PaginationProps> = ({
|
|
18
|
+
currentPage,
|
|
19
|
+
totalPages,
|
|
20
|
+
onPageChange,
|
|
21
|
+
showFirstLast = true,
|
|
22
|
+
maxVisiblePages = 7,
|
|
23
|
+
className,
|
|
24
|
+
category = 1,
|
|
25
|
+
size = 'md'
|
|
26
|
+
}) => {
|
|
27
|
+
const getVisiblePages = () => {
|
|
28
|
+
if (totalPages <= maxVisiblePages) {
|
|
29
|
+
return Array.from({ length: totalPages }, (_, i) => i + 1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const halfVisible = Math.floor(maxVisiblePages / 2);
|
|
33
|
+
let start = Math.max(1, currentPage - halfVisible);
|
|
34
|
+
const end = Math.min(totalPages, start + maxVisiblePages - 1);
|
|
35
|
+
|
|
36
|
+
if (end - start + 1 < maxVisiblePages) {
|
|
37
|
+
start = Math.max(1, end - maxVisiblePages + 1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const pages = [];
|
|
41
|
+
|
|
42
|
+
// Add first page if not in range
|
|
43
|
+
if (start > 1) {
|
|
44
|
+
pages.push(1);
|
|
45
|
+
if (start > 2) {
|
|
46
|
+
pages.push('ellipsis-start');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Add visible range
|
|
51
|
+
for (let i = start; i <= end; i++) {
|
|
52
|
+
pages.push(i);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Add last page if not in range
|
|
56
|
+
if (end < totalPages) {
|
|
57
|
+
if (end < totalPages - 1) {
|
|
58
|
+
pages.push('ellipsis-end');
|
|
59
|
+
}
|
|
60
|
+
pages.push(totalPages);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return pages;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const buttonSizes = {
|
|
67
|
+
sm: 'h-8 w-8 text-xs',
|
|
68
|
+
md: 'h-9 w-9 text-sm',
|
|
69
|
+
lg: 'h-10 w-10 text-sm'
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const visiblePages = getVisiblePages();
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<nav
|
|
76
|
+
className={cn('flex items-center space-x-1', className)}
|
|
77
|
+
aria-label="Pagination"
|
|
78
|
+
data-component-name="Pagination"
|
|
79
|
+
>
|
|
80
|
+
{/* First page button */}
|
|
81
|
+
{showFirstLast && currentPage > 1 && (
|
|
82
|
+
<Button
|
|
83
|
+
variant="outline"
|
|
84
|
+
size="sm"
|
|
85
|
+
onClick={() => onPageChange(1)}
|
|
86
|
+
className={cn(buttonSizes[size])}
|
|
87
|
+
aria-label="Go to first page"
|
|
88
|
+
>
|
|
89
|
+
««
|
|
90
|
+
</Button>
|
|
91
|
+
)}
|
|
92
|
+
|
|
93
|
+
{/* Previous page button */}
|
|
94
|
+
<Button
|
|
95
|
+
variant="outline"
|
|
96
|
+
size="sm"
|
|
97
|
+
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
|
|
98
|
+
disabled={currentPage === 1}
|
|
99
|
+
className={cn(buttonSizes[size])}
|
|
100
|
+
aria-label="Go to previous page"
|
|
101
|
+
>
|
|
102
|
+
<ChevronLeft className="w-4 h-4" />
|
|
103
|
+
</Button>
|
|
104
|
+
|
|
105
|
+
{/* Page number buttons */}
|
|
106
|
+
{visiblePages.map((page, index) => {
|
|
107
|
+
if (typeof page === 'string') {
|
|
108
|
+
return (
|
|
109
|
+
<span
|
|
110
|
+
key={index}
|
|
111
|
+
className={cn(
|
|
112
|
+
'flex items-center justify-center text-muted-foreground',
|
|
113
|
+
buttonSizes[size]
|
|
114
|
+
)}
|
|
115
|
+
>
|
|
116
|
+
<MoreHorizontal className="w-4 h-4" />
|
|
117
|
+
</span>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const isActive = page === currentPage;
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<Button
|
|
125
|
+
key={page}
|
|
126
|
+
variant={isActive ? "default" : "outline"}
|
|
127
|
+
size="sm"
|
|
128
|
+
onClick={() => onPageChange(page)}
|
|
129
|
+
className={cn(
|
|
130
|
+
buttonSizes[size],
|
|
131
|
+
isActive && [
|
|
132
|
+
`bg-category-${category}/15 hover:bg-category-${category}/25`,
|
|
133
|
+
`border-category-${category}/30`,
|
|
134
|
+
`text-category-${category}`,
|
|
135
|
+
'shadow-sm'
|
|
136
|
+
]
|
|
137
|
+
)}
|
|
138
|
+
aria-label={`Go to page ${page}`}
|
|
139
|
+
aria-current={isActive ? 'page' : undefined}
|
|
140
|
+
>
|
|
141
|
+
{page}
|
|
142
|
+
</Button>
|
|
143
|
+
);
|
|
144
|
+
})}
|
|
145
|
+
|
|
146
|
+
{/* Next page button */}
|
|
147
|
+
<Button
|
|
148
|
+
variant="outline"
|
|
149
|
+
size="sm"
|
|
150
|
+
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
|
151
|
+
disabled={currentPage === totalPages}
|
|
152
|
+
className={cn(buttonSizes[size])}
|
|
153
|
+
aria-label="Go to next page"
|
|
154
|
+
>
|
|
155
|
+
<ChevronRight className="w-4 h-4" />
|
|
156
|
+
</Button>
|
|
157
|
+
|
|
158
|
+
{/* Last page button */}
|
|
159
|
+
{showFirstLast && currentPage < totalPages && (
|
|
160
|
+
<Button
|
|
161
|
+
variant="outline"
|
|
162
|
+
size="sm"
|
|
163
|
+
onClick={() => onPageChange(totalPages)}
|
|
164
|
+
className={cn(buttonSizes[size])}
|
|
165
|
+
aria-label="Go to last page"
|
|
166
|
+
>
|
|
167
|
+
»»
|
|
168
|
+
</Button>
|
|
169
|
+
)}
|
|
170
|
+
</nav>
|
|
171
|
+
);
|
|
172
|
+
};
|