@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/backend/AppShell.js +274 -697
- package/dist/backend/AppShell.js.map +3 -3
- package/dist/backend/CrudForm.js +1 -1
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/crud/CollapsibleZoneLayout.js +23 -3
- package/dist/backend/crud/CollapsibleZoneLayout.js.map +2 -2
- package/dist/backend/section-page/SectionNav.js +10 -8
- package/dist/backend/section-page/SectionNav.js.map +2 -2
- package/dist/backend/section-page/SectionPage.js +2 -2
- package/dist/backend/section-page/SectionPage.js.map +2 -2
- package/dist/backend/sidebar/SidebarCustomizationEditor.js +1303 -0
- package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +7 -0
- package/dist/backend/sidebar/customization-helpers.js +150 -0
- package/dist/backend/sidebar/customization-helpers.js.map +7 -0
- package/dist/primitives/switch.js +1 -2
- package/dist/primitives/switch.js.map +2 -2
- package/jest.setup.ts +13 -0
- package/package.json +3 -3
- package/src/backend/AppShell.tsx +245 -732
- package/src/backend/CrudForm.tsx +1 -1
- package/src/backend/__tests__/AppShell.test.tsx +1 -1
- package/src/backend/__tests__/CollapsibleZoneLayout.test.tsx +101 -0
- package/src/backend/__tests__/CrudForm.navigation.test.tsx +42 -0
- package/src/backend/__tests__/SidebarCustomizationEditor.test.tsx +200 -0
- package/src/backend/crud/CollapsibleZoneLayout.tsx +28 -3
- package/src/backend/section-page/SectionNav.tsx +14 -10
- package/src/backend/section-page/SectionPage.tsx +15 -10
- package/src/backend/sidebar/SidebarCustomizationEditor.tsx +1562 -0
- package/src/backend/sidebar/customization-helpers.ts +203 -0
- package/src/primitives/switch.tsx +1 -2
package/src/backend/AppShell.tsx
CHANGED
|
@@ -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 {
|
|
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
|
-
() =>
|
|
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
|
|
448
|
-
const
|
|
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
|
|
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
|
|
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
|
-
|
|
797
|
-
|
|
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
|
|
593
|
+
<div className="flex h-full flex-col gap-3">
|
|
815
594
|
{!hideHeader && (
|
|
816
|
-
<div className=
|
|
817
|
-
<Link
|
|
818
|
-
|
|
819
|
-
{
|
|
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
|
-
|
|
824
|
-
<
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
{
|
|
831
|
-
|
|
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
|
|
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-
|
|
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: `${
|
|
860
|
-
paddingRight: '
|
|
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-
|
|
871
|
-
: '
|
|
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=
|
|
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=
|
|
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
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
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=
|
|
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=
|
|
928
|
-
<Link
|
|
929
|
-
|
|
930
|
-
{
|
|
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
|
|
803
|
+
<div className="flex h-full flex-col gap-3">
|
|
1171
804
|
{!hideHeader && (
|
|
1172
|
-
<div className=
|
|
1173
|
-
<Link
|
|
1174
|
-
|
|
1175
|
-
{
|
|
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
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
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) =>
|
|
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
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
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
|
|
1242
|
-
const
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
const
|
|
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-
|
|
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=
|
|
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=
|
|
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
|
|
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' : '
|
|
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-
|
|
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=
|
|
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=
|
|
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=
|
|
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
|
|
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
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
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 =
|
|
1394
|
-
? 'lg:grid-cols-[
|
|
1395
|
-
:
|
|
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 }}>
|
|
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
|
-
}
|