@fayz-ai/ui 0.8.2 → 0.8.3
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-J3IUFUNR.js → chunk-2GDQUARM.js} +3 -3
- package/dist/{chunk-J3IUFUNR.js.map → chunk-2GDQUARM.js.map} +1 -1
- package/dist/{chunk-W5O2X277.js → chunk-A7O7KGNL.js} +8 -4
- package/dist/chunk-A7O7KGNL.js.map +1 -0
- package/dist/{chunk-FY7OZNKU.js → chunk-BVF2KPTT.js} +7 -7
- package/dist/{chunk-FY7OZNKU.js.map → chunk-BVF2KPTT.js.map} +1 -1
- package/dist/{chunk-MZZMZYSE.js → chunk-ID2HQK2Q.js} +328 -51
- package/dist/chunk-ID2HQK2Q.js.map +1 -0
- package/dist/{chunk-ODY3GZ3Y.cjs → chunk-MI6FOB4O.cjs} +7 -7
- package/dist/{chunk-ODY3GZ3Y.cjs.map → chunk-MI6FOB4O.cjs.map} +1 -1
- package/dist/{chunk-IEYCOJTY.cjs → chunk-SVVKUMSL.cjs} +6 -6
- package/dist/{chunk-IEYCOJTY.cjs.map → chunk-SVVKUMSL.cjs.map} +1 -1
- package/dist/{chunk-NWPMNPGR.cjs → chunk-WF3STNDX.cjs} +38 -34
- package/dist/chunk-WF3STNDX.cjs.map +1 -0
- package/dist/{chunk-XAOGMWLC.cjs → chunk-XIRJGVDK.cjs} +325 -48
- package/dist/chunk-XIRJGVDK.cjs.map +1 -0
- package/dist/dashboard/OnboardingWidget.d.ts.map +1 -1
- package/dist/dashboard/index.cjs +26 -26
- package/dist/dashboard/index.js +2 -2
- package/dist/index.cjs +122 -122
- package/dist/index.js +4 -4
- package/dist/layout/AppShell.d.ts +41 -1
- package/dist/layout/AppShell.d.ts.map +1 -1
- package/dist/layout/ModulePage.d.ts +9 -6
- package/dist/layout/ModulePage.d.ts.map +1 -1
- package/dist/layout/Sidebar.d.ts +20 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Topbar.d.ts +13 -1
- package/dist/layout/Topbar.d.ts.map +1 -1
- package/dist/layout/index.cjs +19 -19
- package/dist/layout/index.js +1 -1
- package/dist/primitives/data-table.d.ts.map +1 -1
- package/dist/primitives/index.cjs +77 -77
- package/dist/primitives/index.js +2 -2
- package/package.json +3 -3
- package/src/dashboard/OnboardingWidget.tsx +10 -1
- package/src/layout/AppShell.tsx +255 -7
- package/src/layout/ModulePage.tsx +32 -3
- package/src/layout/Sidebar.tsx +132 -35
- package/src/layout/Topbar.tsx +51 -4
- package/src/primitives/data-table.tsx +10 -1
- package/src/styles.css +34 -0
- package/dist/chunk-MZZMZYSE.js.map +0 -1
- package/dist/chunk-NWPMNPGR.cjs.map +0 -1
- package/dist/chunk-W5O2X277.js.map +0 -1
- package/dist/chunk-XAOGMWLC.cjs.map +0 -1
package/src/layout/AppShell.tsx
CHANGED
|
@@ -98,6 +98,10 @@ export interface AppShellProps {
|
|
|
98
98
|
onProfile?: () => void
|
|
99
99
|
onSettings?: () => void
|
|
100
100
|
onBilling?: () => void
|
|
101
|
+
/** Current-plan pill shown next to the user in the topbar menu. */
|
|
102
|
+
userPlan?: { label: string; paid: boolean }
|
|
103
|
+
/** i18n label for the billing/subscription user-menu item. */
|
|
104
|
+
billingLabel?: string
|
|
101
105
|
pageTitle?: string
|
|
102
106
|
currentPath?: string
|
|
103
107
|
userMenuExtras?: { label: string; icon?: React.ReactNode; onClick: () => void }[]
|
|
@@ -117,6 +121,35 @@ export interface AppShellProps {
|
|
|
117
121
|
notificationSlot?: React.ReactNode
|
|
118
122
|
/** Unread notification count */
|
|
119
123
|
unreadCount?: number
|
|
124
|
+
/** Optional headings above the sidebar nav sections (contextual sidebars). */
|
|
125
|
+
sidebarSectionLabels?: { main?: string; secondary?: string; settings?: string }
|
|
126
|
+
/** Identity of the current sidebar context — changing it animates the rail
|
|
127
|
+
* swap as a push (new nav in, previous nav out). */
|
|
128
|
+
sidebarNavKey?: string
|
|
129
|
+
/** Push direction for a `sidebarNavKey` change. Default 'forward'. */
|
|
130
|
+
sidebarNavDirection?: 'forward' | 'back'
|
|
131
|
+
/**
|
|
132
|
+
* Full-height column pinned to the right (the assistant dock, a tasks
|
|
133
|
+
* panel). Rendered as a flex SIBLING of the layout, so the app narrows to
|
|
134
|
+
* make room instead of being covered — the difference between a split view
|
|
135
|
+
* and a modal that happens to be tall.
|
|
136
|
+
*
|
|
137
|
+
* Falsy renders nothing and costs nothing: no wrapper, no divider, the
|
|
138
|
+
* layout element stands alone exactly as before.
|
|
139
|
+
*/
|
|
140
|
+
rightRail?: React.ReactNode
|
|
141
|
+
/** Whether the rail is showing. Separate from `rightRail` so the column can
|
|
142
|
+
* animate its width instead of snapping when it closes. */
|
|
143
|
+
rightRailOpen?: boolean
|
|
144
|
+
/** Starting width in px. Ignored once the user drags. Default 380. */
|
|
145
|
+
defaultRightRailWidth?: number
|
|
146
|
+
/** Fired after every drag so the host can persist the preference. */
|
|
147
|
+
onRightRailWidthChange?: (width: number) => void
|
|
148
|
+
/** Drag bounds in px. Defaults 300 / 720. */
|
|
149
|
+
rightRailMinWidth?: number
|
|
150
|
+
rightRailMaxWidth?: number
|
|
151
|
+
/** Accessible name for the drag handle. */
|
|
152
|
+
rightRailResizeLabel?: string
|
|
120
153
|
}
|
|
121
154
|
|
|
122
155
|
// ---------------------------------------------------------------------------
|
|
@@ -147,6 +180,9 @@ function SidebarLayout({
|
|
|
147
180
|
unreadCount = 0,
|
|
148
181
|
hasBottomNav = false,
|
|
149
182
|
mobileHeader = 'minimal',
|
|
183
|
+
sidebarSectionLabels,
|
|
184
|
+
sidebarNavKey,
|
|
185
|
+
sidebarNavDirection,
|
|
150
186
|
}: {
|
|
151
187
|
navigation?: NavigationItem[]
|
|
152
188
|
logo?: React.ReactNode
|
|
@@ -174,6 +210,9 @@ function SidebarLayout({
|
|
|
174
210
|
hasBottomNav?: boolean
|
|
175
211
|
/** Mobile header treatment (<md). Desktop keeps its top bar untouched. */
|
|
176
212
|
mobileHeader?: MobileHeaderVariant
|
|
213
|
+
sidebarSectionLabels?: AppShellProps['sidebarSectionLabels']
|
|
214
|
+
sidebarNavKey?: string
|
|
215
|
+
sidebarNavDirection?: 'forward' | 'back'
|
|
177
216
|
}) {
|
|
178
217
|
const sidebarCollapsed = useLayoutStore((s) => s.sidebarCollapsed)
|
|
179
218
|
const setSidebarCollapsed = useLayoutStore((s) => s.setSidebarCollapsed)
|
|
@@ -213,6 +252,9 @@ function SidebarLayout({
|
|
|
213
252
|
userMenuSlot={userMenuSlot}
|
|
214
253
|
notificationSlot={notificationSlot}
|
|
215
254
|
unreadCount={unreadCount}
|
|
255
|
+
sectionLabels={sidebarSectionLabels}
|
|
256
|
+
navKey={sidebarNavKey}
|
|
257
|
+
navDirection={sidebarNavDirection}
|
|
216
258
|
/>
|
|
217
259
|
</div>
|
|
218
260
|
|
|
@@ -257,7 +299,7 @@ function SidebarLayout({
|
|
|
257
299
|
</div>
|
|
258
300
|
|
|
259
301
|
{/* Page content */}
|
|
260
|
-
<main className="flex-1 overflow-y-auto pb-16 md:pb-0">
|
|
302
|
+
<main className="min-w-0 flex-1 overflow-y-auto pb-16 md:pb-0">
|
|
261
303
|
{children}
|
|
262
304
|
</main>
|
|
263
305
|
|
|
@@ -307,11 +349,15 @@ function TopbarLayout({
|
|
|
307
349
|
onProfile,
|
|
308
350
|
onSettings,
|
|
309
351
|
onBilling,
|
|
352
|
+
userPlan,
|
|
353
|
+
billingLabel,
|
|
310
354
|
userMenuExtras,
|
|
355
|
+
orgSwitcher,
|
|
311
356
|
frame,
|
|
312
357
|
currentPath,
|
|
313
358
|
topbarStart,
|
|
314
359
|
topbarEnd,
|
|
360
|
+
notificationSlot,
|
|
315
361
|
hasBottomNav = false,
|
|
316
362
|
}: {
|
|
317
363
|
navigation?: NavigationItem[]
|
|
@@ -323,11 +369,17 @@ function TopbarLayout({
|
|
|
323
369
|
onProfile?: () => void
|
|
324
370
|
onSettings?: () => void
|
|
325
371
|
onBilling?: () => void
|
|
372
|
+
userPlan?: AppShellProps['userPlan']
|
|
373
|
+
billingLabel?: AppShellProps['billingLabel']
|
|
326
374
|
userMenuExtras?: AppShellProps['userMenuExtras']
|
|
375
|
+
/** Workspace switcher — rendered beside the logo (desktop) and in the mobile drawer. */
|
|
376
|
+
orgSwitcher?: React.ReactNode
|
|
327
377
|
frame?: boolean
|
|
328
378
|
currentPath?: string
|
|
329
379
|
topbarStart?: React.ReactNode
|
|
330
380
|
topbarEnd?: React.ReactNode
|
|
381
|
+
/** Bell/inbox slot — in the topbar layout it renders with the right-side actions. */
|
|
382
|
+
notificationSlot?: React.ReactNode
|
|
331
383
|
/** When a mobile bottom-nav bar is present, the mobile hamburger is
|
|
332
384
|
* suppressed so the bottom nav is the primary mobile navigation. */
|
|
333
385
|
hasBottomNav?: boolean
|
|
@@ -348,17 +400,25 @@ function TopbarLayout({
|
|
|
348
400
|
onProfile={onProfile}
|
|
349
401
|
onSettings={onSettings}
|
|
350
402
|
onBilling={onBilling}
|
|
403
|
+
userPlan={userPlan}
|
|
404
|
+
billingLabel={billingLabel}
|
|
351
405
|
userMenuExtras={userMenuExtras}
|
|
406
|
+
orgSwitcher={orgSwitcher}
|
|
352
407
|
leftContent={topbarStart}
|
|
353
|
-
rightContent={topbarEnd}
|
|
408
|
+
rightContent={(topbarEnd || notificationSlot) ? <>{topbarEnd}{notificationSlot}</> : undefined}
|
|
354
409
|
onMenuClick={hasBottomNav ? undefined : () => setMobileMenuOpen(true)}
|
|
355
410
|
/>
|
|
356
411
|
{/* Frame inset/border only from md up, so mobile stays edge-to-edge. The
|
|
357
412
|
inset gap behind the framed card is the header color (bg-sidebar), so the
|
|
358
413
|
white card reads as a panel floating on the same navy as the topbar. */}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
414
|
+
{/* The frame is fixed to the viewport and the content scrolls INSIDE it,
|
|
415
|
+
mirroring SidebarLayout. With `main` as the scroller the card grew with
|
|
416
|
+
the page and its bottom edge rode off-screen. */}
|
|
417
|
+
<main className={cn('flex min-w-0 min-h-0 flex-1 flex-col overflow-hidden', frame && 'md:bg-sidebar md:p-2')}>
|
|
418
|
+
<div className={cn('flex min-w-0 min-h-0 flex-1 flex-col overflow-hidden', frame && 'md:rounded-xl md:border md:border-border md:bg-card')}>
|
|
419
|
+
<div className="min-h-0 flex-1 overflow-y-auto pb-16 md:pb-0">
|
|
420
|
+
{children}
|
|
421
|
+
</div>
|
|
362
422
|
</div>
|
|
363
423
|
</main>
|
|
364
424
|
|
|
@@ -378,6 +438,7 @@ function TopbarLayout({
|
|
|
378
438
|
onProfile={onProfile}
|
|
379
439
|
onSettings={onSettings}
|
|
380
440
|
onBilling={onBilling}
|
|
441
|
+
orgSwitcher={orgSwitcher}
|
|
381
442
|
userMenuSlot={user ? <DrawerUserRow user={user} /> : undefined}
|
|
382
443
|
onClose={() => setMobileMenuOpen(false)}
|
|
383
444
|
/>
|
|
@@ -427,7 +488,7 @@ function MinimalLayout({
|
|
|
427
488
|
</div>
|
|
428
489
|
{headerEnd && <div className="flex items-center gap-2">{headerEnd}</div>}
|
|
429
490
|
</header>
|
|
430
|
-
<main className="flex-1 overflow-y-auto pb-16 md:pb-0">
|
|
491
|
+
<main className="min-w-0 flex-1 overflow-y-auto pb-16 md:pb-0">
|
|
431
492
|
{children}
|
|
432
493
|
</main>
|
|
433
494
|
</div>
|
|
@@ -629,6 +690,8 @@ export function AppShell({
|
|
|
629
690
|
onProfile,
|
|
630
691
|
onSettings,
|
|
631
692
|
onBilling,
|
|
693
|
+
userPlan,
|
|
694
|
+
billingLabel,
|
|
632
695
|
pageTitle,
|
|
633
696
|
currentPath,
|
|
634
697
|
userMenuExtras,
|
|
@@ -643,6 +706,16 @@ export function AppShell({
|
|
|
643
706
|
userMenuSlot,
|
|
644
707
|
notificationSlot,
|
|
645
708
|
unreadCount = 0,
|
|
709
|
+
sidebarSectionLabels,
|
|
710
|
+
sidebarNavKey,
|
|
711
|
+
sidebarNavDirection,
|
|
712
|
+
rightRail,
|
|
713
|
+
rightRailOpen = true,
|
|
714
|
+
defaultRightRailWidth,
|
|
715
|
+
onRightRailWidthChange,
|
|
716
|
+
rightRailMinWidth,
|
|
717
|
+
rightRailMaxWidth,
|
|
718
|
+
rightRailResizeLabel,
|
|
646
719
|
}: AppShellProps) {
|
|
647
720
|
const hasBottomNav = !!bottomNav?.length
|
|
648
721
|
const bottomBar = hasBottomNav ? (
|
|
@@ -686,6 +759,9 @@ export function AppShell({
|
|
|
686
759
|
unreadCount={unreadCount}
|
|
687
760
|
hasBottomNav={hasBottomNav}
|
|
688
761
|
mobileHeader={mobileHeader}
|
|
762
|
+
sidebarSectionLabels={sidebarSectionLabels}
|
|
763
|
+
sidebarNavKey={sidebarNavKey}
|
|
764
|
+
sidebarNavDirection={sidebarNavDirection}
|
|
689
765
|
>
|
|
690
766
|
{children}
|
|
691
767
|
</SidebarLayout>
|
|
@@ -703,11 +779,15 @@ export function AppShell({
|
|
|
703
779
|
onProfile={onProfile}
|
|
704
780
|
onSettings={onSettings}
|
|
705
781
|
onBilling={onBilling}
|
|
782
|
+
userPlan={userPlan}
|
|
783
|
+
billingLabel={billingLabel}
|
|
706
784
|
userMenuExtras={userMenuExtras}
|
|
785
|
+
orgSwitcher={orgSwitcher}
|
|
707
786
|
frame={contentFrame}
|
|
708
787
|
currentPath={currentPath}
|
|
709
788
|
topbarStart={topbarStart}
|
|
710
789
|
topbarEnd={topbarEnd}
|
|
790
|
+
notificationSlot={notificationSlot}
|
|
711
791
|
hasBottomNav={hasBottomNav}
|
|
712
792
|
>
|
|
713
793
|
{children}
|
|
@@ -731,9 +811,177 @@ export function AppShell({
|
|
|
731
811
|
|
|
732
812
|
return (
|
|
733
813
|
<>
|
|
734
|
-
{
|
|
814
|
+
{rightRail ? (
|
|
815
|
+
<SplitWithRightRail
|
|
816
|
+
rail={rightRail}
|
|
817
|
+
open={rightRailOpen}
|
|
818
|
+
frame={contentFrame}
|
|
819
|
+
defaultWidth={defaultRightRailWidth}
|
|
820
|
+
onWidthChange={onRightRailWidthChange}
|
|
821
|
+
minWidth={rightRailMinWidth}
|
|
822
|
+
maxWidth={rightRailMaxWidth}
|
|
823
|
+
resizeLabel={rightRailResizeLabel}
|
|
824
|
+
>
|
|
825
|
+
{layoutElement}
|
|
826
|
+
</SplitWithRightRail>
|
|
827
|
+
) : (
|
|
828
|
+
layoutElement
|
|
829
|
+
)}
|
|
735
830
|
{floatingAvatar}
|
|
736
831
|
{bottomBar}
|
|
737
832
|
</>
|
|
738
833
|
)
|
|
739
834
|
}
|
|
835
|
+
|
|
836
|
+
// ---------------------------------------------------------------------------
|
|
837
|
+
// SplitWithRightRail — the app on the left, a full-height rail on the right,
|
|
838
|
+
// a divider the user owns between them.
|
|
839
|
+
//
|
|
840
|
+
// The layouts all root at `h-screen overflow-hidden`, so putting them in a
|
|
841
|
+
// `flex-1 min-w-0` cell keeps their height and lets them shrink. `min-w-0` is
|
|
842
|
+
// load-bearing: without it a flex child refuses to go below its content width
|
|
843
|
+
// and the rail gets pushed off-screen instead of the content reflowing.
|
|
844
|
+
// ---------------------------------------------------------------------------
|
|
845
|
+
|
|
846
|
+
function SplitWithRightRail({
|
|
847
|
+
children,
|
|
848
|
+
rail,
|
|
849
|
+
open,
|
|
850
|
+
frame,
|
|
851
|
+
defaultWidth = 380,
|
|
852
|
+
onWidthChange,
|
|
853
|
+
minWidth = 300,
|
|
854
|
+
maxWidth = 720,
|
|
855
|
+
resizeLabel = 'Resize panel',
|
|
856
|
+
}: {
|
|
857
|
+
children: React.ReactNode
|
|
858
|
+
rail: React.ReactNode
|
|
859
|
+
open: boolean
|
|
860
|
+
/** Frame mode: the gutter between content and rail takes the sidebar tone,
|
|
861
|
+
* the same surface the content card floats on. Without this the split shows
|
|
862
|
+
* a bare strip of page background and the rail reads as bolted on. */
|
|
863
|
+
frame?: boolean
|
|
864
|
+
defaultWidth?: number
|
|
865
|
+
onWidthChange?: (width: number) => void
|
|
866
|
+
minWidth?: number
|
|
867
|
+
maxWidth?: number
|
|
868
|
+
resizeLabel?: string
|
|
869
|
+
}) {
|
|
870
|
+
const [width, setWidth] = React.useState(defaultWidth)
|
|
871
|
+
const [dragging, setDragging] = React.useState(false)
|
|
872
|
+
|
|
873
|
+
const clamp = React.useCallback(
|
|
874
|
+
(value: number) => Math.min(maxWidth, Math.max(minWidth, value)),
|
|
875
|
+
[minWidth, maxWidth],
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
// The app follows the pointer to the pixel, so the drag runs on window
|
|
879
|
+
// listeners rather than on the handle: the cursor routinely outruns a 6px
|
|
880
|
+
// target, and a resize that stops when you move too fast feels broken.
|
|
881
|
+
React.useEffect(() => {
|
|
882
|
+
if (!dragging) return
|
|
883
|
+
const onMove = (event: PointerEvent) => {
|
|
884
|
+
event.preventDefault()
|
|
885
|
+
setWidth(clamp(window.innerWidth - event.clientX))
|
|
886
|
+
}
|
|
887
|
+
const stop = () => setDragging(false)
|
|
888
|
+
window.addEventListener('pointermove', onMove)
|
|
889
|
+
window.addEventListener('pointerup', stop)
|
|
890
|
+
window.addEventListener('pointercancel', stop)
|
|
891
|
+
// Text selection across the whole app while dragging a divider is pure
|
|
892
|
+
// noise, and iframes/canvases would otherwise swallow the pointer.
|
|
893
|
+
const previousSelect = document.body.style.userSelect
|
|
894
|
+
const previousCursor = document.body.style.cursor
|
|
895
|
+
document.body.style.userSelect = 'none'
|
|
896
|
+
document.body.style.cursor = 'col-resize'
|
|
897
|
+
return () => {
|
|
898
|
+
window.removeEventListener('pointermove', onMove)
|
|
899
|
+
window.removeEventListener('pointerup', stop)
|
|
900
|
+
window.removeEventListener('pointercancel', stop)
|
|
901
|
+
document.body.style.userSelect = previousSelect
|
|
902
|
+
document.body.style.cursor = previousCursor
|
|
903
|
+
}
|
|
904
|
+
}, [dragging, clamp])
|
|
905
|
+
|
|
906
|
+
// Report only on release: firing per pointer frame would write to
|
|
907
|
+
// localStorage ~60×/second for one gesture.
|
|
908
|
+
React.useEffect(() => {
|
|
909
|
+
if (dragging) return
|
|
910
|
+
onWidthChange?.(width)
|
|
911
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
912
|
+
}, [dragging])
|
|
913
|
+
|
|
914
|
+
const nudge = (delta: number) => setWidth((current) => clamp(current + delta))
|
|
915
|
+
|
|
916
|
+
return (
|
|
917
|
+
// Sidebar tone always: the gutter and the divider sit in the shell's
|
|
918
|
+
// chrome, next to the menu — painting them page-white put a bright strip
|
|
919
|
+
// between the dark chrome and the rail, which read as a seam rather than
|
|
920
|
+
// as space.
|
|
921
|
+
<div className={cn('flex h-screen w-full overflow-hidden bg-sidebar', !frame && 'md:bg-background')}>
|
|
922
|
+
<div className="min-w-0 flex-1">{children}</div>
|
|
923
|
+
|
|
924
|
+
{/* Divider — desktop only; on a phone the rail is a sheet over the app,
|
|
925
|
+
and there is nothing to divide. */}
|
|
926
|
+
<div
|
|
927
|
+
role="separator"
|
|
928
|
+
aria-orientation="vertical"
|
|
929
|
+
aria-label={resizeLabel}
|
|
930
|
+
aria-valuenow={width}
|
|
931
|
+
aria-valuemin={minWidth}
|
|
932
|
+
aria-valuemax={maxWidth}
|
|
933
|
+
tabIndex={0}
|
|
934
|
+
onPointerDown={(event) => {
|
|
935
|
+
event.preventDefault()
|
|
936
|
+
setDragging(true)
|
|
937
|
+
}}
|
|
938
|
+
onDoubleClick={() => setWidth(clamp(defaultWidth))}
|
|
939
|
+
onKeyDown={(event) => {
|
|
940
|
+
// Keyboard resize: a pointer-only divider makes the split view
|
|
941
|
+
// unusable for anyone not using a mouse.
|
|
942
|
+
if (event.key === 'ArrowLeft') { event.preventDefault(); nudge(24) }
|
|
943
|
+
else if (event.key === 'ArrowRight') { event.preventDefault(); nudge(-24) }
|
|
944
|
+
else if (event.key === 'Home') { event.preventDefault(); setWidth(maxWidth) }
|
|
945
|
+
else if (event.key === 'End') { event.preventDefault(); setWidth(minWidth) }
|
|
946
|
+
}}
|
|
947
|
+
className={cn(
|
|
948
|
+
'group relative hidden w-1 shrink-0 cursor-col-resize focus-visible:outline-none',
|
|
949
|
+
open && 'md:block',
|
|
950
|
+
)}
|
|
951
|
+
>
|
|
952
|
+
{/* Invisible at rest: the gutter's own tone already separates the two
|
|
953
|
+
panes, and a permanent rule there just adds a line to look at.
|
|
954
|
+
It appears when you reach for it. */}
|
|
955
|
+
<span
|
|
956
|
+
className={cn(
|
|
957
|
+
'absolute inset-y-6 left-1/2 w-px -translate-x-1/2 rounded-full bg-transparent transition-colors',
|
|
958
|
+
'group-hover:bg-primary/60 group-focus-visible:bg-primary',
|
|
959
|
+
dragging && 'bg-primary',
|
|
960
|
+
)}
|
|
961
|
+
/>
|
|
962
|
+
{/* Hit area wider than the line: 1px is honest visually and hostile
|
|
963
|
+
to aim for. */}
|
|
964
|
+
<span className="absolute inset-y-0 -left-1.5 -right-1.5" />
|
|
965
|
+
</div>
|
|
966
|
+
|
|
967
|
+
{/* Rendered once: two copies would mount the rail's contents twice. On a
|
|
968
|
+
phone the box collapses to zero width and the rail positions itself
|
|
969
|
+
`fixed` over the screen. The frame inset is padding HERE rather than
|
|
970
|
+
margin on the rail — a full-height child plus vertical margins
|
|
971
|
+
overflows its own box. */}
|
|
972
|
+
<aside
|
|
973
|
+
style={{ ['--fayz-rail-w' as string]: `${width}px` }}
|
|
974
|
+
className={cn(
|
|
975
|
+
'w-0 shrink-0 overflow-hidden',
|
|
976
|
+
// Animating WIDTH is what makes the app glide back instead of
|
|
977
|
+
// snapping; skipped mid-drag so the divider stays glued to the cursor.
|
|
978
|
+
!dragging && 'motion-safe:transition-[width] motion-safe:duration-200 motion-safe:ease-out',
|
|
979
|
+
open && 'md:w-[var(--fayz-rail-w)]',
|
|
980
|
+
frame && 'md:py-2 md:pr-2',
|
|
981
|
+
)}
|
|
982
|
+
>
|
|
983
|
+
{rail}
|
|
984
|
+
</aside>
|
|
985
|
+
</div>
|
|
986
|
+
)
|
|
987
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as React from 'react'
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
|
-
import { ChevronDown, ChevronRight, ChevronLeft } from 'lucide-react'
|
|
3
|
+
import { ChevronDown, ChevronRight, ChevronLeft, Crown } from 'lucide-react'
|
|
4
4
|
import { useTranslation } from '@fayz-ai/core'
|
|
5
5
|
import { cn } from '../utils/cn'
|
|
6
6
|
import { ICON_MAP } from './Topbar'
|
|
@@ -28,7 +28,31 @@ export interface ModuleNavItem {
|
|
|
28
28
|
icon?: string
|
|
29
29
|
active?: boolean
|
|
30
30
|
onClick?: () => void
|
|
31
|
-
|
|
31
|
+
/** Feature id for the access gate. When set, `useModuleNavAccess` (in
|
|
32
|
+
* @fayz-ai/saas) resolves this link's role (hide) and plan (Crown) state. */
|
|
33
|
+
feature?: string
|
|
34
|
+
/** Freemium discovery: the user's role allows this link but their plan does
|
|
35
|
+
* not entitle `feature`. Renders a small Crown badge (same vocabulary as the
|
|
36
|
+
* Sidebar top-level nav); the click still navigates and the gated CONTENT
|
|
37
|
+
* shows the UpgradePrompt. Typically set by `useModuleNavAccess`. */
|
|
38
|
+
premium?: boolean
|
|
39
|
+
children?: ModuleNavItem[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// PremiumBadge — the small Crown affordance shared by every module-nav renderer
|
|
44
|
+
// (rail, mobile tabs, GHL header). Same vocabulary as the Sidebar top-level nav
|
|
45
|
+
// so a plan-gated sub-module link reads identically wherever it appears.
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
function PremiumBadge({ className }: { className?: string }) {
|
|
49
|
+
const t = useTranslation()
|
|
50
|
+
const tip = t('upgrade.badgeTooltip')
|
|
51
|
+
return (
|
|
52
|
+
<span title={tip} aria-label={tip} className={cn('inline-flex shrink-0', className)}>
|
|
53
|
+
<Crown className="h-3.5 w-3.5 text-amber-500" aria-hidden />
|
|
54
|
+
</span>
|
|
55
|
+
)
|
|
32
56
|
}
|
|
33
57
|
|
|
34
58
|
export interface ModulePageProps {
|
|
@@ -92,6 +116,7 @@ function NavItem({ item }: { item: ModuleNavItem }) {
|
|
|
92
116
|
>
|
|
93
117
|
{Icon && <Icon className="h-4 w-4 shrink-0" />}
|
|
94
118
|
<span className="flex-1 text-left truncate">{item.label}</span>
|
|
119
|
+
{item.premium && <PremiumBadge className="ml-1" />}
|
|
95
120
|
{hasChildren && (
|
|
96
121
|
expanded
|
|
97
122
|
? <ChevronDown className="h-3.5 w-3.5 shrink-0 opacity-50" />
|
|
@@ -112,7 +137,8 @@ function NavItem({ item }: { item: ModuleNavItem }) {
|
|
|
112
137
|
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
|
113
138
|
)}
|
|
114
139
|
>
|
|
115
|
-
{child.label}
|
|
140
|
+
<span className="flex-1 text-left truncate">{child.label}</span>
|
|
141
|
+
{child.premium && <PremiumBadge className="ml-1" />}
|
|
116
142
|
</button>
|
|
117
143
|
))}
|
|
118
144
|
</div>
|
|
@@ -168,6 +194,7 @@ function MobileTabs({ nav }: { nav: ModuleNavItem[] }) {
|
|
|
168
194
|
>
|
|
169
195
|
{Icon && <Icon className="h-3 w-3" />}
|
|
170
196
|
{item.label}
|
|
197
|
+
{item.premium && <PremiumBadge />}
|
|
171
198
|
{hasChildren && <ChevronDown className={cn('h-2.5 w-2.5 transition-transform', isExpanded && 'rotate-180')} />}
|
|
172
199
|
</button>
|
|
173
200
|
)
|
|
@@ -193,6 +220,7 @@ function MobileTabs({ nav }: { nav: ModuleNavItem[] }) {
|
|
|
193
220
|
)}
|
|
194
221
|
>
|
|
195
222
|
{child.label}
|
|
223
|
+
{child.premium && <PremiumBadge />}
|
|
196
224
|
</button>
|
|
197
225
|
))}
|
|
198
226
|
</div>
|
|
@@ -359,6 +387,7 @@ function ModuleHeader({ nav }: { nav: ModuleNavItem[] }) {
|
|
|
359
387
|
>
|
|
360
388
|
{Icon && <Icon className="h-4 w-4" />}
|
|
361
389
|
{item.label}
|
|
390
|
+
{item.premium && <PremiumBadge />}
|
|
362
391
|
</button>
|
|
363
392
|
)
|
|
364
393
|
})}
|