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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/backend/AppShell.js +274 -697
  3. package/dist/backend/AppShell.js.map +3 -3
  4. package/dist/backend/CrudForm.js +1 -1
  5. package/dist/backend/CrudForm.js.map +2 -2
  6. package/dist/backend/crud/CollapsibleZoneLayout.js +23 -3
  7. package/dist/backend/crud/CollapsibleZoneLayout.js.map +2 -2
  8. package/dist/backend/section-page/SectionNav.js +10 -8
  9. package/dist/backend/section-page/SectionNav.js.map +2 -2
  10. package/dist/backend/section-page/SectionPage.js +2 -2
  11. package/dist/backend/section-page/SectionPage.js.map +2 -2
  12. package/dist/backend/sidebar/SidebarCustomizationEditor.js +1303 -0
  13. package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +7 -0
  14. package/dist/backend/sidebar/customization-helpers.js +150 -0
  15. package/dist/backend/sidebar/customization-helpers.js.map +7 -0
  16. package/dist/primitives/switch.js +1 -2
  17. package/dist/primitives/switch.js.map +2 -2
  18. package/jest.setup.ts +13 -0
  19. package/package.json +3 -3
  20. package/src/backend/AppShell.tsx +245 -732
  21. package/src/backend/CrudForm.tsx +1 -1
  22. package/src/backend/__tests__/AppShell.test.tsx +1 -1
  23. package/src/backend/__tests__/CollapsibleZoneLayout.test.tsx +101 -0
  24. package/src/backend/__tests__/CrudForm.navigation.test.tsx +42 -0
  25. package/src/backend/__tests__/SidebarCustomizationEditor.test.tsx +200 -0
  26. package/src/backend/crud/CollapsibleZoneLayout.tsx +28 -3
  27. package/src/backend/section-page/SectionNav.tsx +14 -10
  28. package/src/backend/section-page/SectionPage.tsx +15 -10
  29. package/src/backend/sidebar/SidebarCustomizationEditor.tsx +1562 -0
  30. package/src/backend/sidebar/customization-helpers.ts +203 -0
  31. package/src/primitives/switch.tsx +1 -2
@@ -0,0 +1,1562 @@
1
+ 'use client'
2
+ import * as React from 'react'
3
+ import { ChevronUp, ChevronDown, GripVertical, RotateCcw, Trash2, Plus, Search, AlertTriangle } from 'lucide-react'
4
+ import { DndContext, closestCenter, PointerSensor, KeyboardSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core'
5
+ import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove } from '@dnd-kit/sortable'
6
+ import { CSS } from '@dnd-kit/utilities'
7
+ import Image from 'next/image'
8
+ import { resolveInjectedIcon } from '../injection/resolveInjectedIcon'
9
+ import { useT, useLocale } from '@open-mercato/shared/lib/i18n/context'
10
+ import { Button } from '../../primitives/button'
11
+ import { IconButton } from '../../primitives/icon-button'
12
+ import { Input } from '../../primitives/input'
13
+ import { Switch } from '../../primitives/switch'
14
+ import { Card, CardContent, CardHeader, CardTitle } from '../../primitives/card'
15
+ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../../primitives/dialog'
16
+ import { Tag } from '../../primitives/tag'
17
+ import {
18
+ Select,
19
+ SelectContent,
20
+ SelectItem,
21
+ SelectTrigger,
22
+ SelectValue,
23
+ } from '../../primitives/select'
24
+ import { apiCall } from '../utils/apiCall'
25
+ import { flash } from '../FlashMessages'
26
+ import { Page, PageBody } from '../Page'
27
+ import { useBackendChrome } from '../BackendChromeProvider'
28
+ import { useConfirmDialog } from '../confirm-dialog'
29
+ import { useGuardedMutation } from '../injection/useGuardedMutation'
30
+ import {
31
+ applyCustomizationDraft,
32
+ applyItemOrder,
33
+ cloneSidebarGroups,
34
+ collectSidebarDefaults,
35
+ filterMainSidebarGroups,
36
+ mergeGroupOrder,
37
+ resolveGroupKey,
38
+ resolveItemKey,
39
+ type SidebarCustomizationDraft,
40
+ type SidebarGroup,
41
+ type SidebarItem,
42
+ } from './customization-helpers'
43
+
44
+ export type SidebarCustomizationEditorProps = {
45
+ onSaved?: () => void
46
+ onCanceled?: () => void
47
+ variantsApiPath?: string
48
+ preferencesApiPath?: string
49
+ groups?: SidebarGroup[]
50
+ }
51
+
52
+ const VARIANTS_API_DEFAULT = '/api/auth/sidebar/variants'
53
+ const PREFERENCES_API_DEFAULT = '/api/auth/sidebar/preferences'
54
+ const REFRESH_SIDEBAR_EVENT = 'om:refresh-sidebar'
55
+ const NEW_VARIANT_KEY = '__new__'
56
+
57
+ // Surface server-provided error messages directly when present (4xx with `error` field
58
+ // like 409 duplicate-name); fall back to the generic copy + status code for opaque 5xx.
59
+ function formatVariantApiError(
60
+ call: { ok: boolean; status: number; result: unknown },
61
+ t: (key: string, fallback?: string) => string,
62
+ ): string {
63
+ const detail = (call.result as { error?: unknown } | null)?.error
64
+ if (typeof detail === 'string' && detail.length > 0 && call.status >= 400 && call.status < 500) {
65
+ return detail
66
+ }
67
+ if (typeof detail === 'string' && detail.length > 0) {
68
+ return `${t('appShell.sidebarCustomizationSaveError')} (${call.status}: ${detail})`
69
+ }
70
+ return `${t('appShell.sidebarCustomizationSaveError')} (${call.status})`
71
+ }
72
+
73
+ type RoleTarget = {
74
+ id: string
75
+ name: string
76
+ hasPreference: boolean
77
+ }
78
+
79
+ type VariantSettings = {
80
+ version: number
81
+ groupOrder: string[]
82
+ groupLabels: Record<string, string>
83
+ itemLabels: Record<string, string>
84
+ hiddenItems: string[]
85
+ itemOrder?: Record<string, string[]>
86
+ }
87
+
88
+ type Variant = {
89
+ id: string
90
+ name: string
91
+ isActive: boolean
92
+ settings: VariantSettings
93
+ createdAt: string
94
+ updatedAt: string | null
95
+ }
96
+
97
+ type VariantListResponse = { locale: string; variants: Variant[] }
98
+ type VariantSingleResponse = { locale: string; variant: Variant }
99
+
100
+ function findItemByKey(items: SidebarItem[], targetKey: string): SidebarItem | null {
101
+ for (const item of items) {
102
+ if (resolveItemKey(item) === targetKey) return item
103
+ if (item.children && item.children.length > 0) {
104
+ const found = findItemByKey(item.children, targetKey)
105
+ if (found) return found
106
+ }
107
+ }
108
+ return null
109
+ }
110
+
111
+ function collectDescendantKeys(item: SidebarItem): string[] {
112
+ const out: string[] = []
113
+ const walk = (node: SidebarItem) => {
114
+ if (!node.children) return
115
+ for (const child of node.children) {
116
+ out.push(resolveItemKey(child))
117
+ walk(child)
118
+ }
119
+ }
120
+ walk(item)
121
+ return out
122
+ }
123
+
124
+ function parseDraftFromSettings(
125
+ rawSettings: VariantSettings | null | undefined,
126
+ baseSnapshot: SidebarGroup[],
127
+ ): SidebarCustomizationDraft {
128
+ const responseOrder = Array.isArray(rawSettings?.groupOrder)
129
+ ? rawSettings.groupOrder
130
+ .map((id) => (typeof id === 'string' ? id.trim() : ''))
131
+ .filter((id) => id.length > 0)
132
+ : []
133
+ const responseGroupLabels: Record<string, string> = {}
134
+ if (rawSettings?.groupLabels && typeof rawSettings.groupLabels === 'object') {
135
+ for (const [key, value] of Object.entries(rawSettings.groupLabels)) {
136
+ if (typeof value !== 'string') continue
137
+ const trimmedKey = key.trim()
138
+ if (!trimmedKey) continue
139
+ responseGroupLabels[trimmedKey] = value
140
+ }
141
+ }
142
+ const responseItemLabels: Record<string, string> = {}
143
+ if (rawSettings?.itemLabels && typeof rawSettings.itemLabels === 'object') {
144
+ for (const [key, value] of Object.entries(rawSettings.itemLabels)) {
145
+ if (typeof value !== 'string') continue
146
+ const trimmedKey = key.trim()
147
+ if (!trimmedKey) continue
148
+ responseItemLabels[trimmedKey] = value
149
+ }
150
+ }
151
+ const responseHiddenItems = Array.isArray(rawSettings?.hiddenItems)
152
+ ? rawSettings.hiddenItems
153
+ .map((itemId) => (typeof itemId === 'string' ? itemId.trim() : ''))
154
+ .filter((itemId) => itemId.length > 0)
155
+ : []
156
+ const responseItemOrder: Record<string, string[]> = {}
157
+ if (rawSettings?.itemOrder && typeof rawSettings.itemOrder === 'object') {
158
+ for (const [groupKey, list] of Object.entries(rawSettings.itemOrder)) {
159
+ if (!Array.isArray(list)) continue
160
+ const trimmedGroup = groupKey.trim()
161
+ if (!trimmedGroup) continue
162
+ const seen = new Set<string>()
163
+ const values: string[] = []
164
+ for (const itemKey of list) {
165
+ if (typeof itemKey !== 'string') continue
166
+ const trimmedItem = itemKey.trim()
167
+ if (!trimmedItem || seen.has(trimmedItem)) continue
168
+ seen.add(trimmedItem)
169
+ values.push(trimmedItem)
170
+ }
171
+ if (values.length > 0) responseItemOrder[trimmedGroup] = values
172
+ }
173
+ }
174
+ const currentIds = baseSnapshot.map((group) => resolveGroupKey(group))
175
+ const order = mergeGroupOrder(responseOrder, currentIds)
176
+ const { itemDefaults } = collectSidebarDefaults(baseSnapshot)
177
+ const hiddenItemIds: Record<string, boolean> = {}
178
+ for (const itemId of responseHiddenItems) {
179
+ if (!itemDefaults.has(itemId)) continue
180
+ hiddenItemIds[itemId] = true
181
+ }
182
+ return {
183
+ order,
184
+ groupLabels: responseGroupLabels,
185
+ itemLabels: responseItemLabels,
186
+ hiddenItemIds,
187
+ itemOrder: responseItemOrder,
188
+ }
189
+ }
190
+
191
+ function emptyDraftFor(baseSnapshot: SidebarGroup[]): SidebarCustomizationDraft {
192
+ return {
193
+ order: baseSnapshot.map((group) => resolveGroupKey(group)),
194
+ groupLabels: {},
195
+ itemLabels: {},
196
+ hiddenItemIds: {},
197
+ itemOrder: {},
198
+ }
199
+ }
200
+
201
+ export function SidebarCustomizationEditor({
202
+ onSaved,
203
+ onCanceled,
204
+ variantsApiPath = VARIANTS_API_DEFAULT,
205
+ preferencesApiPath = PREFERENCES_API_DEFAULT,
206
+ groups: groupsProp,
207
+ }: SidebarCustomizationEditorProps) {
208
+ const t = useT()
209
+ const locale = useLocale()
210
+ const localeLabel = (locale || '').toUpperCase()
211
+ const { payload: chromePayload, isLoading: chromeIsLoading } = useBackendChrome()
212
+ const groupsFromChrome = chromePayload?.groups as SidebarGroup[] | undefined
213
+ const sourceGroups = groupsProp ?? groupsFromChrome ?? []
214
+ const { confirm: confirmDialog, ConfirmDialogElement } = useConfirmDialog()
215
+
216
+ const [loading, setLoading] = React.useState(true)
217
+ const [saving, setSaving] = React.useState(false)
218
+ const [deleting, setDeleting] = React.useState(false)
219
+ const [error, setError] = React.useState<string | null>(null)
220
+ const [variants, setVariants] = React.useState<Variant[]>([])
221
+ const [selectedVariantId, setSelectedVariantId] = React.useState<string | null>(null)
222
+ const [variantName, setVariantName] = React.useState('')
223
+ const [draft, setDraft] = React.useState<SidebarCustomizationDraft | null>(null)
224
+ const [previewGroups, setPreviewGroups] = React.useState<SidebarGroup[]>([])
225
+ const [dirty, setDirty] = React.useState(false)
226
+ const [availableRoleTargets, setAvailableRoleTargets] = React.useState<RoleTarget[]>([])
227
+ const [selectedRoleIds, setSelectedRoleIds] = React.useState<string[]>([])
228
+ const [canApplyToRoles, setCanApplyToRoles] = React.useState(false)
229
+ const [addDialogOpen, setAddDialogOpen] = React.useState(false)
230
+ const [addDialogName, setAddDialogName] = React.useState('')
231
+ const baseSnapshotRef = React.useRef<SidebarGroup[] | null>(null)
232
+ const hasInitializedRef = React.useRef(false)
233
+
234
+ const { runMutation, retryLastMutation } = useGuardedMutation<{
235
+ formId: string
236
+ variantId?: string | null
237
+ operation: string
238
+ retryLastMutation: () => Promise<boolean>
239
+ }>({
240
+ contextId: 'sidebar-customization',
241
+ blockedMessage: t('appShell.sidebarCustomizationSaveError'),
242
+ })
243
+
244
+ const buildMutationContext = React.useCallback(
245
+ (operation: string, variantId?: string | null) => ({
246
+ formId: 'sidebar-customization',
247
+ variantId: variantId ?? null,
248
+ operation,
249
+ retryLastMutation,
250
+ }),
251
+ [retryLastMutation],
252
+ )
253
+
254
+ const isNewVariant = selectedVariantId === null
255
+ const selectedVariant = React.useMemo(
256
+ () => (selectedVariantId ? variants.find((v) => v.id === selectedVariantId) ?? null : null),
257
+ [selectedVariantId, variants],
258
+ )
259
+
260
+ const updateDraft = React.useCallback((updater: (draft: SidebarCustomizationDraft) => SidebarCustomizationDraft) => {
261
+ setDraft((prev) => {
262
+ if (!prev) return prev
263
+ const next = updater(prev)
264
+ if (baseSnapshotRef.current) {
265
+ setPreviewGroups(applyCustomizationDraft(baseSnapshotRef.current, next))
266
+ }
267
+ return next
268
+ })
269
+ setDirty(true)
270
+ }, [])
271
+
272
+ const buildBaseSnapshot = React.useCallback((): SidebarGroup[] => {
273
+ return filterMainSidebarGroups(cloneSidebarGroups(sourceGroups))
274
+ }, [sourceGroups])
275
+
276
+ const loadVariantsList = React.useCallback(async (): Promise<Variant[]> => {
277
+ // Cache-bust to prevent stale browser/Next caches from masking just-created variants.
278
+ const url = `${variantsApiPath}?_=${Date.now()}`
279
+ const call = await apiCall<VariantListResponse>(url, { cache: 'no-store' })
280
+ if (!call.ok) {
281
+ throw new Error('list-failed')
282
+ }
283
+ return call.result?.variants ?? []
284
+ }, [variantsApiPath])
285
+
286
+ const loadRolesPayload = React.useCallback(async (): Promise<{ canApplyToRoles: boolean; roles: RoleTarget[] }> => {
287
+ const call = await apiCall<{ canApplyToRoles?: boolean; roles?: Array<{ id?: string; name?: string; hasPreference?: boolean }> }>(preferencesApiPath)
288
+ if (!call.ok) {
289
+ return { canApplyToRoles: false, roles: [] }
290
+ }
291
+ const data = call.result ?? null
292
+ const can = data?.canApplyToRoles === true
293
+ const roles = Array.isArray(data?.roles)
294
+ ? (data!.roles as Array<{ id?: string; name?: string; hasPreference?: boolean }>)
295
+ .filter((r) => typeof r?.id === 'string' && typeof r?.name === 'string')
296
+ .map((r) => ({ id: r.id as string, name: r.name as string, hasPreference: r.hasPreference === true }))
297
+ : []
298
+ return { canApplyToRoles: can, roles }
299
+ }, [preferencesApiPath])
300
+
301
+ const selectVariantInternal = React.useCallback((variant: Variant | null, list: Variant[]) => {
302
+ const baseSnapshot = baseSnapshotRef.current ?? buildBaseSnapshot()
303
+ baseSnapshotRef.current = baseSnapshot
304
+ if (variant) {
305
+ const initialDraft = parseDraftFromSettings(variant.settings, baseSnapshot)
306
+ setSelectedVariantId(variant.id)
307
+ setVariantName(variant.name)
308
+ setDraft(initialDraft)
309
+ setPreviewGroups(applyCustomizationDraft(baseSnapshot, initialDraft))
310
+ } else {
311
+ const empty = emptyDraftFor(baseSnapshot)
312
+ setSelectedVariantId(null)
313
+ // Suggest a default name based on the existing variants count.
314
+ const usedNumbers = new Set<number>()
315
+ for (const v of list) {
316
+ if (v.name === 'My preferences') usedNumbers.add(1)
317
+ const match = v.name.match(/^My preferences\s+(\d+)$/)
318
+ if (match) usedNumbers.add(Number.parseInt(match[1], 10))
319
+ }
320
+ let next = 1
321
+ while (usedNumbers.has(next)) next += 1
322
+ const suggestion = next === 1 ? 'My preferences' : `My preferences ${next}`
323
+ setVariantName(suggestion)
324
+ setDraft(empty)
325
+ setPreviewGroups(applyCustomizationDraft(baseSnapshot, empty))
326
+ }
327
+ setDirty(false)
328
+ }, [buildBaseSnapshot])
329
+
330
+ // Initial load. No cancelled flag because React Strict Mode in dev runs effects twice
331
+ // and the cleanup-driven cancellation made the only init pass abort silently — leaving
332
+ // `loading` true forever and the editor stuck on the "Loading…" placeholder. The init
333
+ // gate (`hasInitializedRef`) prevents the second Strict-Mode run from doubling work.
334
+ React.useEffect(() => {
335
+ if (hasInitializedRef.current) return
336
+ if (sourceGroups.length === 0) return
337
+ hasInitializedRef.current = true
338
+ async function init() {
339
+ setLoading(true)
340
+ setError(null)
341
+ try {
342
+ const [list, rolesPayload] = await Promise.all([
343
+ loadVariantsList(),
344
+ loadRolesPayload(),
345
+ ])
346
+ setVariants(list)
347
+ setCanApplyToRoles(rolesPayload.canApplyToRoles)
348
+ setAvailableRoleTargets(rolesPayload.roles)
349
+ const active = list.find((v) => v.isActive)
350
+ const initial = active ?? list[0] ?? null
351
+ selectVariantInternal(initial, list)
352
+ setSelectedRoleIds([])
353
+ } catch (err) {
354
+ console.error('Failed to load sidebar variants', err)
355
+ setError(t('appShell.sidebarCustomizationLoadError'))
356
+ } finally {
357
+ setLoading(false)
358
+ }
359
+ }
360
+ void init()
361
+ // eslint-disable-next-line react-hooks/exhaustive-deps
362
+ }, [sourceGroups.length])
363
+
364
+ const toggleRoleSelection = React.useCallback((roleId: string) => {
365
+ setSelectedRoleIds((prev) => (prev.includes(roleId) ? prev.filter((id) => id !== roleId) : [...prev, roleId]))
366
+ setDirty(true)
367
+ }, [])
368
+
369
+ const createNewVariant = React.useCallback(async (proposedName?: string): Promise<boolean> => {
370
+ if (saving || deleting) return false
371
+ if (dirty && selectedVariantId !== null) {
372
+ const proceed = await confirmDialog({
373
+ title: t('appShell.sidebarCustomizationSwitchConfirmTitle', 'Discard unsaved changes?'),
374
+ text: t('appShell.sidebarCustomizationSwitchConfirmText', 'You have unsaved changes for the current variant. Switching will discard them.'),
375
+ confirmText: t('appShell.sidebarCustomizationSwitchConfirmYes', 'Discard and switch'),
376
+ cancelText: t('common.cancel', 'Cancel'),
377
+ variant: 'destructive',
378
+ })
379
+ if (!proceed) return false
380
+ }
381
+ setSaving(true)
382
+ setError(null)
383
+ try {
384
+ const baseSnapshot = baseSnapshotRef.current ?? buildBaseSnapshot()
385
+ baseSnapshotRef.current = baseSnapshot
386
+ const groupOrder = baseSnapshot.map((g) => resolveGroupKey(g))
387
+ const trimmed = (proposedName ?? '').trim()
388
+ const call = await runMutation({
389
+ operation: () =>
390
+ apiCall<VariantSingleResponse>(variantsApiPath, {
391
+ method: 'POST',
392
+ headers: { 'content-type': 'application/json' },
393
+ body: JSON.stringify({
394
+ // If name is omitted, server auto-names ("My preferences", "My preferences 2", …).
395
+ name: trimmed.length > 0 ? trimmed : undefined,
396
+ settings: { groupOrder, groupLabels: {}, itemLabels: {}, hiddenItems: [], itemOrder: {} },
397
+ isActive: true,
398
+ }),
399
+ }),
400
+ context: buildMutationContext('createVariant'),
401
+ mutationPayload: { name: trimmed.length > 0 ? trimmed : null },
402
+ })
403
+ if (!call.ok) {
404
+ setError(formatVariantApiError(call, t))
405
+ return false
406
+ }
407
+ const created = call.result?.variant ?? null
408
+ // Trust POST response as authoritative; refetch in background for any side-effects
409
+ // (e.g. server-side deactivation of previous active variant).
410
+ let nextList: Variant[]
411
+ try {
412
+ nextList = await loadVariantsList()
413
+ } catch {
414
+ nextList = variants
415
+ }
416
+ // Defensive merge: ensure the just-created variant is in the list even if the
417
+ // refetch happened to be served from a stale cache.
418
+ if (created && !nextList.some((v) => v.id === created.id)) {
419
+ nextList = [...nextList, created]
420
+ }
421
+ setVariants(nextList)
422
+ if (created) {
423
+ const fresh = nextList.find((v) => v.id === created.id) ?? created
424
+ selectVariantInternal(fresh, nextList)
425
+ }
426
+ flash(t('appShell.sidebarCustomizationVariantCreated', 'Variant created.'), 'success')
427
+ return true
428
+ } catch (err) {
429
+ console.error('Failed to create sidebar variant', err)
430
+ setError(t('appShell.sidebarCustomizationSaveError'))
431
+ return false
432
+ } finally {
433
+ setSaving(false)
434
+ }
435
+ }, [saving, deleting, dirty, selectedVariantId, confirmDialog, t, buildBaseSnapshot, variantsApiPath, loadVariantsList, selectVariantInternal, variants, runMutation, buildMutationContext])
436
+
437
+ const handleVariantSwitch = React.useCallback(async (key: string) => {
438
+ if (saving || deleting) return
439
+ if (key === selectedVariantId) return
440
+ if (key === NEW_VARIANT_KEY && isNewVariant) return
441
+ if (dirty) {
442
+ const proceed = await confirmDialog({
443
+ title: t('appShell.sidebarCustomizationSwitchConfirmTitle', 'Discard unsaved changes?'),
444
+ text: t('appShell.sidebarCustomizationSwitchConfirmText', 'You have unsaved changes for the current variant. Switching will discard them.'),
445
+ confirmText: t('appShell.sidebarCustomizationSwitchConfirmYes', 'Discard and switch'),
446
+ cancelText: t('common.cancel', 'Cancel'),
447
+ variant: 'destructive',
448
+ })
449
+ if (!proceed) return
450
+ }
451
+ if (key === NEW_VARIANT_KEY) {
452
+ selectVariantInternal(null, variants)
453
+ return
454
+ }
455
+ const next = variants.find((v) => v.id === key) ?? null
456
+ selectVariantInternal(next, variants)
457
+ }, [saving, deleting, selectedVariantId, isNewVariant, dirty, confirmDialog, t, variants, selectVariantInternal])
458
+
459
+ const moveGroup = React.useCallback((groupId: string, offset: number) => {
460
+ updateDraft((draft) => {
461
+ const order = [...draft.order]
462
+ const index = order.indexOf(groupId)
463
+ if (index === -1) return draft
464
+ const nextIndex = Math.max(0, Math.min(order.length - 1, index + offset))
465
+ if (nextIndex === index) return draft
466
+ order.splice(index, 1)
467
+ order.splice(nextIndex, 0, groupId)
468
+ return { ...draft, order }
469
+ })
470
+ }, [updateDraft])
471
+
472
+ const dndSensors = useSensors(
473
+ useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
474
+ useSensor(KeyboardSensor),
475
+ )
476
+
477
+ const handleItemDragEnd = React.useCallback((groupKey: string, currentItemKeys: string[]) => (event: DragEndEvent) => {
478
+ const { active, over } = event
479
+ if (!over || active.id === over.id) return
480
+ const fromId = String(active.id)
481
+ const toId = String(over.id)
482
+ updateDraft((draft) => {
483
+ const baseOrder = draft.itemOrder?.[groupKey]?.length
484
+ ? [...draft.itemOrder[groupKey]]
485
+ : [...currentItemKeys]
486
+ const fromIndex = baseOrder.indexOf(fromId)
487
+ const toIndex = baseOrder.indexOf(toId)
488
+ if (fromIndex === -1 || toIndex === -1) return draft
489
+ const nextOrder = arrayMove(baseOrder, fromIndex, toIndex)
490
+ return {
491
+ ...draft,
492
+ itemOrder: { ...(draft.itemOrder ?? {}), [groupKey]: nextOrder },
493
+ }
494
+ })
495
+ }, [updateDraft])
496
+
497
+ const setGroupLabel = React.useCallback((groupId: string, value: string) => {
498
+ updateDraft((draft) => {
499
+ const next = { ...draft.groupLabels }
500
+ if (value.trim().length === 0) delete next[groupId]
501
+ else next[groupId] = value
502
+ return { ...draft, groupLabels: next }
503
+ })
504
+ }, [updateDraft])
505
+
506
+ const setItemLabel = React.useCallback((itemId: string, value: string) => {
507
+ updateDraft((draft) => {
508
+ const next = { ...draft.itemLabels }
509
+ if (value.trim().length === 0) delete next[itemId]
510
+ else next[itemId] = value
511
+ return { ...draft, itemLabels: next }
512
+ })
513
+ }, [updateDraft])
514
+
515
+ const setItemHidden = React.useCallback((itemId: string, hidden: boolean) => {
516
+ updateDraft((draft) => {
517
+ const next = { ...draft.hiddenItemIds }
518
+ const apply = (id: string) => {
519
+ if (hidden) next[id] = true
520
+ else delete next[id]
521
+ }
522
+ apply(itemId)
523
+ // Cascade: hiding a parent hides every descendant; showing it reveals them too.
524
+ if (baseSnapshotRef.current) {
525
+ for (const group of baseSnapshotRef.current) {
526
+ const target = findItemByKey(group.items, itemId)
527
+ if (!target) continue
528
+ for (const descendantKey of collectDescendantKeys(target)) apply(descendantKey)
529
+ break
530
+ }
531
+ }
532
+ return { ...draft, hiddenItemIds: next }
533
+ })
534
+ }, [updateDraft])
535
+
536
+ const reset = React.useCallback(() => {
537
+ if (!baseSnapshotRef.current) return
538
+ if (selectedVariant) {
539
+ const initialDraft = parseDraftFromSettings(selectedVariant.settings, baseSnapshotRef.current)
540
+ setDraft(initialDraft)
541
+ setPreviewGroups(applyCustomizationDraft(baseSnapshotRef.current, initialDraft))
542
+ } else {
543
+ const empty = emptyDraftFor(baseSnapshotRef.current)
544
+ setDraft(empty)
545
+ setPreviewGroups(applyCustomizationDraft(baseSnapshotRef.current, empty))
546
+ }
547
+ setDirty(false)
548
+ }, [selectedVariant])
549
+
550
+ const cancel = React.useCallback(() => {
551
+ onCanceled?.()
552
+ }, [onCanceled])
553
+
554
+ const submitAddDialog = React.useCallback(async () => {
555
+ const ok = await createNewVariant(addDialogName)
556
+ if (ok) {
557
+ setAddDialogOpen(false)
558
+ setAddDialogName('')
559
+ }
560
+ }, [createNewVariant, addDialogName])
561
+
562
+ const sanitizeSettingsPayload = React.useCallback(() => {
563
+ if (!draft || !baseSnapshotRef.current) return null
564
+ const baseGroups = baseSnapshotRef.current
565
+ const { groupDefaults, itemDefaults } = collectSidebarDefaults(baseGroups)
566
+ const sanitizedGroupLabels: Record<string, string> = {}
567
+ for (const [key, value] of Object.entries(draft.groupLabels)) {
568
+ const trimmed = value.trim()
569
+ const base = groupDefaults.get(key)
570
+ if (!trimmed || !base) continue
571
+ if (trimmed !== base) sanitizedGroupLabels[key] = trimmed
572
+ }
573
+ const sanitizedItemLabels: Record<string, string> = {}
574
+ for (const [itemId, value] of Object.entries(draft.itemLabels)) {
575
+ const trimmed = value.trim()
576
+ const base = itemDefaults.get(itemId)
577
+ if (!trimmed || !base) continue
578
+ if (trimmed !== base) sanitizedItemLabels[itemId] = trimmed
579
+ }
580
+ const sanitizedHiddenItems: string[] = []
581
+ for (const [itemId, hidden] of Object.entries(draft.hiddenItemIds)) {
582
+ if (!hidden) continue
583
+ if (!itemDefaults.has(itemId)) continue
584
+ sanitizedHiddenItems.push(itemId)
585
+ }
586
+ // Build a Set of valid group keys to drop stale itemOrder entries.
587
+ const groupKeys = new Set<string>()
588
+ for (const group of baseGroups) groupKeys.add(resolveGroupKey(group))
589
+ const sanitizedItemOrder: Record<string, string[]> = {}
590
+ for (const [groupKey, list] of Object.entries(draft.itemOrder ?? {})) {
591
+ if (!groupKeys.has(groupKey)) continue
592
+ const seen = new Set<string>()
593
+ const values: string[] = []
594
+ for (const itemKey of list) {
595
+ if (seen.has(itemKey)) continue
596
+ if (!itemDefaults.has(itemKey)) continue
597
+ seen.add(itemKey)
598
+ values.push(itemKey)
599
+ }
600
+ if (values.length > 0) sanitizedItemOrder[groupKey] = values
601
+ }
602
+ return {
603
+ groupOrder: draft.order,
604
+ groupLabels: sanitizedGroupLabels,
605
+ itemLabels: sanitizedItemLabels,
606
+ hiddenItems: sanitizedHiddenItems,
607
+ itemOrder: sanitizedItemOrder,
608
+ }
609
+ }, [draft])
610
+
611
+ const save = React.useCallback(async () => {
612
+ const settings = sanitizeSettingsPayload()
613
+ if (!settings) return
614
+ setSaving(true)
615
+ setError(null)
616
+ try {
617
+ const trimmedName = variantName.trim()
618
+ const isCurrentlyActive = selectedVariant?.isActive ?? false
619
+ let savedVariant: Variant | null = null
620
+ if (isNewVariant) {
621
+ const call = await runMutation({
622
+ operation: () =>
623
+ apiCall<VariantSingleResponse>(variantsApiPath, {
624
+ method: 'POST',
625
+ headers: { 'content-type': 'application/json' },
626
+ body: JSON.stringify({
627
+ name: trimmedName.length > 0 ? trimmedName : undefined,
628
+ settings,
629
+ // New variants are activated by default — there's only one active per scope,
630
+ // others get auto-deactivated server-side.
631
+ isActive: true,
632
+ }),
633
+ }),
634
+ context: buildMutationContext('saveVariant'),
635
+ mutationPayload: { name: trimmedName.length > 0 ? trimmedName : null, isActive: true },
636
+ })
637
+ if (!call.ok) {
638
+ setError(formatVariantApiError(call, t))
639
+ return
640
+ }
641
+ savedVariant = call.result?.variant ?? null
642
+ } else if (selectedVariantId) {
643
+ const call = await runMutation({
644
+ operation: () =>
645
+ apiCall<VariantSingleResponse>(`${variantsApiPath}/${encodeURIComponent(selectedVariantId)}`, {
646
+ method: 'PUT',
647
+ headers: { 'content-type': 'application/json' },
648
+ body: JSON.stringify({
649
+ name: trimmedName.length > 0 ? trimmedName : undefined,
650
+ settings,
651
+ isActive: isCurrentlyActive,
652
+ }),
653
+ }),
654
+ context: buildMutationContext('saveVariant', selectedVariantId),
655
+ mutationPayload: {
656
+ id: selectedVariantId,
657
+ name: trimmedName.length > 0 ? trimmedName : null,
658
+ isActive: isCurrentlyActive,
659
+ },
660
+ })
661
+ if (!call.ok) {
662
+ setError(formatVariantApiError(call, t))
663
+ return
664
+ }
665
+ savedVariant = call.result?.variant ?? null
666
+ }
667
+ // Sync user prefs and (optionally) push to roles via the legacy preferences endpoint.
668
+ // The variant entity is the canonical "saved layout"; the preferences endpoint is what
669
+ // the AppShell sidebar actually reads. Without this sync, the saved variant wouldn't
670
+ // become the user's live sidebar.
671
+ const preferencesPayload: Record<string, unknown> = {
672
+ groupOrder: settings.groupOrder,
673
+ groupLabels: settings.groupLabels,
674
+ itemLabels: settings.itemLabels,
675
+ hiddenItems: settings.hiddenItems,
676
+ itemOrder: settings.itemOrder,
677
+ }
678
+ if (canApplyToRoles) {
679
+ const applyToRolesPayload = [...selectedRoleIds]
680
+ const clearRoleIdsPayload = availableRoleTargets
681
+ .filter((role) => role.hasPreference && !selectedRoleIds.includes(role.id))
682
+ .map((role) => role.id)
683
+ preferencesPayload.applyToRoles = applyToRolesPayload
684
+ preferencesPayload.clearRoleIds = clearRoleIdsPayload
685
+ }
686
+ const preferencesCall = await runMutation({
687
+ operation: () =>
688
+ apiCall(preferencesApiPath, {
689
+ method: 'PUT',
690
+ headers: { 'content-type': 'application/json' },
691
+ body: JSON.stringify(preferencesPayload),
692
+ }),
693
+ context: buildMutationContext('savePreferences', selectedVariantId),
694
+ mutationPayload: preferencesPayload,
695
+ })
696
+ if (!preferencesCall.ok) {
697
+ // The variant entity is the canonical layout; the preferences sync is what the
698
+ // AppShell sidebar actually reads. A failed sync would leave the saved variant
699
+ // not reflected live, so surface it as a save error rather than flashing success.
700
+ setError(formatVariantApiError(preferencesCall, t))
701
+ return
702
+ }
703
+ try { window.dispatchEvent(new Event(REFRESH_SIDEBAR_EVENT)) } catch { /* no listener attached — fine, AppShell will refresh on next navigation */ }
704
+ // Refresh the list so isActive flags are accurate, plus refresh roles so hasPreference flags update.
705
+ const [list, rolesPayload] = await Promise.all([
706
+ loadVariantsList(),
707
+ loadRolesPayload(),
708
+ ])
709
+ // Defensive: ensure the just-saved variant lands in the list even if the refetch
710
+ // was served stale (browser HTTP cache, etc.).
711
+ const mergedList = savedVariant && !list.some((v) => v.id === savedVariant!.id)
712
+ ? [...list, savedVariant]
713
+ : list
714
+ setVariants(mergedList)
715
+ setCanApplyToRoles(rolesPayload.canApplyToRoles)
716
+ setAvailableRoleTargets(rolesPayload.roles)
717
+ if (savedVariant) {
718
+ const fresh = mergedList.find((v) => v.id === savedVariant!.id) ?? savedVariant
719
+ selectVariantInternal(fresh, mergedList)
720
+ } else {
721
+ const active = mergedList.find((v) => v.isActive) ?? mergedList[0] ?? null
722
+ selectVariantInternal(active, mergedList)
723
+ }
724
+ flash(
725
+ isNewVariant
726
+ ? t('appShell.sidebarCustomizationVariantCreated', 'Variant created.')
727
+ : t('appShell.sidebarCustomizationVariantSaved', 'Variant saved.'),
728
+ 'success',
729
+ )
730
+ onSaved?.()
731
+ } catch (err) {
732
+ console.error('Failed to save sidebar variant', err)
733
+ setError(t('appShell.sidebarCustomizationSaveError'))
734
+ } finally {
735
+ setSaving(false)
736
+ }
737
+ }, [draft, variantName, isNewVariant, selectedVariant, selectedVariantId, variantsApiPath, preferencesApiPath, canApplyToRoles, selectedRoleIds, availableRoleTargets, t, sanitizeSettingsPayload, loadVariantsList, loadRolesPayload, selectVariantInternal, onSaved, runMutation, buildMutationContext])
738
+
739
+ const toggleActive = React.useCallback(async (next: boolean) => {
740
+ if (!selectedVariant || saving || deleting) return
741
+ setError(null)
742
+ try {
743
+ const call = await runMutation({
744
+ operation: () =>
745
+ apiCall<VariantSingleResponse>(`${variantsApiPath}/${encodeURIComponent(selectedVariant.id)}`, {
746
+ method: 'PUT',
747
+ headers: { 'content-type': 'application/json' },
748
+ body: JSON.stringify({ isActive: next }),
749
+ }),
750
+ context: buildMutationContext('toggleVariantActive', selectedVariant.id),
751
+ mutationPayload: { id: selectedVariant.id, isActive: next },
752
+ })
753
+ if (!call.ok) {
754
+ setError(t('appShell.sidebarCustomizationSaveError'))
755
+ return
756
+ }
757
+ try { window.dispatchEvent(new Event(REFRESH_SIDEBAR_EVENT)) } catch { /* no listener attached — fine, AppShell will refresh on next navigation */ }
758
+ const list = await loadVariantsList()
759
+ setVariants(list)
760
+ const fresh = list.find((v) => v.id === selectedVariant.id) ?? selectedVariant
761
+ selectVariantInternal(fresh, list)
762
+ } catch (err) {
763
+ console.error('Failed to toggle variant active state', err)
764
+ setError(t('appShell.sidebarCustomizationSaveError'))
765
+ }
766
+ }, [selectedVariant, saving, deleting, variantsApiPath, t, loadVariantsList, selectVariantInternal, runMutation, buildMutationContext])
767
+
768
+ const deleteVariant = React.useCallback(async () => {
769
+ if (!selectedVariant) return
770
+ const proceed = await confirmDialog({
771
+ title: t('appShell.sidebarCustomizationDeleteVariantTitle', 'Delete variant?'),
772
+ text: t(
773
+ 'appShell.sidebarCustomizationDeleteVariantText',
774
+ 'This variant will be removed from your library.',
775
+ ),
776
+ confirmText: t('appShell.sidebarCustomizationDeleteVariantConfirm', 'Delete variant'),
777
+ cancelText: t('common.cancel', 'Cancel'),
778
+ variant: 'destructive',
779
+ })
780
+ if (!proceed) return
781
+ setDeleting(true)
782
+ setError(null)
783
+ try {
784
+ const call = await runMutation({
785
+ operation: () =>
786
+ apiCall(`${variantsApiPath}/${encodeURIComponent(selectedVariant.id)}`, { method: 'DELETE' }),
787
+ context: buildMutationContext('deleteVariant', selectedVariant.id),
788
+ mutationPayload: { id: selectedVariant.id },
789
+ })
790
+ if (!call.ok) {
791
+ setError(t('appShell.sidebarCustomizationSaveError'))
792
+ return
793
+ }
794
+ try { window.dispatchEvent(new Event(REFRESH_SIDEBAR_EVENT)) } catch { /* no listener attached — fine, AppShell will refresh on next navigation */ }
795
+ const list = await loadVariantsList()
796
+ setVariants(list)
797
+ const fallback = list[0] ?? null
798
+ selectVariantInternal(fallback, list)
799
+ } catch (err) {
800
+ console.error('Failed to delete variant', err)
801
+ setError(t('appShell.sidebarCustomizationSaveError'))
802
+ } finally {
803
+ setDeleting(false)
804
+ }
805
+ }, [selectedVariant, confirmDialog, t, variantsApiPath, loadVariantsList, selectVariantInternal, runMutation, buildMutationContext])
806
+
807
+ const isBusy = saving || deleting
808
+
809
+ if (loading && !draft) {
810
+ return (
811
+ <>
812
+ {ConfirmDialogElement}
813
+ <div className="space-y-6">
814
+ <div className="space-y-2">
815
+ <div className="h-7 w-64 animate-pulse rounded bg-muted" />
816
+ <div className="h-4 w-96 animate-pulse rounded bg-muted/60" />
817
+ </div>
818
+ <div className="h-64 animate-pulse rounded-lg border bg-muted/30" />
819
+ </div>
820
+ </>
821
+ )
822
+ }
823
+
824
+ if (!draft || !baseSnapshotRef.current) {
825
+ // While chrome payload streams in or the initial fetch runs, show a neutral loading
826
+ // state instead of the error fallback (otherwise the first visit looks like a crash).
827
+ const stillLoading = loading || chromeIsLoading || sourceGroups.length === 0
828
+ return (
829
+ <>
830
+ {ConfirmDialogElement}
831
+ <div className="rounded-lg border border-dashed bg-muted/30 p-6 text-sm text-muted-foreground">
832
+ {stillLoading
833
+ ? t('appShell.sidebarCustomizationLoading', 'Loading…')
834
+ : (error ?? t('appShell.sidebarCustomizationLoadError'))}
835
+ </div>
836
+ </>
837
+ )
838
+ }
839
+
840
+ const baseGroupsForDefaults = baseSnapshotRef.current
841
+ const baseGroupMap = new Map<string, SidebarGroup>()
842
+ for (const group of baseGroupsForDefaults) {
843
+ baseGroupMap.set(resolveGroupKey(group), group)
844
+ }
845
+ const orderedGroupIds = mergeGroupOrder(draft.order, Array.from(baseGroupMap.keys()))
846
+ const totalGroups = orderedGroupIds.length
847
+
848
+ const selectValue = isNewVariant ? NEW_VARIANT_KEY : selectedVariantId ?? NEW_VARIANT_KEY
849
+ const showVariantPicker = variants.length > 0 || isNewVariant
850
+
851
+ return (
852
+ <>
853
+ {ConfirmDialogElement}
854
+ <Dialog
855
+ open={addDialogOpen}
856
+ onOpenChange={(next) => {
857
+ if (!next) {
858
+ setAddDialogOpen(false)
859
+ setAddDialogName('')
860
+ }
861
+ }}
862
+ >
863
+ <DialogContent className="sm:max-w-md">
864
+ <DialogHeader>
865
+ <DialogTitle>
866
+ {t('appShell.sidebarCustomizationAddDialogTitle', 'Add new variant')}
867
+ </DialogTitle>
868
+ <DialogDescription>
869
+ {t('appShell.sidebarCustomizationAddDialogDescription', 'Choose a name for the new sidebar variant. Leave blank to auto-name it.')}
870
+ </DialogDescription>
871
+ </DialogHeader>
872
+ <div className="flex flex-col gap-1.5">
873
+ <label className="text-xs font-medium uppercase tracking-wider text-muted-foreground/70">
874
+ {t('appShell.sidebarCustomizationVariantNameLabel', 'Variant name')}
875
+ </label>
876
+ <Input
877
+ autoFocus
878
+ value={addDialogName}
879
+ onChange={(event) => setAddDialogName(event.target.value)}
880
+ onKeyDown={(event) => {
881
+ if (event.key === 'Enter' && !event.shiftKey) {
882
+ event.preventDefault()
883
+ void submitAddDialog()
884
+ }
885
+ }}
886
+ placeholder={t('appShell.sidebarCustomizationVariantNamePlaceholder', 'My preferences')}
887
+ disabled={saving}
888
+ />
889
+ </div>
890
+ <DialogFooter className="mt-2">
891
+ <Button
892
+ type="button"
893
+ variant="outline"
894
+ onClick={() => {
895
+ setAddDialogOpen(false)
896
+ setAddDialogName('')
897
+ }}
898
+ disabled={saving}
899
+ >
900
+ {t('appShell.sidebarCustomizationCancel')}
901
+ </Button>
902
+ <Button
903
+ type="button"
904
+ onClick={() => { void submitAddDialog() }}
905
+ disabled={saving}
906
+ >
907
+ {saving
908
+ ? t('appShell.sidebarCustomizationCreating', 'Creating…')
909
+ : t('appShell.sidebarCustomizationCreateVariant', 'Create variant')}
910
+ </Button>
911
+ </DialogFooter>
912
+ </DialogContent>
913
+ </Dialog>
914
+ <Page>
915
+ <header className="space-y-1">
916
+ <h1 className="text-xl sm:text-2xl font-semibold leading-tight">
917
+ {t('appShell.sidebarCustomizationHeading')}
918
+ </h1>
919
+ <p className="text-sm text-muted-foreground">
920
+ {t('appShell.sidebarCustomizationHint', { locale: localeLabel })}
921
+ </p>
922
+ </header>
923
+
924
+ {error ? (
925
+ <div className="rounded-lg border border-destructive/40 bg-destructive/5 px-4 py-3 text-sm text-destructive">
926
+ {error}
927
+ </div>
928
+ ) : null}
929
+
930
+ {/* Two-column: editor (variant + roles + order) + preview */}
931
+ <PageBody className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,360px)]">
932
+ <div className="space-y-6">
933
+ {(() => {
934
+ const showRolesCard = canApplyToRoles && availableRoleTargets.length > 0
935
+ if (!showVariantPicker && !showRolesCard) return null
936
+ return (
937
+ <Card>
938
+ <CardContent className="flex flex-col gap-6">
939
+ {showVariantPicker ? (
940
+ <div className="flex flex-col gap-4">
941
+ {/* Row: combobox-style name input with chevron picker + DS-compliant add button */}
942
+ <div className="flex flex-col gap-1.5">
943
+ <label className="text-xs font-medium uppercase tracking-wider text-muted-foreground/70">
944
+ {t('appShell.sidebarCustomizationVariantNameLabel', 'Variant name')}
945
+ </label>
946
+ <div className="flex items-stretch gap-2">
947
+ {/* Combobox group: input + chevron picker share a single visual border. */}
948
+ <div className="relative flex flex-1 items-stretch">
949
+ <Input
950
+ value={variantName}
951
+ onChange={(event) => {
952
+ setVariantName(event.target.value)
953
+ setDirty(true)
954
+ }}
955
+ placeholder={t('appShell.sidebarCustomizationVariantNamePlaceholder', 'My preferences')}
956
+ disabled={isBusy}
957
+ className="w-full pr-10"
958
+ />
959
+ <Select
960
+ value={selectValue}
961
+ onValueChange={(value) => { void handleVariantSwitch(value) }}
962
+ disabled={isBusy || loading}
963
+ >
964
+ <SelectTrigger
965
+ className="pointer-events-none absolute inset-0 h-full w-full justify-end border-0 bg-transparent px-3 shadow-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0 [&>span]:hidden [&>svg]:pointer-events-auto"
966
+ aria-label={t('appShell.sidebarCustomizationVariantPickerLabel', 'Pick variant')}
967
+ >
968
+ <SelectValue />
969
+ </SelectTrigger>
970
+ <SelectContent>
971
+ {variants.length > 0 ? (
972
+ variants.map((variant) => (
973
+ <SelectItem key={variant.id} value={variant.id}>
974
+ {variant.name}
975
+ </SelectItem>
976
+ ))
977
+ ) : (
978
+ <SelectItem value={NEW_VARIANT_KEY} disabled>
979
+ {t('appShell.sidebarCustomizationVariantsEmpty', 'No saved variants yet')}
980
+ </SelectItem>
981
+ )}
982
+ </SelectContent>
983
+ </Select>
984
+ </div>
985
+ <Button
986
+ type="button"
987
+ onClick={() => {
988
+ setAddDialogName('')
989
+ setAddDialogOpen(true)
990
+ }}
991
+ disabled={isBusy}
992
+ title={t('appShell.sidebarCustomizationVariantNew', 'Add new variant')}
993
+ >
994
+ <Plus className="size-4" />
995
+ {t('appShell.sidebarCustomizationCreateNew', 'Create new')}
996
+ </Button>
997
+ </div>
998
+ </div>
999
+
1000
+ {isNewVariant ? (
1001
+ <p className="text-xs text-muted-foreground">
1002
+ {t('appShell.sidebarCustomizationVariantNewHint', 'Saving will create a new variant. If you leave the name blank, it will be auto-named.')}
1003
+ </p>
1004
+ ) : null}
1005
+
1006
+ {/* Row: active switch */}
1007
+ <div className="flex items-center gap-2">
1008
+ <Switch
1009
+ checked={selectedVariant?.isActive ?? isNewVariant}
1010
+ onCheckedChange={(next) => {
1011
+ if (isNewVariant) return
1012
+ void toggleActive(next === true)
1013
+ }}
1014
+ disabled={isBusy || isNewVariant}
1015
+ aria-label={t('appShell.sidebarCustomizationVariantActiveLabel', 'Active')}
1016
+ />
1017
+ <span className="text-xs font-medium uppercase tracking-wider text-muted-foreground/70">
1018
+ {t('appShell.sidebarCustomizationVariantActiveLabel', 'Active')}
1019
+ </span>
1020
+ </div>
1021
+ </div>
1022
+ ) : null}
1023
+
1024
+ {showVariantPicker && showRolesCard ? (
1025
+ <div className="-mx-6 border-t" aria-hidden />
1026
+ ) : null}
1027
+
1028
+ {showRolesCard ? (
1029
+ <div className="flex flex-col gap-3">
1030
+ <div className="space-y-1">
1031
+ <h3 className="text-base font-semibold leading-none text-foreground">
1032
+ {t('appShell.sidebarApplyToRolesTitle')}
1033
+ </h3>
1034
+ <p className="text-sm text-muted-foreground">{t('appShell.sidebarApplyToRolesDescription')}</p>
1035
+ </div>
1036
+ <div className="flex flex-col gap-1.5 max-w-sm">
1037
+ {availableRoleTargets.map((role) => {
1038
+ const checked = selectedRoleIds.includes(role.id)
1039
+ const willClear = role.hasPreference && !checked
1040
+ return (
1041
+ <label
1042
+ key={role.id}
1043
+ className="flex cursor-pointer items-center gap-3 rounded-lg border bg-background px-3 py-2 text-sm transition-colors hover:bg-muted"
1044
+ >
1045
+ <Switch
1046
+ checked={checked}
1047
+ onCheckedChange={() => toggleRoleSelection(role.id)}
1048
+ disabled={isBusy}
1049
+ />
1050
+ <span className="flex-1 truncate font-medium text-foreground">{role.name}</span>
1051
+ {role.hasPreference ? (
1052
+ <Tag variant={willClear ? 'error' : 'info'} dot={!willClear}>
1053
+ {willClear ? <AlertTriangle className="size-3" aria-hidden /> : null}
1054
+ {willClear ? t('appShell.sidebarRoleWillClear') : t('appShell.sidebarRoleHasPreset')}
1055
+ </Tag>
1056
+ ) : null}
1057
+ </label>
1058
+ )
1059
+ })}
1060
+ </div>
1061
+ </div>
1062
+ ) : null}
1063
+
1064
+ {/* Footer: Reset / Cancel / Save (right) + Delete (left). All gated on dirty
1065
+ except Delete which acts on the persisted variant regardless of edits. */}
1066
+ <div className="-mx-6 border-t" aria-hidden />
1067
+ <div className="flex flex-wrap items-center justify-between gap-3">
1068
+ {selectedVariant ? (
1069
+ <Button
1070
+ type="button"
1071
+ variant="outline"
1072
+ onClick={() => { void deleteVariant() }}
1073
+ disabled={isBusy}
1074
+ className="text-destructive hover:text-destructive"
1075
+ >
1076
+ <Trash2 className="size-4" />
1077
+ {deleting
1078
+ ? t('appShell.sidebarCustomizationDeleteVariantInProgress', 'Deleting…')
1079
+ : t('appShell.sidebarCustomizationDeleteVariant', 'Delete variant')}
1080
+ </Button>
1081
+ ) : <span />}
1082
+ <div className="flex items-center gap-2">
1083
+ <Button
1084
+ type="button"
1085
+ variant="ghost"
1086
+ onClick={reset}
1087
+ disabled={isBusy || !dirty}
1088
+ >
1089
+ {t('appShell.sidebarCustomizationReset')}
1090
+ </Button>
1091
+ <Button
1092
+ type="button"
1093
+ variant="outline"
1094
+ onClick={cancel}
1095
+ disabled={isBusy || !dirty}
1096
+ >
1097
+ {t('appShell.sidebarCustomizationCancel')}
1098
+ </Button>
1099
+ <Button
1100
+ type="button"
1101
+ onClick={save}
1102
+ disabled={isBusy || (!isNewVariant && !dirty)}
1103
+ >
1104
+ {saving
1105
+ ? (isNewVariant
1106
+ ? t('appShell.sidebarCustomizationCreating', 'Creating…')
1107
+ : t('appShell.sidebarCustomizationSaving'))
1108
+ : (isNewVariant
1109
+ ? t('appShell.sidebarCustomizationCreateVariant', 'Create variant')
1110
+ : t('appShell.sidebarCustomizationSave'))}
1111
+ </Button>
1112
+ </div>
1113
+ </div>
1114
+ </CardContent>
1115
+ </Card>
1116
+ )
1117
+ })()}
1118
+ <Card>
1119
+ <CardHeader>
1120
+ <CardTitle className="text-base">
1121
+ {t('appShell.sidebarCustomizationOrderHeading', 'Order & visibility')}
1122
+ </CardTitle>
1123
+ <p className="text-sm text-muted-foreground">
1124
+ {t('appShell.sidebarCustomizationOrderDescription', 'Reorder groups, rename them, and toggle individual items on or off.')}
1125
+ </p>
1126
+ </CardHeader>
1127
+ <CardContent className="space-y-3">
1128
+ {orderedGroupIds.map((groupId, index) => {
1129
+ const baseGroup = baseGroupMap.get(groupId)
1130
+ if (!baseGroup) return null
1131
+ const placeholder = baseGroup.defaultName ?? baseGroup.name
1132
+ const value = draft.groupLabels[groupId] ?? ''
1133
+ const trimmedValue = value.trim()
1134
+ const isGroupModified = trimmedValue.length > 0 && trimmedValue !== placeholder
1135
+ return (
1136
+ <div key={groupId} className="rounded-lg border bg-background">
1137
+ <div className="flex items-start gap-3 border-b px-4 py-3">
1138
+ <div className="flex flex-1 flex-col gap-1.5">
1139
+ <label className="text-xs font-medium uppercase tracking-wider text-muted-foreground/70">
1140
+ {t('appShell.sidebarCustomizationGroupLabel')}
1141
+ </label>
1142
+ <div className="flex items-center gap-2">
1143
+ <Input
1144
+ value={value}
1145
+ onChange={(event) => setGroupLabel(groupId, event.target.value)}
1146
+ placeholder={placeholder}
1147
+ disabled={isBusy}
1148
+ className="flex-1"
1149
+ />
1150
+ {isGroupModified ? (
1151
+ <IconButton
1152
+ type="button"
1153
+ variant="ghost"
1154
+ size="sm"
1155
+ onClick={() => setGroupLabel(groupId, '')}
1156
+ disabled={isBusy}
1157
+ aria-label={t('appShell.sidebarCustomizationResetField', 'Reset to default')}
1158
+ title={t('appShell.sidebarCustomizationResetField', 'Reset to default')}
1159
+ >
1160
+ <RotateCcw className="size-3.5" />
1161
+ </IconButton>
1162
+ ) : null}
1163
+ </div>
1164
+ {isGroupModified ? (
1165
+ <p className="text-xs text-muted-foreground">
1166
+ {t('appShell.sidebarCustomizationDefault', 'Default:')}{' '}
1167
+ <span className="font-medium text-foreground/80">{placeholder}</span>
1168
+ </p>
1169
+ ) : null}
1170
+ </div>
1171
+ <div className="flex shrink-0 items-center gap-1 mt-[26px]">
1172
+ <IconButton
1173
+ type="button"
1174
+ variant="outline"
1175
+ size="sm"
1176
+ className="text-muted-foreground hover:text-foreground"
1177
+ onClick={() => moveGroup(groupId, -1)}
1178
+ disabled={index === 0 || isBusy}
1179
+ aria-label={t('appShell.sidebarCustomizationMoveUp')}
1180
+ title={t('appShell.sidebarCustomizationMoveUp')}
1181
+ >
1182
+ <ChevronUp className="size-4" />
1183
+ </IconButton>
1184
+ <IconButton
1185
+ type="button"
1186
+ variant="outline"
1187
+ size="sm"
1188
+ className="text-muted-foreground hover:text-foreground"
1189
+ onClick={() => moveGroup(groupId, 1)}
1190
+ disabled={index === totalGroups - 1 || isBusy}
1191
+ aria-label={t('appShell.sidebarCustomizationMoveDown')}
1192
+ title={t('appShell.sidebarCustomizationMoveDown')}
1193
+ >
1194
+ <ChevronDown className="size-4" />
1195
+ </IconButton>
1196
+ </div>
1197
+ </div>
1198
+ <div className="flex flex-col divide-y">
1199
+ <ItemRows
1200
+ items={baseGroup.items}
1201
+ draft={draft}
1202
+ saving={isBusy}
1203
+ onLabelChange={setItemLabel}
1204
+ onHiddenChange={setItemHidden}
1205
+ t={t}
1206
+ groupKey={groupId}
1207
+ sensors={dndSensors}
1208
+ onDragEnd={handleItemDragEnd(groupId, baseGroup.items.map((item) => resolveItemKey(item)))}
1209
+ />
1210
+ </div>
1211
+ </div>
1212
+ )
1213
+ })}
1214
+ </CardContent>
1215
+ </Card>
1216
+ </div>
1217
+
1218
+ <aside className="hidden lg:block">
1219
+ <div className="sticky top-6">
1220
+ <div className="relative">
1221
+ <span className="absolute left-1/2 top-0 z-10 -translate-x-1/2 -translate-y-1/2 rounded-md bg-accent-indigo px-3 py-1 text-xs font-semibold uppercase tracking-wider text-accent-indigo-foreground shadow-sm">
1222
+ {t('appShell.sidebarCustomizationPreview', 'Preview')}
1223
+ </span>
1224
+ <SidebarPreview
1225
+ groups={previewGroups}
1226
+ productName={t('appShell.productName', 'Open Mercato')}
1227
+ pickFirstActive
1228
+ />
1229
+ </div>
1230
+ </div>
1231
+ </aside>
1232
+ </PageBody>
1233
+ </Page>
1234
+ </>
1235
+ )
1236
+ }
1237
+
1238
+ type ItemRowProps = {
1239
+ item: SidebarItem
1240
+ draft: SidebarCustomizationDraft
1241
+ saving: boolean
1242
+ onLabelChange: (itemId: string, value: string) => void
1243
+ onHiddenChange: (itemId: string, hidden: boolean) => void
1244
+ t: ReturnType<typeof useT>
1245
+ depth: number
1246
+ dragHandle?: React.ReactNode
1247
+ /** True when an ancestor in the tree is hidden — child controls become read-only. */
1248
+ ancestorHidden?: boolean
1249
+ }
1250
+
1251
+ function ItemRow({ item, draft, saving, onLabelChange, onHiddenChange, t, depth, dragHandle, ancestorHidden = false }: ItemRowProps) {
1252
+ const itemKey = resolveItemKey(item)
1253
+ const placeholder = item.defaultTitle ?? item.title
1254
+ const value = draft.itemLabels[itemKey] ?? ''
1255
+ const trimmedValue = value.trim()
1256
+ const isModified = trimmedValue.length > 0 && trimmedValue !== placeholder
1257
+ const hidden = draft.hiddenItemIds[itemKey] === true
1258
+ const effectivelyDimmed = hidden || ancestorHidden
1259
+ return (
1260
+ <div
1261
+ className="flex items-start gap-3 px-4 py-3 transition-colors hover:bg-muted/40"
1262
+ style={depth ? { paddingLeft: 16 + depth * 24 } : undefined}
1263
+ >
1264
+ {dragHandle ?? (depth > 0 ? <span className="w-4 shrink-0" aria-hidden /> : null)}
1265
+ <div className={`min-w-0 flex-1 flex flex-col gap-1.5 ${effectivelyDimmed ? 'opacity-60' : ''}`}>
1266
+ <div className="flex items-center gap-2">
1267
+ <div className="min-w-0 flex-1">
1268
+ <Input
1269
+ value={value}
1270
+ onChange={(event) => onLabelChange(itemKey, event.target.value)}
1271
+ placeholder={placeholder}
1272
+ disabled={saving}
1273
+ />
1274
+ </div>
1275
+ {isModified ? (
1276
+ <IconButton
1277
+ type="button"
1278
+ variant="ghost"
1279
+ size="sm"
1280
+ onClick={() => onLabelChange(itemKey, '')}
1281
+ disabled={saving}
1282
+ aria-label={t('appShell.sidebarCustomizationResetField', 'Reset to default')}
1283
+ title={t('appShell.sidebarCustomizationResetField', 'Reset to default')}
1284
+ >
1285
+ <RotateCcw className="size-3.5" />
1286
+ </IconButton>
1287
+ ) : null}
1288
+ </div>
1289
+ {isModified ? (
1290
+ <p className="text-xs text-muted-foreground">
1291
+ {t('appShell.sidebarCustomizationDefault', 'Default:')}{' '}
1292
+ <span className="font-medium text-foreground/80">{placeholder}</span>
1293
+ </p>
1294
+ ) : null}
1295
+ </div>
1296
+ <div className="flex shrink-0 items-center gap-2 pt-1.5">
1297
+ {hidden ? (
1298
+ <span className="rounded-full border border-border bg-muted px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
1299
+ {t('appShell.sidebarCustomizationHiddenBadge', 'Hidden')}
1300
+ </span>
1301
+ ) : null}
1302
+ <Switch
1303
+ checked={!hidden}
1304
+ onCheckedChange={(next) => onHiddenChange(itemKey, next !== true)}
1305
+ disabled={saving || ancestorHidden}
1306
+ aria-label={t('appShell.sidebarCustomizationShowItem')}
1307
+ title={ancestorHidden ? t('appShell.sidebarCustomizationParentHiddenHint', 'Parent is hidden — show parent first.') : undefined}
1308
+ />
1309
+ </div>
1310
+ </div>
1311
+ )
1312
+ }
1313
+
1314
+ type SortableItemRowProps = ItemRowProps & { id: string }
1315
+
1316
+ function SortableItemRow({ id, ...rowProps }: SortableItemRowProps) {
1317
+ const t = useT()
1318
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging, setActivatorNodeRef } = useSortable({ id })
1319
+ const style: React.CSSProperties = {
1320
+ transform: CSS.Transform.toString(transform),
1321
+ transition,
1322
+ opacity: isDragging ? 0.5 : 1,
1323
+ }
1324
+ const dragHandle = (
1325
+ <IconButton
1326
+ ref={setActivatorNodeRef}
1327
+ type="button"
1328
+ variant="ghost"
1329
+ size="sm"
1330
+ className="shrink-0 mt-1.5 cursor-grab touch-none active:cursor-grabbing"
1331
+ aria-label={t('appShell.sidebarCustomizationDragToReorder', 'Drag to reorder')}
1332
+ disabled={rowProps.saving}
1333
+ {...attributes}
1334
+ {...listeners}
1335
+ >
1336
+ <GripVertical className="size-4" />
1337
+ </IconButton>
1338
+ )
1339
+ return (
1340
+ <div ref={setNodeRef} style={style}>
1341
+ <ItemRow {...rowProps} dragHandle={dragHandle} />
1342
+ </div>
1343
+ )
1344
+ }
1345
+
1346
+ type ItemRowsProps = {
1347
+ items: SidebarItem[]
1348
+ draft: SidebarCustomizationDraft
1349
+ saving: boolean
1350
+ onLabelChange: (itemId: string, value: string) => void
1351
+ onHiddenChange: (itemId: string, hidden: boolean) => void
1352
+ t: ReturnType<typeof useT>
1353
+ depth?: number
1354
+ groupKey?: string
1355
+ sensors?: ReturnType<typeof useSensors>
1356
+ onDragEnd?: (event: DragEndEvent) => void
1357
+ ancestorHidden?: boolean
1358
+ }
1359
+
1360
+ function ItemRows({
1361
+ items,
1362
+ draft,
1363
+ saving,
1364
+ onLabelChange,
1365
+ onHiddenChange,
1366
+ t,
1367
+ depth = 0,
1368
+ groupKey,
1369
+ sensors,
1370
+ onDragEnd,
1371
+ ancestorHidden = false,
1372
+ }: ItemRowsProps) {
1373
+ if (items.length === 0) return null
1374
+
1375
+ const renderRecursiveChildren = (item: SidebarItem, parentHidden: boolean) =>
1376
+ item.children && item.children.length > 0 ? (
1377
+ <ItemRows
1378
+ items={item.children}
1379
+ draft={draft}
1380
+ saving={saving}
1381
+ onLabelChange={onLabelChange}
1382
+ onHiddenChange={onHiddenChange}
1383
+ t={t}
1384
+ depth={depth + 1}
1385
+ ancestorHidden={parentHidden}
1386
+ />
1387
+ ) : null
1388
+
1389
+ // Top-level rows in a group → enable DnD reordering.
1390
+ if (depth === 0 && groupKey && sensors && onDragEnd) {
1391
+ const ordered = applyItemOrder(items, resolveItemKey, draft.itemOrder?.[groupKey])
1392
+ const ids = ordered.map((item) => resolveItemKey(item))
1393
+ return (
1394
+ <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
1395
+ <SortableContext items={ids} strategy={verticalListSortingStrategy}>
1396
+ {ordered.map((item) => {
1397
+ const itemKey = resolveItemKey(item)
1398
+ const ownHidden = draft.hiddenItemIds[itemKey] === true
1399
+ return (
1400
+ <React.Fragment key={itemKey}>
1401
+ <SortableItemRow
1402
+ id={itemKey}
1403
+ item={item}
1404
+ draft={draft}
1405
+ saving={saving}
1406
+ onLabelChange={onLabelChange}
1407
+ onHiddenChange={onHiddenChange}
1408
+ t={t}
1409
+ depth={depth}
1410
+ ancestorHidden={ancestorHidden}
1411
+ />
1412
+ {renderRecursiveChildren(item, ancestorHidden || ownHidden)}
1413
+ </React.Fragment>
1414
+ )
1415
+ })}
1416
+ </SortableContext>
1417
+ </DndContext>
1418
+ )
1419
+ }
1420
+
1421
+ // Nested children → static rendering, no drag handle.
1422
+ return (
1423
+ <>
1424
+ {items.map((item) => {
1425
+ const itemKey = resolveItemKey(item)
1426
+ const ownHidden = draft.hiddenItemIds[itemKey] === true
1427
+ return (
1428
+ <React.Fragment key={itemKey}>
1429
+ <ItemRow
1430
+ item={item}
1431
+ draft={draft}
1432
+ saving={saving}
1433
+ onLabelChange={onLabelChange}
1434
+ onHiddenChange={onHiddenChange}
1435
+ t={t}
1436
+ depth={depth}
1437
+ ancestorHidden={ancestorHidden}
1438
+ />
1439
+ {renderRecursiveChildren(item, ancestorHidden || ownHidden)}
1440
+ </React.Fragment>
1441
+ )
1442
+ })}
1443
+ </>
1444
+ )
1445
+ }
1446
+
1447
+ function SidebarPreviewIcon({ item }: { item: SidebarItem }) {
1448
+ if (item.icon) return <>{item.icon}</>
1449
+ if (item.iconName) {
1450
+ const resolved = resolveInjectedIcon(item.iconName)
1451
+ if (resolved) return <>{resolved}</>
1452
+ }
1453
+ if (item.iconMarkup) {
1454
+ return <span aria-hidden="true" dangerouslySetInnerHTML={{ __html: item.iconMarkup }} />
1455
+ }
1456
+ // Fallback default icon — same shape as AppShell's DefaultIcon
1457
+ return (
1458
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
1459
+ <circle cx="12" cy="12" r="3" />
1460
+ </svg>
1461
+ )
1462
+ }
1463
+
1464
+ function SidebarPreview({
1465
+ groups,
1466
+ productName,
1467
+ pickFirstActive,
1468
+ }: {
1469
+ groups: SidebarGroup[]
1470
+ productName: string
1471
+ pickFirstActive: boolean
1472
+ }) {
1473
+ const t = useT()
1474
+ // Pre-compute the first visible item so we can render it as the "active" preview state.
1475
+ // This shows the user what the active state of their sidebar will look like.
1476
+ const activeKey = React.useMemo<string | null>(() => {
1477
+ if (!pickFirstActive) return null
1478
+ for (const group of groups) {
1479
+ for (const item of group.items) {
1480
+ if (item.hidden === true) continue
1481
+ return resolveItemKey(item)
1482
+ }
1483
+ }
1484
+ return null
1485
+ }, [groups, pickFirstActive])
1486
+
1487
+ return (
1488
+ <div className="relative w-[240px] overflow-hidden rounded-xl border bg-background shadow-sm">
1489
+ {/* Match AppShell's outer aside: border-r, py-4, px-3 — minus border-r since the
1490
+ card border already serves that purpose, plus rounded so it reads as a preview tile. */}
1491
+ <div className="flex flex-col gap-3 px-3 py-4">
1492
+ {/* Brand block — same classes as AppShell brand tile */}
1493
+ <div className="mb-2">
1494
+ <div className="flex items-center gap-3 rounded-xl p-3">
1495
+ <Image
1496
+ src="/open-mercato.svg"
1497
+ alt={productName}
1498
+ width={40}
1499
+ height={40}
1500
+ className="rounded-full shrink-0"
1501
+ />
1502
+ <span className="text-sm font-medium text-foreground truncate">{productName}</span>
1503
+ </div>
1504
+ </div>
1505
+ {/* Search input mock — same container styling as the real sidebar */}
1506
+ <div className="mb-2 flex items-center gap-2 rounded-lg border border-border bg-background pl-2.5 pr-2 py-2 shadow-sm">
1507
+ <Search className="size-4 shrink-0 text-muted-foreground" aria-hidden />
1508
+ <span className="min-w-0 flex-1 text-sm text-muted-foreground/70 truncate">
1509
+ {t('appShell.sidebarCustomizationPreviewSearchPlaceholder', 'Search...')}
1510
+ </span>
1511
+ </div>
1512
+ {groups.length === 0 ? (
1513
+ <p className="px-2 text-sm text-muted-foreground">
1514
+ {t('appShell.sidebarCustomizationPreviewEmpty', 'No groups to preview.')}
1515
+ </p>
1516
+ ) : (
1517
+ <nav className="flex flex-col gap-2">
1518
+ {groups.map((group, gi) => {
1519
+ const visibleItems = group.items.filter((item) => item.hidden !== true)
1520
+ if (visibleItems.length === 0) return null
1521
+ return (
1522
+ <div key={resolveGroupKey(group)}>
1523
+ <div className="w-full px-1 justify-between flex text-xs font-medium uppercase tracking-wider text-muted-foreground/70 py-1">
1524
+ <span>{group.name}</span>
1525
+ </div>
1526
+ <div className="flex flex-col gap-1">
1527
+ {visibleItems.map((item) => {
1528
+ const itemKey = resolveItemKey(item)
1529
+ const isActive = activeKey === itemKey
1530
+ return (
1531
+ <div
1532
+ key={itemKey}
1533
+ className={`relative text-sm font-medium rounded-lg inline-flex items-center w-full px-3 py-2 gap-2 ${
1534
+ isActive ? 'bg-muted text-foreground' : 'text-muted-foreground'
1535
+ }`}
1536
+ >
1537
+ {isActive ? (
1538
+ <span
1539
+ aria-hidden
1540
+ className="absolute left-[-12px] top-2 w-1 h-5 rounded-r bg-foreground"
1541
+ />
1542
+ ) : null}
1543
+ <span className="flex items-center justify-center shrink-0">
1544
+ <SidebarPreviewIcon item={item} />
1545
+ </span>
1546
+ <span className="truncate">{item.title}</span>
1547
+ </div>
1548
+ )
1549
+ })}
1550
+ </div>
1551
+ {gi < groups.length - 1 ? <div className="my-2 border-t -ml-3 -mr-4" /> : null}
1552
+ </div>
1553
+ )
1554
+ })}
1555
+ </nav>
1556
+ )}
1557
+ </div>
1558
+ </div>
1559
+ )
1560
+ }
1561
+
1562
+ export default SidebarCustomizationEditor