@open-mercato/ui 0.4.2-canary-c02407ff85
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/build.mjs +62 -0
- package/dist/backend/AppShell.js +902 -0
- package/dist/backend/AppShell.js.map +7 -0
- package/dist/backend/ConfirmDialog.js +17 -0
- package/dist/backend/ConfirmDialog.js.map +7 -0
- package/dist/backend/ContextHelp.js +31 -0
- package/dist/backend/ContextHelp.js.map +7 -0
- package/dist/backend/CrudForm.js +2028 -0
- package/dist/backend/CrudForm.js.map +7 -0
- package/dist/backend/DataTable.js +1363 -0
- package/dist/backend/DataTable.js.map +7 -0
- package/dist/backend/EmptyState.js +52 -0
- package/dist/backend/EmptyState.js.map +7 -0
- package/dist/backend/FilterBar.js +140 -0
- package/dist/backend/FilterBar.js.map +7 -0
- package/dist/backend/FilterOverlay.js +279 -0
- package/dist/backend/FilterOverlay.js.map +7 -0
- package/dist/backend/FlashMessages.js +66 -0
- package/dist/backend/FlashMessages.js.map +7 -0
- package/dist/backend/JsonBuilder.js +322 -0
- package/dist/backend/JsonBuilder.js.map +7 -0
- package/dist/backend/JsonDisplay.js +203 -0
- package/dist/backend/JsonDisplay.js.map +7 -0
- package/dist/backend/Page.js +27 -0
- package/dist/backend/Page.js.map +7 -0
- package/dist/backend/PerspectiveSidebar.js +282 -0
- package/dist/backend/PerspectiveSidebar.js.map +7 -0
- package/dist/backend/RowActions.js +148 -0
- package/dist/backend/RowActions.js.map +7 -0
- package/dist/backend/TruncatedCell.js +92 -0
- package/dist/backend/TruncatedCell.js.map +7 -0
- package/dist/backend/UserMenu.js +107 -0
- package/dist/backend/UserMenu.js.map +7 -0
- package/dist/backend/ValueIcons.js +34 -0
- package/dist/backend/ValueIcons.js.map +7 -0
- package/dist/backend/custom-fields/FieldDefinitionsEditor.js +1264 -0
- package/dist/backend/custom-fields/FieldDefinitionsEditor.js.map +7 -0
- package/dist/backend/custom-fields/FieldDefinitionsManager.js +332 -0
- package/dist/backend/custom-fields/FieldDefinitionsManager.js.map +7 -0
- package/dist/backend/dashboard/DashboardScreen.js +578 -0
- package/dist/backend/dashboard/DashboardScreen.js.map +7 -0
- package/dist/backend/dashboard/index.js +5 -0
- package/dist/backend/dashboard/index.js.map +7 -0
- package/dist/backend/dashboard/widgetRegistry.js +55 -0
- package/dist/backend/dashboard/widgetRegistry.js.map +7 -0
- package/dist/backend/detail/ActivitiesSection.js +962 -0
- package/dist/backend/detail/ActivitiesSection.js.map +7 -0
- package/dist/backend/detail/AddressEditor.js +413 -0
- package/dist/backend/detail/AddressEditor.js.map +7 -0
- package/dist/backend/detail/AddressTiles.js +437 -0
- package/dist/backend/detail/AddressTiles.js.map +7 -0
- package/dist/backend/detail/AddressesSection.js +264 -0
- package/dist/backend/detail/AddressesSection.js.map +7 -0
- package/dist/backend/detail/AttachmentDeleteDialog.js +41 -0
- package/dist/backend/detail/AttachmentDeleteDialog.js.map +7 -0
- package/dist/backend/detail/AttachmentMetadataDialog.js +517 -0
- package/dist/backend/detail/AttachmentMetadataDialog.js.map +7 -0
- package/dist/backend/detail/AttachmentsSection.js +367 -0
- package/dist/backend/detail/AttachmentsSection.js.map +7 -0
- package/dist/backend/detail/CustomDataSection.js +433 -0
- package/dist/backend/detail/CustomDataSection.js.map +7 -0
- package/dist/backend/detail/DetailFieldsSection.js +75 -0
- package/dist/backend/detail/DetailFieldsSection.js.map +7 -0
- package/dist/backend/detail/ErrorMessage.js +28 -0
- package/dist/backend/detail/ErrorMessage.js.map +7 -0
- package/dist/backend/detail/InlineEditors.js +681 -0
- package/dist/backend/detail/InlineEditors.js.map +7 -0
- package/dist/backend/detail/LoadingMessage.js +14 -0
- package/dist/backend/detail/LoadingMessage.js.map +7 -0
- package/dist/backend/detail/NotesSection.js +1032 -0
- package/dist/backend/detail/NotesSection.js.map +7 -0
- package/dist/backend/detail/TabEmptyState.js +25 -0
- package/dist/backend/detail/TabEmptyState.js.map +7 -0
- package/dist/backend/detail/TagsSection.js +254 -0
- package/dist/backend/detail/TagsSection.js.map +7 -0
- package/dist/backend/detail/addressFormat.js +77 -0
- package/dist/backend/detail/addressFormat.js.map +7 -0
- package/dist/backend/detail/index.js +34 -0
- package/dist/backend/detail/index.js.map +7 -0
- package/dist/backend/fields/registry.generated.js +8 -0
- package/dist/backend/fields/registry.generated.js.map +7 -0
- package/dist/backend/fields/registry.js +29 -0
- package/dist/backend/fields/registry.js.map +7 -0
- package/dist/backend/indexes/PartialIndexBanner.js +58 -0
- package/dist/backend/indexes/PartialIndexBanner.js.map +7 -0
- package/dist/backend/indexes/store.js +62 -0
- package/dist/backend/indexes/store.js.map +7 -0
- package/dist/backend/injection/InjectionSpot.js +179 -0
- package/dist/backend/injection/InjectionSpot.js.map +7 -0
- package/dist/backend/injection/PageInjectionBoundary.js +26 -0
- package/dist/backend/injection/PageInjectionBoundary.js.map +7 -0
- package/dist/backend/injection/helpers.js +26 -0
- package/dist/backend/injection/helpers.js.map +7 -0
- package/dist/backend/injection/widgetRegistry.js +55 -0
- package/dist/backend/injection/widgetRegistry.js.map +7 -0
- package/dist/backend/inputs/ComboboxInput.js +225 -0
- package/dist/backend/inputs/ComboboxInput.js.map +7 -0
- package/dist/backend/inputs/LookupSelect.js +191 -0
- package/dist/backend/inputs/LookupSelect.js.map +7 -0
- package/dist/backend/inputs/PhoneNumberField.js +100 -0
- package/dist/backend/inputs/PhoneNumberField.js.map +7 -0
- package/dist/backend/inputs/SwitchableMarkdownInput.js +92 -0
- package/dist/backend/inputs/SwitchableMarkdownInput.js.map +7 -0
- package/dist/backend/inputs/TagsInput.js +222 -0
- package/dist/backend/inputs/TagsInput.js.map +7 -0
- package/dist/backend/inputs/index.js +6 -0
- package/dist/backend/inputs/index.js.map +7 -0
- package/dist/backend/operations/LastOperationBanner.js +80 -0
- package/dist/backend/operations/LastOperationBanner.js.map +7 -0
- package/dist/backend/operations/store.js +183 -0
- package/dist/backend/operations/store.js.map +7 -0
- package/dist/backend/schedule/ScheduleAgenda.js +107 -0
- package/dist/backend/schedule/ScheduleAgenda.js.map +7 -0
- package/dist/backend/schedule/ScheduleGrid.js +107 -0
- package/dist/backend/schedule/ScheduleGrid.js.map +7 -0
- package/dist/backend/schedule/ScheduleToolbar.js +166 -0
- package/dist/backend/schedule/ScheduleToolbar.js.map +7 -0
- package/dist/backend/schedule/ScheduleView.js +165 -0
- package/dist/backend/schedule/ScheduleView.js.map +7 -0
- package/dist/backend/schedule/index.js +6 -0
- package/dist/backend/schedule/index.js.map +7 -0
- package/dist/backend/schedule/recurrence.js +83 -0
- package/dist/backend/schedule/recurrence.js.map +7 -0
- package/dist/backend/schedule/types.js +1 -0
- package/dist/backend/schedule/types.js.map +7 -0
- package/dist/backend/upgrades/UpgradeActionBanner.js +91 -0
- package/dist/backend/upgrades/UpgradeActionBanner.js.map +7 -0
- package/dist/backend/utils/api.js +127 -0
- package/dist/backend/utils/api.js.map +7 -0
- package/dist/backend/utils/apiCall.js +48 -0
- package/dist/backend/utils/apiCall.js.map +7 -0
- package/dist/backend/utils/crud.js +126 -0
- package/dist/backend/utils/crud.js.map +7 -0
- package/dist/backend/utils/customFieldColumns.js +56 -0
- package/dist/backend/utils/customFieldColumns.js.map +7 -0
- package/dist/backend/utils/customFieldDefs.js +143 -0
- package/dist/backend/utils/customFieldDefs.js.map +7 -0
- package/dist/backend/utils/customFieldFilters.js +126 -0
- package/dist/backend/utils/customFieldFilters.js.map +7 -0
- package/dist/backend/utils/customFieldForms.js +162 -0
- package/dist/backend/utils/customFieldForms.js.map +7 -0
- package/dist/backend/utils/customFieldValues.js +26 -0
- package/dist/backend/utils/customFieldValues.js.map +7 -0
- package/dist/backend/utils/flash.js +16 -0
- package/dist/backend/utils/flash.js.map +7 -0
- package/dist/backend/utils/nav.js +185 -0
- package/dist/backend/utils/nav.js.map +7 -0
- package/dist/backend/utils/serverErrors.js +230 -0
- package/dist/backend/utils/serverErrors.js.map +7 -0
- package/dist/frontend/AuthFooter.js +23 -0
- package/dist/frontend/AuthFooter.js.map +7 -0
- package/dist/frontend/LanguageSwitcher.js +57 -0
- package/dist/frontend/LanguageSwitcher.js.map +7 -0
- package/dist/frontend/Layout.js +14 -0
- package/dist/frontend/Layout.js.map +7 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +7 -0
- package/dist/primitives/DataLoader.js +67 -0
- package/dist/primitives/DataLoader.js.map +7 -0
- package/dist/primitives/ErrorNotice.js +20 -0
- package/dist/primitives/ErrorNotice.js.map +7 -0
- package/dist/primitives/alert.js +38 -0
- package/dist/primitives/alert.js.map +7 -0
- package/dist/primitives/badge.js +28 -0
- package/dist/primitives/badge.js.map +7 -0
- package/dist/primitives/button.js +44 -0
- package/dist/primitives/button.js.map +7 -0
- package/dist/primitives/card.js +91 -0
- package/dist/primitives/card.js.map +7 -0
- package/dist/primitives/checkbox.js +28 -0
- package/dist/primitives/checkbox.js.map +7 -0
- package/dist/primitives/dialog.js +90 -0
- package/dist/primitives/dialog.js.map +7 -0
- package/dist/primitives/input.js +22 -0
- package/dist/primitives/input.js.map +7 -0
- package/dist/primitives/label.js +21 -0
- package/dist/primitives/label.js.map +7 -0
- package/dist/primitives/separator.js +9 -0
- package/dist/primitives/separator.js.map +7 -0
- package/dist/primitives/spinner.js +24 -0
- package/dist/primitives/spinner.js.map +7 -0
- package/dist/primitives/switch.js +80 -0
- package/dist/primitives/switch.js.map +7 -0
- package/dist/primitives/table.js +29 -0
- package/dist/primitives/table.js.map +7 -0
- package/dist/primitives/tabs.js +87 -0
- package/dist/primitives/tabs.js.map +7 -0
- package/dist/primitives/textarea.js +21 -0
- package/dist/primitives/textarea.js.map +7 -0
- package/dist/primitives/tooltip.js +60 -0
- package/dist/primitives/tooltip.js.map +7 -0
- package/dist/theme/QueryProvider.js +44 -0
- package/dist/theme/QueryProvider.js.map +7 -0
- package/dist/theme/ThemeProvider.js +95 -0
- package/dist/theme/ThemeProvider.js.map +7 -0
- package/dist/theme/ThemeToggle.js +88 -0
- package/dist/theme/ThemeToggle.js.map +7 -0
- package/dist/theme/index.js +10 -0
- package/dist/theme/index.js.map +7 -0
- package/dist/types/react-big-calendar.d.js +1 -0
- package/dist/types/react-big-calendar.d.js.map +7 -0
- package/jest.config.cjs +23 -0
- package/jest.setup.ts +55 -0
- package/package.json +105 -0
- package/src/backend/AppShell.tsx +1096 -0
- package/src/backend/ConfirmDialog.tsx +19 -0
- package/src/backend/ContextHelp.tsx +38 -0
- package/src/backend/CrudForm.tsx +2503 -0
- package/src/backend/DataTable.tsx +1730 -0
- package/src/backend/EmptyState.tsx +65 -0
- package/src/backend/FilterBar.tsx +161 -0
- package/src/backend/FilterOverlay.tsx +328 -0
- package/src/backend/FlashMessages.tsx +82 -0
- package/src/backend/JsonBuilder.tsx +362 -0
- package/src/backend/JsonDisplay.tsx +254 -0
- package/src/backend/Page.tsx +30 -0
- package/src/backend/PerspectiveSidebar.tsx +337 -0
- package/src/backend/RowActions.tsx +151 -0
- package/src/backend/TruncatedCell.tsx +133 -0
- package/src/backend/UserMenu.tsx +118 -0
- package/src/backend/ValueIcons.tsx +48 -0
- package/src/backend/__tests__/AppShell.test.tsx +115 -0
- package/src/backend/__tests__/CrudForm.render.test.tsx +30 -0
- package/src/backend/__tests__/DataTable.render.test.tsx +48 -0
- package/src/backend/__tests__/custom-field-filters.test.ts +72 -0
- package/src/backend/__tests__/custom-field-forms.test.ts +54 -0
- package/src/backend/__tests__/serverErrors.test.ts +83 -0
- package/src/backend/custom-fields/FieldDefinitionsEditor.tsx +1292 -0
- package/src/backend/custom-fields/FieldDefinitionsManager.tsx +381 -0
- package/src/backend/dashboard/DashboardScreen.tsx +684 -0
- package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +112 -0
- package/src/backend/dashboard/index.ts +1 -0
- package/src/backend/dashboard/widgetRegistry.ts +68 -0
- package/src/backend/detail/ActivitiesSection.tsx +1284 -0
- package/src/backend/detail/AddressEditor.tsx +472 -0
- package/src/backend/detail/AddressTiles.tsx +587 -0
- package/src/backend/detail/AddressesSection.tsx +346 -0
- package/src/backend/detail/AttachmentDeleteDialog.tsx +56 -0
- package/src/backend/detail/AttachmentMetadataDialog.tsx +672 -0
- package/src/backend/detail/AttachmentsSection.tsx +414 -0
- package/src/backend/detail/CustomDataSection.tsx +530 -0
- package/src/backend/detail/DetailFieldsSection.tsx +147 -0
- package/src/backend/detail/ErrorMessage.tsx +32 -0
- package/src/backend/detail/InlineEditors.tsx +877 -0
- package/src/backend/detail/LoadingMessage.tsx +14 -0
- package/src/backend/detail/NotesSection.tsx +1275 -0
- package/src/backend/detail/TabEmptyState.tsx +48 -0
- package/src/backend/detail/TagsSection.tsx +314 -0
- package/src/backend/detail/addressFormat.tsx +121 -0
- package/src/backend/detail/index.ts +44 -0
- package/src/backend/fields/registry.generated.ts +8 -0
- package/src/backend/fields/registry.ts +38 -0
- package/src/backend/indexes/PartialIndexBanner.tsx +68 -0
- package/src/backend/indexes/store.ts +88 -0
- package/src/backend/injection/InjectionSpot.tsx +236 -0
- package/src/backend/injection/PageInjectionBoundary.tsx +31 -0
- package/src/backend/injection/helpers.ts +35 -0
- package/src/backend/injection/widgetRegistry.ts +68 -0
- package/src/backend/inputs/ComboboxInput.tsx +269 -0
- package/src/backend/inputs/LookupSelect.tsx +247 -0
- package/src/backend/inputs/PhoneNumberField.tsx +129 -0
- package/src/backend/inputs/SwitchableMarkdownInput.tsx +128 -0
- package/src/backend/inputs/TagsInput.tsx +259 -0
- package/src/backend/inputs/index.ts +5 -0
- package/src/backend/operations/LastOperationBanner.tsx +85 -0
- package/src/backend/operations/__tests__/LastOperationBanner.test.tsx +99 -0
- package/src/backend/operations/store.ts +230 -0
- package/src/backend/schedule/ScheduleAgenda.tsx +136 -0
- package/src/backend/schedule/ScheduleGrid.tsx +136 -0
- package/src/backend/schedule/ScheduleToolbar.tsx +178 -0
- package/src/backend/schedule/ScheduleView.tsx +198 -0
- package/src/backend/schedule/index.ts +5 -0
- package/src/backend/schedule/recurrence.ts +99 -0
- package/src/backend/schedule/types.ts +26 -0
- package/src/backend/upgrades/UpgradeActionBanner.tsx +128 -0
- package/src/backend/utils/__tests__/apiCall.test.ts +109 -0
- package/src/backend/utils/__tests__/crud.test.ts +87 -0
- package/src/backend/utils/__tests__/customFieldDefs.test.ts +25 -0
- package/src/backend/utils/__tests__/customFieldValues.test.ts +35 -0
- package/src/backend/utils/api.ts +149 -0
- package/src/backend/utils/apiCall.ts +96 -0
- package/src/backend/utils/crud.ts +174 -0
- package/src/backend/utils/customFieldColumns.ts +71 -0
- package/src/backend/utils/customFieldDefs.ts +245 -0
- package/src/backend/utils/customFieldFilters.ts +145 -0
- package/src/backend/utils/customFieldForms.ts +196 -0
- package/src/backend/utils/customFieldValues.ts +41 -0
- package/src/backend/utils/flash.ts +17 -0
- package/src/backend/utils/nav.ts +238 -0
- package/src/backend/utils/serverErrors.ts +302 -0
- package/src/frontend/AuthFooter.tsx +29 -0
- package/src/frontend/LanguageSwitcher.tsx +66 -0
- package/src/frontend/Layout.tsx +13 -0
- package/src/index.ts +32 -0
- package/src/primitives/DataLoader.tsx +92 -0
- package/src/primitives/ErrorNotice.tsx +26 -0
- package/src/primitives/alert.tsx +52 -0
- package/src/primitives/badge.tsx +31 -0
- package/src/primitives/button.tsx +47 -0
- package/src/primitives/card.tsx +92 -0
- package/src/primitives/checkbox.tsx +28 -0
- package/src/primitives/dialog.tsx +110 -0
- package/src/primitives/input.tsx +20 -0
- package/src/primitives/label.tsx +18 -0
- package/src/primitives/separator.tsx +7 -0
- package/src/primitives/spinner.tsx +27 -0
- package/src/primitives/switch.tsx +86 -0
- package/src/primitives/table.tsx +27 -0
- package/src/primitives/tabs.tsx +128 -0
- package/src/primitives/textarea.tsx +20 -0
- package/src/primitives/tooltip.tsx +85 -0
- package/src/theme/QueryProvider.tsx +46 -0
- package/src/theme/ThemeProvider.tsx +120 -0
- package/src/theme/ThemeToggle.tsx +88 -0
- package/src/theme/index.ts +3 -0
- package/src/types/react-big-calendar.d.ts +16 -0
- package/tsconfig.build.json +11 -0
- package/tsconfig.json +9 -0
- package/watch.mjs +6 -0
|
@@ -0,0 +1,1096 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import Link from 'next/link'
|
|
4
|
+
import Image from 'next/image'
|
|
5
|
+
import { Separator } from '../primitives/separator'
|
|
6
|
+
import { FlashMessages } from './FlashMessages'
|
|
7
|
+
import { usePathname } from 'next/navigation'
|
|
8
|
+
import { apiCall } from './utils/apiCall'
|
|
9
|
+
import { LanguageSwitcher } from '../frontend/LanguageSwitcher'
|
|
10
|
+
import { ThemeToggle } from '../theme/ThemeToggle'
|
|
11
|
+
import { LastOperationBanner } from './operations/LastOperationBanner'
|
|
12
|
+
import { UpgradeActionBanner } from './upgrades/UpgradeActionBanner'
|
|
13
|
+
import { PartialIndexBanner } from './indexes/PartialIndexBanner'
|
|
14
|
+
import { useLocale, useT } from '@open-mercato/shared/lib/i18n/context'
|
|
15
|
+
import { slugifySidebarId } from '@open-mercato/shared/modules/navigation/sidebarPreferences'
|
|
16
|
+
|
|
17
|
+
export type AppShellProps = {
|
|
18
|
+
productName?: string
|
|
19
|
+
email?: string
|
|
20
|
+
groups: {
|
|
21
|
+
id?: string
|
|
22
|
+
name: string
|
|
23
|
+
defaultName?: string
|
|
24
|
+
items: {
|
|
25
|
+
href: string
|
|
26
|
+
title: string
|
|
27
|
+
defaultTitle?: string
|
|
28
|
+
icon?: React.ReactNode
|
|
29
|
+
enabled?: boolean
|
|
30
|
+
hidden?: boolean
|
|
31
|
+
children?: {
|
|
32
|
+
href: string
|
|
33
|
+
title: string
|
|
34
|
+
defaultTitle?: string
|
|
35
|
+
icon?: React.ReactNode
|
|
36
|
+
enabled?: boolean
|
|
37
|
+
hidden?: boolean
|
|
38
|
+
}[]
|
|
39
|
+
}[]
|
|
40
|
+
}[]
|
|
41
|
+
children: React.ReactNode
|
|
42
|
+
rightHeaderSlot?: React.ReactNode
|
|
43
|
+
sidebarCollapsedDefault?: boolean
|
|
44
|
+
currentTitle?: string
|
|
45
|
+
breadcrumb?: Array<{ label: string; href?: string }>
|
|
46
|
+
// Optional: full admin nav API to refresh sidebar client-side
|
|
47
|
+
adminNavApi?: string
|
|
48
|
+
version?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type Breadcrumb = Array<{ label: string; href?: string }>
|
|
52
|
+
|
|
53
|
+
type SidebarCustomizationDraft = {
|
|
54
|
+
order: string[]
|
|
55
|
+
groupLabels: Record<string, string>
|
|
56
|
+
itemLabels: Record<string, string>
|
|
57
|
+
hiddenItemIds: Record<string, boolean>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type SidebarGroup = AppShellProps['groups'][number]
|
|
61
|
+
type SidebarItem = SidebarGroup['items'][number]
|
|
62
|
+
type SidebarRoleTarget = { id: string; name: string; hasPreference: boolean }
|
|
63
|
+
|
|
64
|
+
function resolveGroupKey(group: SidebarGroup): string {
|
|
65
|
+
if (group.id && group.id.length) return group.id
|
|
66
|
+
if (group.defaultName && group.defaultName.length) return slugifySidebarId(group.defaultName)
|
|
67
|
+
return slugifySidebarId(group.name)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const HeaderContext = React.createContext<{
|
|
71
|
+
setBreadcrumb: (b?: Breadcrumb) => void
|
|
72
|
+
setTitle: (t?: string) => void
|
|
73
|
+
} | null>(null)
|
|
74
|
+
|
|
75
|
+
export function ApplyBreadcrumb({ breadcrumb, title, titleKey }: { breadcrumb?: Array<{ label: string; href?: string; labelKey?: string }>; title?: string; titleKey?: string }) {
|
|
76
|
+
const ctx = React.useContext(HeaderContext)
|
|
77
|
+
const t = useT()
|
|
78
|
+
const resolvedBreadcrumb = React.useMemo<Breadcrumb | undefined>(() => {
|
|
79
|
+
if (!breadcrumb) return undefined
|
|
80
|
+
return breadcrumb.map(({ label, labelKey, href }) => {
|
|
81
|
+
const translated = labelKey ? t(labelKey) : undefined
|
|
82
|
+
const finalLabel = translated && translated !== labelKey ? translated : label
|
|
83
|
+
return {
|
|
84
|
+
href,
|
|
85
|
+
label: finalLabel,
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
}, [breadcrumb, t])
|
|
89
|
+
const resolvedTitle = React.useMemo(() => {
|
|
90
|
+
if (!titleKey) return title
|
|
91
|
+
const translated = t(titleKey)
|
|
92
|
+
if (translated && translated !== titleKey) return translated
|
|
93
|
+
return title
|
|
94
|
+
}, [titleKey, title, t])
|
|
95
|
+
React.useEffect(() => {
|
|
96
|
+
ctx?.setBreadcrumb(resolvedBreadcrumb)
|
|
97
|
+
if (resolvedTitle !== undefined) ctx?.setTitle(resolvedTitle)
|
|
98
|
+
}, [ctx, resolvedBreadcrumb, resolvedTitle])
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const DefaultIcon = (
|
|
103
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
104
|
+
<path d="M8 6h13M8 12h13M8 18h13"/>
|
|
105
|
+
<path d="M3 6h.01M3 12h.01M3 18h.01"/>
|
|
106
|
+
</svg>
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
// DataTable icon used for dynamic custom entity records links
|
|
110
|
+
const DataTableIcon = (
|
|
111
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
112
|
+
<rect x="3" y="4" width="18" height="16" rx="2" ry="2"/>
|
|
113
|
+
<line x1="3" y1="8" x2="21" y2="8"/>
|
|
114
|
+
<line x1="9" y1="8" x2="9" y2="20"/>
|
|
115
|
+
<line x1="15" y1="8" x2="15" y2="20"/>
|
|
116
|
+
</svg>
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
const CustomizeIcon = (
|
|
120
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
121
|
+
<circle cx="12" cy="12" r="3" />
|
|
122
|
+
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.05.05a2 2 0 1 1-2.83 2.83l-.05-.05A1.65 1.65 0 0 0 15 19.4a1.65 1.65 0 0 0-1 .6 1.65 1.65 0 0 0-.33 1.82l-.05.05a2 2 0 1 1-2.83-2.83l.05-.05A1.65 1.65 0 0 0 9 15a1.65 1.65 0 0 0-1-.6 1.65 1.65 0 0 0-1.82.33l-.05.05a2 2 0 1 1-2.83-2.83l.05-.05A1.65 1.65 0 0 0 4.6 9 1.65 1.65 0 0 0 4 8a1.65 1.65 0 0 0-.6-1.82l-.05-.05a2 2 0 1 1 2.83-2.83l.05.05A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-.6 1.65 1.65 0 0 0 .33-1.82l.05-.05a2 2 0 1 1 2.83 2.83l-.05.05A1.65 1.65 0 0 0 15 9a1.65 1.65 0 0 0 1 .6 1.65 1.65 0 0 0 1.82-.33l.05-.05a2 2 0 1 1 2.83 2.83l-.05.05A1.65 1.65 0 0 0 19.4 15z" />
|
|
123
|
+
</svg>
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
function Chevron({ open }: { open: boolean }) {
|
|
127
|
+
return (
|
|
128
|
+
<svg className={`transition-transform ${open ? 'rotate-180' : ''}`} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6"/></svg>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function AppShell({ productName, email, groups, rightHeaderSlot, children, sidebarCollapsedDefault = false, currentTitle, breadcrumb, adminNavApi, version }: AppShellProps) {
|
|
133
|
+
const pathname = usePathname()
|
|
134
|
+
const t = useT()
|
|
135
|
+
const locale = useLocale()
|
|
136
|
+
const resolvedProductName = productName ?? t('appShell.productName')
|
|
137
|
+
const [mobileOpen, setMobileOpen] = React.useState(false)
|
|
138
|
+
// Initialize from server-provided prop only to avoid hydration flicker
|
|
139
|
+
const [collapsed, setCollapsed] = React.useState(sidebarCollapsedDefault)
|
|
140
|
+
// Maintain internal nav state so we can augment it client-side
|
|
141
|
+
const [navGroups, setNavGroups] = React.useState(AppShell.cloneGroups(groups))
|
|
142
|
+
const [openGroups, setOpenGroups] = React.useState<Record<string, boolean>>(() =>
|
|
143
|
+
Object.fromEntries(groups.map((g) => [resolveGroupKey(g), true])) as Record<string, boolean>
|
|
144
|
+
)
|
|
145
|
+
const [customizing, setCustomizing] = React.useState(false)
|
|
146
|
+
const [customDraft, setCustomDraft] = React.useState<SidebarCustomizationDraft | null>(null)
|
|
147
|
+
const [loadingPreferences, setLoadingPreferences] = React.useState(false)
|
|
148
|
+
const [savingPreferences, setSavingPreferences] = React.useState(false)
|
|
149
|
+
const [customizationError, setCustomizationError] = React.useState<string | null>(null)
|
|
150
|
+
const [availableRoleTargets, setAvailableRoleTargets] = React.useState<SidebarRoleTarget[]>([])
|
|
151
|
+
const [selectedRoleIds, setSelectedRoleIds] = React.useState<string[]>([])
|
|
152
|
+
const [canApplyToRoles, setCanApplyToRoles] = React.useState(false)
|
|
153
|
+
const originalNavRef = React.useRef<SidebarGroup[] | null>(null)
|
|
154
|
+
const [headerTitle, setHeaderTitle] = React.useState<string | undefined>(currentTitle)
|
|
155
|
+
const [headerBreadcrumb, setHeaderBreadcrumb] = React.useState<Breadcrumb | undefined>(breadcrumb)
|
|
156
|
+
const effectiveCollapsed = customizing ? false : collapsed
|
|
157
|
+
const expandedSidebarWidth = customizing ? '320px' : '240px'
|
|
158
|
+
|
|
159
|
+
React.useEffect(() => {
|
|
160
|
+
try {
|
|
161
|
+
const savedOpen = typeof window !== 'undefined' ? localStorage.getItem('om:sidebarOpenGroups') : null
|
|
162
|
+
if (!savedOpen) return
|
|
163
|
+
const parsed = JSON.parse(savedOpen) as Record<string, boolean>
|
|
164
|
+
setOpenGroups((prev) => {
|
|
165
|
+
const next = { ...prev }
|
|
166
|
+
for (const group of groups) {
|
|
167
|
+
const key = resolveGroupKey(group)
|
|
168
|
+
if (key in parsed) next[key] = !!parsed[key]
|
|
169
|
+
else if (group.name in parsed) next[key] = !!parsed[group.name]
|
|
170
|
+
}
|
|
171
|
+
return next
|
|
172
|
+
})
|
|
173
|
+
} catch {
|
|
174
|
+
// ignore localStorage errors to avoid breaking hydration
|
|
175
|
+
}
|
|
176
|
+
}, [groups])
|
|
177
|
+
|
|
178
|
+
const toggleGroup = (groupId: string) => setOpenGroups((prev) => ({ ...prev, [groupId]: !prev[groupId] }))
|
|
179
|
+
|
|
180
|
+
const updateDraft = React.useCallback((updater: (draft: SidebarCustomizationDraft) => SidebarCustomizationDraft) => {
|
|
181
|
+
setCustomDraft((prev) => {
|
|
182
|
+
if (!prev) return prev
|
|
183
|
+
const next = updater(prev)
|
|
184
|
+
if (originalNavRef.current) {
|
|
185
|
+
setNavGroups(applyCustomizationDraft(originalNavRef.current, next))
|
|
186
|
+
}
|
|
187
|
+
return next
|
|
188
|
+
})
|
|
189
|
+
}, [])
|
|
190
|
+
|
|
191
|
+
const startCustomization = React.useCallback(async () => {
|
|
192
|
+
if (customizing || loadingPreferences) return
|
|
193
|
+
setCustomizationError(null)
|
|
194
|
+
setLoadingPreferences(true)
|
|
195
|
+
try {
|
|
196
|
+
const baseSnapshot = AppShell.cloneGroups(navGroups)
|
|
197
|
+
const call = await apiCall<{
|
|
198
|
+
settings?: Record<string, unknown>
|
|
199
|
+
canApplyToRoles?: boolean
|
|
200
|
+
roles?: Array<{ id?: string; name?: string; hasPreference?: boolean }>
|
|
201
|
+
}>('/api/auth/sidebar/preferences')
|
|
202
|
+
const data = call.ok ? (call.result ?? null) : null
|
|
203
|
+
const rawSettings = data?.settings
|
|
204
|
+
const responseOrder = Array.isArray(rawSettings?.groupOrder)
|
|
205
|
+
? rawSettings.groupOrder
|
|
206
|
+
.map((id: unknown) => (typeof id === 'string' ? id.trim() : ''))
|
|
207
|
+
.filter((id: string) => id.length > 0)
|
|
208
|
+
: []
|
|
209
|
+
const responseGroupLabels: Record<string, string> = {}
|
|
210
|
+
if (rawSettings?.groupLabels && typeof rawSettings.groupLabels === 'object') {
|
|
211
|
+
for (const [key, value] of Object.entries(rawSettings.groupLabels as Record<string, unknown>)) {
|
|
212
|
+
if (typeof value !== 'string') continue
|
|
213
|
+
const trimmedKey = key.trim()
|
|
214
|
+
if (!trimmedKey) continue
|
|
215
|
+
responseGroupLabels[trimmedKey] = value
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const responseItemLabels: Record<string, string> = {}
|
|
219
|
+
if (rawSettings?.itemLabels && typeof rawSettings.itemLabels === 'object') {
|
|
220
|
+
for (const [key, value] of Object.entries(rawSettings.itemLabels as Record<string, unknown>)) {
|
|
221
|
+
if (typeof value !== 'string') continue
|
|
222
|
+
const trimmedKey = key.trim()
|
|
223
|
+
if (!trimmedKey) continue
|
|
224
|
+
responseItemLabels[trimmedKey] = value
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const responseHiddenItems = Array.isArray(rawSettings?.hiddenItems)
|
|
228
|
+
? rawSettings.hiddenItems
|
|
229
|
+
.map((href: unknown) => (typeof href === 'string' ? href.trim() : ''))
|
|
230
|
+
.filter((href: string) => href.length > 0)
|
|
231
|
+
: []
|
|
232
|
+
const canManageRoles = data?.canApplyToRoles === true
|
|
233
|
+
setCanApplyToRoles(canManageRoles)
|
|
234
|
+
if (canManageRoles) {
|
|
235
|
+
const roles = Array.isArray(data?.roles)
|
|
236
|
+
? (data.roles as Array<{ id?: string; name?: string; hasPreference?: boolean }>).filter((role) => typeof role?.id === 'string' && typeof role?.name === 'string')
|
|
237
|
+
: []
|
|
238
|
+
const mappedRoles: SidebarRoleTarget[] = roles.map((role) => ({
|
|
239
|
+
id: role.id as string,
|
|
240
|
+
name: role.name as string,
|
|
241
|
+
hasPreference: role.hasPreference === true,
|
|
242
|
+
}))
|
|
243
|
+
setAvailableRoleTargets(mappedRoles)
|
|
244
|
+
setSelectedRoleIds(mappedRoles.filter((role) => role.hasPreference).map((role) => role.id))
|
|
245
|
+
} else {
|
|
246
|
+
setAvailableRoleTargets([])
|
|
247
|
+
setSelectedRoleIds([])
|
|
248
|
+
}
|
|
249
|
+
const currentIds = baseSnapshot.map((group) => resolveGroupKey(group))
|
|
250
|
+
const order = mergeGroupOrder(responseOrder, currentIds)
|
|
251
|
+
const { itemDefaults } = collectSidebarDefaults(baseSnapshot)
|
|
252
|
+
const hiddenItemIds: Record<string, boolean> = {}
|
|
253
|
+
for (const href of responseHiddenItems) {
|
|
254
|
+
if (!itemDefaults.has(href)) continue
|
|
255
|
+
hiddenItemIds[href] = true
|
|
256
|
+
}
|
|
257
|
+
const draft: SidebarCustomizationDraft = {
|
|
258
|
+
order,
|
|
259
|
+
groupLabels: { ...responseGroupLabels },
|
|
260
|
+
itemLabels: { ...responseItemLabels },
|
|
261
|
+
hiddenItemIds,
|
|
262
|
+
}
|
|
263
|
+
originalNavRef.current = baseSnapshot
|
|
264
|
+
setCustomDraft(draft)
|
|
265
|
+
setNavGroups(applyCustomizationDraft(baseSnapshot, draft))
|
|
266
|
+
setCustomizing(true)
|
|
267
|
+
} catch (error) {
|
|
268
|
+
console.error('Failed to load sidebar preferences', error)
|
|
269
|
+
setCustomizationError(t('appShell.sidebarCustomizationLoadError'))
|
|
270
|
+
} finally {
|
|
271
|
+
setLoadingPreferences(false)
|
|
272
|
+
}
|
|
273
|
+
}, [customizing, loadingPreferences, navGroups, t])
|
|
274
|
+
|
|
275
|
+
const cancelCustomization = React.useCallback(() => {
|
|
276
|
+
setCustomizing(false)
|
|
277
|
+
setCustomDraft(null)
|
|
278
|
+
setCustomizationError(null)
|
|
279
|
+
setAvailableRoleTargets([])
|
|
280
|
+
setSelectedRoleIds([])
|
|
281
|
+
setCanApplyToRoles(false)
|
|
282
|
+
if (originalNavRef.current) {
|
|
283
|
+
setNavGroups(AppShell.cloneGroups(originalNavRef.current))
|
|
284
|
+
}
|
|
285
|
+
originalNavRef.current = null
|
|
286
|
+
}, [])
|
|
287
|
+
|
|
288
|
+
const resetCustomization = React.useCallback(() => {
|
|
289
|
+
if (!originalNavRef.current) return
|
|
290
|
+
const base = AppShell.cloneGroups(originalNavRef.current)
|
|
291
|
+
const order = base.map((group) => resolveGroupKey(group))
|
|
292
|
+
const draft: SidebarCustomizationDraft = { order, groupLabels: {}, itemLabels: {}, hiddenItemIds: {} }
|
|
293
|
+
originalNavRef.current = base
|
|
294
|
+
setCustomDraft(draft)
|
|
295
|
+
setNavGroups(applyCustomizationDraft(base, draft))
|
|
296
|
+
if (canApplyToRoles) {
|
|
297
|
+
setSelectedRoleIds(availableRoleTargets.filter((role) => role.hasPreference).map((role) => role.id))
|
|
298
|
+
}
|
|
299
|
+
}, [availableRoleTargets, canApplyToRoles])
|
|
300
|
+
|
|
301
|
+
const saveCustomization = React.useCallback(async () => {
|
|
302
|
+
if (!customDraft) return
|
|
303
|
+
setSavingPreferences(true)
|
|
304
|
+
setCustomizationError(null)
|
|
305
|
+
try {
|
|
306
|
+
const baseGroups = originalNavRef.current ?? AppShell.cloneGroups(navGroups)
|
|
307
|
+
const { groupDefaults, itemDefaults } = collectSidebarDefaults(baseGroups)
|
|
308
|
+
const sanitizedGroupLabels: Record<string, string> = {}
|
|
309
|
+
for (const [key, value] of Object.entries(customDraft.groupLabels)) {
|
|
310
|
+
const trimmed = value.trim()
|
|
311
|
+
const base = groupDefaults.get(key)
|
|
312
|
+
if (!trimmed || !base) continue
|
|
313
|
+
if (trimmed !== base) sanitizedGroupLabels[key] = trimmed
|
|
314
|
+
}
|
|
315
|
+
const sanitizedItemLabels: Record<string, string> = {}
|
|
316
|
+
for (const [href, value] of Object.entries(customDraft.itemLabels)) {
|
|
317
|
+
const trimmed = value.trim()
|
|
318
|
+
const base = itemDefaults.get(href)
|
|
319
|
+
if (!trimmed || !base) continue
|
|
320
|
+
if (trimmed !== base) sanitizedItemLabels[href] = trimmed
|
|
321
|
+
}
|
|
322
|
+
const sanitizedHiddenItems: string[] = []
|
|
323
|
+
for (const [href, hidden] of Object.entries(customDraft.hiddenItemIds)) {
|
|
324
|
+
if (!hidden) continue
|
|
325
|
+
if (!itemDefaults.has(href)) continue
|
|
326
|
+
sanitizedHiddenItems.push(href)
|
|
327
|
+
}
|
|
328
|
+
const applyToRolesPayload = canApplyToRoles ? [...selectedRoleIds] : []
|
|
329
|
+
const clearRoleIdsPayload = canApplyToRoles
|
|
330
|
+
? availableRoleTargets
|
|
331
|
+
.filter((role) => role.hasPreference && !selectedRoleIds.includes(role.id))
|
|
332
|
+
.map((role) => role.id)
|
|
333
|
+
: []
|
|
334
|
+
const payload: Record<string, unknown> = {
|
|
335
|
+
groupOrder: customDraft.order,
|
|
336
|
+
groupLabels: sanitizedGroupLabels,
|
|
337
|
+
itemLabels: sanitizedItemLabels,
|
|
338
|
+
hiddenItems: sanitizedHiddenItems,
|
|
339
|
+
}
|
|
340
|
+
if (canApplyToRoles) {
|
|
341
|
+
payload.applyToRoles = applyToRolesPayload
|
|
342
|
+
payload.clearRoleIds = clearRoleIdsPayload
|
|
343
|
+
}
|
|
344
|
+
const call = await apiCall<{
|
|
345
|
+
canApplyToRoles?: boolean
|
|
346
|
+
roles?: Array<{ id?: string; name?: string; hasPreference?: boolean }>
|
|
347
|
+
}>('/api/auth/sidebar/preferences', {
|
|
348
|
+
method: 'PUT',
|
|
349
|
+
headers: { 'content-type': 'application/json' },
|
|
350
|
+
body: JSON.stringify(payload),
|
|
351
|
+
})
|
|
352
|
+
if (!call.ok) {
|
|
353
|
+
setCustomizationError(t('appShell.sidebarCustomizationSaveError'))
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
const data = call.result ?? null
|
|
357
|
+
if (data?.canApplyToRoles !== undefined) {
|
|
358
|
+
setCanApplyToRoles(data.canApplyToRoles === true)
|
|
359
|
+
}
|
|
360
|
+
if (Array.isArray(data?.roles)) {
|
|
361
|
+
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) => ({
|
|
362
|
+
id: role.id as string,
|
|
363
|
+
name: role.name as string,
|
|
364
|
+
hasPreference: role.hasPreference === true,
|
|
365
|
+
}))
|
|
366
|
+
setAvailableRoleTargets(mappedRoles)
|
|
367
|
+
setSelectedRoleIds(mappedRoles.filter((role) => role.hasPreference).map((role) => role.id))
|
|
368
|
+
}
|
|
369
|
+
originalNavRef.current = applyCustomizationDraft(baseGroups, customDraft)
|
|
370
|
+
setNavGroups(AppShell.cloneGroups(originalNavRef.current))
|
|
371
|
+
setCustomizing(false)
|
|
372
|
+
setCustomDraft(null)
|
|
373
|
+
try { window.dispatchEvent(new Event('om:refresh-sidebar')) } catch {}
|
|
374
|
+
} catch (error) {
|
|
375
|
+
console.error('Failed to save sidebar preferences', error)
|
|
376
|
+
setCustomizationError(t('appShell.sidebarCustomizationSaveError'))
|
|
377
|
+
} finally {
|
|
378
|
+
setSavingPreferences(false)
|
|
379
|
+
}
|
|
380
|
+
}, [customDraft, navGroups, t])
|
|
381
|
+
|
|
382
|
+
const moveGroup = React.useCallback((groupId: string, offset: number) => {
|
|
383
|
+
updateDraft((draft) => {
|
|
384
|
+
const order = [...draft.order]
|
|
385
|
+
const index = order.indexOf(groupId)
|
|
386
|
+
if (index === -1) return draft
|
|
387
|
+
const nextIndex = Math.max(0, Math.min(order.length - 1, index + offset))
|
|
388
|
+
if (nextIndex === index) return draft
|
|
389
|
+
order.splice(index, 1)
|
|
390
|
+
order.splice(nextIndex, 0, groupId)
|
|
391
|
+
return { ...draft, order }
|
|
392
|
+
})
|
|
393
|
+
}, [updateDraft])
|
|
394
|
+
|
|
395
|
+
const setGroupLabel = React.useCallback((groupId: string, value: string) => {
|
|
396
|
+
updateDraft((draft) => {
|
|
397
|
+
const next = { ...draft.groupLabels }
|
|
398
|
+
if (value.trim().length === 0) delete next[groupId]
|
|
399
|
+
else next[groupId] = value
|
|
400
|
+
return { ...draft, groupLabels: next }
|
|
401
|
+
})
|
|
402
|
+
}, [updateDraft])
|
|
403
|
+
|
|
404
|
+
const setItemLabel = React.useCallback((href: string, value: string) => {
|
|
405
|
+
updateDraft((draft) => {
|
|
406
|
+
const next = { ...draft.itemLabels }
|
|
407
|
+
if (value.trim().length === 0) delete next[href]
|
|
408
|
+
else next[href] = value
|
|
409
|
+
return { ...draft, itemLabels: next }
|
|
410
|
+
})
|
|
411
|
+
}, [updateDraft])
|
|
412
|
+
const setItemHidden = React.useCallback((href: string, hidden: boolean) => {
|
|
413
|
+
updateDraft((draft) => {
|
|
414
|
+
const next = { ...draft.hiddenItemIds }
|
|
415
|
+
if (hidden) next[href] = true
|
|
416
|
+
else delete next[href]
|
|
417
|
+
return { ...draft, hiddenItemIds: next }
|
|
418
|
+
})
|
|
419
|
+
}, [updateDraft])
|
|
420
|
+
|
|
421
|
+
const toggleRoleSelection = React.useCallback((roleId: string) => {
|
|
422
|
+
setSelectedRoleIds((prev) => (prev.includes(roleId) ? prev.filter((id) => id !== roleId) : [...prev, roleId]))
|
|
423
|
+
}, [])
|
|
424
|
+
|
|
425
|
+
const asideWidth = effectiveCollapsed ? '72px' : expandedSidebarWidth
|
|
426
|
+
// Use min-h-svh so the border extends with tall content; keep overflow for long menus
|
|
427
|
+
const asideClassesBase = `border-r bg-background/60 py-4 min-h-svh overflow-y-auto`;
|
|
428
|
+
|
|
429
|
+
// Persist collapse state to localStorage and cookie
|
|
430
|
+
React.useEffect(() => {
|
|
431
|
+
try { localStorage.setItem('om:sidebarCollapsed', collapsed ? '1' : '0') } catch {}
|
|
432
|
+
try {
|
|
433
|
+
document.cookie = `om_sidebar_collapsed=${collapsed ? '1' : '0'}; path=/; max-age=31536000; samesite=lax`
|
|
434
|
+
} catch {}
|
|
435
|
+
}, [collapsed])
|
|
436
|
+
React.useEffect(() => {
|
|
437
|
+
try { localStorage.setItem('om:sidebarOpenGroups', JSON.stringify(openGroups)) } catch {}
|
|
438
|
+
}, [openGroups])
|
|
439
|
+
|
|
440
|
+
// Ensure current route's group is expanded on load
|
|
441
|
+
React.useEffect(() => {
|
|
442
|
+
const activeGroup = navGroups.find((g) => g.items.some((i) => pathname?.startsWith(i.href)))
|
|
443
|
+
if (!activeGroup) return
|
|
444
|
+
const key = resolveGroupKey(activeGroup)
|
|
445
|
+
setOpenGroups((prev) => (prev[key] === false ? { ...prev, [key]: true } : prev))
|
|
446
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
447
|
+
}, [pathname, navGroups])
|
|
448
|
+
// Keep header state in sync with props (server-side updates)
|
|
449
|
+
React.useEffect(() => {
|
|
450
|
+
setHeaderTitle(currentTitle)
|
|
451
|
+
setHeaderBreadcrumb(breadcrumb)
|
|
452
|
+
}, [currentTitle, breadcrumb])
|
|
453
|
+
|
|
454
|
+
// Keep navGroups in sync when server-provided groups change
|
|
455
|
+
React.useEffect(() => {
|
|
456
|
+
if (customizing && customDraft && originalNavRef.current) {
|
|
457
|
+
originalNavRef.current = AppShell.cloneGroups(groups)
|
|
458
|
+
setNavGroups(applyCustomizationDraft(originalNavRef.current, customDraft))
|
|
459
|
+
return
|
|
460
|
+
}
|
|
461
|
+
setNavGroups(AppShell.cloneGroups(groups))
|
|
462
|
+
}, [groups, customizing, customDraft])
|
|
463
|
+
|
|
464
|
+
// Optional: full refresh from adminNavApi, used to reflect RBAC/org/entity changes without page reload
|
|
465
|
+
React.useEffect(() => {
|
|
466
|
+
let cancelled = false
|
|
467
|
+
function indexIcons(groupsToIndex: AppShellProps['groups']): Map<string, React.ReactNode | undefined> {
|
|
468
|
+
const map = new Map<string, React.ReactNode | undefined>()
|
|
469
|
+
for (const g of groupsToIndex) {
|
|
470
|
+
for (const i of g.items) {
|
|
471
|
+
map.set(i.href, i.icon)
|
|
472
|
+
if (i.children) for (const c of i.children) map.set(c.href, c.icon)
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return map
|
|
476
|
+
}
|
|
477
|
+
function mergePreservingIcons(oldG: AppShellProps['groups'], newG: AppShellProps['groups']): AppShellProps['groups'] {
|
|
478
|
+
const iconMap = indexIcons(oldG)
|
|
479
|
+
const merged = newG.map((g) => ({
|
|
480
|
+
id: g.id,
|
|
481
|
+
name: g.name,
|
|
482
|
+
defaultName: g.defaultName,
|
|
483
|
+
items: g.items.map((i) => ({
|
|
484
|
+
href: i.href,
|
|
485
|
+
title: i.title,
|
|
486
|
+
defaultTitle: i.defaultTitle,
|
|
487
|
+
enabled: i.enabled,
|
|
488
|
+
hidden: i.hidden,
|
|
489
|
+
icon: i.icon ?? iconMap.get(i.href),
|
|
490
|
+
children: i.children?.map((c) => ({
|
|
491
|
+
href: c.href,
|
|
492
|
+
title: c.title,
|
|
493
|
+
defaultTitle: c.defaultTitle,
|
|
494
|
+
enabled: c.enabled,
|
|
495
|
+
hidden: c.hidden,
|
|
496
|
+
icon: c.icon ?? iconMap.get(c.href),
|
|
497
|
+
})),
|
|
498
|
+
})),
|
|
499
|
+
}))
|
|
500
|
+
return merged
|
|
501
|
+
}
|
|
502
|
+
async function refreshFullNav() {
|
|
503
|
+
if (!adminNavApi) return
|
|
504
|
+
try {
|
|
505
|
+
const call = await apiCall<{ groups?: unknown[] }>(adminNavApi, { credentials: 'include' as any })
|
|
506
|
+
if (!call.ok) return
|
|
507
|
+
const data = call.result
|
|
508
|
+
if (cancelled) return
|
|
509
|
+
const nextGroups = Array.isArray(data?.groups) ? data.groups : []
|
|
510
|
+
if (nextGroups.length) setNavGroups((prev) => AppShell.cloneGroups(mergePreservingIcons(prev, nextGroups as any)))
|
|
511
|
+
} catch {}
|
|
512
|
+
}
|
|
513
|
+
// Refresh on window focus
|
|
514
|
+
const onFocus = () => refreshFullNav()
|
|
515
|
+
window.addEventListener('focus', onFocus)
|
|
516
|
+
return () => { cancelled = true; window.removeEventListener('focus', onFocus) }
|
|
517
|
+
}, [adminNavApi])
|
|
518
|
+
|
|
519
|
+
// Refresh sidebar when other parts of the app dispatch an explicit event
|
|
520
|
+
React.useEffect(() => {
|
|
521
|
+
if (!adminNavApi) return
|
|
522
|
+
const api = adminNavApi as string
|
|
523
|
+
let cancelled = false
|
|
524
|
+
function indexIcons(groupsToIndex: AppShellProps['groups']): Map<string, React.ReactNode | undefined> {
|
|
525
|
+
const map = new Map<string, React.ReactNode | undefined>()
|
|
526
|
+
for (const g of groupsToIndex) {
|
|
527
|
+
for (const i of g.items) {
|
|
528
|
+
map.set(i.href, i.icon)
|
|
529
|
+
if (i.children) for (const c of i.children) map.set(c.href, c.icon)
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
return map
|
|
533
|
+
}
|
|
534
|
+
function mergePreservingIcons(oldG: AppShellProps['groups'], newG: AppShellProps['groups']): AppShellProps['groups'] {
|
|
535
|
+
const iconMap = indexIcons(oldG)
|
|
536
|
+
const merged = newG.map((g) => ({
|
|
537
|
+
name: g.name,
|
|
538
|
+
items: g.items.map((i) => ({
|
|
539
|
+
href: i.href,
|
|
540
|
+
title: i.title,
|
|
541
|
+
enabled: i.enabled,
|
|
542
|
+
hidden: i.hidden,
|
|
543
|
+
icon: i.icon ?? iconMap.get(i.href),
|
|
544
|
+
children: i.children?.map((c) => ({
|
|
545
|
+
href: c.href,
|
|
546
|
+
title: c.title,
|
|
547
|
+
enabled: c.enabled,
|
|
548
|
+
hidden: c.hidden,
|
|
549
|
+
icon: c.icon ?? iconMap.get(c.href),
|
|
550
|
+
})),
|
|
551
|
+
})),
|
|
552
|
+
}))
|
|
553
|
+
return merged
|
|
554
|
+
}
|
|
555
|
+
async function refreshFullNav() {
|
|
556
|
+
try {
|
|
557
|
+
const call = await apiCall<{ groups?: unknown[] }>(api, { credentials: 'include' as any })
|
|
558
|
+
if (!call.ok) return
|
|
559
|
+
const data = call.result
|
|
560
|
+
if (cancelled) return
|
|
561
|
+
const nextGroups = Array.isArray(data?.groups) ? data.groups : []
|
|
562
|
+
if (nextGroups.length) setNavGroups((prev) => AppShell.cloneGroups(mergePreservingIcons(prev, nextGroups as any)))
|
|
563
|
+
} catch {}
|
|
564
|
+
}
|
|
565
|
+
const onRefresh = () => { refreshFullNav() }
|
|
566
|
+
window.addEventListener('om:refresh-sidebar', onRefresh as any)
|
|
567
|
+
return () => { cancelled = true; window.removeEventListener('om:refresh-sidebar', onRefresh as any) }
|
|
568
|
+
}, [adminNavApi])
|
|
569
|
+
|
|
570
|
+
// adminNavApi already includes user entities; no extra fetch
|
|
571
|
+
|
|
572
|
+
function renderSidebar(compact: boolean, hideHeader?: boolean) {
|
|
573
|
+
const isMobileVariant = !!hideHeader
|
|
574
|
+
const baseGroupsForDefaults = originalNavRef.current ?? navGroups
|
|
575
|
+
const baseGroupMap = new Map<string, SidebarGroup>()
|
|
576
|
+
for (const group of baseGroupsForDefaults) {
|
|
577
|
+
baseGroupMap.set(resolveGroupKey(group), group)
|
|
578
|
+
}
|
|
579
|
+
const localeLabel = (locale || '').toUpperCase()
|
|
580
|
+
|
|
581
|
+
const orderedGroupIds = customDraft
|
|
582
|
+
? mergeGroupOrder(customDraft.order, Array.from(baseGroupMap.keys()))
|
|
583
|
+
: navGroups.map((group) => resolveGroupKey(group))
|
|
584
|
+
|
|
585
|
+
const lastVisibleGroupIndex = (() => {
|
|
586
|
+
for (let idx = navGroups.length - 1; idx >= 0; idx -= 1) {
|
|
587
|
+
if (navGroups[idx].items.some((item) => item.hidden !== true)) return idx
|
|
588
|
+
}
|
|
589
|
+
return -1
|
|
590
|
+
})()
|
|
591
|
+
|
|
592
|
+
const renderEditableItems = (baseItems: SidebarItem[], currentItems: SidebarItem[], depth = 0): React.ReactNode => {
|
|
593
|
+
if (!customDraft) return null
|
|
594
|
+
return baseItems.map((baseItem) => {
|
|
595
|
+
const current = currentItems.find((item) => item.href === baseItem.href) ?? baseItem
|
|
596
|
+
const placeholder = baseItem.defaultTitle ?? baseItem.title
|
|
597
|
+
const value = customDraft.itemLabels[baseItem.href] ?? ''
|
|
598
|
+
const hidden = customDraft.hiddenItemIds[baseItem.href] === true
|
|
599
|
+
return (
|
|
600
|
+
<div
|
|
601
|
+
key={baseItem.href}
|
|
602
|
+
className={`flex flex-col gap-1 ${hidden ? 'opacity-60' : ''}`}
|
|
603
|
+
style={depth ? { marginLeft: depth * 16 } : undefined}
|
|
604
|
+
>
|
|
605
|
+
<span className="text-xs font-medium text-muted-foreground">{placeholder}</span>
|
|
606
|
+
<div className="flex items-center gap-2">
|
|
607
|
+
<input
|
|
608
|
+
type="checkbox"
|
|
609
|
+
className="h-4 w-4 accent-foreground"
|
|
610
|
+
checked={!hidden}
|
|
611
|
+
onChange={(event) => setItemHidden(baseItem.href, !event.target.checked)}
|
|
612
|
+
disabled={savingPreferences}
|
|
613
|
+
aria-label={t('appShell.sidebarCustomizationShowItem')}
|
|
614
|
+
title={t('appShell.sidebarCustomizationShowItem')}
|
|
615
|
+
/>
|
|
616
|
+
<input
|
|
617
|
+
value={value}
|
|
618
|
+
onChange={(event) => setItemLabel(baseItem.href, event.target.value)}
|
|
619
|
+
placeholder={placeholder}
|
|
620
|
+
disabled={savingPreferences}
|
|
621
|
+
className="h-8 flex-1 rounded border bg-background px-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-60"
|
|
622
|
+
/>
|
|
623
|
+
</div>
|
|
624
|
+
{baseItem.children && baseItem.children.length > 0 ? (
|
|
625
|
+
<div className="flex flex-col gap-1">
|
|
626
|
+
{renderEditableItems(baseItem.children, current.children ?? [], depth + 1)}
|
|
627
|
+
</div>
|
|
628
|
+
) : null}
|
|
629
|
+
</div>
|
|
630
|
+
)
|
|
631
|
+
})
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const customizationEditor = customizing ? (
|
|
635
|
+
customDraft ? (
|
|
636
|
+
<div className="flex flex-col gap-3 rounded border border-dashed bg-muted/20 p-3">
|
|
637
|
+
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
638
|
+
<div className="text-sm font-semibold">{t('appShell.sidebarCustomizationHeading')}</div>
|
|
639
|
+
<div className="flex items-center gap-2">
|
|
640
|
+
<button
|
|
641
|
+
type="button"
|
|
642
|
+
className="h-8 rounded border px-3 text-sm"
|
|
643
|
+
onClick={resetCustomization}
|
|
644
|
+
disabled={savingPreferences}
|
|
645
|
+
>
|
|
646
|
+
{t('appShell.sidebarCustomizationReset')}
|
|
647
|
+
</button>
|
|
648
|
+
<button
|
|
649
|
+
type="button"
|
|
650
|
+
className="h-8 rounded border px-3 text-sm"
|
|
651
|
+
onClick={cancelCustomization}
|
|
652
|
+
disabled={savingPreferences}
|
|
653
|
+
>
|
|
654
|
+
{t('appShell.sidebarCustomizationCancel')}
|
|
655
|
+
</button>
|
|
656
|
+
<button
|
|
657
|
+
type="button"
|
|
658
|
+
className="h-8 rounded bg-foreground px-3 text-sm font-medium text-background disabled:opacity-60"
|
|
659
|
+
onClick={saveCustomization}
|
|
660
|
+
disabled={savingPreferences}
|
|
661
|
+
>
|
|
662
|
+
{savingPreferences ? t('appShell.sidebarCustomizationSaving') : t('appShell.sidebarCustomizationSave')}
|
|
663
|
+
</button>
|
|
664
|
+
</div>
|
|
665
|
+
</div>
|
|
666
|
+
<p className="text-xs text-muted-foreground">{t('appShell.sidebarCustomizationHint', { locale: localeLabel })}</p>
|
|
667
|
+
{canApplyToRoles ? (
|
|
668
|
+
<div className="flex flex-col gap-2 rounded border bg-background/70 p-3 shadow-sm">
|
|
669
|
+
<div>
|
|
670
|
+
<div className="text-sm font-semibold">{t('appShell.sidebarApplyToRolesTitle')}</div>
|
|
671
|
+
<p className="text-xs text-muted-foreground">{t('appShell.sidebarApplyToRolesDescription')}</p>
|
|
672
|
+
</div>
|
|
673
|
+
{availableRoleTargets.length > 0 ? (
|
|
674
|
+
<div className="flex flex-col gap-2">
|
|
675
|
+
{availableRoleTargets.map((role) => {
|
|
676
|
+
const checked = selectedRoleIds.includes(role.id)
|
|
677
|
+
const willClear = role.hasPreference && !checked
|
|
678
|
+
return (
|
|
679
|
+
<label key={role.id} className="flex items-center gap-2 rounded border bg-background px-2 py-1 text-sm shadow-sm">
|
|
680
|
+
<input
|
|
681
|
+
type="checkbox"
|
|
682
|
+
className="h-4 w-4 accent-foreground"
|
|
683
|
+
checked={checked}
|
|
684
|
+
onChange={() => toggleRoleSelection(role.id)}
|
|
685
|
+
disabled={savingPreferences}
|
|
686
|
+
/>
|
|
687
|
+
<span className="flex-1 truncate">{role.name}</span>
|
|
688
|
+
{role.hasPreference ? (
|
|
689
|
+
<span className={`text-xs ${willClear ? 'text-destructive' : 'text-muted-foreground'}`}>
|
|
690
|
+
{willClear ? t('appShell.sidebarRoleWillClear') : t('appShell.sidebarRoleHasPreset')}
|
|
691
|
+
</span>
|
|
692
|
+
) : null}
|
|
693
|
+
</label>
|
|
694
|
+
)
|
|
695
|
+
})}
|
|
696
|
+
</div>
|
|
697
|
+
) : (
|
|
698
|
+
<p className="text-xs text-muted-foreground">{t('appShell.sidebarApplyToRolesEmpty')}</p>
|
|
699
|
+
)}
|
|
700
|
+
</div>
|
|
701
|
+
) : null}
|
|
702
|
+
{customizationError ? <p className="text-xs text-destructive">{customizationError}</p> : null}
|
|
703
|
+
<div className="flex flex-col gap-3">
|
|
704
|
+
{orderedGroupIds.map((groupId, index) => {
|
|
705
|
+
const baseGroup = baseGroupMap.get(groupId)
|
|
706
|
+
if (!baseGroup) return null
|
|
707
|
+
const currentGroup = navGroups.find((group) => resolveGroupKey(group) === groupId) ?? baseGroup
|
|
708
|
+
const placeholder = baseGroup.defaultName ?? baseGroup.name
|
|
709
|
+
const value = customDraft.groupLabels[groupId] ?? ''
|
|
710
|
+
return (
|
|
711
|
+
<div key={groupId} className="flex flex-col gap-3 rounded border bg-background p-3 shadow-sm">
|
|
712
|
+
<div className={`flex ${compact ? 'flex-col gap-2' : 'items-center gap-2'}`}>
|
|
713
|
+
<div className="flex-1">
|
|
714
|
+
<span className="text-xs font-medium text-muted-foreground">{t('appShell.sidebarCustomizationGroupLabel')}</span>
|
|
715
|
+
<input
|
|
716
|
+
value={value}
|
|
717
|
+
onChange={(event) => setGroupLabel(groupId, event.target.value)}
|
|
718
|
+
placeholder={placeholder}
|
|
719
|
+
disabled={savingPreferences}
|
|
720
|
+
className="mt-1 h-8 w-full rounded border bg-background px-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-60"
|
|
721
|
+
/>
|
|
722
|
+
</div>
|
|
723
|
+
<div className="flex items-center gap-1 self-start">
|
|
724
|
+
<button
|
|
725
|
+
type="button"
|
|
726
|
+
className="h-8 w-8 rounded border text-muted-foreground hover:text-foreground disabled:opacity-40"
|
|
727
|
+
onClick={() => moveGroup(groupId, -1)}
|
|
728
|
+
disabled={index === 0 || savingPreferences}
|
|
729
|
+
aria-label={t('appShell.sidebarCustomizationMoveUp')}
|
|
730
|
+
>
|
|
731
|
+
▲
|
|
732
|
+
</button>
|
|
733
|
+
<button
|
|
734
|
+
type="button"
|
|
735
|
+
className="h-8 w-8 rounded border text-muted-foreground hover:text-foreground disabled:opacity-40"
|
|
736
|
+
onClick={() => moveGroup(groupId, 1)}
|
|
737
|
+
disabled={index === orderedGroupIds.length - 1 || savingPreferences}
|
|
738
|
+
aria-label={t('appShell.sidebarCustomizationMoveDown')}
|
|
739
|
+
>
|
|
740
|
+
▼
|
|
741
|
+
</button>
|
|
742
|
+
</div>
|
|
743
|
+
</div>
|
|
744
|
+
<div className="flex flex-col gap-2">
|
|
745
|
+
{renderEditableItems(baseGroup.items, currentGroup.items)}
|
|
746
|
+
</div>
|
|
747
|
+
</div>
|
|
748
|
+
)
|
|
749
|
+
})}
|
|
750
|
+
</div>
|
|
751
|
+
</div>
|
|
752
|
+
) : (
|
|
753
|
+
<div className="rounded border border-dashed bg-muted/20 p-3 text-sm text-muted-foreground">
|
|
754
|
+
{t('appShell.sidebarCustomizationLoading')}
|
|
755
|
+
</div>
|
|
756
|
+
)
|
|
757
|
+
) : null
|
|
758
|
+
|
|
759
|
+
return (
|
|
760
|
+
<div className="flex flex-col min-h-full gap-3">
|
|
761
|
+
{!hideHeader && (
|
|
762
|
+
<div className={`flex items-center ${compact ? 'justify-center' : 'justify-between'} mb-2`}>
|
|
763
|
+
<Link href="/backend" className="flex items-center gap-2" aria-label={t('appShell.goToDashboard')}>
|
|
764
|
+
<Image src="/open-mercato.svg" alt={resolvedProductName} width={32} height={32} className="rounded m-4" />
|
|
765
|
+
{!compact && <div className="text-m font-semibold">{resolvedProductName}</div>}
|
|
766
|
+
</Link>
|
|
767
|
+
</div>
|
|
768
|
+
)}
|
|
769
|
+
<div className="flex flex-1 flex-col gap-3 overflow-y-auto pr-1">
|
|
770
|
+
{customizing ? (
|
|
771
|
+
customizationEditor
|
|
772
|
+
) : (
|
|
773
|
+
<nav className="flex flex-col gap-2">
|
|
774
|
+
{navGroups.map((g, gi) => {
|
|
775
|
+
const groupId = resolveGroupKey(g)
|
|
776
|
+
const open = openGroups[groupId] !== false
|
|
777
|
+
const visibleItems = g.items.filter((item) => item.hidden !== true)
|
|
778
|
+
if (visibleItems.length === 0) return null
|
|
779
|
+
return (
|
|
780
|
+
<div key={groupId}>
|
|
781
|
+
<button
|
|
782
|
+
type="button"
|
|
783
|
+
onClick={() => toggleGroup(groupId)}
|
|
784
|
+
className={`w-full ${compact ? 'px-0 justify-center' : 'px-2 justify-between'} flex items-center text-xs uppercase text-muted-foreground/90 py-2`}
|
|
785
|
+
aria-expanded={open}
|
|
786
|
+
>
|
|
787
|
+
{!compact && <span>{g.name}</span>}
|
|
788
|
+
{!compact && <Chevron open={open} />}
|
|
789
|
+
</button>
|
|
790
|
+
{open && (
|
|
791
|
+
<div className={`flex flex-col ${compact ? 'items-center' : ''} gap-1 ${!compact ? 'pl-1' : ''}`}>
|
|
792
|
+
{visibleItems.map((i) => {
|
|
793
|
+
const childItems = (i.children ?? []).filter((child) => child.hidden !== true)
|
|
794
|
+
const showChildren = !!pathname && childItems.length > 0 && pathname.startsWith(i.href)
|
|
795
|
+
const hasActiveChild = !!(pathname && childItems.some((c) => pathname.startsWith(c.href)))
|
|
796
|
+
const isParentActive = (pathname === i.href) || (showChildren && !hasActiveChild)
|
|
797
|
+
const base = compact ? 'w-10 h-10 justify-center' : 'px-2 py-1 gap-2'
|
|
798
|
+
return (
|
|
799
|
+
<React.Fragment key={i.href}>
|
|
800
|
+
<Link
|
|
801
|
+
href={i.href}
|
|
802
|
+
className={`relative text-sm rounded inline-flex items-center ${base} ${
|
|
803
|
+
isParentActive ? 'bg-background border shadow-sm' : 'hover:bg-accent hover:text-accent-foreground'
|
|
804
|
+
} ${i.enabled === false ? 'pointer-events-none opacity-50' : ''}`}
|
|
805
|
+
aria-disabled={i.enabled === false}
|
|
806
|
+
title={compact ? i.title : undefined}
|
|
807
|
+
onClick={() => setMobileOpen(false)}
|
|
808
|
+
>
|
|
809
|
+
{isParentActive ? (
|
|
810
|
+
<span className="absolute left-0 top-1 bottom-1 w-0.5 rounded bg-foreground" />
|
|
811
|
+
) : null}
|
|
812
|
+
<span className={`flex items-center justify-center shrink-0 ${compact ? '' : 'text-muted-foreground'}`}>
|
|
813
|
+
{i.icon ?? DefaultIcon}
|
|
814
|
+
</span>
|
|
815
|
+
{!compact && <span>{i.title}</span>}
|
|
816
|
+
</Link>
|
|
817
|
+
{showChildren ? (
|
|
818
|
+
<div className={`flex flex-col ${compact ? 'items-center' : ''} gap-1 ${!compact ? 'pl-4' : ''}`}>
|
|
819
|
+
{childItems.map((c) => {
|
|
820
|
+
const childActive = pathname?.startsWith(c.href)
|
|
821
|
+
const childBase = compact ? 'w-10 h-8 justify-center' : 'px-2 py-1 gap-2'
|
|
822
|
+
return (
|
|
823
|
+
<Link
|
|
824
|
+
key={c.href}
|
|
825
|
+
href={c.href}
|
|
826
|
+
className={`relative text-sm rounded inline-flex items-center ${childBase} ${
|
|
827
|
+
childActive ? 'bg-background border shadow-sm' : 'hover:bg-accent hover:text-accent-foreground'
|
|
828
|
+
} ${c.enabled === false ? 'pointer-events-none opacity-50' : ''}`}
|
|
829
|
+
aria-disabled={c.enabled === false}
|
|
830
|
+
title={compact ? c.title : undefined}
|
|
831
|
+
onClick={() => setMobileOpen(false)}
|
|
832
|
+
>
|
|
833
|
+
{childActive ? (
|
|
834
|
+
<span className="absolute left-0 top-1 bottom-1 w-0.5 rounded bg-foreground" />
|
|
835
|
+
) : null}
|
|
836
|
+
<span className={`flex items-center justify-center shrink-0 ${compact ? '' : 'text-muted-foreground'}`}>
|
|
837
|
+
{c.icon ?? (c.href.includes('/backend/entities/user/') && c.href.endsWith('/records') ? DataTableIcon : DefaultIcon)}
|
|
838
|
+
</span>
|
|
839
|
+
{!compact && <span>{c.title}</span>}
|
|
840
|
+
</Link>
|
|
841
|
+
)
|
|
842
|
+
})}
|
|
843
|
+
</div>
|
|
844
|
+
) : null}
|
|
845
|
+
</React.Fragment>
|
|
846
|
+
)
|
|
847
|
+
})}
|
|
848
|
+
</div>
|
|
849
|
+
)}
|
|
850
|
+
{gi !== lastVisibleGroupIndex && <div className="my-2 border-t border-dotted" />}
|
|
851
|
+
</div>
|
|
852
|
+
)
|
|
853
|
+
})}
|
|
854
|
+
</nav>
|
|
855
|
+
)}
|
|
856
|
+
</div>
|
|
857
|
+
{!customizing && (
|
|
858
|
+
<button
|
|
859
|
+
type="button"
|
|
860
|
+
onClick={startCustomization}
|
|
861
|
+
className={`mt-auto inline-flex items-center justify-center gap-2 rounded border hover:bg-accent hover:text-accent-foreground disabled:opacity-60 ${
|
|
862
|
+
compact || isMobileVariant ? 'h-10 w-10 p-0' : 'h-9 px-3 text-sm font-medium'
|
|
863
|
+
}`}
|
|
864
|
+
disabled={loadingPreferences}
|
|
865
|
+
aria-label={t('appShell.customizeSidebar')}
|
|
866
|
+
>
|
|
867
|
+
<span className="flex items-center justify-center">{CustomizeIcon}</span>
|
|
868
|
+
{!(compact || isMobileVariant) && (
|
|
869
|
+
<span>{loadingPreferences ? t('appShell.sidebarCustomizationLoading') : t('appShell.customizeSidebar')}</span>
|
|
870
|
+
)}
|
|
871
|
+
</button>
|
|
872
|
+
)}
|
|
873
|
+
</div>
|
|
874
|
+
)
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const gridColsClass = customizing
|
|
878
|
+
? 'lg:grid-cols-[320px_1fr]'
|
|
879
|
+
: (effectiveCollapsed ? 'lg:grid-cols-[72px_1fr]' : 'lg:grid-cols-[240px_1fr]')
|
|
880
|
+
const headerCtxValue = React.useMemo(() => ({
|
|
881
|
+
setBreadcrumb: setHeaderBreadcrumb,
|
|
882
|
+
setTitle: setHeaderTitle,
|
|
883
|
+
}), [])
|
|
884
|
+
|
|
885
|
+
return (
|
|
886
|
+
<HeaderContext.Provider value={headerCtxValue}>
|
|
887
|
+
<div className={`min-h-svh lg:grid ${gridColsClass}`}>
|
|
888
|
+
{/* Desktop sidebar */}
|
|
889
|
+
<aside className={`${asideClassesBase} ${effectiveCollapsed ? 'px-2' : 'px-3'} hidden lg:block`} style={{ width: asideWidth }}>{renderSidebar(effectiveCollapsed)}</aside>
|
|
890
|
+
|
|
891
|
+
<div className="flex min-h-svh flex-col min-w-0">
|
|
892
|
+
<header className="border-b bg-background/60 px-3 lg:px-4 py-3 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
|
893
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
894
|
+
{/* Mobile menu button */}
|
|
895
|
+
<button type="button" className="lg:hidden rounded border px-2 py-1" aria-label={t('appShell.openMenu')} onClick={() => setMobileOpen(true)}>
|
|
896
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
|
|
897
|
+
</button>
|
|
898
|
+
{/* Desktop collapse toggle */}
|
|
899
|
+
<button
|
|
900
|
+
type="button"
|
|
901
|
+
className="hidden lg:inline-flex rounded border px-2 py-1 disabled:opacity-60"
|
|
902
|
+
aria-label={t('appShell.toggleSidebar')}
|
|
903
|
+
onClick={() => setCollapsed((c) => !c)}
|
|
904
|
+
disabled={customizing}
|
|
905
|
+
>
|
|
906
|
+
{/* Sidebar toggle icon */}
|
|
907
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
908
|
+
<rect x="3" y="4" width="18" height="16" rx="2"/>
|
|
909
|
+
<path d="M9 4v16"/>
|
|
910
|
+
</svg>
|
|
911
|
+
</button>
|
|
912
|
+
{/* Header breadcrumb: always starts with Dashboard */}
|
|
913
|
+
{(() => {
|
|
914
|
+
const dashboardLabel = t('dashboard.title')
|
|
915
|
+
const root: Breadcrumb = [{ label: dashboardLabel, href: '/backend' }]
|
|
916
|
+
let rest: Breadcrumb = []
|
|
917
|
+
if (headerBreadcrumb && headerBreadcrumb.length) {
|
|
918
|
+
const first = headerBreadcrumb[0]
|
|
919
|
+
const dup = first && (first.href === '/backend' || first.label === dashboardLabel || first.label?.toLowerCase() === 'dashboard')
|
|
920
|
+
rest = dup ? headerBreadcrumb.slice(1) : headerBreadcrumb
|
|
921
|
+
} else if (headerTitle) {
|
|
922
|
+
rest = [{ label: headerTitle }]
|
|
923
|
+
}
|
|
924
|
+
const items = [...root, ...rest]
|
|
925
|
+
return (
|
|
926
|
+
<nav className="flex items-center gap-2 text-sm">
|
|
927
|
+
{items.map((b, i) => (
|
|
928
|
+
<React.Fragment key={i}>
|
|
929
|
+
{i > 0 && <span className="text-muted-foreground">/</span>}
|
|
930
|
+
{b.href ? (
|
|
931
|
+
<Link href={b.href} className="text-muted-foreground hover:text-foreground">
|
|
932
|
+
{b.label}
|
|
933
|
+
</Link>
|
|
934
|
+
) : (
|
|
935
|
+
<span className="font-medium truncate max-w-[60vw]">{b.label}</span>
|
|
936
|
+
)}
|
|
937
|
+
</React.Fragment>
|
|
938
|
+
))}
|
|
939
|
+
</nav>
|
|
940
|
+
)
|
|
941
|
+
})()}
|
|
942
|
+
</div>
|
|
943
|
+
<div className="flex items-center gap-2 text-sm w-full lg:w-auto lg:justify-end">
|
|
944
|
+
<ThemeToggle />
|
|
945
|
+
<Separator className="w-px h-5 mx-1" />
|
|
946
|
+
{rightHeaderSlot ? (
|
|
947
|
+
rightHeaderSlot
|
|
948
|
+
) : (
|
|
949
|
+
<span className="opacity-80">{email || t('appShell.userFallback')}</span>
|
|
950
|
+
)}
|
|
951
|
+
</div>
|
|
952
|
+
</header>
|
|
953
|
+
<main className="flex-1 p-4 lg:p-6">
|
|
954
|
+
<FlashMessages />
|
|
955
|
+
<PartialIndexBanner />
|
|
956
|
+
<UpgradeActionBanner />
|
|
957
|
+
<LastOperationBanner />
|
|
958
|
+
{children}
|
|
959
|
+
</main>
|
|
960
|
+
<footer className="border-t bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/50 px-4 py-3 flex flex-wrap items-center justify-end gap-4">
|
|
961
|
+
{version ? (
|
|
962
|
+
<span className="text-xs text-muted-foreground">
|
|
963
|
+
{t('appShell.version', { version })}
|
|
964
|
+
</span>
|
|
965
|
+
) : null}
|
|
966
|
+
<nav className="flex items-center gap-3 text-xs text-muted-foreground">
|
|
967
|
+
<Link href="/terms" className="transition hover:text-foreground">
|
|
968
|
+
{t('common.terms')}
|
|
969
|
+
</Link>
|
|
970
|
+
<Link href="/privacy" className="transition hover:text-foreground">
|
|
971
|
+
{t('common.privacy')}
|
|
972
|
+
</Link>
|
|
973
|
+
</nav>
|
|
974
|
+
<LanguageSwitcher />
|
|
975
|
+
</footer>
|
|
976
|
+
</div>
|
|
977
|
+
|
|
978
|
+
{/* Mobile drawer */}
|
|
979
|
+
{mobileOpen && (
|
|
980
|
+
<div className="lg:hidden fixed inset-0 z-50">
|
|
981
|
+
<div className="absolute inset-0 bg-black/40" onClick={() => setMobileOpen(false)} />
|
|
982
|
+
<aside className="absolute left-0 top-0 h-full w-[260px] bg-background border-r p-3">
|
|
983
|
+
<div className="mb-2 flex items-center justify-between">
|
|
984
|
+
<Link href="/backend" className="flex items-center gap-2 text-sm font-semibold" onClick={() => setMobileOpen(false)} aria-label={t('appShell.goToDashboard')}>
|
|
985
|
+
<Image src="/open-mercato.svg" alt={resolvedProductName} width={28} height={28} className="rounded" />
|
|
986
|
+
{resolvedProductName}
|
|
987
|
+
</Link>
|
|
988
|
+
<button className="rounded border px-2 py-1" onClick={() => setMobileOpen(false)} aria-label={t('appShell.closeMenu')}>✕</button>
|
|
989
|
+
</div>
|
|
990
|
+
{/* Force expanded sidebar in mobile drawer, hide its header and collapse toggle */}
|
|
991
|
+
{renderSidebar(false, true)}
|
|
992
|
+
</aside>
|
|
993
|
+
</div>
|
|
994
|
+
)}
|
|
995
|
+
</div>
|
|
996
|
+
</HeaderContext.Provider>
|
|
997
|
+
)
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// Helper: deep-clone minimal shape we mutate (children arrays)
|
|
1001
|
+
AppShell.cloneGroups = function cloneGroups(groups: AppShellProps['groups']): AppShellProps['groups'] {
|
|
1002
|
+
const cloneItem = (item: SidebarItem): SidebarItem => ({
|
|
1003
|
+
href: item.href,
|
|
1004
|
+
title: item.title,
|
|
1005
|
+
defaultTitle: item.defaultTitle,
|
|
1006
|
+
icon: item.icon,
|
|
1007
|
+
enabled: item.enabled,
|
|
1008
|
+
hidden: item.hidden,
|
|
1009
|
+
children: item.children ? item.children.map((child) => cloneItem(child)) : undefined,
|
|
1010
|
+
})
|
|
1011
|
+
return groups.map((group) => ({
|
|
1012
|
+
id: group.id,
|
|
1013
|
+
name: group.name,
|
|
1014
|
+
defaultName: group.defaultName,
|
|
1015
|
+
items: group.items.map((item) => cloneItem(item)),
|
|
1016
|
+
}))
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function applyCustomizationDraft(baseGroups: SidebarGroup[], draft: SidebarCustomizationDraft): SidebarGroup[] {
|
|
1020
|
+
const clones = AppShell.cloneGroups(baseGroups)
|
|
1021
|
+
const byId = new Map<string, SidebarGroup>()
|
|
1022
|
+
for (const group of clones) {
|
|
1023
|
+
byId.set(resolveGroupKey(group), group)
|
|
1024
|
+
}
|
|
1025
|
+
const orderedIds = mergeGroupOrder(draft.order, Array.from(byId.keys()))
|
|
1026
|
+
const seen = new Set<string>()
|
|
1027
|
+
const result: SidebarGroup[] = []
|
|
1028
|
+
for (const id of orderedIds) {
|
|
1029
|
+
if (seen.has(id)) continue
|
|
1030
|
+
const group = byId.get(id)
|
|
1031
|
+
if (!group) continue
|
|
1032
|
+
seen.add(id)
|
|
1033
|
+
const baseName = group.defaultName ?? group.name
|
|
1034
|
+
const override = draft.groupLabels[id]?.trim()
|
|
1035
|
+
result.push({
|
|
1036
|
+
...group,
|
|
1037
|
+
name: override && override.length > 0 ? override : baseName,
|
|
1038
|
+
items: group.items.map((item) => applyItemDraft(item, draft)),
|
|
1039
|
+
})
|
|
1040
|
+
}
|
|
1041
|
+
return result
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function applyItemDraft(item: SidebarItem, draft: SidebarCustomizationDraft): SidebarItem {
|
|
1045
|
+
const baseTitle = item.defaultTitle ?? item.title
|
|
1046
|
+
const override = draft.itemLabels[item.href]?.trim()
|
|
1047
|
+
const children = item.children
|
|
1048
|
+
? item.children
|
|
1049
|
+
.map((child) => applyItemDraft(child, draft))
|
|
1050
|
+
: undefined
|
|
1051
|
+
const hidden = draft.hiddenItemIds[item.href] === true
|
|
1052
|
+
return {
|
|
1053
|
+
...item,
|
|
1054
|
+
title: override && override.length > 0 ? override : baseTitle,
|
|
1055
|
+
hidden,
|
|
1056
|
+
children,
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function mergeGroupOrder(preferred: string[], current: string[]): string[] {
|
|
1061
|
+
const seen = new Set<string>()
|
|
1062
|
+
const merged: string[] = []
|
|
1063
|
+
for (const id of preferred) {
|
|
1064
|
+
const trimmed = id.trim()
|
|
1065
|
+
if (!trimmed || seen.has(trimmed) || !current.includes(trimmed)) continue
|
|
1066
|
+
seen.add(trimmed)
|
|
1067
|
+
merged.push(trimmed)
|
|
1068
|
+
}
|
|
1069
|
+
for (const id of current) {
|
|
1070
|
+
if (seen.has(id)) continue
|
|
1071
|
+
seen.add(id)
|
|
1072
|
+
merged.push(id)
|
|
1073
|
+
}
|
|
1074
|
+
return merged
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function collectSidebarDefaults(groups: SidebarGroup[]) {
|
|
1078
|
+
const groupDefaults = new Map<string, string>()
|
|
1079
|
+
const itemDefaults = new Map<string, string>()
|
|
1080
|
+
|
|
1081
|
+
const visitItems = (items: SidebarItem[]) => {
|
|
1082
|
+
for (const item of items) {
|
|
1083
|
+
const baseTitle = item.defaultTitle ?? item.title
|
|
1084
|
+
itemDefaults.set(item.href, baseTitle)
|
|
1085
|
+
if (item.children && item.children.length > 0) visitItems(item.children)
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
for (const group of groups) {
|
|
1090
|
+
const key = resolveGroupKey(group)
|
|
1091
|
+
groupDefaults.set(key, group.defaultName ?? group.name)
|
|
1092
|
+
visitItems(group.items)
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
return { groupDefaults, itemDefaults }
|
|
1096
|
+
}
|