@spree/dashboard-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +418 -0
  3. package/package.json +84 -0
  4. package/src/client.ts +25 -0
  5. package/src/components/address-form-dialog.tsx +376 -0
  6. package/src/components/app-sidebar.tsx +70 -0
  7. package/src/components/bulk-action-bar.tsx +504 -0
  8. package/src/components/can.tsx +30 -0
  9. package/src/components/country-combobox.tsx +238 -0
  10. package/src/components/country-state-fields.tsx +86 -0
  11. package/src/components/currency-select.tsx +181 -0
  12. package/src/components/export-button.tsx +156 -0
  13. package/src/components/file-upload-field.tsx +265 -0
  14. package/src/components/image-upload-field.tsx +53 -0
  15. package/src/components/import-button.tsx +202 -0
  16. package/src/components/locale-select.tsx +268 -0
  17. package/src/components/market-combobox.tsx +89 -0
  18. package/src/components/nav-main.tsx +186 -0
  19. package/src/components/page-header.tsx +286 -0
  20. package/src/components/page-tabs.tsx +78 -0
  21. package/src/components/preferences-form.tsx +404 -0
  22. package/src/components/resource-combobox.tsx +126 -0
  23. package/src/components/resource-multi-autocomplete.tsx +176 -0
  24. package/src/components/resource-picker-sheet.tsx +235 -0
  25. package/src/components/resource-table.tsx +652 -0
  26. package/src/components/settings-sidebar.tsx +139 -0
  27. package/src/components/slot.tsx +50 -0
  28. package/src/components/store-date-picker.tsx +18 -0
  29. package/src/components/store-switcher.tsx +70 -0
  30. package/src/components/table-toolbar.tsx +818 -0
  31. package/src/components/tag-combobox.tsx +99 -0
  32. package/src/components/top-bar.tsx +210 -0
  33. package/src/hooks/use-auth.ts +10 -0
  34. package/src/hooks/use-command-palette.tsx +34 -0
  35. package/src/hooks/use-countries.ts +15 -0
  36. package/src/hooks/use-custom-fields.ts +142 -0
  37. package/src/hooks/use-direct-upload.ts +104 -0
  38. package/src/hooks/use-display-name.ts +42 -0
  39. package/src/hooks/use-export.ts +75 -0
  40. package/src/hooks/use-global-search.ts +79 -0
  41. package/src/hooks/use-host-form.ts +34 -0
  42. package/src/hooks/use-import.ts +62 -0
  43. package/src/hooks/use-resource-mutation.ts +99 -0
  44. package/src/hooks/use-switch-admin-locale.ts +55 -0
  45. package/src/index.ts +97 -0
  46. package/src/lib/custom-field-components.ts +56 -0
  47. package/src/lib/download.ts +49 -0
  48. package/src/lib/filters-to-ransack.ts +47 -0
  49. package/src/lib/form-errors.ts +103 -0
  50. package/src/lib/form-fields-registry.ts +102 -0
  51. package/src/lib/form-mappers.ts +30 -0
  52. package/src/lib/formatters.ts +19 -0
  53. package/src/lib/i18n.ts +210 -0
  54. package/src/lib/nav-registry.ts +224 -0
  55. package/src/lib/permissions.ts +55 -0
  56. package/src/lib/query-client.ts +11 -0
  57. package/src/lib/query-keys.ts +69 -0
  58. package/src/lib/route-registry.ts +158 -0
  59. package/src/lib/search-registry.ts +129 -0
  60. package/src/lib/settings-nav-registry.ts +158 -0
  61. package/src/lib/slot-registry.ts +116 -0
  62. package/src/lib/table-registry.ts +274 -0
  63. package/src/lib/timestamp-columns.tsx +58 -0
  64. package/src/locales/ar.json +629 -0
  65. package/src/locales/de.json +629 -0
  66. package/src/locales/en.json +629 -0
  67. package/src/locales/fr.json +629 -0
  68. package/src/locales/pl.json +629 -0
  69. package/src/locales/zh-CN.json +629 -0
  70. package/src/plugin.ts +222 -0
  71. package/src/providers/auth-provider.tsx +188 -0
  72. package/src/providers/permission-provider.tsx +118 -0
  73. package/src/providers/store-provider.tsx +111 -0
  74. package/src/virtual.d.ts +5 -0
  75. package/src/vite/discover.ts +170 -0
  76. package/src/vite/index.ts +270 -0
@@ -0,0 +1,268 @@
1
+ import {
2
+ Combobox,
3
+ ComboboxChip,
4
+ ComboboxChips,
5
+ ComboboxChipsInput,
6
+ ComboboxContent,
7
+ ComboboxEmpty,
8
+ ComboboxInput,
9
+ ComboboxItem,
10
+ ComboboxList,
11
+ ComboboxValue,
12
+ Select,
13
+ SelectContent,
14
+ SelectItem,
15
+ SelectTrigger,
16
+ SelectValue,
17
+ useComboboxAnchor,
18
+ } from '@spree/dashboard-ui'
19
+ import { useMemo, useState } from 'react'
20
+ import { useTranslation } from 'react-i18next'
21
+ import { useDisplayName } from '../hooks/use-display-name'
22
+ import { useStore } from '../providers/store-provider'
23
+
24
+ interface BaseProps {
25
+ /** Locale codes to pick from. Defaults to the current store's `supported_locales`. */
26
+ options?: string[]
27
+ /** Locale to filter out (e.g. the default locale when picking additional supported ones). */
28
+ excludeCode?: string
29
+ /** Marks the field required for screen readers + native form submission. */
30
+ required?: boolean
31
+ disabled?: boolean
32
+ placeholder?: string
33
+ /** ID for the trigger / chips input — paired with the parent `<FieldLabel htmlFor>`. */
34
+ id?: string
35
+ }
36
+
37
+ interface SingleProps extends BaseProps {
38
+ multiple?: false
39
+ value: string | null | undefined
40
+ onChange: (locale: string) => void
41
+ }
42
+
43
+ interface MultiProps extends BaseProps {
44
+ multiple: true
45
+ value: string[]
46
+ onChange: (locales: string[]) => void
47
+ }
48
+
49
+ type LocaleSelectProps = SingleProps | MultiProps
50
+
51
+ /**
52
+ * Resolves a locale code (`en`, `pt-BR`) to its display name (`English`,
53
+ * `Portuguese (Brazil)`) in the admin UI language, falling back to the code
54
+ * itself when the runtime lacks coverage.
55
+ */
56
+ function useLocaleDisplayName() {
57
+ const displayName = useDisplayName('language')
58
+ return (code: string) => displayName(code) ?? code
59
+ }
60
+
61
+ /** Mirrors `CurrencySelect`'s row format: `CODE — Localized Name`. */
62
+ function formatOption(code: string, name: string) {
63
+ const upper = code.toUpperCase()
64
+ return name === code ? upper : `${upper} — ${name}`
65
+ }
66
+
67
+ /** Above this many options a plain `<Select>` is unwieldy — switch to a
68
+ * searchable combobox. Matches `CurrencySelect`. */
69
+ const SEARCHABLE_THRESHOLD = 12
70
+
71
+ /**
72
+ * Combobox `filter` factory shared by the single- and multi-select pickers:
73
+ * case-insensitive match on both the raw code and the localized `CODE — Name`
74
+ * label, so typing "pt", "BR", or "Portuguese" all find `pt-BR`.
75
+ */
76
+ function localeFilter(labelFor: (locale: string) => string) {
77
+ return (locale: string, query: string) => {
78
+ const q = query.trim().toLowerCase()
79
+ if (!q) return true
80
+ return locale.toLowerCase().includes(q) || labelFor(locale).toLowerCase().includes(q)
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Static text label for a locale code: `EN — English`. Use this in tables
86
+ * and read-only contexts where the picker isn't needed but the same
87
+ * formatting is. Falls back to the upper-cased code when `Intl.DisplayNames`
88
+ * has no entry.
89
+ */
90
+ export function LocaleLabel({ code }: { code: string }) {
91
+ const displayNameFor = useLocaleDisplayName()
92
+ if (!code) return null
93
+ return <>{formatOption(code, displayNameFor(code))}</>
94
+ }
95
+
96
+ /**
97
+ * Picker for one of the current store's `supported_locales`. Single-select by
98
+ * default; pass `multiple` for a chips multi-select. Each option reads
99
+ * `CODE — Localized Name` (e.g. `EN — English`), localized via the browser's
100
+ * `Intl.DisplayNames` against the store's `default_locale`. Use this anywhere
101
+ * the merchant is choosing a storefront language (market default, supported
102
+ * locales list, customer preference, etc.).
103
+ */
104
+ export function LocaleSelect(props: LocaleSelectProps) {
105
+ const { t } = useTranslation()
106
+ const { locales: storeLocales } = useStore()
107
+ const displayNameFor = useLocaleDisplayName()
108
+
109
+ // Union of `props.options` (everything the picker should show) and the
110
+ // currently selected values — so editing an existing record doesn't
111
+ // silently drop chips when the store's locale list later narrows.
112
+ // An empty `options` (store not loaded yet, or a backend that omits
113
+ // `available_locales`) falls back to the store's configured locales rather
114
+ // than rendering an empty picker — matching `CurrencySelect`.
115
+ const items = useMemo(() => {
116
+ const base = props.options?.length ? props.options : storeLocales
117
+ const selected = props.multiple ? props.value : props.value ? [props.value] : []
118
+ const merged = Array.from(new Set([...base, ...selected]))
119
+ return props.excludeCode ? merged.filter((l) => l !== props.excludeCode) : merged
120
+ }, [props.options, props.excludeCode, props.multiple, props.value, storeLocales])
121
+
122
+ // Precompute each option's `CODE — Name` label once. The searchable combobox
123
+ // below runs `renderOption` across every item on each keystroke (filter +
124
+ // render), so resolving `Intl.DisplayNames` per call would repeat ~N lookups
125
+ // per character; a lookup map keeps it to a single pass per list change.
126
+ const labels = useMemo(() => {
127
+ const map = new Map<string, string>()
128
+ for (const locale of items) map.set(locale, formatOption(locale, displayNameFor(locale)))
129
+ return map
130
+ }, [items, displayNameFor])
131
+
132
+ // `CODE — Name` label for a locale, from the precomputed map (falls back to a
133
+ // direct lookup for a value not in the current option list, e.g. a selected
134
+ // chip the list later dropped).
135
+ const labelFor = (locale: string) =>
136
+ labels.get(locale) ?? formatOption(locale, displayNameFor(locale))
137
+
138
+ if (props.multiple) {
139
+ return (
140
+ <MultiLocalePicker
141
+ id={props.id}
142
+ items={items}
143
+ value={props.value}
144
+ onChange={props.onChange}
145
+ labelFor={labelFor}
146
+ placeholder={props.placeholder ?? t('admin.components.locale_select.multi_placeholder')}
147
+ emptyText={t('admin.components.locale_select.empty')}
148
+ disabled={props.disabled}
149
+ />
150
+ )
151
+ }
152
+
153
+ const value = props.value ?? ''
154
+ const placeholder = props.placeholder ?? t('admin.components.locale_select.placeholder')
155
+
156
+ // Long lists (e.g. the full set of translatable locales) are unusable as a
157
+ // plain dropdown — switch to a searchable combobox, matching `CurrencySelect`.
158
+ if (items.length > SEARCHABLE_THRESHOLD) {
159
+ return (
160
+ <Combobox
161
+ items={items}
162
+ value={value}
163
+ onValueChange={(next: string | null) => props.onChange(next ?? '')}
164
+ itemToStringLabel={(locale: string | null) => (locale ? labelFor(locale) : '')}
165
+ itemToStringValue={(locale: string | null) => locale ?? ''}
166
+ filter={localeFilter(labelFor)}
167
+ disabled={props.disabled}
168
+ >
169
+ <ComboboxInput id={props.id} aria-required={props.required} placeholder={placeholder} />
170
+ <ComboboxContent>
171
+ <ComboboxEmpty>{t('admin.components.locale_select.empty')}</ComboboxEmpty>
172
+ <ComboboxList>
173
+ {(locale: string) => (
174
+ <ComboboxItem key={locale} value={locale}>
175
+ {labelFor(locale)}
176
+ </ComboboxItem>
177
+ )}
178
+ </ComboboxList>
179
+ </ComboboxContent>
180
+ </Combobox>
181
+ )
182
+ }
183
+
184
+ return (
185
+ <Select value={value} onValueChange={props.onChange} disabled={props.disabled}>
186
+ <SelectTrigger id={props.id} aria-required={props.required}>
187
+ {/* Base UI's `<SelectValue>` defaults to the raw locale code. Use
188
+ the children render-prop so the trigger shows the same
189
+ `CODE — Name` as the items. */}
190
+ <SelectValue placeholder={placeholder}>
191
+ {(v) => (v ? labelFor(v as string) : (v as string))}
192
+ </SelectValue>
193
+ </SelectTrigger>
194
+ <SelectContent>
195
+ {items.map((locale) => (
196
+ <SelectItem key={locale} value={locale}>
197
+ {labelFor(locale)}
198
+ </SelectItem>
199
+ ))}
200
+ </SelectContent>
201
+ </Select>
202
+ )
203
+ }
204
+
205
+ function MultiLocalePicker({
206
+ id,
207
+ items,
208
+ value,
209
+ onChange,
210
+ labelFor,
211
+ placeholder,
212
+ emptyText,
213
+ disabled,
214
+ }: {
215
+ id?: string
216
+ items: string[]
217
+ value: string[]
218
+ onChange: (locales: string[]) => void
219
+ labelFor: (code: string) => string
220
+ placeholder: string
221
+ emptyText: string
222
+ disabled?: boolean
223
+ }) {
224
+ const anchorRef = useComboboxAnchor()
225
+ const [inputValue, setInputValue] = useState('')
226
+
227
+ return (
228
+ <Combobox
229
+ multiple
230
+ items={items}
231
+ value={value}
232
+ onValueChange={(next: string[]) => {
233
+ onChange(next)
234
+ setInputValue('')
235
+ }}
236
+ // Search the same `CODE — Name` labels as the single-select picker — the
237
+ // canonical locale list is too long to scan by raw code alone.
238
+ itemToStringLabel={(locale: string | null) => (locale ? labelFor(locale) : '')}
239
+ itemToStringValue={(locale: string | null) => locale ?? ''}
240
+ filter={localeFilter(labelFor)}
241
+ disabled={disabled}
242
+ >
243
+ <ComboboxChips ref={anchorRef}>
244
+ <ComboboxValue>
245
+ {(selectedLocales: string[]) =>
246
+ selectedLocales.map((l) => <ComboboxChip key={l}>{labelFor(l)}</ComboboxChip>)
247
+ }
248
+ </ComboboxValue>
249
+ <ComboboxChipsInput
250
+ id={id}
251
+ placeholder={placeholder}
252
+ value={inputValue}
253
+ onChange={(e) => setInputValue((e.target as HTMLInputElement).value)}
254
+ />
255
+ </ComboboxChips>
256
+ <ComboboxContent anchor={anchorRef}>
257
+ <ComboboxEmpty>{emptyText}</ComboboxEmpty>
258
+ <ComboboxList>
259
+ {(locale: string) => (
260
+ <ComboboxItem key={locale} value={locale}>
261
+ {labelFor(locale)}
262
+ </ComboboxItem>
263
+ )}
264
+ </ComboboxList>
265
+ </ComboboxContent>
266
+ </Combobox>
267
+ )
268
+ }
@@ -0,0 +1,89 @@
1
+ import type { Market } from '@spree/admin-sdk'
2
+ import {
3
+ Combobox,
4
+ ComboboxChip,
5
+ ComboboxChips,
6
+ ComboboxChipsInput,
7
+ ComboboxContent,
8
+ ComboboxEmpty,
9
+ ComboboxItem,
10
+ ComboboxList,
11
+ ComboboxValue,
12
+ useComboboxAnchor,
13
+ } from '@spree/dashboard-ui'
14
+ import { useMemo, useState } from 'react'
15
+ import { useTranslation } from 'react-i18next'
16
+
17
+ /**
18
+ * Searchable multi-select market picker. Value is an array of prefixed
19
+ * market IDs; filters by name client-side against the caller-supplied
20
+ * `markets` list (typically the long-cached `useAllMarkets()` set in
21
+ * `@spree/dashboard`). Mirrors `<CountryMultiCombobox>` so the two
22
+ * pickers feel identical to use.
23
+ */
24
+ export function MarketMultiCombobox({
25
+ markets,
26
+ value,
27
+ onValueChange,
28
+ placeholder,
29
+ emptyText,
30
+ disabled = false,
31
+ }: {
32
+ markets: Market[]
33
+ /** Selected prefixed market IDs. */
34
+ value: string[]
35
+ onValueChange: (ids: string[]) => void
36
+ placeholder?: string
37
+ emptyText?: string
38
+ disabled?: boolean
39
+ }) {
40
+ const { t } = useTranslation()
41
+ const anchorRef = useComboboxAnchor()
42
+ const [inputValue, setInputValue] = useState('')
43
+
44
+ // Selected IDs not yet present in `markets` (list still loading) fall
45
+ // back to a stub so chips don't vanish on first render.
46
+ const selected = useMemo<Market[]>(
47
+ () => value.map((id) => markets.find((m) => m.id === id) ?? ({ id, name: id } as Market)),
48
+ [value, markets],
49
+ )
50
+
51
+ return (
52
+ <Combobox
53
+ multiple
54
+ items={markets}
55
+ value={selected}
56
+ onValueChange={(next: Market[]) => {
57
+ onValueChange(next.map((m) => m.id))
58
+ setInputValue('')
59
+ }}
60
+ itemToStringLabel={(m: Market | null) => m?.name ?? ''}
61
+ itemToStringValue={(m: Market | null) => m?.id ?? ''}
62
+ isItemEqualToValue={(a: Market, b: Market) => a.id === b.id}
63
+ disabled={disabled}
64
+ >
65
+ <ComboboxChips ref={anchorRef}>
66
+ <ComboboxValue>
67
+ {(selectedMarkets: Market[]) =>
68
+ selectedMarkets.map((m) => <ComboboxChip key={m.id}>{m.name}</ComboboxChip>)
69
+ }
70
+ </ComboboxValue>
71
+ <ComboboxChipsInput
72
+ placeholder={placeholder ?? t('admin.components.market_combobox.search_placeholder')}
73
+ value={inputValue}
74
+ onChange={(e) => setInputValue((e.target as HTMLInputElement).value)}
75
+ />
76
+ </ComboboxChips>
77
+ <ComboboxContent anchor={anchorRef}>
78
+ <ComboboxEmpty>{emptyText ?? t('admin.components.market_combobox.empty')}</ComboboxEmpty>
79
+ <ComboboxList>
80
+ {(market: Market) => (
81
+ <ComboboxItem key={market.id} value={market}>
82
+ {market.name}
83
+ </ComboboxItem>
84
+ )}
85
+ </ComboboxList>
86
+ </ComboboxContent>
87
+ </Combobox>
88
+ )
89
+ }
@@ -0,0 +1,186 @@
1
+ import {
2
+ DropdownMenu,
3
+ DropdownMenuContent,
4
+ DropdownMenuItem,
5
+ DropdownMenuTrigger,
6
+ SidebarGroup,
7
+ SidebarMenu,
8
+ SidebarMenuButton,
9
+ SidebarMenuItem,
10
+ SidebarMenuSub,
11
+ SidebarMenuSubButton,
12
+ SidebarMenuSubItem,
13
+ useSidebar,
14
+ } from '@spree/dashboard-ui'
15
+ import { Link, useRouterState } from '@tanstack/react-router'
16
+ import type { LucideIcon } from 'lucide-react'
17
+ import { type ComponentType, useState } from 'react'
18
+ import type { SubjectName } from '../lib/permissions'
19
+
20
+ export type NavItem = {
21
+ title: string
22
+ url: string
23
+ icon: LucideIcon
24
+ /** CanCanCan subject required to see this item. If omitted, item is always visible. */
25
+ subject?: SubjectName
26
+ /** Component rendered after the label (e.g. a count badge). May return null. */
27
+ badge?: ComponentType
28
+ items?: { title: string; url: string; subject?: SubjectName }[]
29
+ }
30
+
31
+ export function NavIcon({ icon: Icon, isActive }: { icon: NavItem['icon']; isActive?: boolean }) {
32
+ return (
33
+ <span
34
+ className={
35
+ 'inline-flex shrink-0 items-center justify-center rounded-lg p-[0.2rem] transition-colors duration-100 ' +
36
+ (isActive ? 'text-foreground' : 'group-hover/menu-button:text-foreground')
37
+ }
38
+ >
39
+ <Icon size={16} strokeWidth={2} />
40
+ </span>
41
+ )
42
+ }
43
+
44
+ function CollapsedDropdown({ item, children }: { item: NavItem; children: React.ReactNode }) {
45
+ const [open, setOpen] = useState(false)
46
+
47
+ return (
48
+ <DropdownMenu open={open} onOpenChange={setOpen}>
49
+ {/* `nativeButton={false}` because the trigger renders a `<div>` wrapping
50
+ a `SidebarMenuButton` (whose inner element is a `<Link>` → `<a>`),
51
+ not a `<button>`. The wrapper exists to attach `onMouseLeave` for
52
+ the hover-out close, so we can't drop it; instead we tell Base UI
53
+ we're rendering a non-button trigger and let it apply menu role +
54
+ keyboard semantics. */}
55
+ <DropdownMenuTrigger asChild nativeButton={false} onMouseEnter={() => setOpen(true)}>
56
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: hover trigger for collapsed nav */}
57
+ <div onMouseLeave={() => setOpen(false)}>{children}</div>
58
+ </DropdownMenuTrigger>
59
+ <DropdownMenuContent
60
+ side="right"
61
+ align="start"
62
+ sideOffset={4}
63
+ onMouseLeave={() => setOpen(false)}
64
+ onMouseEnter={() => setOpen(true)}
65
+ >
66
+ {item.items!.map((subItem) => (
67
+ <DropdownMenuItem key={subItem.title} asChild>
68
+ <Link to={subItem.url} className="no-underline">
69
+ {subItem.title}
70
+ </Link>
71
+ </DropdownMenuItem>
72
+ ))}
73
+ </DropdownMenuContent>
74
+ </DropdownMenu>
75
+ )
76
+ }
77
+
78
+ function NavItemContent({
79
+ item,
80
+ currentPath,
81
+ isCollapsed,
82
+ }: {
83
+ item: NavItem
84
+ currentPath: string
85
+ isCollapsed: boolean
86
+ }) {
87
+ const isExactActive = currentPath === item.url || currentPath === `${item.url}/`
88
+ // Only prefix-match for items with a sub-path after the storeId segment (e.g. /store_abc/orders)
89
+ const hasSubPath = item.url.split('/').filter(Boolean).length > 1
90
+ const isActive = isExactActive || (hasSubPath && currentPath.startsWith(item.url))
91
+ const hasActiveChild = item.items?.some(
92
+ (sub) => currentPath === sub.url || currentPath.startsWith(sub.url),
93
+ )
94
+ const showSubmenu = isActive || hasActiveChild
95
+ const itemIsActive = isActive || !!hasActiveChild
96
+
97
+ const button = (
98
+ <SidebarMenuButton
99
+ tooltip={!item.items || !isCollapsed ? item.title : undefined}
100
+ asChild
101
+ isActive={itemIsActive}
102
+ >
103
+ <Link to={item.url}>
104
+ <NavIcon icon={item.icon} isActive={itemIsActive} />
105
+ <span>{item.title}</span>
106
+ {item.badge && (
107
+ <span className="ml-auto">
108
+ <item.badge />
109
+ </span>
110
+ )}
111
+ </Link>
112
+ </SidebarMenuButton>
113
+ )
114
+
115
+ return (
116
+ <SidebarMenuItem>
117
+ {isCollapsed && item.items ? (
118
+ <CollapsedDropdown item={item}>{button}</CollapsedDropdown>
119
+ ) : (
120
+ button
121
+ )}
122
+ {item.items && showSubmenu && (
123
+ <SidebarMenuSub>
124
+ {item.items.map((subItem) => {
125
+ const subActive = currentPath === subItem.url || currentPath.startsWith(subItem.url)
126
+ return (
127
+ <SidebarMenuSubItem key={subItem.title}>
128
+ <SidebarMenuSubButton asChild isActive={subActive}>
129
+ <Link to={subItem.url}>
130
+ <span>{subItem.title}</span>
131
+ </Link>
132
+ </SidebarMenuSubButton>
133
+ </SidebarMenuSubItem>
134
+ )
135
+ })}
136
+ </SidebarMenuSub>
137
+ )}
138
+ </SidebarMenuItem>
139
+ )
140
+ }
141
+
142
+ export function NavMain({ items, bottomItems }: { items: NavItem[]; bottomItems?: NavItem[] }) {
143
+ const routerState = useRouterState()
144
+ const currentPath = routerState.location.pathname
145
+ const { state } = useSidebar()
146
+ const isCollapsed = state === 'collapsed'
147
+
148
+ return (
149
+ <>
150
+ <SidebarGroup>
151
+ <SidebarMenu>
152
+ {items.map((item) => (
153
+ <NavItemContent
154
+ key={item.title}
155
+ item={item}
156
+ currentPath={currentPath}
157
+ isCollapsed={isCollapsed}
158
+ />
159
+ ))}
160
+ </SidebarMenu>
161
+ </SidebarGroup>
162
+
163
+ {bottomItems && bottomItems.length > 0 && (
164
+ <SidebarGroup className="mt-auto mb-2">
165
+ <SidebarMenu>
166
+ {bottomItems.map((item) => {
167
+ const isActive =
168
+ currentPath === item.url || (item.url !== '/' && currentPath.startsWith(item.url))
169
+
170
+ return (
171
+ <SidebarMenuItem key={item.title}>
172
+ <SidebarMenuButton tooltip={item.title} asChild isActive={isActive}>
173
+ <Link to={item.url}>
174
+ <NavIcon icon={item.icon} isActive={isActive} />
175
+ <span>{item.title}</span>
176
+ </Link>
177
+ </SidebarMenuButton>
178
+ </SidebarMenuItem>
179
+ )
180
+ })}
181
+ </SidebarMenu>
182
+ </SidebarGroup>
183
+ )}
184
+ </>
185
+ )
186
+ }