@fayz-ai/ui 0.9.0 → 0.10.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/README.md +2 -2
- package/dist/analytics/AnalyticsCard.d.ts +10 -0
- package/dist/analytics/AnalyticsCard.d.ts.map +1 -0
- package/dist/analytics/AnalyticsControls.d.ts +18 -0
- package/dist/analytics/AnalyticsControls.d.ts.map +1 -0
- package/dist/analytics/AnalyticsGrid.d.ts +16 -0
- package/dist/analytics/AnalyticsGrid.d.ts.map +1 -0
- package/dist/analytics/context.d.ts +58 -0
- package/dist/analytics/context.d.ts.map +1 -0
- package/dist/analytics/derive.d.ts +81 -0
- package/dist/analytics/derive.d.ts.map +1 -0
- package/dist/analytics/index.d.ts +13 -0
- package/dist/analytics/index.d.ts.map +1 -0
- package/dist/analytics/range.d.ts +22 -0
- package/dist/analytics/range.d.ts.map +1 -0
- package/dist/{chunk-PVIB724L.js → chunk-22EVFMS3.js} +5 -3
- package/dist/chunk-22EVFMS3.js.map +1 -0
- package/dist/{chunk-G6OKZ7WL.js → chunk-6V5MGYXH.js} +13 -9
- package/dist/chunk-6V5MGYXH.js.map +1 -0
- package/dist/{chunk-KV3MHB6N.js → chunk-H63TOSQ7.js} +196 -53
- package/dist/chunk-H63TOSQ7.js.map +1 -0
- package/dist/{chunk-OUPJDGKE.js → chunk-I6CPKAOD.js} +134 -37
- package/dist/chunk-I6CPKAOD.js.map +1 -0
- package/dist/{chunk-YRVE4FOD.js → chunk-OKZ6WQB3.js} +3 -3
- package/dist/chunk-OKZ6WQB3.js.map +1 -0
- package/dist/{chunk-S5KNM4D2.js → chunk-SEIY2F5F.js} +235 -107
- package/dist/chunk-SEIY2F5F.js.map +1 -0
- package/dist/{chunk-75EFDLST.js → chunk-VAKWUUVC.js} +157 -6
- package/dist/chunk-VAKWUUVC.js.map +1 -0
- package/dist/dashboard/OnboardingWidget.d.ts +2 -14
- package/dist/dashboard/OnboardingWidget.d.ts.map +1 -1
- package/dist/dashboard/SidebarOnboarding.d.ts +28 -0
- package/dist/dashboard/SidebarOnboarding.d.ts.map +1 -0
- package/dist/dashboard/index.d.ts +2 -0
- package/dist/dashboard/index.d.ts.map +1 -1
- package/dist/dashboard/index.js +4 -4
- package/dist/dashboard/types.d.ts +18 -0
- package/dist/dashboard/types.d.ts.map +1 -1
- package/dist/dashboard/useOnboardingProgress.d.ts +28 -0
- package/dist/dashboard/useOnboardingProgress.d.ts.map +1 -0
- package/dist/icons.js +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +960 -14
- package/dist/index.js.map +1 -1
- package/dist/layout/AppShell.d.ts +14 -1
- package/dist/layout/AppShell.d.ts.map +1 -1
- package/dist/layout/ModulePage.d.ts +11 -2
- package/dist/layout/ModulePage.d.ts.map +1 -1
- package/dist/layout/Sidebar.d.ts +8 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/index.js +4 -4
- package/dist/lib/cn.d.ts.map +1 -0
- package/dist/preset.d.ts +3 -0
- package/dist/preset.d.ts.map +1 -0
- package/dist/primitives/data-table.d.ts +19 -4
- package/dist/primitives/data-table.d.ts.map +1 -1
- package/dist/primitives/date-picker.d.ts +4 -2
- package/dist/primitives/date-picker.d.ts.map +1 -1
- package/dist/primitives/filter-chip.d.ts +26 -0
- package/dist/primitives/filter-chip.d.ts.map +1 -0
- package/dist/primitives/header-tabs.d.ts +32 -0
- package/dist/primitives/header-tabs.d.ts.map +1 -0
- package/dist/primitives/hint.d.ts +25 -0
- package/dist/primitives/hint.d.ts.map +1 -0
- package/dist/primitives/index.d.ts +7 -0
- package/dist/primitives/index.d.ts.map +1 -1
- package/dist/primitives/index.js +4 -4
- package/dist/primitives/list-view.d.ts +14 -1
- package/dist/primitives/list-view.d.ts.map +1 -1
- package/dist/primitives/page-header.d.ts +23 -0
- package/dist/primitives/page-header.d.ts.map +1 -0
- package/dist/primitives/stat-tiles.d.ts +30 -0
- package/dist/primitives/stat-tiles.d.ts.map +1 -0
- package/dist/primitives/switch.d.ts +11 -0
- package/dist/primitives/switch.d.ts.map +1 -0
- package/dist/stores/index.d.ts +1 -0
- package/dist/stores/index.d.ts.map +1 -1
- package/dist/stores/page-header.store.d.ts +69 -0
- package/dist/stores/page-header.store.d.ts.map +1 -0
- package/package.json +4 -13
- package/dist/chunk-75EFDLST.js.map +0 -1
- package/dist/chunk-G6OKZ7WL.js.map +0 -1
- package/dist/chunk-KV3MHB6N.js.map +0 -1
- package/dist/chunk-OUPJDGKE.js.map +0 -1
- package/dist/chunk-PVIB724L.js.map +0 -1
- package/dist/chunk-S5KNM4D2.js.map +0 -1
- package/dist/chunk-YRVE4FOD.js.map +0 -1
- package/dist/utils/cn.d.ts.map +0 -1
- package/src/dashboard/ChartWidget.tsx +0 -50
- package/src/dashboard/ChartWidgetInner.tsx +0 -83
- package/src/dashboard/DashboardCanvas.tsx +0 -210
- package/src/dashboard/DashboardCustomizeMenu.tsx +0 -53
- package/src/dashboard/DashboardGrid.tsx +0 -35
- package/src/dashboard/DashboardRange.tsx +0 -62
- package/src/dashboard/GoldCard.tsx +0 -38
- package/src/dashboard/KpiCard.tsx +0 -238
- package/src/dashboard/OnboardingWidget.tsx +0 -140
- package/src/dashboard/TableWidget.tsx +0 -51
- package/src/dashboard/define.ts +0 -53
- package/src/dashboard/icon.ts +0 -13
- package/src/dashboard/index.ts +0 -38
- package/src/dashboard/layout.ts +0 -50
- package/src/dashboard/preferences.ts +0 -71
- package/src/dashboard/types.ts +0 -41
- package/src/icons.ts +0 -66
- package/src/index.ts +0 -238
- package/src/layout/AppShell.tsx +0 -987
- package/src/layout/ContentSplit.tsx +0 -153
- package/src/layout/ModulePage.tsx +0 -466
- package/src/layout/PageTransition.tsx +0 -57
- package/src/layout/SaveBar.tsx +0 -247
- package/src/layout/Sidebar.tsx +0 -535
- package/src/layout/Topbar.tsx +0 -462
- package/src/layout/index.ts +0 -39
- package/src/layout/useCrossModuleBack.ts +0 -30
- package/src/primitives/Avatar.tsx +0 -48
- package/src/primitives/SearchCombobox.tsx +0 -216
- package/src/primitives/badge.tsx +0 -68
- package/src/primitives/breadcrumb.tsx +0 -43
- package/src/primitives/button.tsx +0 -60
- package/src/primitives/card.tsx +0 -79
- package/src/primitives/checkbox.tsx +0 -53
- package/src/primitives/confirm-dialog.tsx +0 -125
- package/src/primitives/currency-input.tsx +0 -121
- package/src/primitives/data-table.tsx +0 -160
- package/src/primitives/date-picker.tsx +0 -225
- package/src/primitives/dropdown.tsx +0 -196
- package/src/primitives/index.ts +0 -87
- package/src/primitives/input.tsx +0 -50
- package/src/primitives/list-view.tsx +0 -252
- package/src/primitives/markdown-editor.tsx +0 -75
- package/src/primitives/markdown.tsx +0 -169
- package/src/primitives/modal.tsx +0 -156
- package/src/primitives/phone-link.tsx +0 -138
- package/src/primitives/popover.tsx +0 -30
- package/src/primitives/search-select.tsx +0 -314
- package/src/primitives/segmented-control.tsx +0 -66
- package/src/primitives/select.tsx +0 -157
- package/src/primitives/separator.tsx +0 -29
- package/src/primitives/sheet.tsx +0 -181
- package/src/primitives/skeleton.tsx +0 -46
- package/src/primitives/tabs.tsx +0 -53
- package/src/primitives/time-picker.tsx +0 -148
- package/src/primitives/toast.tsx +0 -46
- package/src/primitives/tooltip.tsx +0 -28
- package/src/stores/index.ts +0 -10
- package/src/stores/layout.store.ts +0 -31
- package/src/stores/theme.store.ts +0 -457
- package/src/styles.css +0 -461
- package/src/tailwind.ts +0 -43
- package/src/theme/index.ts +0 -94
- package/src/theme/preset.ts +0 -209
- package/src/theme/presets.ts +0 -106
- package/src/utils/cn.ts +0 -6
- /package/dist/{utils → lib}/cn.d.ts +0 -0
package/src/layout/Sidebar.tsx
DELETED
|
@@ -1,535 +0,0 @@
|
|
|
1
|
-
import * as React from 'react'
|
|
2
|
-
import * as Tooltip from '@radix-ui/react-tooltip'
|
|
3
|
-
import {
|
|
4
|
-
Bell, ChevronDown, Crown, ExternalLink, Home, PanelLeft, PanelLeftClose, Search, Settings, User,
|
|
5
|
-
type LucideIcon,
|
|
6
|
-
} from 'lucide-react'
|
|
7
|
-
import { ICON_MAP } from '../icons'
|
|
8
|
-
import { cn } from '../utils/cn'
|
|
9
|
-
import { useLayoutStore } from '../stores/layout.store'
|
|
10
|
-
|
|
11
|
-
export interface NavigationItem {
|
|
12
|
-
id: string
|
|
13
|
-
label: string
|
|
14
|
-
icon: string
|
|
15
|
-
route: string
|
|
16
|
-
section: 'main' | 'secondary' | 'settings'
|
|
17
|
-
badge?: string | number
|
|
18
|
-
permission?: { feature: string; action: 'read' | 'create' | 'edit' | 'delete' }
|
|
19
|
-
/** Freemium discovery: the user's role allows this item, but their plan does
|
|
20
|
-
* not entitle the feature. The item stays visible with a small Crown badge
|
|
21
|
-
* and, on click, leads to the plan's UpgradePrompt. */
|
|
22
|
-
premium?: boolean
|
|
23
|
-
/** Sales-channel / external link: when set, the item opens this URL in a new
|
|
24
|
-
* tab instead of navigating an internal route. Rendered as an anchor with an
|
|
25
|
-
* external-link glyph. `route` is ignored for matching when `href` is set. */
|
|
26
|
-
href?: string
|
|
27
|
-
children?: NavigationItem[]
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export interface SidebarUser {
|
|
31
|
-
fullName: string
|
|
32
|
-
avatarUrl?: string
|
|
33
|
-
email: string
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface SidebarProps {
|
|
37
|
-
navigation: NavigationItem[]
|
|
38
|
-
logo?: React.ReactNode
|
|
39
|
-
collapsed: boolean
|
|
40
|
-
onToggle: () => void
|
|
41
|
-
onNavigate?: (route: string) => void
|
|
42
|
-
currentPath?: string
|
|
43
|
-
user?: SidebarUser
|
|
44
|
-
onSignOut?: () => void
|
|
45
|
-
onProfile?: () => void
|
|
46
|
-
onSettings?: () => void
|
|
47
|
-
onBilling?: () => void
|
|
48
|
-
userMenuExtras?: { label: string; icon?: React.ReactNode; onClick: () => void }[]
|
|
49
|
-
orgSwitcher?: React.ReactNode
|
|
50
|
-
topContent?: React.ReactNode
|
|
51
|
-
footerContent?: React.ReactNode
|
|
52
|
-
/** When true, removes all borders for a cleaner framed look */
|
|
53
|
-
borderless?: boolean
|
|
54
|
-
/** User menu component to render at the bottom */
|
|
55
|
-
userMenuSlot?: React.ReactNode
|
|
56
|
-
/** Notification slot to render at the bottom */
|
|
57
|
-
notificationSlot?: React.ReactNode
|
|
58
|
-
/** Unread notification count for the bell indicator */
|
|
59
|
-
unreadCount?: number
|
|
60
|
-
/** Optional headings above each nav section. Used by contextual sidebars
|
|
61
|
-
* (e.g. /settings swaps the rail for "Settings links" + "Plugins"); the
|
|
62
|
-
* normal app rail leaves them undefined and renders no headings. */
|
|
63
|
-
sectionLabels?: { main?: string; secondary?: string; settings?: string }
|
|
64
|
-
/** Identity of the current rail context. When it changes, the rail animates
|
|
65
|
-
* as a push: the new nav slides in while the previous one slides out.
|
|
66
|
-
* Leave undefined for a static rail (no swap animation). */
|
|
67
|
-
navKey?: string
|
|
68
|
-
/** Push direction for a `navKey` change. 'forward' pulls the new rail in
|
|
69
|
-
* from the right (drilling in), 'back' from the left (returning). */
|
|
70
|
-
navDirection?: 'forward' | 'back'
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// ICON_MAP now lives in ../icons (central curated registry, shared by every
|
|
74
|
-
// string-name icon lookup in the SDK); re-exported here for compat.
|
|
75
|
-
export { ICON_MAP }
|
|
76
|
-
|
|
77
|
-
/** Kept in sync with --saas-rail-duration in styles.css. */
|
|
78
|
-
const RAIL_PUSH_MS = 260
|
|
79
|
-
|
|
80
|
-
/** Layout effect on the client, plain effect during SSR (avoids the warning).
|
|
81
|
-
* The rail push needs the layout variant: the animation classes must be on
|
|
82
|
-
* the DOM before paint, or the new rail shows one frame at its final spot. */
|
|
83
|
-
const useIsomorphicLayoutEffect =
|
|
84
|
-
typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect
|
|
85
|
-
|
|
86
|
-
function getIcon(name: string): LucideIcon {
|
|
87
|
-
return ICON_MAP[name] ?? Home
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function NavItem({
|
|
91
|
-
item,
|
|
92
|
-
collapsed,
|
|
93
|
-
isActive,
|
|
94
|
-
onNavigate,
|
|
95
|
-
}: {
|
|
96
|
-
item: NavigationItem
|
|
97
|
-
collapsed: boolean
|
|
98
|
-
isActive: boolean
|
|
99
|
-
onNavigate: (route: string) => void
|
|
100
|
-
}) {
|
|
101
|
-
const Icon = getIcon(item.icon)
|
|
102
|
-
const isExternal = !!item.href
|
|
103
|
-
|
|
104
|
-
const className = cn(
|
|
105
|
-
'flex w-full items-center gap-3 rounded-md px-2.5 py-1.5 text-[13px] font-medium transition-colors',
|
|
106
|
-
'hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground',
|
|
107
|
-
isActive
|
|
108
|
-
? 'bg-sidebar-accent font-semibold text-sidebar-accent-foreground shadow-sm'
|
|
109
|
-
: 'text-sidebar-foreground/80',
|
|
110
|
-
collapsed && 'justify-center px-2',
|
|
111
|
-
)
|
|
112
|
-
|
|
113
|
-
const inner = (
|
|
114
|
-
<>
|
|
115
|
-
<Icon className="h-4 w-4 shrink-0" />
|
|
116
|
-
{!collapsed && (
|
|
117
|
-
<>
|
|
118
|
-
<span className="flex-1 truncate text-left">{item.label}</span>
|
|
119
|
-
{isExternal && (
|
|
120
|
-
<ExternalLink className="ml-auto h-3.5 w-3.5 shrink-0 opacity-50" aria-hidden />
|
|
121
|
-
)}
|
|
122
|
-
{item.premium && (
|
|
123
|
-
<Crown className={cn('h-3.5 w-3.5 shrink-0 text-amber-500', !isExternal && 'ml-auto')} aria-hidden />
|
|
124
|
-
)}
|
|
125
|
-
{item.badge !== undefined && (
|
|
126
|
-
<span className={cn('rounded-full bg-primary px-2 py-0.5 text-xs font-semibold text-primary-foreground', !isExternal && !item.premium && 'ml-auto')}>
|
|
127
|
-
{item.badge}
|
|
128
|
-
</span>
|
|
129
|
-
)}
|
|
130
|
-
</>
|
|
131
|
-
)}
|
|
132
|
-
</>
|
|
133
|
-
)
|
|
134
|
-
|
|
135
|
-
const content = isExternal ? (
|
|
136
|
-
<a href={item.href} target="_blank" rel="noopener noreferrer" className={className}>
|
|
137
|
-
{inner}
|
|
138
|
-
</a>
|
|
139
|
-
) : (
|
|
140
|
-
<button onClick={() => onNavigate(item.route)} className={className}>
|
|
141
|
-
{inner}
|
|
142
|
-
</button>
|
|
143
|
-
)
|
|
144
|
-
|
|
145
|
-
if (collapsed) {
|
|
146
|
-
return (
|
|
147
|
-
<Tooltip.Root delayDuration={0}>
|
|
148
|
-
<Tooltip.Trigger asChild>{content}</Tooltip.Trigger>
|
|
149
|
-
<Tooltip.Portal>
|
|
150
|
-
<Tooltip.Content
|
|
151
|
-
side="right"
|
|
152
|
-
sideOffset={8}
|
|
153
|
-
className="fayz-glass-surface z-50 rounded-md bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md"
|
|
154
|
-
>
|
|
155
|
-
{item.label}
|
|
156
|
-
{item.premium && (
|
|
157
|
-
<Crown className="ml-1.5 inline h-3 w-3 text-amber-500" aria-hidden />
|
|
158
|
-
)}
|
|
159
|
-
{item.badge !== undefined && (
|
|
160
|
-
<span className="ml-2 rounded-full bg-primary px-1.5 py-0.5 text-xs text-primary-foreground">
|
|
161
|
-
{item.badge}
|
|
162
|
-
</span>
|
|
163
|
-
)}
|
|
164
|
-
<Tooltip.Arrow className="fill-popover" />
|
|
165
|
-
</Tooltip.Content>
|
|
166
|
-
</Tooltip.Portal>
|
|
167
|
-
</Tooltip.Root>
|
|
168
|
-
)
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
return content
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/** How specifically `route` matches `path` (-1 = no match; higher = more specific).
|
|
175
|
-
* An exact match beats a prefix match; a longer prefix beats a shorter one. */
|
|
176
|
-
function routeMatchScore(route: string, path: string): number {
|
|
177
|
-
if (path === route) return route.length + 1
|
|
178
|
-
if (route !== '/' && path.startsWith(route + '/')) return route.length
|
|
179
|
-
return -1
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/** The single most-specific nav route that matches the current path (longest wins).
|
|
183
|
-
* Prevents a parent like `/courses` from staying active on `/courses/sales`. */
|
|
184
|
-
function resolveActiveRoute(navigation: NavigationItem[], path: string): string | null {
|
|
185
|
-
let best: string | null = null
|
|
186
|
-
let bestScore = 0
|
|
187
|
-
const consider = (route?: string) => {
|
|
188
|
-
if (!route) return
|
|
189
|
-
const score = routeMatchScore(route, path)
|
|
190
|
-
if (score > bestScore) { bestScore = score; best = route }
|
|
191
|
-
}
|
|
192
|
-
for (const item of navigation) {
|
|
193
|
-
consider(item.route)
|
|
194
|
-
for (const child of item.children ?? []) consider(child.route)
|
|
195
|
-
}
|
|
196
|
-
return best
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function NavGroup({
|
|
200
|
-
item,
|
|
201
|
-
collapsed,
|
|
202
|
-
activeRoute,
|
|
203
|
-
onNavigate,
|
|
204
|
-
}: {
|
|
205
|
-
item: NavigationItem
|
|
206
|
-
collapsed: boolean
|
|
207
|
-
activeRoute: string | null
|
|
208
|
-
onNavigate: (route: string) => void
|
|
209
|
-
}) {
|
|
210
|
-
const isChildActive = item.children?.some((c) => c.route === activeRoute)
|
|
211
|
-
const [open, setOpen] = React.useState(() => isChildActive ?? false)
|
|
212
|
-
const Icon = getIcon(item.icon)
|
|
213
|
-
|
|
214
|
-
if (collapsed) {
|
|
215
|
-
return (
|
|
216
|
-
<Tooltip.Root delayDuration={0}>
|
|
217
|
-
<Tooltip.Trigger asChild>
|
|
218
|
-
<button
|
|
219
|
-
onClick={() => onNavigate(item.route)}
|
|
220
|
-
className={cn(
|
|
221
|
-
'flex w-full items-center justify-center rounded-md px-2 py-2 text-sm font-medium transition-colors',
|
|
222
|
-
'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
|
223
|
-
isChildActive ? 'bg-sidebar-accent text-sidebar-accent-foreground' : 'text-sidebar-muted'
|
|
224
|
-
)}
|
|
225
|
-
>
|
|
226
|
-
<Icon className="h-4 w-4 shrink-0" />
|
|
227
|
-
</button>
|
|
228
|
-
</Tooltip.Trigger>
|
|
229
|
-
<Tooltip.Portal>
|
|
230
|
-
<Tooltip.Content side="right" sideOffset={8} className="fayz-glass-surface z-50 rounded-md bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md">
|
|
231
|
-
{item.label}
|
|
232
|
-
<Tooltip.Arrow className="fill-popover" />
|
|
233
|
-
</Tooltip.Content>
|
|
234
|
-
</Tooltip.Portal>
|
|
235
|
-
</Tooltip.Root>
|
|
236
|
-
)
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
return (
|
|
240
|
-
<div>
|
|
241
|
-
<button
|
|
242
|
-
onClick={() => setOpen(!open)}
|
|
243
|
-
className={cn(
|
|
244
|
-
'flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
|
245
|
-
'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
|
246
|
-
isChildActive ? 'text-sidebar-accent-foreground' : 'text-sidebar-muted'
|
|
247
|
-
)}
|
|
248
|
-
>
|
|
249
|
-
<Icon className="h-4 w-4 shrink-0" />
|
|
250
|
-
<span className="flex-1 truncate text-left">{item.label}</span>
|
|
251
|
-
<ChevronDown className={cn('h-3.5 w-3.5 shrink-0 transition-transform', open && 'rotate-180')} />
|
|
252
|
-
</button>
|
|
253
|
-
{open && (
|
|
254
|
-
<div className="ml-4 mt-0.5 space-y-0.5 border-l border-sidebar-border/50 pl-3">
|
|
255
|
-
{item.children?.map((child) => {
|
|
256
|
-
const ChildIcon = getIcon(child.icon)
|
|
257
|
-
const isActive = child.route === activeRoute
|
|
258
|
-
const childExternal = !!child.href
|
|
259
|
-
const childClass = cn(
|
|
260
|
-
'flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-sm transition-colors',
|
|
261
|
-
isActive
|
|
262
|
-
? 'font-medium text-sidebar-accent-foreground'
|
|
263
|
-
: 'text-sidebar-muted hover:text-sidebar-accent-foreground'
|
|
264
|
-
)
|
|
265
|
-
const childInner = (
|
|
266
|
-
<>
|
|
267
|
-
<ChildIcon className="h-3.5 w-3.5 shrink-0" />
|
|
268
|
-
<span className="flex-1 truncate text-left">{child.label}</span>
|
|
269
|
-
{childExternal && (
|
|
270
|
-
<ExternalLink className="h-3 w-3 shrink-0 opacity-50" aria-hidden />
|
|
271
|
-
)}
|
|
272
|
-
{child.premium && (
|
|
273
|
-
<Crown className="h-3 w-3 shrink-0 text-amber-500" aria-hidden />
|
|
274
|
-
)}
|
|
275
|
-
</>
|
|
276
|
-
)
|
|
277
|
-
return childExternal ? (
|
|
278
|
-
<a key={child.id} href={child.href} target="_blank" rel="noopener noreferrer" className={childClass}>
|
|
279
|
-
{childInner}
|
|
280
|
-
</a>
|
|
281
|
-
) : (
|
|
282
|
-
<button key={child.id} onClick={() => onNavigate(child.route)} className={childClass}>
|
|
283
|
-
{childInner}
|
|
284
|
-
</button>
|
|
285
|
-
)
|
|
286
|
-
})}
|
|
287
|
-
</div>
|
|
288
|
-
)}
|
|
289
|
-
</div>
|
|
290
|
-
)
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
export function Sidebar({
|
|
294
|
-
navigation,
|
|
295
|
-
logo,
|
|
296
|
-
collapsed,
|
|
297
|
-
onToggle,
|
|
298
|
-
onNavigate,
|
|
299
|
-
currentPath: currentPathProp = '/',
|
|
300
|
-
user: _user,
|
|
301
|
-
orgSwitcher,
|
|
302
|
-
topContent,
|
|
303
|
-
footerContent,
|
|
304
|
-
borderless,
|
|
305
|
-
userMenuSlot,
|
|
306
|
-
notificationSlot,
|
|
307
|
-
unreadCount = 0,
|
|
308
|
-
sectionLabels,
|
|
309
|
-
navKey,
|
|
310
|
-
navDirection = 'forward',
|
|
311
|
-
}: SidebarProps) {
|
|
312
|
-
const currentPath = currentPathProp
|
|
313
|
-
const setCommandPaletteOpen = useLayoutStore((s) => s.setCommandPaletteOpen)
|
|
314
|
-
|
|
315
|
-
// Most-specific match wins, so sibling routes that share a prefix (e.g.
|
|
316
|
-
// /courses vs /courses/sales) don't both light up.
|
|
317
|
-
const activeRoute = resolveActiveRoute(navigation, currentPath)
|
|
318
|
-
|
|
319
|
-
const mainItems = navigation.filter((item) => item.section === 'main')
|
|
320
|
-
const secondaryItems = navigation.filter((item) => item.section === 'secondary')
|
|
321
|
-
const settingsItems = navigation.filter((item) => item.section === 'settings')
|
|
322
|
-
|
|
323
|
-
const handleNavigate = (route: string) => {
|
|
324
|
-
onNavigate?.(route)
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
const sectionHeading = (label?: string) =>
|
|
328
|
-
label && !collapsed ? (
|
|
329
|
-
<p className="px-2.5 pb-1 pt-1 text-[10px] font-semibold uppercase tracking-wider text-sidebar-muted">
|
|
330
|
-
{label}
|
|
331
|
-
</p>
|
|
332
|
-
) : null
|
|
333
|
-
|
|
334
|
-
const renderItem = (item: NavigationItem) =>
|
|
335
|
-
item.children && item.children.length > 0 ? (
|
|
336
|
-
<NavGroup key={item.id} item={item} collapsed={collapsed} activeRoute={activeRoute} onNavigate={handleNavigate} />
|
|
337
|
-
) : (
|
|
338
|
-
<NavItem
|
|
339
|
-
key={item.id}
|
|
340
|
-
item={item}
|
|
341
|
-
collapsed={collapsed}
|
|
342
|
-
isActive={item.route === activeRoute}
|
|
343
|
-
onNavigate={handleNavigate}
|
|
344
|
-
/>
|
|
345
|
-
)
|
|
346
|
-
|
|
347
|
-
// The swappable part of the rail (top slot + both nav sections). Kept as one
|
|
348
|
-
// node so a context change can snapshot the outgoing rail and animate it out.
|
|
349
|
-
const navBody = (
|
|
350
|
-
<>
|
|
351
|
-
{topContent && (
|
|
352
|
-
<div className="mb-4 space-y-2">
|
|
353
|
-
{React.isValidElement(topContent)
|
|
354
|
-
? React.cloneElement(topContent as React.ReactElement<any>, { collapsed })
|
|
355
|
-
: topContent}
|
|
356
|
-
</div>
|
|
357
|
-
)}
|
|
358
|
-
{mainItems.length > 0 && (
|
|
359
|
-
<div className="space-y-1">
|
|
360
|
-
{sectionHeading(sectionLabels?.main)}
|
|
361
|
-
{mainItems.map(renderItem)}
|
|
362
|
-
</div>
|
|
363
|
-
)}
|
|
364
|
-
{secondaryItems.length > 0 && (
|
|
365
|
-
<div className="mt-6 space-y-1">
|
|
366
|
-
{sectionHeading(sectionLabels?.secondary)}
|
|
367
|
-
{secondaryItems.map(renderItem)}
|
|
368
|
-
</div>
|
|
369
|
-
)}
|
|
370
|
-
</>
|
|
371
|
-
)
|
|
372
|
-
|
|
373
|
-
// Rail push: when `navKey` changes, hold the previous rail on screen for one
|
|
374
|
-
// animation cycle. `lastBodyRef` is refreshed after every render, so the
|
|
375
|
-
// snapshot is the rail exactly as it looked before the swap (correct active
|
|
376
|
-
// item included) rather than a stale one.
|
|
377
|
-
const [outgoing, setOutgoing] = React.useState<{ body: React.ReactNode; direction: 'forward' | 'back' } | null>(null)
|
|
378
|
-
const lastBodyRef = React.useRef<React.ReactNode>(null)
|
|
379
|
-
const lastKeyRef = React.useRef(navKey)
|
|
380
|
-
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
381
|
-
|
|
382
|
-
useIsomorphicLayoutEffect(() => {
|
|
383
|
-
if (lastKeyRef.current !== navKey) {
|
|
384
|
-
const previousBody = lastBodyRef.current
|
|
385
|
-
lastKeyRef.current = navKey
|
|
386
|
-
if (previousBody) {
|
|
387
|
-
setOutgoing({ body: previousBody, direction: navDirection })
|
|
388
|
-
if (timerRef.current) clearTimeout(timerRef.current)
|
|
389
|
-
timerRef.current = setTimeout(() => setOutgoing(null), RAIL_PUSH_MS)
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
lastBodyRef.current = navBody
|
|
393
|
-
})
|
|
394
|
-
|
|
395
|
-
React.useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current) }, [])
|
|
396
|
-
|
|
397
|
-
return (
|
|
398
|
-
<Tooltip.Provider>
|
|
399
|
-
<aside
|
|
400
|
-
data-print="hide"
|
|
401
|
-
className={cn(
|
|
402
|
-
'flex h-full flex-col bg-sidebar text-sidebar-foreground transition-all duration-200',
|
|
403
|
-
!borderless && 'border-r border-sidebar-border',
|
|
404
|
-
collapsed ? 'w-16' : 'w-64'
|
|
405
|
-
)}
|
|
406
|
-
>
|
|
407
|
-
{/* Logo + Collapse Toggle */}
|
|
408
|
-
<div
|
|
409
|
-
className={cn(
|
|
410
|
-
'flex h-14 items-center justify-between px-4',
|
|
411
|
-
!borderless && 'border-b border-sidebar-border',
|
|
412
|
-
collapsed && 'justify-center px-2'
|
|
413
|
-
)}
|
|
414
|
-
>
|
|
415
|
-
{!collapsed && (
|
|
416
|
-
<>
|
|
417
|
-
{logo ?? <span className="text-lg font-bold">App</span>}
|
|
418
|
-
<button
|
|
419
|
-
onClick={onToggle}
|
|
420
|
-
className="ml-auto rounded-md p-1.5 text-sidebar-muted transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
|
421
|
-
aria-label="Collapse sidebar"
|
|
422
|
-
>
|
|
423
|
-
<PanelLeftClose className="h-4 w-4" />
|
|
424
|
-
</button>
|
|
425
|
-
</>
|
|
426
|
-
)}
|
|
427
|
-
{collapsed && (
|
|
428
|
-
<button
|
|
429
|
-
onClick={onToggle}
|
|
430
|
-
className="rounded-md p-1.5 text-sidebar-muted transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
|
431
|
-
aria-label="Expand sidebar"
|
|
432
|
-
>
|
|
433
|
-
<PanelLeft className="h-4 w-4" />
|
|
434
|
-
</button>
|
|
435
|
-
)}
|
|
436
|
-
</div>
|
|
437
|
-
|
|
438
|
-
{/* Main Navigation. During a rail swap the outgoing nav is pinned on
|
|
439
|
-
top of the incoming one so the two cross-slide; overflow is clipped
|
|
440
|
-
for the duration so neither pane can scroll the rail sideways. */}
|
|
441
|
-
<nav
|
|
442
|
-
className={cn(
|
|
443
|
-
'relative flex-1 p-2',
|
|
444
|
-
// scrollbar-none: the rail still scrolls, but without the native
|
|
445
|
-
// scrollbar gutter eating into the nav items.
|
|
446
|
-
outgoing ? 'overflow-hidden' : 'overflow-y-auto scrollbar-none',
|
|
447
|
-
)}
|
|
448
|
-
>
|
|
449
|
-
{outgoing && (
|
|
450
|
-
<div
|
|
451
|
-
aria-hidden
|
|
452
|
-
className={cn(
|
|
453
|
-
'pointer-events-none absolute inset-x-2 top-2 space-y-1',
|
|
454
|
-
outgoing.direction === 'back' ? 'saas-rail-out-back' : 'saas-rail-out-forward',
|
|
455
|
-
)}
|
|
456
|
-
>
|
|
457
|
-
{outgoing.body}
|
|
458
|
-
</div>
|
|
459
|
-
)}
|
|
460
|
-
<div
|
|
461
|
-
key={navKey}
|
|
462
|
-
className={cn(
|
|
463
|
-
'space-y-1',
|
|
464
|
-
outgoing && (outgoing.direction === 'back' ? 'saas-rail-in-back' : 'saas-rail-in-forward'),
|
|
465
|
-
)}
|
|
466
|
-
>
|
|
467
|
-
{navBody}
|
|
468
|
-
</div>
|
|
469
|
-
</nav>
|
|
470
|
-
|
|
471
|
-
{/* Settings nav items */}
|
|
472
|
-
{settingsItems.length > 0 && (
|
|
473
|
-
<div className={cn('p-2 space-y-1', !borderless && 'border-t border-sidebar-border')}>
|
|
474
|
-
{settingsItems.map((item) => (
|
|
475
|
-
<NavItem
|
|
476
|
-
key={item.id}
|
|
477
|
-
item={item}
|
|
478
|
-
collapsed={collapsed}
|
|
479
|
-
isActive={item.route === activeRoute}
|
|
480
|
-
onNavigate={handleNavigate}
|
|
481
|
-
/>
|
|
482
|
-
))}
|
|
483
|
-
</div>
|
|
484
|
-
)}
|
|
485
|
-
|
|
486
|
-
{footerContent && (
|
|
487
|
-
<div className={cn('p-2', !borderless && 'border-t border-sidebar-border')}>
|
|
488
|
-
<div className="space-y-2">{footerContent}</div>
|
|
489
|
-
</div>
|
|
490
|
-
)}
|
|
491
|
-
|
|
492
|
-
{/* Org Switcher — above user row */}
|
|
493
|
-
{orgSwitcher && React.isValidElement(orgSwitcher)
|
|
494
|
-
? React.cloneElement(orgSwitcher as React.ReactElement<any>, { collapsed })
|
|
495
|
-
: orgSwitcher}
|
|
496
|
-
|
|
497
|
-
{/* Bottom: User row with inline action icons */}
|
|
498
|
-
<div className={cn('p-2', !borderless && 'border-t border-sidebar-border')}>
|
|
499
|
-
<div className={cn(
|
|
500
|
-
'flex items-center rounded-md',
|
|
501
|
-
collapsed ? 'flex-col gap-1' : 'gap-1 px-1'
|
|
502
|
-
)}>
|
|
503
|
-
{/* User Menu slot */}
|
|
504
|
-
{userMenuSlot}
|
|
505
|
-
|
|
506
|
-
{/* Spacer pushes icons to right when expanded */}
|
|
507
|
-
{!collapsed && <div className="flex-1" />}
|
|
508
|
-
|
|
509
|
-
{/* Search — opens command palette */}
|
|
510
|
-
<button
|
|
511
|
-
onClick={() => setCommandPaletteOpen(true)}
|
|
512
|
-
className="inline-flex shrink-0 items-center justify-center rounded-md p-2 text-sidebar-muted transition-colors hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground"
|
|
513
|
-
aria-label="Search"
|
|
514
|
-
>
|
|
515
|
-
<Search className="h-4 w-4" />
|
|
516
|
-
</button>
|
|
517
|
-
|
|
518
|
-
{/* Notification slot or default bell */}
|
|
519
|
-
{notificationSlot ?? (
|
|
520
|
-
<button
|
|
521
|
-
className="relative inline-flex shrink-0 items-center justify-center rounded-md p-2 text-sidebar-muted transition-colors hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground"
|
|
522
|
-
aria-label={unreadCount > 0 ? `${unreadCount} unread notifications` : 'Notifications'}
|
|
523
|
-
>
|
|
524
|
-
<Bell className="h-4 w-4" />
|
|
525
|
-
{unreadCount > 0 && (
|
|
526
|
-
<span className="absolute right-1 top-1 h-2 w-2 rounded-full bg-destructive" />
|
|
527
|
-
)}
|
|
528
|
-
</button>
|
|
529
|
-
)}
|
|
530
|
-
</div>
|
|
531
|
-
</div>
|
|
532
|
-
</aside>
|
|
533
|
-
</Tooltip.Provider>
|
|
534
|
-
)
|
|
535
|
-
}
|