@scripso-homepad/ui 0.3.8 → 0.4.0
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/chunk-C7GHBVMM.js +614 -0
- package/dist/chunk-C7GHBVMM.js.map +1 -0
- package/dist/index.cjs +33 -22
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +29 -624
- package/dist/index.js.map +1 -1
- package/dist/web/index.cjs +1211 -0
- package/dist/web/index.cjs.map +1 -0
- package/dist/web/index.d.cts +156 -0
- package/dist/web/index.d.ts +156 -0
- package/dist/web/index.js +761 -0
- package/dist/web/index.js.map +1 -0
- package/package.json +26 -2
- package/src/components/Input.tsx +12 -1
- package/src/web/hooks/useMediaQuery.ts +23 -0
- package/src/web/hooks/useOnClickOutside.ts +30 -0
- package/src/web/icons/BellIcon.tsx +27 -0
- package/src/web/icons/BuildingIcon.tsx +55 -0
- package/src/web/index.ts +37 -0
- package/src/web/layout/AppHeader.stories.tsx +85 -0
- package/src/web/layout/AppHeader.tsx +115 -0
- package/src/web/layout/BuildingSelect.stories.tsx +60 -0
- package/src/web/layout/BuildingSelect.tsx +208 -0
- package/src/web/layout/DashboardLayout.stories.tsx +87 -0
- package/src/web/layout/DashboardLayout.tsx +37 -0
- package/src/web/layout/Sidebar.stories.tsx +80 -0
- package/src/web/layout/Sidebar.tsx +244 -0
- package/src/web/layout/SidebarMobileHeader.stories.tsx +47 -0
- package/src/web/layout/SidebarMobileHeader.tsx +48 -0
- package/src/web/layout/SidebarNavItem.tsx +60 -0
- package/src/web/layout/SidebarUserCard.tsx +79 -0
- package/src/web/layout/story-fixtures.tsx +93 -0
- package/src/web/layout/story-helpers.tsx +5 -0
- package/src/web/layout/types.ts +48 -0
- package/src/web/utils/cn.ts +6 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { PanelLeftClose, PanelLeftOpen, X } from 'lucide-react';
|
|
2
|
+
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
|
3
|
+
import { useLocation } from 'react-router-dom';
|
|
4
|
+
|
|
5
|
+
import { useMediaQuery } from '../hooks/useMediaQuery';
|
|
6
|
+
import { cn } from '../utils/cn';
|
|
7
|
+
import { SidebarNavItem } from './SidebarNavItem';
|
|
8
|
+
import { SidebarUserCard } from './SidebarUserCard';
|
|
9
|
+
import type { AdminNavItem, AdminSidebarBranding, AdminSidebarLabels, AdminSidebarUser } from './types';
|
|
10
|
+
|
|
11
|
+
export type SidebarProps = {
|
|
12
|
+
navItems: AdminNavItem[];
|
|
13
|
+
footerNavItems: AdminNavItem[];
|
|
14
|
+
branding: AdminSidebarBranding;
|
|
15
|
+
labels: AdminSidebarLabels;
|
|
16
|
+
sidebarStorageKey: string;
|
|
17
|
+
user?: AdminSidebarUser;
|
|
18
|
+
onLogout?: () => void;
|
|
19
|
+
className?: string;
|
|
20
|
+
mobileOpen: boolean;
|
|
21
|
+
onMobileClose?: () => void;
|
|
22
|
+
onNavItemClick?: (item: AdminNavItem) => void;
|
|
23
|
+
collapseIcon?: ReactNode;
|
|
24
|
+
expandIcon?: ReactNode;
|
|
25
|
+
closeIcon?: ReactNode;
|
|
26
|
+
initialCollapsed?: boolean;
|
|
27
|
+
collapsed?: boolean;
|
|
28
|
+
onCollapsedChange?: (collapsed: boolean) => void;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function readInitialCollapsedState(storageKey: string, initialCollapsed?: boolean): boolean {
|
|
32
|
+
if (initialCollapsed !== undefined) {
|
|
33
|
+
return initialCollapsed;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (typeof window === 'undefined') {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const stored = window.localStorage.getItem(storageKey);
|
|
41
|
+
if (stored === null) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return stored !== 'true';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function Sidebar({
|
|
49
|
+
navItems,
|
|
50
|
+
footerNavItems,
|
|
51
|
+
branding,
|
|
52
|
+
labels,
|
|
53
|
+
sidebarStorageKey,
|
|
54
|
+
user,
|
|
55
|
+
onLogout,
|
|
56
|
+
className,
|
|
57
|
+
mobileOpen,
|
|
58
|
+
onMobileClose,
|
|
59
|
+
onNavItemClick,
|
|
60
|
+
collapseIcon,
|
|
61
|
+
expandIcon,
|
|
62
|
+
closeIcon,
|
|
63
|
+
initialCollapsed,
|
|
64
|
+
collapsed,
|
|
65
|
+
onCollapsedChange,
|
|
66
|
+
}: SidebarProps) {
|
|
67
|
+
const location = useLocation();
|
|
68
|
+
const isDesktop = useMediaQuery('(min-width: 768px)');
|
|
69
|
+
const [internalCollapsed, setInternalCollapsed] = useState(() =>
|
|
70
|
+
readInitialCollapsedState(sidebarStorageKey, initialCollapsed),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const isCollapsed = collapsed ?? internalCollapsed;
|
|
74
|
+
const isExpanded = isDesktop ? !isCollapsed : true;
|
|
75
|
+
|
|
76
|
+
const setCollapsed = useCallback(
|
|
77
|
+
(next: boolean) => {
|
|
78
|
+
if (collapsed === undefined) {
|
|
79
|
+
setInternalCollapsed(next);
|
|
80
|
+
window.localStorage.setItem(sidebarStorageKey, String(!next));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
onCollapsedChange?.(next);
|
|
84
|
+
},
|
|
85
|
+
[collapsed, onCollapsedChange, sidebarStorageKey],
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const toggleCollapsed = useCallback(() => {
|
|
89
|
+
setCollapsed(!isCollapsed);
|
|
90
|
+
}, [isCollapsed, setCollapsed]);
|
|
91
|
+
|
|
92
|
+
const handleHeaderAction = useCallback(() => {
|
|
93
|
+
if (isDesktop) {
|
|
94
|
+
toggleCollapsed();
|
|
95
|
+
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
onMobileClose?.();
|
|
100
|
+
}, [isDesktop, onMobileClose, toggleCollapsed]);
|
|
101
|
+
|
|
102
|
+
const handleNavItemNavigate = useCallback(
|
|
103
|
+
(item: AdminNavItem) => {
|
|
104
|
+
onNavItemClick?.(item);
|
|
105
|
+
|
|
106
|
+
if (!isDesktop) {
|
|
107
|
+
onMobileClose?.();
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
[isDesktop, onMobileClose, onNavItemClick],
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const pathnameRef = useRef(location.pathname);
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (pathnameRef.current === location.pathname) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
pathnameRef.current = location.pathname;
|
|
121
|
+
|
|
122
|
+
if (!isDesktop) {
|
|
123
|
+
onMobileClose?.();
|
|
124
|
+
}
|
|
125
|
+
}, [location.pathname, isDesktop, onMobileClose]);
|
|
126
|
+
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
if (!mobileOpen || isDesktop) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const previousOverflow = document.body.style.overflow;
|
|
133
|
+
document.body.style.overflow = 'hidden';
|
|
134
|
+
|
|
135
|
+
return () => {
|
|
136
|
+
document.body.style.overflow = previousOverflow;
|
|
137
|
+
};
|
|
138
|
+
}, [mobileOpen, isDesktop]);
|
|
139
|
+
|
|
140
|
+
const visibleNavItems = navItems.filter((item) => !item.hidden);
|
|
141
|
+
const visibleFooterNavItems = footerNavItems.filter((item) => !item.hidden);
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<>
|
|
145
|
+
<button
|
|
146
|
+
type="button"
|
|
147
|
+
aria-label={labels.closeMenu}
|
|
148
|
+
onClick={onMobileClose}
|
|
149
|
+
className={cn(
|
|
150
|
+
'fixed inset-0 z-40 bg-navy-800/60 backdrop-blur-[1px] transition-opacity duration-300 ease-in-out md:hidden',
|
|
151
|
+
mobileOpen ? 'opacity-100' : 'pointer-events-none opacity-0',
|
|
152
|
+
)}
|
|
153
|
+
/>
|
|
154
|
+
|
|
155
|
+
<aside
|
|
156
|
+
className={cn(
|
|
157
|
+
'fixed inset-y-0 left-0 z-50 flex h-screen w-[min(300px,85vw)] flex-col gap-8 overflow-hidden bg-navy p-4 shadow-xl',
|
|
158
|
+
'transition-[transform,width,padding,gap] duration-300 ease-in-out will-change-[transform,width]',
|
|
159
|
+
mobileOpen ? 'translate-x-0' : '-translate-x-full',
|
|
160
|
+
'md:relative md:z-auto md:w-[300px] md:translate-x-0 md:shadow-none',
|
|
161
|
+
isExpanded ? 'md:w-[300px]' : 'md:w-[52px] md:items-center md:gap-4 md:px-3 md:py-4',
|
|
162
|
+
className,
|
|
163
|
+
)}
|
|
164
|
+
aria-hidden={!isDesktop && !mobileOpen}
|
|
165
|
+
>
|
|
166
|
+
<div
|
|
167
|
+
className={cn(
|
|
168
|
+
'flex w-full items-center transition-[justify-content] duration-300 ease-in-out',
|
|
169
|
+
isExpanded ? 'justify-between' : 'justify-center',
|
|
170
|
+
)}
|
|
171
|
+
>
|
|
172
|
+
<div
|
|
173
|
+
className={cn(
|
|
174
|
+
'flex min-w-0 items-center gap-1.5 overflow-hidden transition-[max-width,opacity] duration-300 ease-in-out',
|
|
175
|
+
isExpanded ? 'max-w-[240px] opacity-100' : 'max-w-0 opacity-0',
|
|
176
|
+
)}
|
|
177
|
+
>
|
|
178
|
+
<img src={branding.logoIconSrc} alt="" className="h-5 w-[29px] shrink-0" />
|
|
179
|
+
<div className="flex min-w-0 flex-col leading-none">
|
|
180
|
+
<span className="text-[21px] font-bold tracking-tight whitespace-nowrap text-white">
|
|
181
|
+
{branding.logoTitle}
|
|
182
|
+
</span>
|
|
183
|
+
<span className="text-[6.5px] font-normal tracking-[-0.26px] text-navy-300 uppercase">
|
|
184
|
+
{branding.logoTagline}
|
|
185
|
+
</span>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
onClick={handleHeaderAction}
|
|
191
|
+
className="shrink-0 cursor-pointer text-white transition-[opacity,transform] duration-200 hover:opacity-80 active:scale-95"
|
|
192
|
+
aria-label={
|
|
193
|
+
isDesktop
|
|
194
|
+
? isExpanded
|
|
195
|
+
? labels.collapse
|
|
196
|
+
: labels.expand
|
|
197
|
+
: labels.closeMenu
|
|
198
|
+
}
|
|
199
|
+
>
|
|
200
|
+
{isDesktop ? (
|
|
201
|
+
isExpanded ? (
|
|
202
|
+
collapseIcon ?? <PanelLeftClose size={20} strokeWidth={1.75} />
|
|
203
|
+
) : (
|
|
204
|
+
expandIcon ?? <PanelLeftOpen size={20} strokeWidth={1.75} />
|
|
205
|
+
)
|
|
206
|
+
) : (
|
|
207
|
+
closeIcon ?? <X size={20} strokeWidth={1.75} />
|
|
208
|
+
)}
|
|
209
|
+
</button>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
<nav className="flex flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden">
|
|
213
|
+
{visibleNavItems.map((item) => (
|
|
214
|
+
<SidebarNavItem
|
|
215
|
+
key={item.to}
|
|
216
|
+
item={item}
|
|
217
|
+
isOpen={isExpanded}
|
|
218
|
+
onNavigate={handleNavItemNavigate}
|
|
219
|
+
/>
|
|
220
|
+
))}
|
|
221
|
+
</nav>
|
|
222
|
+
|
|
223
|
+
<div className="flex w-full flex-col gap-4">
|
|
224
|
+
{visibleFooterNavItems.map((item) => (
|
|
225
|
+
<SidebarNavItem
|
|
226
|
+
key={item.to}
|
|
227
|
+
item={item}
|
|
228
|
+
isOpen={isExpanded}
|
|
229
|
+
onNavigate={handleNavItemNavigate}
|
|
230
|
+
/>
|
|
231
|
+
))}
|
|
232
|
+
{user ? (
|
|
233
|
+
<SidebarUserCard
|
|
234
|
+
user={user}
|
|
235
|
+
isOpen={isExpanded}
|
|
236
|
+
logoutLabel={labels.logout}
|
|
237
|
+
onLogout={onLogout}
|
|
238
|
+
/>
|
|
239
|
+
) : null}
|
|
240
|
+
</div>
|
|
241
|
+
</aside>
|
|
242
|
+
</>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { fn } from '@storybook/test';
|
|
3
|
+
|
|
4
|
+
import { WebLayoutCanvas } from './story-helpers';
|
|
5
|
+
import { SidebarMobileHeader } from './SidebarMobileHeader';
|
|
6
|
+
import { storyBranding, storyLabels, storyLogoIconDarkSrc } from './story-fixtures';
|
|
7
|
+
|
|
8
|
+
const meta = {
|
|
9
|
+
title: 'Web Layout/SidebarMobileHeader',
|
|
10
|
+
component: SidebarMobileHeader,
|
|
11
|
+
parameters: {
|
|
12
|
+
layout: 'fullscreen',
|
|
13
|
+
webLayout: true,
|
|
14
|
+
},
|
|
15
|
+
args: {
|
|
16
|
+
branding: {
|
|
17
|
+
logoIconSrc: storyLogoIconDarkSrc,
|
|
18
|
+
logoTitle: storyBranding.logoTitle,
|
|
19
|
+
logoTagline: storyBranding.logoTagline,
|
|
20
|
+
},
|
|
21
|
+
openMenuLabel: storyLabels.sidebar.openMenu,
|
|
22
|
+
onOpenMenu: fn(),
|
|
23
|
+
menuOpen: false,
|
|
24
|
+
},
|
|
25
|
+
} satisfies Meta<typeof SidebarMobileHeader>;
|
|
26
|
+
|
|
27
|
+
export default meta;
|
|
28
|
+
type Story = StoryObj<typeof meta>;
|
|
29
|
+
|
|
30
|
+
export const Default: Story = {
|
|
31
|
+
render: (args) => (
|
|
32
|
+
<WebLayoutCanvas>
|
|
33
|
+
<SidebarMobileHeader {...args} />
|
|
34
|
+
</WebLayoutCanvas>
|
|
35
|
+
),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const MenuOpen: Story = {
|
|
39
|
+
args: {
|
|
40
|
+
menuOpen: true,
|
|
41
|
+
},
|
|
42
|
+
render: (args) => (
|
|
43
|
+
<WebLayoutCanvas>
|
|
44
|
+
<SidebarMobileHeader {...args} />
|
|
45
|
+
</WebLayoutCanvas>
|
|
46
|
+
),
|
|
47
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Menu } from 'lucide-react';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../utils/cn';
|
|
4
|
+
import type { AdminSidebarBranding } from './types';
|
|
5
|
+
|
|
6
|
+
export type SidebarMobileHeaderProps = {
|
|
7
|
+
branding: Pick<AdminSidebarBranding, 'logoIconSrc' | 'logoTitle' | 'logoTagline'>;
|
|
8
|
+
openMenuLabel: string;
|
|
9
|
+
onOpenMenu: () => void;
|
|
10
|
+
menuOpen?: boolean;
|
|
11
|
+
className?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function SidebarMobileHeader({
|
|
15
|
+
branding,
|
|
16
|
+
openMenuLabel,
|
|
17
|
+
onOpenMenu,
|
|
18
|
+
menuOpen = false,
|
|
19
|
+
className,
|
|
20
|
+
}: SidebarMobileHeaderProps) {
|
|
21
|
+
return (
|
|
22
|
+
<header
|
|
23
|
+
className={cn(
|
|
24
|
+
'sticky top-0 z-30 flex h-14 shrink-0 items-center gap-3 border-b border-storm-gray-50 bg-storm-gray-0 px-4 md:hidden',
|
|
25
|
+
className,
|
|
26
|
+
)}
|
|
27
|
+
>
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
onClick={onOpenMenu}
|
|
31
|
+
className="flex size-10 items-center justify-center rounded-xl text-navy transition-colors hover:bg-storm-gray-50"
|
|
32
|
+
aria-label={openMenuLabel}
|
|
33
|
+
aria-expanded={menuOpen}
|
|
34
|
+
>
|
|
35
|
+
<Menu size={22} strokeWidth={1.75} />
|
|
36
|
+
</button>
|
|
37
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
38
|
+
<img src={branding.logoIconSrc} alt="" className="h-[30px] w-[42px] shrink-0" />
|
|
39
|
+
<div className="min-w-0 leading-none">
|
|
40
|
+
<p className="truncate text-base font-bold tracking-tight text-navy">{branding.logoTitle}</p>
|
|
41
|
+
<p className="truncate text-[6.5px] font-normal tracking-[-0.26px] text-navy-300 uppercase">
|
|
42
|
+
{branding.logoTagline}
|
|
43
|
+
</p>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
</header>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { NavLink } from 'react-router-dom';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../utils/cn';
|
|
4
|
+
import type { AdminNavItem } from './types';
|
|
5
|
+
|
|
6
|
+
export type SidebarNavItemProps = {
|
|
7
|
+
item: AdminNavItem;
|
|
8
|
+
isOpen: boolean;
|
|
9
|
+
onNavigate?: (item: AdminNavItem) => void;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function SidebarNavItem({ item, isOpen, onNavigate }: SidebarNavItemProps) {
|
|
13
|
+
if (item.hidden) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const handleClick = () => {
|
|
18
|
+
item.onClick?.();
|
|
19
|
+
onNavigate?.(item);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<NavLink
|
|
24
|
+
to={item.to}
|
|
25
|
+
end={item.end}
|
|
26
|
+
onClick={handleClick}
|
|
27
|
+
aria-disabled={item.disabled}
|
|
28
|
+
tabIndex={item.disabled ? -1 : undefined}
|
|
29
|
+
className={({ isActive }) =>
|
|
30
|
+
cn(
|
|
31
|
+
'relative flex items-center rounded-xl px-4 py-3 transition-[background-color,color,gap,padding] duration-300 ease-in-out',
|
|
32
|
+
isOpen ? 'w-full gap-4' : 'justify-center gap-0 px-3',
|
|
33
|
+
item.disabled && 'pointer-events-none opacity-50',
|
|
34
|
+
isActive ? 'bg-black/12 text-white' : 'text-navy-100 hover:bg-white/5',
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
>
|
|
38
|
+
{({ isActive }) => (
|
|
39
|
+
<>
|
|
40
|
+
<span
|
|
41
|
+
aria-hidden
|
|
42
|
+
className={cn(
|
|
43
|
+
'absolute top-1.5 -left-1 h-8 w-2 rounded-full bg-white transition-[opacity,transform] duration-300 ease-in-out',
|
|
44
|
+
isActive ? 'scale-100 opacity-100' : 'scale-75 opacity-0',
|
|
45
|
+
)}
|
|
46
|
+
/>
|
|
47
|
+
<span className="flex size-5 shrink-0 items-center justify-center">{item.icon}</span>
|
|
48
|
+
<span
|
|
49
|
+
className={cn(
|
|
50
|
+
'overflow-hidden text-sm font-semibold whitespace-nowrap transition-[max-width,opacity] duration-300 ease-in-out',
|
|
51
|
+
isOpen ? 'max-w-[180px] opacity-100' : 'max-w-0 opacity-0',
|
|
52
|
+
)}
|
|
53
|
+
>
|
|
54
|
+
{item.label}
|
|
55
|
+
</span>
|
|
56
|
+
</>
|
|
57
|
+
)}
|
|
58
|
+
</NavLink>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { LogOut } from 'lucide-react';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../utils/cn';
|
|
4
|
+
import type { AdminSidebarUser } from './types';
|
|
5
|
+
|
|
6
|
+
export type SidebarUserCardProps = {
|
|
7
|
+
user: AdminSidebarUser;
|
|
8
|
+
isOpen: boolean;
|
|
9
|
+
logoutLabel: string;
|
|
10
|
+
onLogout?: () => void;
|
|
11
|
+
className?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function getInitials(fullName: string, email: string): string {
|
|
15
|
+
const parts = fullName.trim().split(/\s+/).filter(Boolean);
|
|
16
|
+
if (parts.length > 0) {
|
|
17
|
+
return parts
|
|
18
|
+
.slice(0, 2)
|
|
19
|
+
.map((part) => part[0]?.toUpperCase() ?? '')
|
|
20
|
+
.join('');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return email.slice(0, 2).toUpperCase();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function SidebarUserCard({
|
|
27
|
+
user,
|
|
28
|
+
isOpen,
|
|
29
|
+
logoutLabel,
|
|
30
|
+
onLogout,
|
|
31
|
+
className,
|
|
32
|
+
}: SidebarUserCardProps) {
|
|
33
|
+
const initials = user.initials ?? getInitials(user.fullName, user.email);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
className={cn(
|
|
38
|
+
'flex w-full items-center rounded-xl p-3 transition-[background-color,justify-content,gap] duration-300 ease-in-out',
|
|
39
|
+
isOpen ? 'justify-between gap-3 bg-black/12' : 'justify-center',
|
|
40
|
+
className,
|
|
41
|
+
)}
|
|
42
|
+
>
|
|
43
|
+
<div
|
|
44
|
+
className={cn(
|
|
45
|
+
'flex min-w-0 items-center transition-[gap,flex] duration-300 ease-in-out',
|
|
46
|
+
isOpen ? 'flex-1 gap-3' : 'gap-0',
|
|
47
|
+
)}
|
|
48
|
+
>
|
|
49
|
+
<div
|
|
50
|
+
className="flex size-11 shrink-0 items-center justify-center rounded-full bg-white/5 text-sm font-bold text-white"
|
|
51
|
+
aria-hidden
|
|
52
|
+
>
|
|
53
|
+
{initials}
|
|
54
|
+
</div>
|
|
55
|
+
<div
|
|
56
|
+
className={cn(
|
|
57
|
+
'min-w-0 overflow-hidden transition-[max-width,opacity] duration-300 ease-in-out',
|
|
58
|
+
isOpen ? 'max-w-[160px] opacity-100' : 'max-w-0 opacity-0',
|
|
59
|
+
)}
|
|
60
|
+
>
|
|
61
|
+
<p className="truncate text-sm font-bold text-white">{user.fullName}</p>
|
|
62
|
+
<p className="truncate text-xs text-storm-gray-100">{user.email}</p>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
<button
|
|
66
|
+
type="button"
|
|
67
|
+
onClick={() => onLogout?.()}
|
|
68
|
+
className={cn(
|
|
69
|
+
'shrink-0 cursor-pointer text-navy-150 transition-[opacity,transform,max-width] duration-300 ease-in-out hover:opacity-80 active:scale-95',
|
|
70
|
+
isOpen ? 'max-w-8 scale-100 opacity-100' : 'pointer-events-none max-w-0 scale-90 opacity-0',
|
|
71
|
+
)}
|
|
72
|
+
aria-label={logoutLabel}
|
|
73
|
+
tabIndex={isOpen ? 0 : -1}
|
|
74
|
+
>
|
|
75
|
+
<LogOut size={20} strokeWidth={1.75} />
|
|
76
|
+
</button>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FilePenLine,
|
|
3
|
+
LayoutGrid,
|
|
4
|
+
Megaphone,
|
|
5
|
+
Settings,
|
|
6
|
+
UserRoundCog,
|
|
7
|
+
Wallet,
|
|
8
|
+
} from 'lucide-react';
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
AdminLayoutLabels,
|
|
12
|
+
AdminNavItem,
|
|
13
|
+
AdminSidebarBranding,
|
|
14
|
+
AdminSidebarUser,
|
|
15
|
+
} from './types';
|
|
16
|
+
|
|
17
|
+
const iconSize = { size: 20, strokeWidth: 1.75 } as const;
|
|
18
|
+
|
|
19
|
+
export const storyLogoIconSrc = '/logo-icon.svg';
|
|
20
|
+
export const storyLogoIconDarkSrc = '/logo-icon-dark.svg';
|
|
21
|
+
|
|
22
|
+
export const storyBranding: AdminSidebarBranding = {
|
|
23
|
+
logoIconSrc: storyLogoIconSrc,
|
|
24
|
+
logoTitle: 'HomePad',
|
|
25
|
+
logoTagline: 'Building Management Platform',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const storyLabels: AdminLayoutLabels = {
|
|
29
|
+
sidebar: {
|
|
30
|
+
collapse: 'Collapse sidebar',
|
|
31
|
+
expand: 'Expand sidebar',
|
|
32
|
+
openMenu: 'Open menu',
|
|
33
|
+
closeMenu: 'Close menu',
|
|
34
|
+
logout: 'Log out',
|
|
35
|
+
},
|
|
36
|
+
header: {
|
|
37
|
+
searchPlaceholder: 'Search...',
|
|
38
|
+
selectBuilding: 'Select building',
|
|
39
|
+
notifications: 'Notifications',
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const storyBuildingOptions = [
|
|
44
|
+
{ value: 'komitas-15-3', label: 'Komitas 15/3' },
|
|
45
|
+
{ value: 'abovyan-12', label: 'Abovyan 12' },
|
|
46
|
+
{ value: 'saryan-8', label: 'Saryan 8' },
|
|
47
|
+
{ value: 'tumanyan-4', label: 'Tumanyan 4' },
|
|
48
|
+
{ value: 'mashtots-21', label: 'Mashtots 21' },
|
|
49
|
+
{ value: 'baghramyan-9', label: 'Baghramyan 9' },
|
|
50
|
+
{ value: 'pushkin-2', label: 'Pushkin 2' },
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
export const storyUser: AdminSidebarUser = {
|
|
54
|
+
fullName: 'Anna Grigoryan',
|
|
55
|
+
email: 'anna466@gmail.com',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const storyNavItems: AdminNavItem[] = [
|
|
59
|
+
{
|
|
60
|
+
to: '/',
|
|
61
|
+
label: 'Dashboard',
|
|
62
|
+
icon: <LayoutGrid {...iconSize} />,
|
|
63
|
+
end: true,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
to: '/residents',
|
|
67
|
+
label: 'Residents',
|
|
68
|
+
icon: <UserRoundCog {...iconSize} />,
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
to: '/requests',
|
|
72
|
+
label: 'Requests',
|
|
73
|
+
icon: <FilePenLine {...iconSize} />,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
to: '/payments',
|
|
77
|
+
label: 'Payments',
|
|
78
|
+
icon: <Wallet {...iconSize} />,
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
to: '/announcements',
|
|
82
|
+
label: 'Announcements',
|
|
83
|
+
icon: <Megaphone {...iconSize} />,
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
export const storyFooterNavItems: AdminNavItem[] = [
|
|
88
|
+
{
|
|
89
|
+
to: '/settings',
|
|
90
|
+
label: 'Settings',
|
|
91
|
+
icon: <Settings {...iconSize} />,
|
|
92
|
+
},
|
|
93
|
+
];
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
export type AdminNavItem = {
|
|
4
|
+
to: string;
|
|
5
|
+
label: string;
|
|
6
|
+
icon: ReactNode;
|
|
7
|
+
end?: boolean;
|
|
8
|
+
hidden?: boolean;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
onClick?: () => void;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type AdminSidebarUser = {
|
|
14
|
+
fullName: string;
|
|
15
|
+
email: string;
|
|
16
|
+
initials?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type AdminSidebarBranding = {
|
|
20
|
+
logoIconSrc: string;
|
|
21
|
+
logoTitle: string;
|
|
22
|
+
logoTagline: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type AdminSidebarLabels = {
|
|
26
|
+
collapse: string;
|
|
27
|
+
expand: string;
|
|
28
|
+
openMenu: string;
|
|
29
|
+
closeMenu: string;
|
|
30
|
+
logout: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type AdminHeaderLabels = {
|
|
34
|
+
searchPlaceholder: string;
|
|
35
|
+
selectBuilding: string;
|
|
36
|
+
notifications: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type AdminBuildingOption = {
|
|
40
|
+
value: string;
|
|
41
|
+
label: string;
|
|
42
|
+
disabled?: boolean;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type AdminLayoutLabels = {
|
|
46
|
+
sidebar: AdminSidebarLabels;
|
|
47
|
+
header: AdminHeaderLabels;
|
|
48
|
+
};
|