@open-mercato/ui 0.5.1-develop.2975.ccbadc8198 → 0.5.1-develop.2996.ce62fd491c

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.
Files changed (31) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/backend/AppShell.js +274 -697
  3. package/dist/backend/AppShell.js.map +3 -3
  4. package/dist/backend/CrudForm.js +1 -1
  5. package/dist/backend/CrudForm.js.map +2 -2
  6. package/dist/backend/crud/CollapsibleZoneLayout.js +23 -3
  7. package/dist/backend/crud/CollapsibleZoneLayout.js.map +2 -2
  8. package/dist/backend/section-page/SectionNav.js +10 -8
  9. package/dist/backend/section-page/SectionNav.js.map +2 -2
  10. package/dist/backend/section-page/SectionPage.js +2 -2
  11. package/dist/backend/section-page/SectionPage.js.map +2 -2
  12. package/dist/backend/sidebar/SidebarCustomizationEditor.js +1303 -0
  13. package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +7 -0
  14. package/dist/backend/sidebar/customization-helpers.js +150 -0
  15. package/dist/backend/sidebar/customization-helpers.js.map +7 -0
  16. package/dist/primitives/switch.js +1 -2
  17. package/dist/primitives/switch.js.map +2 -2
  18. package/jest.setup.ts +13 -0
  19. package/package.json +3 -3
  20. package/src/backend/AppShell.tsx +245 -732
  21. package/src/backend/CrudForm.tsx +1 -1
  22. package/src/backend/__tests__/AppShell.test.tsx +1 -1
  23. package/src/backend/__tests__/CollapsibleZoneLayout.test.tsx +101 -0
  24. package/src/backend/__tests__/CrudForm.navigation.test.tsx +42 -0
  25. package/src/backend/__tests__/SidebarCustomizationEditor.test.tsx +200 -0
  26. package/src/backend/crud/CollapsibleZoneLayout.tsx +28 -3
  27. package/src/backend/section-page/SectionNav.tsx +14 -10
  28. package/src/backend/section-page/SectionPage.tsx +15 -10
  29. package/src/backend/sidebar/SidebarCustomizationEditor.tsx +1562 -0
  30. package/src/backend/sidebar/customization-helpers.ts +203 -0
  31. package/src/primitives/switch.tsx +1 -2
@@ -3,9 +3,10 @@ import * as React from 'react'
3
3
  import { createContext, useContext } from 'react'
4
4
  import Link from 'next/link'
5
5
  import Image from 'next/image'
6
- import { ChevronUp, ChevronDown } from 'lucide-react'
6
+ import { ChevronDown, Search, X } from 'lucide-react'
7
7
  import { Button } from '../primitives/button'
8
8
  import { IconButton } from '../primitives/icon-button'
9
+ import { Input } from '../primitives/input'
9
10
  import { Checkbox } from '../primitives/checkbox'
10
11
  import { Separator } from '../primitives/separator'
11
12
  import { FlashMessages } from './FlashMessages'
@@ -18,6 +19,7 @@ import { UpgradeActionBanner } from './upgrades/UpgradeActionBanner'
18
19
  import { PartialIndexBanner } from './indexes/PartialIndexBanner'
19
20
  import { useLocale, useT } from '@open-mercato/shared/lib/i18n/context'
20
21
  import { slugifySidebarId } from '@open-mercato/shared/modules/navigation/sidebarPreferences'
22
+ import { cloneSidebarGroups } from './sidebar/customization-helpers'
21
23
  import type { SectionNavGroup } from './section-page/types'
22
24
  import { InjectionSpot } from './injection/InjectionSpot'
23
25
  import type { InjectionMenuItem } from '@open-mercato/shared/modules/widgets/injection'
@@ -99,16 +101,8 @@ export type AppShellProps = {
99
101
 
100
102
  type Breadcrumb = Array<{ label: string; href?: string }>
101
103
 
102
- type SidebarCustomizationDraft = {
103
- order: string[]
104
- groupLabels: Record<string, string>
105
- itemLabels: Record<string, string>
106
- hiddenItemIds: Record<string, boolean>
107
- }
108
-
109
104
  type SidebarGroup = AppShellProps['groups'][number]
110
105
  type SidebarItem = SidebarGroup['items'][number]
111
- type SidebarRoleTarget = { id: string; name: string; hasPreference: boolean }
112
106
 
113
107
  function convertInjectedMenuItemToSidebarItem(item: InjectionMenuItem, title: string): SidebarItem | null {
114
108
  if (!item.href) return null
@@ -412,7 +406,7 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
412
406
  const locale = useLocale()
413
407
  const { payload: chromePayload, isReady: isChromeReady, isLoading: isChromeLoading } = useBackendChrome()
414
408
  const resolvedGroups = React.useMemo(
415
- () => AppShell.cloneGroups(chromePayload?.groups ?? groups),
409
+ () => cloneSidebarGroups(chromePayload?.groups ?? groups),
416
410
  [chromePayload?.groups, groups],
417
411
  )
418
412
  const resolvedSettingsSections = chromePayload?.settingsSections ?? settingsSections
@@ -433,19 +427,51 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
433
427
  const [openGroups, setOpenGroups] = React.useState<Record<string, boolean>>(() =>
434
428
  Object.fromEntries(resolvedGroups.map((g) => [resolveGroupKey(g), true])) as Record<string, boolean>
435
429
  )
436
- const [customizing, setCustomizing] = React.useState(false)
437
- const [customDraft, setCustomDraft] = React.useState<SidebarCustomizationDraft | null>(null)
438
- const [loadingPreferences, setLoadingPreferences] = React.useState(false)
439
- const [savingPreferences, setSavingPreferences] = React.useState(false)
440
- const [customizationError, setCustomizationError] = React.useState<string | null>(null)
441
- const [availableRoleTargets, setAvailableRoleTargets] = React.useState<SidebarRoleTarget[]>([])
442
- const [selectedRoleIds, setSelectedRoleIds] = React.useState<string[]>([])
443
- const [canApplyToRoles, setCanApplyToRoles] = React.useState(false)
444
- const originalNavRef = React.useRef<SidebarGroup[] | null>(null)
445
430
  const [headerTitle, setHeaderTitle] = React.useState<string | undefined>(currentTitle)
446
431
  const [headerBreadcrumb, setHeaderBreadcrumb] = React.useState<Breadcrumb | undefined>(breadcrumb)
447
- const effectiveCollapsed = customizing ? false : collapsed
448
- const expandedSidebarWidth = customizing ? '320px' : '240px'
432
+ const [navQuery, setNavQuery] = React.useState('')
433
+ const navQueryNorm = navQuery.trim().toLowerCase()
434
+ const navQueryActive = navQueryNorm.length > 0
435
+ const matchesQuery = React.useCallback((label: string | undefined) => {
436
+ if (!navQueryActive) return true
437
+ if (!label) return false
438
+ return label.toLowerCase().includes(navQueryNorm)
439
+ }, [navQueryActive, navQueryNorm])
440
+ const effectiveCollapsed = collapsed
441
+ const expandedSidebarWidth = '240px'
442
+
443
+ // Track scroll position of the desktop sidebar's inner scroll container so we can
444
+ // flip the affordance chevron between down/up (and hide it entirely when content
445
+ // fits without scrolling). The inner div is rendered deep in renderSidebar /
446
+ // renderSectionSidebar — we tag it with `data-sidebar-scroll="true"` and look it
447
+ // up via the aside ref so we don't have to thread refs through the JSX tree.
448
+ const sidebarAsideRef = React.useRef<HTMLElement>(null)
449
+ const [sidebarScrollState, setSidebarScrollState] = React.useState<'down' | 'up' | 'none'>('down')
450
+ React.useEffect(() => {
451
+ const aside = sidebarAsideRef.current
452
+ if (!aside) return
453
+ const target = aside.querySelector<HTMLElement>('[data-sidebar-scroll="true"]')
454
+ if (!target) return
455
+ const update = () => {
456
+ const { scrollTop, scrollHeight, clientHeight } = target
457
+ const canScroll = scrollHeight > clientHeight + 1
458
+ if (!canScroll) {
459
+ setSidebarScrollState('none')
460
+ return
461
+ }
462
+ const atBottom = scrollTop + clientHeight >= scrollHeight - 8
463
+ setSidebarScrollState(atBottom ? 'up' : 'down')
464
+ }
465
+ update()
466
+ target.addEventListener('scroll', update, { passive: true })
467
+ const ro = new ResizeObserver(update)
468
+ ro.observe(target)
469
+ return () => {
470
+ target.removeEventListener('scroll', update)
471
+ ro.disconnect()
472
+ }
473
+ // eslint-disable-next-line react-hooks/exhaustive-deps
474
+ }, [pathname, effectiveCollapsed])
449
475
  const injectionContext = React.useMemo(
450
476
  () => ({
451
477
  path: pathname ?? '',
@@ -507,264 +533,22 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
507
533
 
508
534
  const toggleGroup = (groupId: string) => setOpenGroups((prev) => ({ ...prev, [groupId]: prev[groupId] === false }))
509
535
 
510
- const updateDraft = React.useCallback((updater: (draft: SidebarCustomizationDraft) => SidebarCustomizationDraft) => {
511
- setCustomDraft((prev) => {
512
- if (!prev) return prev
513
- const next = updater(prev)
514
- if (originalNavRef.current) {
515
- setNavGroups(applyCustomizationDraft(originalNavRef.current, next))
516
- }
517
- return next
518
- })
519
- }, [])
520
-
521
- const startCustomization = React.useCallback(async () => {
522
- if (customizing || loadingPreferences) return
523
- setCustomizationError(null)
524
- setLoadingPreferences(true)
525
- try {
526
- const baseSnapshot = filterMainSidebarGroups(AppShell.cloneGroups(navGroups))
527
- const call = await apiCall<{
528
- settings?: Record<string, unknown>
529
- canApplyToRoles?: boolean
530
- roles?: Array<{ id?: string; name?: string; hasPreference?: boolean }>
531
- }>('/api/auth/sidebar/preferences')
532
- const data = call.ok ? (call.result ?? null) : null
533
- const rawSettings = data?.settings
534
- const responseOrder = Array.isArray(rawSettings?.groupOrder)
535
- ? rawSettings.groupOrder
536
- .map((id: unknown) => (typeof id === 'string' ? id.trim() : ''))
537
- .filter((id: string) => id.length > 0)
538
- : []
539
- const responseGroupLabels: Record<string, string> = {}
540
- if (rawSettings?.groupLabels && typeof rawSettings.groupLabels === 'object') {
541
- for (const [key, value] of Object.entries(rawSettings.groupLabels as Record<string, unknown>)) {
542
- if (typeof value !== 'string') continue
543
- const trimmedKey = key.trim()
544
- if (!trimmedKey) continue
545
- responseGroupLabels[trimmedKey] = value
546
- }
547
- }
548
- const responseItemLabels: Record<string, string> = {}
549
- if (rawSettings?.itemLabels && typeof rawSettings.itemLabels === 'object') {
550
- for (const [key, value] of Object.entries(rawSettings.itemLabels as Record<string, unknown>)) {
551
- if (typeof value !== 'string') continue
552
- const trimmedKey = key.trim()
553
- if (!trimmedKey) continue
554
- responseItemLabels[trimmedKey] = value
555
- }
556
- }
557
- const responseHiddenItems = Array.isArray(rawSettings?.hiddenItems)
558
- ? rawSettings.hiddenItems
559
- .map((itemId: unknown) => (typeof itemId === 'string' ? itemId.trim() : ''))
560
- .filter((itemId: string) => itemId.length > 0)
561
- : []
562
- const canManageRoles = data?.canApplyToRoles === true
563
- setCanApplyToRoles(canManageRoles)
564
- if (canManageRoles) {
565
- const roles = Array.isArray(data?.roles)
566
- ? (data.roles as Array<{ id?: string; name?: string; hasPreference?: boolean }>).filter((role) => typeof role?.id === 'string' && typeof role?.name === 'string')
567
- : []
568
- const mappedRoles: SidebarRoleTarget[] = roles.map((role) => ({
569
- id: role.id as string,
570
- name: role.name as string,
571
- hasPreference: role.hasPreference === true,
572
- }))
573
- setAvailableRoleTargets(mappedRoles)
574
- setSelectedRoleIds(mappedRoles.filter((role) => role.hasPreference).map((role) => role.id))
575
- } else {
576
- setAvailableRoleTargets([])
577
- setSelectedRoleIds([])
578
- }
579
- const currentIds = baseSnapshot.map((group) => resolveGroupKey(group))
580
- const order = mergeGroupOrder(responseOrder, currentIds)
581
- const { itemDefaults } = collectSidebarDefaults(baseSnapshot)
582
- const hiddenItemIds: Record<string, boolean> = {}
583
- for (const itemId of responseHiddenItems) {
584
- if (!itemDefaults.has(itemId)) continue
585
- hiddenItemIds[itemId] = true
586
- }
587
- const draft: SidebarCustomizationDraft = {
588
- order,
589
- groupLabels: { ...responseGroupLabels },
590
- itemLabels: { ...responseItemLabels },
591
- hiddenItemIds,
592
- }
593
- originalNavRef.current = baseSnapshot
594
- setCustomDraft(draft)
595
- setNavGroups(applyCustomizationDraft(baseSnapshot, draft))
596
- setCustomizing(true)
597
- } catch (error) {
598
- console.error('Failed to load sidebar preferences', error)
599
- setCustomizationError(t('appShell.sidebarCustomizationLoadError'))
600
- } finally {
601
- setLoadingPreferences(false)
602
- }
603
- }, [customizing, loadingPreferences, navGroups, t])
604
-
605
- const cancelCustomization = React.useCallback(() => {
606
- setCustomizing(false)
607
- setCustomDraft(null)
608
- setCustomizationError(null)
609
- setAvailableRoleTargets([])
610
- setSelectedRoleIds([])
611
- setCanApplyToRoles(false)
612
- if (originalNavRef.current) {
613
- setNavGroups(AppShell.cloneGroups(originalNavRef.current))
614
- }
615
- originalNavRef.current = null
616
- }, [])
617
-
618
- const resetCustomization = React.useCallback(() => {
619
- if (!originalNavRef.current) return
620
- const base = AppShell.cloneGroups(originalNavRef.current)
621
- const order = base.map((group) => resolveGroupKey(group))
622
- const draft: SidebarCustomizationDraft = { order, groupLabels: {}, itemLabels: {}, hiddenItemIds: {} }
623
- originalNavRef.current = base
624
- setCustomDraft(draft)
625
- setNavGroups(applyCustomizationDraft(base, draft))
626
- if (canApplyToRoles) {
627
- setSelectedRoleIds(availableRoleTargets.filter((role) => role.hasPreference).map((role) => role.id))
628
- }
629
- }, [availableRoleTargets, canApplyToRoles])
630
-
631
- const saveCustomization = React.useCallback(async () => {
632
- if (!customDraft) return
633
- setSavingPreferences(true)
634
- setCustomizationError(null)
635
- try {
636
- const baseGroups = originalNavRef.current ?? filterMainSidebarGroups(AppShell.cloneGroups(navGroups))
637
- const { groupDefaults, itemDefaults } = collectSidebarDefaults(baseGroups)
638
- const sanitizedGroupLabels: Record<string, string> = {}
639
- for (const [key, value] of Object.entries(customDraft.groupLabels)) {
640
- const trimmed = value.trim()
641
- const base = groupDefaults.get(key)
642
- if (!trimmed || !base) continue
643
- if (trimmed !== base) sanitizedGroupLabels[key] = trimmed
644
- }
645
- const sanitizedItemLabels: Record<string, string> = {}
646
- for (const [itemId, value] of Object.entries(customDraft.itemLabels)) {
647
- const trimmed = value.trim()
648
- const base = itemDefaults.get(itemId)
649
- if (!trimmed || !base) continue
650
- if (trimmed !== base) sanitizedItemLabels[itemId] = trimmed
651
- }
652
- const sanitizedHiddenItems: string[] = []
653
- for (const [itemId, hidden] of Object.entries(customDraft.hiddenItemIds)) {
654
- if (!hidden) continue
655
- if (!itemDefaults.has(itemId)) continue
656
- sanitizedHiddenItems.push(itemId)
657
- }
658
- const applyToRolesPayload = canApplyToRoles ? [...selectedRoleIds] : []
659
- const clearRoleIdsPayload = canApplyToRoles
660
- ? availableRoleTargets
661
- .filter((role) => role.hasPreference && !selectedRoleIds.includes(role.id))
662
- .map((role) => role.id)
663
- : []
664
- const payload: Record<string, unknown> = {
665
- groupOrder: customDraft.order,
666
- groupLabels: sanitizedGroupLabels,
667
- itemLabels: sanitizedItemLabels,
668
- hiddenItems: sanitizedHiddenItems,
669
- }
670
- if (canApplyToRoles) {
671
- payload.applyToRoles = applyToRolesPayload
672
- payload.clearRoleIds = clearRoleIdsPayload
673
- }
674
- const call = await apiCall<{
675
- canApplyToRoles?: boolean
676
- roles?: Array<{ id?: string; name?: string; hasPreference?: boolean }>
677
- }>('/api/auth/sidebar/preferences', {
678
- method: 'PUT',
679
- headers: { 'content-type': 'application/json' },
680
- body: JSON.stringify(payload),
681
- })
682
- if (!call.ok) {
683
- setCustomizationError(t('appShell.sidebarCustomizationSaveError'))
684
- return
685
- }
686
- const data = call.result ?? null
687
- if (data?.canApplyToRoles !== undefined) {
688
- setCanApplyToRoles(data.canApplyToRoles === true)
689
- }
690
- if (Array.isArray(data?.roles)) {
691
- const mappedRoles: SidebarRoleTarget[] = (data.roles as Array<{ id?: string; name?: string; hasPreference?: boolean }>).filter((role) => typeof role?.id === 'string' && typeof role?.name === 'string').map((role) => ({
692
- id: role.id as string,
693
- name: role.name as string,
694
- hasPreference: role.hasPreference === true,
695
- }))
696
- setAvailableRoleTargets(mappedRoles)
697
- setSelectedRoleIds(mappedRoles.filter((role) => role.hasPreference).map((role) => role.id))
698
- }
699
- originalNavRef.current = applyCustomizationDraft(baseGroups, customDraft)
700
- setNavGroups(AppShell.cloneGroups(originalNavRef.current))
701
- setCustomizing(false)
702
- setCustomDraft(null)
703
- try { window.dispatchEvent(new Event('om:refresh-sidebar')) } catch {}
704
- } catch (error) {
705
- console.error('Failed to save sidebar preferences', error)
706
- setCustomizationError(t('appShell.sidebarCustomizationSaveError'))
707
- } finally {
708
- setSavingPreferences(false)
709
- }
710
- }, [customDraft, navGroups, t])
711
-
712
- const moveGroup = React.useCallback((groupId: string, offset: number) => {
713
- updateDraft((draft) => {
714
- const order = [...draft.order]
715
- const index = order.indexOf(groupId)
716
- if (index === -1) return draft
717
- const nextIndex = Math.max(0, Math.min(order.length - 1, index + offset))
718
- if (nextIndex === index) return draft
719
- order.splice(index, 1)
720
- order.splice(nextIndex, 0, groupId)
721
- return { ...draft, order }
722
- })
723
- }, [updateDraft])
724
-
725
- const setGroupLabel = React.useCallback((groupId: string, value: string) => {
726
- updateDraft((draft) => {
727
- const next = { ...draft.groupLabels }
728
- if (value.trim().length === 0) delete next[groupId]
729
- else next[groupId] = value
730
- return { ...draft, groupLabels: next }
731
- })
732
- }, [updateDraft])
733
-
734
- const setItemLabel = React.useCallback((itemId: string, value: string) => {
735
- updateDraft((draft) => {
736
- const next = { ...draft.itemLabels }
737
- if (value.trim().length === 0) delete next[itemId]
738
- else next[itemId] = value
739
- return { ...draft, itemLabels: next }
740
- })
741
- }, [updateDraft])
742
- const setItemHidden = React.useCallback((itemId: string, hidden: boolean) => {
743
- updateDraft((draft) => {
744
- const next = { ...draft.hiddenItemIds }
745
- if (hidden) next[itemId] = true
746
- else delete next[itemId]
747
- return { ...draft, hiddenItemIds: next }
748
- })
749
- }, [updateDraft])
750
-
751
- const toggleRoleSelection = React.useCallback((roleId: string) => {
752
- setSelectedRoleIds((prev) => (prev.includes(roleId) ? prev.filter((id) => id !== roleId) : [...prev, roleId]))
753
- }, [])
754
-
755
- const asideWidth = effectiveCollapsed ? '72px' : expandedSidebarWidth
536
+ const asideWidth = effectiveCollapsed ? '80px' : expandedSidebarWidth
756
537
  // Use min-h-svh so the border extends with tall content; no overflow so sticky bottom works
757
- const asideClassesBase = `border-r bg-background/80 py-4 min-h-svh`;
538
+ const asideClassesBase = `border-r bg-background py-4`;
758
539
 
759
- // Persist collapse state to localStorage and cookie
540
+ // Persist collapse state to localStorage and cookie. Both writes can throw in
541
+ // private/incognito mode (storage blocked) or when cookies are disabled —
542
+ // the persisted preference is purely a UX nice-to-have, never functional, so
543
+ // swallow the failure and let the component fall back to the default state.
760
544
  React.useEffect(() => {
761
- try { localStorage.setItem('om:sidebarCollapsed', collapsed ? '1' : '0') } catch {}
545
+ try { localStorage.setItem('om:sidebarCollapsed', collapsed ? '1' : '0') } catch { /* localStorage blocked (private mode) — non-critical */ }
762
546
  try {
763
547
  document.cookie = `om_sidebar_collapsed=${collapsed ? '1' : '0'}; path=/; max-age=31536000; samesite=lax`
764
- } catch {}
548
+ } catch { /* cookies disabled — non-critical */ }
765
549
  }, [collapsed])
766
550
  React.useEffect(() => {
767
- try { localStorage.setItem('om:sidebarOpenGroups', JSON.stringify(openGroups)) } catch {}
551
+ try { localStorage.setItem('om:sidebarOpenGroups', JSON.stringify(openGroups)) } catch { /* localStorage blocked (private mode) — non-critical */ }
768
552
  }, [openGroups])
769
553
 
770
554
  // Ensure current route's group is expanded on load
@@ -793,13 +577,8 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
793
577
 
794
578
  // Keep navGroups in sync when server-provided groups change
795
579
  React.useEffect(() => {
796
- if (customizing && customDraft && originalNavRef.current) {
797
- originalNavRef.current = filterMainSidebarGroups(AppShell.cloneGroups(resolvedGroups))
798
- setNavGroups(applyCustomizationDraft(originalNavRef.current, customDraft))
799
- return
800
- }
801
- setNavGroups(AppShell.cloneGroups(resolvedGroups))
802
- }, [resolvedGroups, customizing, customDraft])
580
+ setNavGroups(cloneSidebarGroups(resolvedGroups))
581
+ }, [resolvedGroups])
803
582
 
804
583
  function renderSectionSidebar(
805
584
  sections: SectionNavGroup[],
@@ -811,27 +590,53 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
811
590
  const lastVisibleIndex = sortedSections.length - 1
812
591
 
813
592
  return (
814
- <div className="flex flex-col min-h-full gap-3">
593
+ <div className="flex h-full flex-col gap-3">
815
594
  {!hideHeader && (
816
- <div className={`flex items-center ${compact ? 'justify-center' : 'justify-between'} mb-2`}>
817
- <Link href="/backend" className="flex items-center gap-2" aria-label={t('appShell.goToDashboard')}>
818
- <Image src={logo?.src ?? "/open-mercato.svg"} alt={logo?.alt ?? resolvedProductName} width={32} height={32} className="rounded m-4" />
819
- {!compact && <div className="text-m font-semibold">{resolvedProductName}</div>}
595
+ <div className="mb-2">
596
+ <Link
597
+ href="/backend"
598
+ className={`flex items-center gap-3 rounded-xl transition-colors hover:bg-muted ${compact ? 'p-2 justify-center' : 'p-3'}`}
599
+ aria-label={t('appShell.goToDashboard')}
600
+ >
601
+ <Image src={logo?.src ?? "/open-mercato.svg"} alt={logo?.alt ?? resolvedProductName} width={40} height={40} className="rounded-full shrink-0" />
602
+ {!compact && <span className="text-sm font-medium text-foreground">{resolvedProductName}</span>}
820
603
  </Link>
821
604
  </div>
822
605
  )}
823
- <div className="flex flex-1 flex-col gap-3 overflow-y-auto pr-1">
824
- <Link
825
- href="/backend"
826
- className={`flex items-center gap-2 ${compact ? 'justify-center px-2' : 'px-2'} py-1 text-sm text-muted-foreground hover:text-foreground transition-colors`}
827
- aria-label={t('backend.nav.backToMain', 'Back')}
828
- >
829
- <span className="flex items-center justify-center shrink-0">{BackArrowIcon}</span>
830
- {!compact && <span>{title}</span>}
831
- </Link>
606
+ {!compact && (
607
+ <Input
608
+ type="text"
609
+ value={navQuery}
610
+ onChange={(e) => setNavQuery(e.target.value)}
611
+ placeholder={t('appShell.searchNavPlaceholder', 'Search...')}
612
+ aria-label={t('appShell.searchNavAria', 'Search navigation')}
613
+ leftIcon={<Search aria-hidden />}
614
+ rightIcon={navQueryActive ? (
615
+ <IconButton
616
+ type="button"
617
+ variant="ghost"
618
+ size="xs"
619
+ onClick={() => setNavQuery('')}
620
+ aria-label={t('appShell.searchNavClear', 'Clear search')}
621
+ >
622
+ <X className="size-3.5" aria-hidden />
623
+ </IconButton>
624
+ ) : undefined}
625
+ className="mb-2"
626
+ />
627
+ )}
628
+ <div data-sidebar-scroll="true" className={`flex flex-1 flex-col gap-3 overflow-y-auto scrollbar-hide pr-1 ${compact ? '-ml-2 pl-2' : '-ml-3 pl-3'}`}>
832
629
  <nav className="flex flex-col gap-2">
833
630
  {sortedSections.map((section, sectionIndex) => {
834
- const visibleItems = section.items
631
+ const matchesItemQuery = (item: typeof section.items[number]): boolean => {
632
+ if (!navQueryActive) return true
633
+ const label = item.labelKey ? t(item.labelKey, item.label) : item.label
634
+ if (matchesQuery(label)) return true
635
+ return Array.isArray(item.children) && item.children.some(matchesItemQuery)
636
+ }
637
+ const visibleItems = navQueryActive
638
+ ? section.items.filter(matchesItemQuery)
639
+ : section.items
835
640
  if (visibleItems.length === 0) return null
836
641
  const sortedItems = [...visibleItems].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
837
642
  const sectionLabel = section.labelKey ? t(section.labelKey, section.label) : section.label
@@ -839,10 +644,15 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
839
644
  const open = openGroups[sectionKey] !== false
840
645
  const sortSectionItems = (items: typeof section.items = []) =>
841
646
  [...items].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
647
+ const filterChildren = (children: typeof section.items | undefined) => {
648
+ if (!children) return [] as typeof section.items
649
+ if (!navQueryActive) return [...children]
650
+ return children.filter(matchesItemQuery)
651
+ }
842
652
 
843
653
  const renderSectionItem = (item: (typeof section.items)[number], depth = 0): React.ReactNode => {
844
654
  const label = item.labelKey ? t(item.labelKey, item.label) : item.label
845
- const childItems = sortSectionItems(item.children)
655
+ const childItems = sortSectionItems(filterChildren(item.children))
846
656
  const isOnItemBranch = !!pathname && (
847
657
  pathname === item.href ||
848
658
  pathname.startsWith(`${item.href}/`)
@@ -851,13 +661,13 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
851
661
  pathname === child.href ||
852
662
  pathname.startsWith(`${child.href}/`)
853
663
  )))
854
- const showChildren = childItems.length > 0 && isOnItemBranch
664
+ const showChildren = childItems.length > 0 && (isOnItemBranch || navQueryActive)
855
665
  const isActive = isOnItemBranch || hasActiveChild
856
- const base = compact ? 'w-10 h-10 justify-center' : 'py-1 gap-2'
666
+ const base = compact ? 'w-10 h-10 justify-center' : 'w-full py-2 gap-2'
857
667
  const spacingStyle = !compact
858
668
  ? {
859
- paddingLeft: `${8 + depth * 16}px`,
860
- paddingRight: '8px',
669
+ paddingLeft: `${12 + depth * 16}px`,
670
+ paddingRight: '12px',
861
671
  }
862
672
  : undefined
863
673
 
@@ -865,10 +675,10 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
865
675
  <React.Fragment key={item.id}>
866
676
  <Link
867
677
  href={item.href}
868
- className={`relative text-sm rounded inline-flex items-center ${base} ${
678
+ className={`relative text-sm font-medium rounded-lg inline-flex items-center ${base} ${
869
679
  isActive
870
- ? 'bg-background border shadow-sm'
871
- : 'hover:bg-accent hover:text-accent-foreground'
680
+ ? 'bg-muted text-foreground'
681
+ : 'text-muted-foreground hover:bg-muted'
872
682
  }`}
873
683
  style={spacingStyle}
874
684
  title={compact ? label : undefined}
@@ -876,9 +686,9 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
876
686
  onClick={() => setMobileOpen(false)}
877
687
  >
878
688
  {isActive && (
879
- <span className="absolute left-0 top-1 bottom-1 w-0.5 rounded bg-foreground" />
689
+ <span aria-hidden className={`absolute ${compact ? 'left-[-20px]' : 'left-[-12px]'} top-2 w-1 h-5 rounded-r bg-foreground`} />
880
690
  )}
881
- <span className={`flex items-center justify-center shrink-0 ${compact ? '' : 'text-muted-foreground'}`}>
691
+ <span className="flex items-center justify-center shrink-0">
882
692
  {renderIcon(
883
693
  item.icon,
884
694
  item.iconName,
@@ -895,21 +705,23 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
895
705
 
896
706
  return (
897
707
  <div key={section.id}>
898
- <Button
899
- variant="muted"
900
- onClick={() => toggleGroup(sectionKey)}
901
- className={`w-full ${compact ? 'px-0 justify-center' : 'px-2 justify-between'} flex text-xs uppercase text-muted-foreground/90 py-2`}
902
- aria-expanded={open}
903
- >
904
- {!compact && <span>{sectionLabel}</span>}
905
- {!compact && <Chevron open={open} />}
906
- </Button>
907
- {open && (
908
- <div className={`flex flex-col ${compact ? 'items-center' : ''} gap-1 ${!compact ? 'pl-1' : ''}`}>
708
+ {!compact && (
709
+ <Button
710
+ variant="muted"
711
+ onClick={() => toggleGroup(sectionKey)}
712
+ className="w-full px-1 justify-between flex text-xs font-medium uppercase tracking-wider text-muted-foreground/70 py-1"
713
+ aria-expanded={open}
714
+ >
715
+ <span>{sectionLabel}</span>
716
+ <Chevron open={open} />
717
+ </Button>
718
+ )}
719
+ {(open || compact) && (
720
+ <div className={`flex flex-col ${compact ? 'items-center' : ''} gap-1`}>
909
721
  {sortedItems.map((item) => renderSectionItem(item))}
910
722
  </div>
911
723
  )}
912
- {sectionIndex !== lastVisibleIndex && <div className="my-2 border-t border-dotted" />}
724
+ {sectionIndex !== lastVisibleIndex && <div className={`my-2 border-t ${compact ? '-ml-2 -mr-3' : '-ml-3 -mr-4'}`} />}
913
725
  </div>
914
726
  )
915
727
  })}
@@ -924,10 +736,14 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
924
736
  return (
925
737
  <div className="flex flex-col min-h-full gap-3" data-testid="backend-chrome-loading">
926
738
  {!hideHeader ? (
927
- <div className={`flex items-center ${compact ? 'justify-center' : 'justify-between'} mb-2`}>
928
- <Link href="/backend" className="flex items-center gap-2" aria-label={t('appShell.goToDashboard')}>
929
- <Image src={logo?.src ?? "/open-mercato.svg"} alt={logo?.alt ?? resolvedProductName} width={32} height={32} className="rounded m-4" />
930
- {!compact && <div className="text-m font-semibold">{resolvedProductName}</div>}
739
+ <div className="mb-2">
740
+ <Link
741
+ href="/backend"
742
+ className={`flex items-center gap-3 rounded-xl transition-colors hover:bg-muted ${compact ? 'p-2 justify-center' : 'p-3'}`}
743
+ aria-label={t('appShell.goToDashboard')}
744
+ >
745
+ <Image src={logo?.src ?? "/open-mercato.svg"} alt={logo?.alt ?? resolvedProductName} width={40} height={40} className="rounded-full shrink-0" />
746
+ {!compact && <span className="text-sm font-medium text-foreground">{resolvedProductName}</span>}
931
747
  </Link>
932
748
  </div>
933
749
  ) : null}
@@ -982,197 +798,18 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
982
798
 
983
799
  const isMobileVariant = !!hideHeader
984
800
  const shouldRenderSidebarInjectionSpots = !isMobileVariant
985
- const baseGroupsForDefaults = originalNavRef.current ?? mainNavGroupsWithInjected
986
- const baseGroupMap = new Map<string, SidebarGroup>()
987
- for (const group of baseGroupsForDefaults) {
988
- baseGroupMap.set(resolveGroupKey(group), group)
989
- }
990
- const localeLabel = (locale || '').toUpperCase()
991
-
992
- const orderedGroupIds = customDraft
993
- ? mergeGroupOrder(customDraft.order, Array.from(baseGroupMap.keys()))
994
- : mainNavGroupsWithInjected.map((group) => resolveGroupKey(group))
995
-
996
- const lastVisibleGroupIndex = (() => {
997
- for (let idx = navGroups.length - 1; idx >= 0; idx -= 1) {
998
- if (navGroups[idx].items.some((item) => item.hidden !== true)) return idx
999
- }
1000
- return -1
1001
- })()
1002
-
1003
- const renderEditableItems = (baseItems: SidebarItem[], currentItems: SidebarItem[], depth = 0): React.ReactNode => {
1004
- if (!customDraft) return null
1005
- return baseItems.map((baseItem) => {
1006
- const itemKey = resolveItemKey(baseItem)
1007
- const current = currentItems.find((item) => item.href === baseItem.href) ?? baseItem
1008
- const placeholder = baseItem.defaultTitle ?? baseItem.title
1009
- const value = customDraft.itemLabels[itemKey] ?? ''
1010
- const hidden = customDraft.hiddenItemIds[itemKey] === true
1011
- return (
1012
- <div
1013
- key={itemKey}
1014
- className={`flex flex-col gap-1 ${hidden ? 'opacity-60' : ''}`}
1015
- style={depth ? { marginLeft: depth * 16 } : undefined}
1016
- >
1017
- <span className="text-xs font-medium text-muted-foreground">{placeholder}</span>
1018
- <div className="flex items-center gap-2">
1019
- <Checkbox
1020
- checked={!hidden}
1021
- onCheckedChange={(next) => setItemHidden(itemKey, next !== true)}
1022
- disabled={savingPreferences}
1023
- aria-label={t('appShell.sidebarCustomizationShowItem')}
1024
- title={t('appShell.sidebarCustomizationShowItem')}
1025
- />
1026
- <input
1027
- value={value}
1028
- onChange={(event) => setItemLabel(itemKey, event.target.value)}
1029
- placeholder={placeholder}
1030
- disabled={savingPreferences}
1031
- className="h-8 flex-1 rounded border bg-background px-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
1032
- />
1033
- </div>
1034
- {baseItem.children && baseItem.children.length > 0 ? (
1035
- <div className="flex flex-col gap-1">
1036
- {renderEditableItems(baseItem.children, current.children ?? [], depth + 1)}
1037
- </div>
1038
- ) : null}
1039
- </div>
1040
- )
1041
- })
1042
- }
1043
-
1044
- const customizationEditor = customizing ? (
1045
- customDraft ? (
1046
- <div className="flex flex-col gap-3 rounded border border-dashed bg-muted/30 p-3">
1047
- <div className="flex flex-wrap items-center justify-between gap-2">
1048
- <div className="text-sm font-semibold">{t('appShell.sidebarCustomizationHeading')}</div>
1049
- <div className="flex items-center gap-2">
1050
- <Button
1051
- variant="outline"
1052
- size="sm"
1053
- onClick={resetCustomization}
1054
- disabled={savingPreferences}
1055
- >
1056
- {t('appShell.sidebarCustomizationReset')}
1057
- </Button>
1058
- <Button
1059
- variant="outline"
1060
- size="sm"
1061
- onClick={cancelCustomization}
1062
- disabled={savingPreferences}
1063
- >
1064
- {t('appShell.sidebarCustomizationCancel')}
1065
- </Button>
1066
- <Button
1067
- size="sm"
1068
- className="bg-foreground text-background hover:bg-foreground/90"
1069
- onClick={saveCustomization}
1070
- disabled={savingPreferences}
1071
- >
1072
- {savingPreferences ? t('appShell.sidebarCustomizationSaving') : t('appShell.sidebarCustomizationSave')}
1073
- </Button>
1074
- </div>
1075
- </div>
1076
- <p className="text-xs text-muted-foreground">{t('appShell.sidebarCustomizationHint', { locale: localeLabel })}</p>
1077
- {canApplyToRoles ? (
1078
- <div className="flex flex-col gap-2 rounded border bg-background/80 p-3 shadow-sm">
1079
- <div>
1080
- <div className="text-sm font-semibold">{t('appShell.sidebarApplyToRolesTitle')}</div>
1081
- <p className="text-xs text-muted-foreground">{t('appShell.sidebarApplyToRolesDescription')}</p>
1082
- </div>
1083
- {availableRoleTargets.length > 0 ? (
1084
- <div className="flex flex-col gap-2">
1085
- {availableRoleTargets.map((role) => {
1086
- const checked = selectedRoleIds.includes(role.id)
1087
- const willClear = role.hasPreference && !checked
1088
- return (
1089
- <label key={role.id} className="flex items-center gap-2 rounded-md border bg-background px-2 py-1 text-sm shadow-sm cursor-pointer">
1090
- <Checkbox
1091
- checked={checked}
1092
- onCheckedChange={() => toggleRoleSelection(role.id)}
1093
- disabled={savingPreferences}
1094
- />
1095
- <span className="flex-1 truncate">{role.name}</span>
1096
- {role.hasPreference ? (
1097
- <span className={`text-xs ${willClear ? 'text-destructive' : 'text-muted-foreground'}`}>
1098
- {willClear ? t('appShell.sidebarRoleWillClear') : t('appShell.sidebarRoleHasPreset')}
1099
- </span>
1100
- ) : null}
1101
- </label>
1102
- )
1103
- })}
1104
- </div>
1105
- ) : (
1106
- <p className="text-xs text-muted-foreground">{t('appShell.sidebarApplyToRolesEmpty')}</p>
1107
- )}
1108
- </div>
1109
- ) : null}
1110
- {customizationError ? <p className="text-xs text-destructive">{customizationError}</p> : null}
1111
- <div className="flex flex-col gap-3">
1112
- {orderedGroupIds.map((groupId, index) => {
1113
- const baseGroup = baseGroupMap.get(groupId)
1114
- if (!baseGroup) return null
1115
- const currentGroup = navGroups.find((group) => resolveGroupKey(group) === groupId) ?? baseGroup
1116
- const placeholder = baseGroup.defaultName ?? baseGroup.name
1117
- const value = customDraft.groupLabels[groupId] ?? ''
1118
- return (
1119
- <div key={groupId} className="flex flex-col gap-3 rounded border bg-background p-3 shadow-sm">
1120
- <div className={`flex ${compact ? 'flex-col gap-2' : 'items-center gap-2'}`}>
1121
- <div className="flex-1">
1122
- <span className="text-xs font-medium text-muted-foreground">{t('appShell.sidebarCustomizationGroupLabel')}</span>
1123
- <input
1124
- value={value}
1125
- onChange={(event) => setGroupLabel(groupId, event.target.value)}
1126
- placeholder={placeholder}
1127
- disabled={savingPreferences}
1128
- className="mt-1 h-8 w-full rounded border bg-background px-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
1129
- />
1130
- </div>
1131
- <div className="flex items-center gap-1 self-start">
1132
- <IconButton
1133
- variant="outline"
1134
- size="sm"
1135
- className="text-muted-foreground hover:text-foreground"
1136
- onClick={() => moveGroup(groupId, -1)}
1137
- disabled={index === 0 || savingPreferences}
1138
- aria-label={t('appShell.sidebarCustomizationMoveUp')}
1139
- >
1140
- <ChevronUp className="size-4" />
1141
- </IconButton>
1142
- <IconButton
1143
- variant="outline"
1144
- size="sm"
1145
- className="text-muted-foreground hover:text-foreground"
1146
- onClick={() => moveGroup(groupId, 1)}
1147
- disabled={index === orderedGroupIds.length - 1 || savingPreferences}
1148
- aria-label={t('appShell.sidebarCustomizationMoveDown')}
1149
- >
1150
- <ChevronDown className="size-4" />
1151
- </IconButton>
1152
- </div>
1153
- </div>
1154
- <div className="flex flex-col gap-2">
1155
- {renderEditableItems(baseGroup.items, currentGroup.items)}
1156
- </div>
1157
- </div>
1158
- )
1159
- })}
1160
- </div>
1161
- </div>
1162
- ) : (
1163
- <div className="rounded border border-dashed bg-muted/30 p-3 text-sm text-muted-foreground">
1164
- {t('appShell.sidebarCustomizationLoading')}
1165
- </div>
1166
- )
1167
- ) : null
1168
801
 
1169
802
  return (
1170
- <div className="flex flex-col min-h-full gap-3">
803
+ <div className="flex h-full flex-col gap-3">
1171
804
  {!hideHeader && (
1172
- <div className={`flex items-center ${compact ? 'justify-center' : 'justify-between'} mb-2`}>
1173
- <Link href="/backend" className="flex items-center gap-2" aria-label={t('appShell.goToDashboard')}>
1174
- <Image src={logo?.src ?? "/open-mercato.svg"} alt={logo?.alt ?? resolvedProductName} width={32} height={32} className="rounded m-4" />
1175
- {!compact && <div className="text-m font-semibold">{resolvedProductName}</div>}
805
+ <div className="mb-2">
806
+ <Link
807
+ href="/backend"
808
+ className={`flex items-center gap-3 rounded-xl transition-colors hover:bg-muted ${compact ? 'p-2 justify-center' : 'p-3'}`}
809
+ aria-label={t('appShell.goToDashboard')}
810
+ >
811
+ <Image src={logo?.src ?? "/open-mercato.svg"} alt={logo?.alt ?? resolvedProductName} width={40} height={40} className="rounded-full shrink-0" />
812
+ {!compact && <span className="text-sm font-medium text-foreground">{resolvedProductName}</span>}
1176
813
  </Link>
1177
814
  </div>
1178
815
  )}
@@ -1182,11 +819,30 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
1182
819
  context={injectionContext}
1183
820
  />
1184
821
  ) : null}
1185
- <div className="flex flex-1 flex-col gap-3 pr-1">
1186
- {customizing ? (
1187
- customizationEditor
1188
- ) : (
1189
- (() => {
822
+ {!compact && (
823
+ <Input
824
+ type="text"
825
+ value={navQuery}
826
+ onChange={(e) => setNavQuery(e.target.value)}
827
+ placeholder={t('appShell.searchNavPlaceholder', 'Search...')}
828
+ aria-label={t('appShell.searchNavAria', 'Search navigation')}
829
+ leftIcon={<Search aria-hidden />}
830
+ rightIcon={navQueryActive ? (
831
+ <IconButton
832
+ type="button"
833
+ variant="ghost"
834
+ size="xs"
835
+ onClick={() => setNavQuery('')}
836
+ aria-label={t('appShell.searchNavClear', 'Clear search')}
837
+ >
838
+ <X className="size-3.5" aria-hidden />
839
+ </IconButton>
840
+ ) : undefined}
841
+ className="mb-2"
842
+ />
843
+ )}
844
+ <div data-sidebar-scroll="true" className={`flex flex-1 flex-col gap-3 overflow-y-auto scrollbar-hide pr-1 ${compact ? '-ml-2 pl-2' : '-ml-3 pl-3'}`}>
845
+ {(() => {
1190
846
  const isSettingsPath = (href: string) => {
1191
847
  if (href === '/backend/settings') return true
1192
848
  return resolvedSettingsPathPrefixes.some((prefix) => href.startsWith(prefix))
@@ -1221,34 +877,48 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
1221
877
  ) : null}
1222
878
  {mainGroups.map((g, gi) => {
1223
879
  const groupId = resolveGroupKey(g)
1224
- const open = openGroups[groupId] !== false
1225
- const visibleItems = g.items.filter((item) => item.hidden !== true)
880
+ const open = navQueryActive ? true : openGroups[groupId] !== false
881
+ const visibleItems = g.items.filter((item) => {
882
+ if (item.hidden === true) return false
883
+ if (!navQueryActive) return true
884
+ if (matchesQuery(item.title)) return true
885
+ const itemChildren = (item.children ?? []).filter((c) => c.hidden !== true)
886
+ return itemChildren.some((c) => matchesQuery(c.title))
887
+ })
1226
888
  if (visibleItems.length === 0) return null
1227
889
  return (
1228
890
  <div key={groupId}>
1229
- <Button
1230
- variant="muted"
1231
- onClick={() => toggleGroup(groupId)}
1232
- className={`w-full ${compact ? 'px-0 justify-center' : 'px-2 justify-between'} flex text-xs uppercase text-muted-foreground/90 py-2`}
1233
- aria-expanded={open}
1234
- >
1235
- {!compact && <span>{g.name}</span>}
1236
- {!compact && <Chevron open={open} />}
1237
- </Button>
1238
- {open && (
1239
- <div className={`flex flex-col ${compact ? 'items-center' : ''} gap-1 ${!compact ? 'pl-1' : ''}`}>
891
+ {!compact && (
892
+ <Button
893
+ variant="muted"
894
+ onClick={() => toggleGroup(groupId)}
895
+ className="w-full px-1 justify-between flex text-xs font-medium uppercase tracking-wider text-muted-foreground/70 py-1"
896
+ aria-expanded={open}
897
+ >
898
+ <span>{g.name}</span>
899
+ <Chevron open={open} />
900
+ </Button>
901
+ )}
902
+ {(open || compact) && (
903
+ <div className={`flex flex-col ${compact ? 'items-center' : ''} gap-1`}>
1240
904
  {visibleItems.map((i) => {
1241
- const childItems = (i.children ?? []).filter((child) => child.hidden !== true)
1242
- const showChildren = !!pathname && childItems.length > 0 && pathname.startsWith(i.href)
1243
- const hasActiveChild = !!(pathname && childItems.some((c) => pathname.startsWith(c.href)))
1244
- const isParentActive = (pathname === i.href) || (showChildren && !hasActiveChild)
1245
- const base = compact ? 'w-10 h-10 justify-center' : 'px-2 py-1 gap-2'
905
+ const allChildItems = (i.children ?? []).filter((child) => child.hidden !== true)
906
+ const matchingChildItems = navQueryActive
907
+ ? allChildItems.filter((c) => matchesQuery(c.title))
908
+ : allChildItems
909
+ const childItems = navQueryActive ? matchingChildItems : allChildItems
910
+ const showChildren = navQueryActive
911
+ ? matchingChildItems.length > 0
912
+ : (!!pathname && allChildItems.length > 0 && pathname.startsWith(i.href))
913
+ const hasActiveChild = !!(pathname && allChildItems.some((c) => pathname.startsWith(c.href)))
914
+ const isParentActive = (pathname === i.href) || (!navQueryActive && showChildren && !hasActiveChild)
915
+ const base = compact ? 'w-10 h-10 justify-center' : 'w-full px-3 py-2 gap-2'
1246
916
  return (
1247
917
  <React.Fragment key={i.href}>
1248
918
  <Link
1249
919
  href={i.href}
1250
- className={`relative text-sm rounded inline-flex items-center ${base} ${
1251
- isParentActive ? 'bg-background border shadow-sm' : 'hover:bg-accent hover:text-accent-foreground'
920
+ className={`relative text-sm font-medium rounded-lg inline-flex items-center ${base} ${
921
+ isParentActive ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted'
1252
922
  } ${i.enabled === false ? 'pointer-events-none opacity-50' : ''}`}
1253
923
  aria-disabled={i.enabled === false}
1254
924
  title={compact ? i.title : undefined}
@@ -1256,9 +926,9 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
1256
926
  onClick={() => setMobileOpen(false)}
1257
927
  >
1258
928
  {isParentActive ? (
1259
- <span className="absolute left-0 top-1 bottom-1 w-0.5 rounded bg-foreground" />
929
+ <span aria-hidden className={`absolute ${compact ? 'left-[-20px]' : 'left-[-12px]'} top-2 w-1 h-5 rounded-r bg-foreground`} />
1260
930
  ) : null}
1261
- <span className={`flex items-center justify-center shrink-0 ${compact ? '' : 'text-muted-foreground'}`}>
931
+ <span className="flex items-center justify-center shrink-0">
1262
932
  {renderIcon(
1263
933
  i.icon,
1264
934
  i.iconName,
@@ -1269,16 +939,19 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
1269
939
  {!compact && <span>{i.title}</span>}
1270
940
  </Link>
1271
941
  {showChildren ? (
1272
- <div className={`flex flex-col ${compact ? 'items-center' : ''} gap-1 ${!compact ? 'pl-4' : ''}`}>
942
+ <div className={`relative flex flex-col ${compact ? 'items-center' : ''} gap-1`}>
943
+ {!compact && (
944
+ <span aria-hidden className="pointer-events-none absolute left-1.5 top-1 bottom-1 w-px bg-border" />
945
+ )}
1273
946
  {childItems.map((c) => {
1274
947
  const childActive = pathname?.startsWith(c.href)
1275
- const childBase = compact ? 'w-10 h-8 justify-center' : 'px-2 py-1 gap-2'
948
+ const childBase = compact ? 'w-10 h-8 justify-center' : 'w-full pl-5 pr-3 py-2 gap-2'
1276
949
  return (
1277
950
  <Link
1278
951
  key={c.href}
1279
952
  href={c.href}
1280
- className={`relative text-sm rounded inline-flex items-center ${childBase} ${
1281
- childActive ? 'bg-background border shadow-sm' : 'hover:bg-accent hover:text-accent-foreground'
953
+ className={`relative text-sm font-medium rounded-lg inline-flex items-center ${childBase} ${
954
+ childActive ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted'
1282
955
  } ${c.enabled === false ? 'pointer-events-none opacity-50' : ''}`}
1283
956
  aria-disabled={c.enabled === false}
1284
957
  title={compact ? c.title : undefined}
@@ -1286,9 +959,9 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
1286
959
  onClick={() => setMobileOpen(false)}
1287
960
  >
1288
961
  {childActive ? (
1289
- <span className="absolute left-0 top-1 bottom-1 w-0.5 rounded bg-foreground" />
962
+ <span aria-hidden className={`absolute ${compact ? 'left-[-20px]' : 'left-[-12px]'} top-2 w-1 h-5 rounded-r bg-foreground`} />
1290
963
  ) : null}
1291
- <span className={`flex items-center justify-center shrink-0 ${compact ? '' : 'text-muted-foreground'}`}>
964
+ <span className="flex items-center justify-center shrink-0">
1292
965
  {renderIcon(
1293
966
  c.icon,
1294
967
  c.iconName,
@@ -1307,78 +980,28 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
1307
980
  })}
1308
981
  </div>
1309
982
  )}
1310
- {gi !== mainLastVisibleGroupIndex && <div className="my-2 border-t border-dotted" />}
983
+ {gi !== mainLastVisibleGroupIndex && <div className={`my-2 border-t ${compact ? '-ml-2 -mr-3' : '-ml-3 -mr-4'}`} />}
1311
984
  </div>
1312
985
  )
1313
986
  })}
1314
987
  </nav>
1315
988
  </>
1316
989
  )
1317
- })()
1318
- )}
990
+ })()}
1319
991
  </div>
1320
- <div className="sticky bottom-0 pt-4 border-t bg-background/80 backdrop-blur-sm pb-1">
992
+ <div className="sticky bottom-0 bg-background pb-1">
1321
993
  {shouldRenderSidebarInjectionSpots ? (
1322
994
  <InjectionSpot
1323
995
  spotId={BACKEND_SIDEBAR_NAV_FOOTER_INJECTION_SPOT_ID}
1324
996
  context={injectionContext}
1325
997
  />
1326
998
  ) : null}
1327
- <Link
1328
- href="/backend/settings"
1329
- className={`relative text-sm rounded inline-flex items-center w-full ${
1330
- compact ? 'w-10 h-10 justify-center' : 'px-2 py-1 gap-2'
1331
- } ${
1332
- pathname?.startsWith('/backend/settings') || pathname?.startsWith('/backend/config') || pathname?.startsWith('/backend/users') || pathname?.startsWith('/backend/roles') || pathname?.startsWith('/backend/api-keys') || pathname?.startsWith('/backend/entities') || pathname?.startsWith('/backend/query-indexes') || pathname?.startsWith('/backend/definitions') || pathname?.startsWith('/backend/instances') || pathname?.startsWith('/backend/tasks') || pathname?.startsWith('/backend/events') || pathname?.startsWith('/backend/rules') || pathname?.startsWith('/backend/sets') || pathname?.startsWith('/backend/logs') || pathname?.startsWith('/backend/directory') || pathname?.startsWith('/backend/feature-toggles')
1333
- ? 'bg-background border shadow-sm font-medium'
1334
- : 'hover:bg-accent hover:text-accent-foreground'
1335
- }`}
1336
- title={compact ? t('backend.nav.settings', 'Settings') : undefined}
1337
- onClick={() => setMobileOpen(false)}
1338
- >
1339
- {(pathname?.startsWith('/backend/settings') || pathname?.startsWith('/backend/config') || pathname?.startsWith('/backend/users') || pathname?.startsWith('/backend/roles') || pathname?.startsWith('/backend/api-keys') || pathname?.startsWith('/backend/entities') || pathname?.startsWith('/backend/query-indexes') || pathname?.startsWith('/backend/definitions') || pathname?.startsWith('/backend/instances') || pathname?.startsWith('/backend/tasks') || pathname?.startsWith('/backend/events') || pathname?.startsWith('/backend/rules') || pathname?.startsWith('/backend/sets') || pathname?.startsWith('/backend/logs') || pathname?.startsWith('/backend/directory') || pathname?.startsWith('/backend/feature-toggles')) && (
1340
- <span className="absolute left-0 top-1 bottom-1 w-0.5 rounded bg-foreground" />
1341
- )}
1342
- <span className={`flex items-center justify-center shrink-0 ${compact ? '' : 'text-muted-foreground'}`}>
1343
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1344
- <circle cx="12" cy="12" r="3" />
1345
- <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
1346
- </svg>
1347
- </span>
1348
- {!compact && <span>{t('backend.nav.settings', 'Settings')}</span>}
1349
- </Link>
1350
- {!customizing && (
1351
- <div className="mt-2">
1352
- {shouldRenderSidebarInjectionSpots ? (
1353
- <StatusBadgeInjectionSpot
1354
- spotId={GLOBAL_SIDEBAR_STATUS_BADGES_INJECTION_SPOT_ID}
1355
- context={injectionContext}
1356
- />
1357
- ) : null}
1358
- {compact || isMobileVariant ? (
1359
- <IconButton
1360
- variant="outline"
1361
- size="lg"
1362
- onClick={startCustomization}
1363
- disabled={loadingPreferences}
1364
- aria-label={t('appShell.customizeSidebar')}
1365
- >
1366
- {CustomizeIcon}
1367
- </IconButton>
1368
- ) : (
1369
- <Button
1370
- variant="outline"
1371
- size="default"
1372
- onClick={startCustomization}
1373
- disabled={loadingPreferences}
1374
- aria-label={t('appShell.customizeSidebar')}
1375
- >
1376
- {CustomizeIcon}
1377
- {loadingPreferences ? t('appShell.sidebarCustomizationLoading') : t('appShell.customizeSidebar')}
1378
- </Button>
1379
- )}
1380
- </div>
1381
- )}
999
+ {shouldRenderSidebarInjectionSpots ? (
1000
+ <StatusBadgeInjectionSpot
1001
+ spotId={GLOBAL_SIDEBAR_STATUS_BADGES_INJECTION_SPOT_ID}
1002
+ context={injectionContext}
1003
+ />
1004
+ ) : null}
1382
1005
  {shouldRenderSidebarInjectionSpots ? (
1383
1006
  <InjectionSpot
1384
1007
  spotId={BACKEND_SIDEBAR_FOOTER_INJECTION_SPOT_ID}
@@ -1390,9 +1013,9 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
1390
1013
  )
1391
1014
  }
1392
1015
 
1393
- const gridColsClass = customizing
1394
- ? 'lg:grid-cols-[320px_1fr]'
1395
- : (effectiveCollapsed ? 'lg:grid-cols-[72px_1fr]' : 'lg:grid-cols-[240px_1fr]')
1016
+ const gridColsClass = effectiveCollapsed
1017
+ ? 'lg:grid-cols-[80px_1fr]'
1018
+ : 'lg:grid-cols-[240px_1fr]'
1396
1019
  const headerCtxValue = React.useMemo(() => ({
1397
1020
  setBreadcrumb: setHeaderBreadcrumb,
1398
1021
  setTitle: setHeaderTitle,
@@ -1434,7 +1057,25 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
1434
1057
  <HeaderContext.Provider value={headerCtxValue}>
1435
1058
  <div className={`min-h-svh lg:grid ${gridColsClass}`}>
1436
1059
  {/* Desktop sidebar */}
1437
- <aside className={`${asideClassesBase} ${effectiveCollapsed ? 'px-2' : 'px-3'} hidden lg:block`} style={{ width: asideWidth }}>{renderSidebar(effectiveCollapsed)}</aside>
1060
+ <aside ref={sidebarAsideRef} className={`${asideClassesBase} ${effectiveCollapsed ? 'px-2' : 'px-3'} hidden lg:block lg:sticky lg:top-0 lg:h-svh lg:self-start lg:overflow-hidden lg:relative`} style={{ width: asideWidth }}>
1061
+ {renderSidebar(effectiveCollapsed)}
1062
+ {/* Scroll affordance — gradient fade + chevron that flips up when the user
1063
+ reaches the bottom and disappears when nothing is scrollable. */}
1064
+ {sidebarScrollState !== 'none' ? (
1065
+ <div
1066
+ aria-hidden
1067
+ className="pointer-events-none absolute inset-x-0 bottom-0 flex h-10 items-end justify-center bg-gradient-to-t from-background via-background/80 to-transparent pb-1.5"
1068
+ >
1069
+ {/* Outer div owns the rotate transition so it doesn't fight with the
1070
+ animate-bounce keyframes (both target `transform`). */}
1071
+ <span
1072
+ className={`inline-flex transition-transform duration-300 ${sidebarScrollState === 'up' ? 'rotate-180' : ''}`}
1073
+ >
1074
+ <ChevronDown className="size-4 animate-bounce text-muted-foreground/70" />
1075
+ </span>
1076
+ </div>
1077
+ ) : null}
1078
+ </aside>
1438
1079
 
1439
1080
  <div className="flex min-h-svh flex-col min-w-0">
1440
1081
  <header className="border-b bg-background/80 px-3 lg:px-4 py-2 lg:py-3 flex items-center justify-between gap-2">
@@ -1455,7 +1096,6 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
1455
1096
  className="hidden lg:inline-flex"
1456
1097
  aria-label={t('appShell.toggleSidebar')}
1457
1098
  onClick={() => setCollapsed((c) => !c)}
1458
- disabled={customizing}
1459
1099
  >
1460
1100
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
1461
1101
  <rect x="3" y="4" width="18" height="16" rx="2"/>
@@ -1578,130 +1218,3 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
1578
1218
  )
1579
1219
  }
1580
1220
 
1581
- // Helper: deep-clone minimal shape we mutate (children arrays)
1582
- AppShell.cloneGroups = function cloneGroups(groups: AppShellProps['groups']): AppShellProps['groups'] {
1583
- const cloneItem = (item: SidebarItem): SidebarItem => ({
1584
- id: item.id,
1585
- href: item.href,
1586
- title: item.title,
1587
- defaultTitle: item.defaultTitle,
1588
- icon: item.icon,
1589
- iconName: item.iconName,
1590
- iconMarkup: item.iconMarkup,
1591
- enabled: item.enabled,
1592
- hidden: item.hidden,
1593
- pageContext: item.pageContext,
1594
- children: item.children ? item.children.map((child) => cloneItem(child)) : undefined,
1595
- })
1596
- return groups.map((group) => ({
1597
- id: group.id,
1598
- name: group.name,
1599
- defaultName: group.defaultName,
1600
- items: group.items.map((item) => cloneItem(item)),
1601
- }))
1602
- }
1603
-
1604
- function applyCustomizationDraft(baseGroups: SidebarGroup[], draft: SidebarCustomizationDraft): SidebarGroup[] {
1605
- const clones = AppShell.cloneGroups(baseGroups)
1606
- const byId = new Map<string, SidebarGroup>()
1607
- for (const group of clones) {
1608
- byId.set(resolveGroupKey(group), group)
1609
- }
1610
- const orderedIds = mergeGroupOrder(draft.order, Array.from(byId.keys()))
1611
- const seen = new Set<string>()
1612
- const result: SidebarGroup[] = []
1613
- for (const id of orderedIds) {
1614
- if (seen.has(id)) continue
1615
- const group = byId.get(id)
1616
- if (!group) continue
1617
- seen.add(id)
1618
- const baseName = group.defaultName ?? group.name
1619
- const override = draft.groupLabels[id]?.trim()
1620
- result.push({
1621
- ...group,
1622
- name: override && override.length > 0 ? override : baseName,
1623
- items: group.items.map((item) => applyItemDraft(item, draft)),
1624
- })
1625
- }
1626
- return result
1627
- }
1628
-
1629
- function applyItemDraft(item: SidebarItem, draft: SidebarCustomizationDraft): SidebarItem {
1630
- const itemKey = resolveItemKey(item)
1631
- const baseTitle = item.defaultTitle ?? item.title
1632
- const override = draft.itemLabels[itemKey]?.trim()
1633
- const children = item.children
1634
- ? item.children
1635
- .map((child) => applyItemDraft(child, draft))
1636
- : undefined
1637
- const hidden = draft.hiddenItemIds[itemKey] === true
1638
- return {
1639
- ...item,
1640
- title: override && override.length > 0 ? override : baseTitle,
1641
- hidden,
1642
- children,
1643
- }
1644
- }
1645
-
1646
- function mergeGroupOrder(preferred: string[], current: string[]): string[] {
1647
- const seen = new Set<string>()
1648
- const merged: string[] = []
1649
- for (const id of preferred) {
1650
- const trimmed = id.trim()
1651
- if (!trimmed || seen.has(trimmed) || !current.includes(trimmed)) continue
1652
- seen.add(trimmed)
1653
- merged.push(trimmed)
1654
- }
1655
- for (const id of current) {
1656
- if (seen.has(id)) continue
1657
- seen.add(id)
1658
- merged.push(id)
1659
- }
1660
- return merged
1661
- }
1662
-
1663
- function collectSidebarDefaults(groups: SidebarGroup[]) {
1664
- const groupDefaults = new Map<string, string>()
1665
- const itemDefaults = new Map<string, string>()
1666
-
1667
- const visitItems = (items: SidebarItem[]) => {
1668
- for (const item of items) {
1669
- const key = resolveItemKey(item)
1670
- const baseTitle = item.defaultTitle ?? item.title
1671
- itemDefaults.set(key, baseTitle)
1672
- // Backward-compatible alias for legacy stored href-based preferences.
1673
- itemDefaults.set(item.href, baseTitle)
1674
- if (item.children && item.children.length > 0) visitItems(item.children)
1675
- }
1676
- }
1677
-
1678
- for (const group of groups) {
1679
- const key = resolveGroupKey(group)
1680
- groupDefaults.set(key, group.defaultName ?? group.name)
1681
- visitItems(group.items)
1682
- }
1683
-
1684
- return { groupDefaults, itemDefaults }
1685
- }
1686
-
1687
- /**
1688
- * Filters groups to include only main sidebar items.
1689
- * Excludes items with pageContext 'settings' or 'profile' from customization.
1690
- * Per SPEC-007: Sidebar customization applies only to the main sidebar.
1691
- */
1692
- function filterMainSidebarGroups(groups: SidebarGroup[]): SidebarGroup[] {
1693
- const isMainItem = (item: SidebarItem): boolean => {
1694
- if (item.pageContext && item.pageContext !== 'main') return false
1695
- return true
1696
- }
1697
-
1698
- return groups
1699
- .map((group) => ({
1700
- ...group,
1701
- items: group.items.filter(isMainItem).map((item) => ({
1702
- ...item,
1703
- children: item.children?.filter(isMainItem),
1704
- })),
1705
- }))
1706
- .filter((group) => group.items.length > 0)
1707
- }