@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.
Files changed (319) hide show
  1. package/build.mjs +62 -0
  2. package/dist/backend/AppShell.js +902 -0
  3. package/dist/backend/AppShell.js.map +7 -0
  4. package/dist/backend/ConfirmDialog.js +17 -0
  5. package/dist/backend/ConfirmDialog.js.map +7 -0
  6. package/dist/backend/ContextHelp.js +31 -0
  7. package/dist/backend/ContextHelp.js.map +7 -0
  8. package/dist/backend/CrudForm.js +2028 -0
  9. package/dist/backend/CrudForm.js.map +7 -0
  10. package/dist/backend/DataTable.js +1363 -0
  11. package/dist/backend/DataTable.js.map +7 -0
  12. package/dist/backend/EmptyState.js +52 -0
  13. package/dist/backend/EmptyState.js.map +7 -0
  14. package/dist/backend/FilterBar.js +140 -0
  15. package/dist/backend/FilterBar.js.map +7 -0
  16. package/dist/backend/FilterOverlay.js +279 -0
  17. package/dist/backend/FilterOverlay.js.map +7 -0
  18. package/dist/backend/FlashMessages.js +66 -0
  19. package/dist/backend/FlashMessages.js.map +7 -0
  20. package/dist/backend/JsonBuilder.js +322 -0
  21. package/dist/backend/JsonBuilder.js.map +7 -0
  22. package/dist/backend/JsonDisplay.js +203 -0
  23. package/dist/backend/JsonDisplay.js.map +7 -0
  24. package/dist/backend/Page.js +27 -0
  25. package/dist/backend/Page.js.map +7 -0
  26. package/dist/backend/PerspectiveSidebar.js +282 -0
  27. package/dist/backend/PerspectiveSidebar.js.map +7 -0
  28. package/dist/backend/RowActions.js +148 -0
  29. package/dist/backend/RowActions.js.map +7 -0
  30. package/dist/backend/TruncatedCell.js +92 -0
  31. package/dist/backend/TruncatedCell.js.map +7 -0
  32. package/dist/backend/UserMenu.js +107 -0
  33. package/dist/backend/UserMenu.js.map +7 -0
  34. package/dist/backend/ValueIcons.js +34 -0
  35. package/dist/backend/ValueIcons.js.map +7 -0
  36. package/dist/backend/custom-fields/FieldDefinitionsEditor.js +1264 -0
  37. package/dist/backend/custom-fields/FieldDefinitionsEditor.js.map +7 -0
  38. package/dist/backend/custom-fields/FieldDefinitionsManager.js +332 -0
  39. package/dist/backend/custom-fields/FieldDefinitionsManager.js.map +7 -0
  40. package/dist/backend/dashboard/DashboardScreen.js +578 -0
  41. package/dist/backend/dashboard/DashboardScreen.js.map +7 -0
  42. package/dist/backend/dashboard/index.js +5 -0
  43. package/dist/backend/dashboard/index.js.map +7 -0
  44. package/dist/backend/dashboard/widgetRegistry.js +55 -0
  45. package/dist/backend/dashboard/widgetRegistry.js.map +7 -0
  46. package/dist/backend/detail/ActivitiesSection.js +962 -0
  47. package/dist/backend/detail/ActivitiesSection.js.map +7 -0
  48. package/dist/backend/detail/AddressEditor.js +413 -0
  49. package/dist/backend/detail/AddressEditor.js.map +7 -0
  50. package/dist/backend/detail/AddressTiles.js +437 -0
  51. package/dist/backend/detail/AddressTiles.js.map +7 -0
  52. package/dist/backend/detail/AddressesSection.js +264 -0
  53. package/dist/backend/detail/AddressesSection.js.map +7 -0
  54. package/dist/backend/detail/AttachmentDeleteDialog.js +41 -0
  55. package/dist/backend/detail/AttachmentDeleteDialog.js.map +7 -0
  56. package/dist/backend/detail/AttachmentMetadataDialog.js +517 -0
  57. package/dist/backend/detail/AttachmentMetadataDialog.js.map +7 -0
  58. package/dist/backend/detail/AttachmentsSection.js +367 -0
  59. package/dist/backend/detail/AttachmentsSection.js.map +7 -0
  60. package/dist/backend/detail/CustomDataSection.js +433 -0
  61. package/dist/backend/detail/CustomDataSection.js.map +7 -0
  62. package/dist/backend/detail/DetailFieldsSection.js +75 -0
  63. package/dist/backend/detail/DetailFieldsSection.js.map +7 -0
  64. package/dist/backend/detail/ErrorMessage.js +28 -0
  65. package/dist/backend/detail/ErrorMessage.js.map +7 -0
  66. package/dist/backend/detail/InlineEditors.js +681 -0
  67. package/dist/backend/detail/InlineEditors.js.map +7 -0
  68. package/dist/backend/detail/LoadingMessage.js +14 -0
  69. package/dist/backend/detail/LoadingMessage.js.map +7 -0
  70. package/dist/backend/detail/NotesSection.js +1032 -0
  71. package/dist/backend/detail/NotesSection.js.map +7 -0
  72. package/dist/backend/detail/TabEmptyState.js +25 -0
  73. package/dist/backend/detail/TabEmptyState.js.map +7 -0
  74. package/dist/backend/detail/TagsSection.js +254 -0
  75. package/dist/backend/detail/TagsSection.js.map +7 -0
  76. package/dist/backend/detail/addressFormat.js +77 -0
  77. package/dist/backend/detail/addressFormat.js.map +7 -0
  78. package/dist/backend/detail/index.js +34 -0
  79. package/dist/backend/detail/index.js.map +7 -0
  80. package/dist/backend/fields/registry.generated.js +8 -0
  81. package/dist/backend/fields/registry.generated.js.map +7 -0
  82. package/dist/backend/fields/registry.js +29 -0
  83. package/dist/backend/fields/registry.js.map +7 -0
  84. package/dist/backend/indexes/PartialIndexBanner.js +58 -0
  85. package/dist/backend/indexes/PartialIndexBanner.js.map +7 -0
  86. package/dist/backend/indexes/store.js +62 -0
  87. package/dist/backend/indexes/store.js.map +7 -0
  88. package/dist/backend/injection/InjectionSpot.js +179 -0
  89. package/dist/backend/injection/InjectionSpot.js.map +7 -0
  90. package/dist/backend/injection/PageInjectionBoundary.js +26 -0
  91. package/dist/backend/injection/PageInjectionBoundary.js.map +7 -0
  92. package/dist/backend/injection/helpers.js +26 -0
  93. package/dist/backend/injection/helpers.js.map +7 -0
  94. package/dist/backend/injection/widgetRegistry.js +55 -0
  95. package/dist/backend/injection/widgetRegistry.js.map +7 -0
  96. package/dist/backend/inputs/ComboboxInput.js +225 -0
  97. package/dist/backend/inputs/ComboboxInput.js.map +7 -0
  98. package/dist/backend/inputs/LookupSelect.js +191 -0
  99. package/dist/backend/inputs/LookupSelect.js.map +7 -0
  100. package/dist/backend/inputs/PhoneNumberField.js +100 -0
  101. package/dist/backend/inputs/PhoneNumberField.js.map +7 -0
  102. package/dist/backend/inputs/SwitchableMarkdownInput.js +92 -0
  103. package/dist/backend/inputs/SwitchableMarkdownInput.js.map +7 -0
  104. package/dist/backend/inputs/TagsInput.js +222 -0
  105. package/dist/backend/inputs/TagsInput.js.map +7 -0
  106. package/dist/backend/inputs/index.js +6 -0
  107. package/dist/backend/inputs/index.js.map +7 -0
  108. package/dist/backend/operations/LastOperationBanner.js +80 -0
  109. package/dist/backend/operations/LastOperationBanner.js.map +7 -0
  110. package/dist/backend/operations/store.js +183 -0
  111. package/dist/backend/operations/store.js.map +7 -0
  112. package/dist/backend/schedule/ScheduleAgenda.js +107 -0
  113. package/dist/backend/schedule/ScheduleAgenda.js.map +7 -0
  114. package/dist/backend/schedule/ScheduleGrid.js +107 -0
  115. package/dist/backend/schedule/ScheduleGrid.js.map +7 -0
  116. package/dist/backend/schedule/ScheduleToolbar.js +166 -0
  117. package/dist/backend/schedule/ScheduleToolbar.js.map +7 -0
  118. package/dist/backend/schedule/ScheduleView.js +165 -0
  119. package/dist/backend/schedule/ScheduleView.js.map +7 -0
  120. package/dist/backend/schedule/index.js +6 -0
  121. package/dist/backend/schedule/index.js.map +7 -0
  122. package/dist/backend/schedule/recurrence.js +83 -0
  123. package/dist/backend/schedule/recurrence.js.map +7 -0
  124. package/dist/backend/schedule/types.js +1 -0
  125. package/dist/backend/schedule/types.js.map +7 -0
  126. package/dist/backend/upgrades/UpgradeActionBanner.js +91 -0
  127. package/dist/backend/upgrades/UpgradeActionBanner.js.map +7 -0
  128. package/dist/backend/utils/api.js +127 -0
  129. package/dist/backend/utils/api.js.map +7 -0
  130. package/dist/backend/utils/apiCall.js +48 -0
  131. package/dist/backend/utils/apiCall.js.map +7 -0
  132. package/dist/backend/utils/crud.js +126 -0
  133. package/dist/backend/utils/crud.js.map +7 -0
  134. package/dist/backend/utils/customFieldColumns.js +56 -0
  135. package/dist/backend/utils/customFieldColumns.js.map +7 -0
  136. package/dist/backend/utils/customFieldDefs.js +143 -0
  137. package/dist/backend/utils/customFieldDefs.js.map +7 -0
  138. package/dist/backend/utils/customFieldFilters.js +126 -0
  139. package/dist/backend/utils/customFieldFilters.js.map +7 -0
  140. package/dist/backend/utils/customFieldForms.js +162 -0
  141. package/dist/backend/utils/customFieldForms.js.map +7 -0
  142. package/dist/backend/utils/customFieldValues.js +26 -0
  143. package/dist/backend/utils/customFieldValues.js.map +7 -0
  144. package/dist/backend/utils/flash.js +16 -0
  145. package/dist/backend/utils/flash.js.map +7 -0
  146. package/dist/backend/utils/nav.js +185 -0
  147. package/dist/backend/utils/nav.js.map +7 -0
  148. package/dist/backend/utils/serverErrors.js +230 -0
  149. package/dist/backend/utils/serverErrors.js.map +7 -0
  150. package/dist/frontend/AuthFooter.js +23 -0
  151. package/dist/frontend/AuthFooter.js.map +7 -0
  152. package/dist/frontend/LanguageSwitcher.js +57 -0
  153. package/dist/frontend/LanguageSwitcher.js.map +7 -0
  154. package/dist/frontend/Layout.js +14 -0
  155. package/dist/frontend/Layout.js.map +7 -0
  156. package/dist/index.js +32 -0
  157. package/dist/index.js.map +7 -0
  158. package/dist/primitives/DataLoader.js +67 -0
  159. package/dist/primitives/DataLoader.js.map +7 -0
  160. package/dist/primitives/ErrorNotice.js +20 -0
  161. package/dist/primitives/ErrorNotice.js.map +7 -0
  162. package/dist/primitives/alert.js +38 -0
  163. package/dist/primitives/alert.js.map +7 -0
  164. package/dist/primitives/badge.js +28 -0
  165. package/dist/primitives/badge.js.map +7 -0
  166. package/dist/primitives/button.js +44 -0
  167. package/dist/primitives/button.js.map +7 -0
  168. package/dist/primitives/card.js +91 -0
  169. package/dist/primitives/card.js.map +7 -0
  170. package/dist/primitives/checkbox.js +28 -0
  171. package/dist/primitives/checkbox.js.map +7 -0
  172. package/dist/primitives/dialog.js +90 -0
  173. package/dist/primitives/dialog.js.map +7 -0
  174. package/dist/primitives/input.js +22 -0
  175. package/dist/primitives/input.js.map +7 -0
  176. package/dist/primitives/label.js +21 -0
  177. package/dist/primitives/label.js.map +7 -0
  178. package/dist/primitives/separator.js +9 -0
  179. package/dist/primitives/separator.js.map +7 -0
  180. package/dist/primitives/spinner.js +24 -0
  181. package/dist/primitives/spinner.js.map +7 -0
  182. package/dist/primitives/switch.js +80 -0
  183. package/dist/primitives/switch.js.map +7 -0
  184. package/dist/primitives/table.js +29 -0
  185. package/dist/primitives/table.js.map +7 -0
  186. package/dist/primitives/tabs.js +87 -0
  187. package/dist/primitives/tabs.js.map +7 -0
  188. package/dist/primitives/textarea.js +21 -0
  189. package/dist/primitives/textarea.js.map +7 -0
  190. package/dist/primitives/tooltip.js +60 -0
  191. package/dist/primitives/tooltip.js.map +7 -0
  192. package/dist/theme/QueryProvider.js +44 -0
  193. package/dist/theme/QueryProvider.js.map +7 -0
  194. package/dist/theme/ThemeProvider.js +95 -0
  195. package/dist/theme/ThemeProvider.js.map +7 -0
  196. package/dist/theme/ThemeToggle.js +88 -0
  197. package/dist/theme/ThemeToggle.js.map +7 -0
  198. package/dist/theme/index.js +10 -0
  199. package/dist/theme/index.js.map +7 -0
  200. package/dist/types/react-big-calendar.d.js +1 -0
  201. package/dist/types/react-big-calendar.d.js.map +7 -0
  202. package/jest.config.cjs +23 -0
  203. package/jest.setup.ts +55 -0
  204. package/package.json +105 -0
  205. package/src/backend/AppShell.tsx +1096 -0
  206. package/src/backend/ConfirmDialog.tsx +19 -0
  207. package/src/backend/ContextHelp.tsx +38 -0
  208. package/src/backend/CrudForm.tsx +2503 -0
  209. package/src/backend/DataTable.tsx +1730 -0
  210. package/src/backend/EmptyState.tsx +65 -0
  211. package/src/backend/FilterBar.tsx +161 -0
  212. package/src/backend/FilterOverlay.tsx +328 -0
  213. package/src/backend/FlashMessages.tsx +82 -0
  214. package/src/backend/JsonBuilder.tsx +362 -0
  215. package/src/backend/JsonDisplay.tsx +254 -0
  216. package/src/backend/Page.tsx +30 -0
  217. package/src/backend/PerspectiveSidebar.tsx +337 -0
  218. package/src/backend/RowActions.tsx +151 -0
  219. package/src/backend/TruncatedCell.tsx +133 -0
  220. package/src/backend/UserMenu.tsx +118 -0
  221. package/src/backend/ValueIcons.tsx +48 -0
  222. package/src/backend/__tests__/AppShell.test.tsx +115 -0
  223. package/src/backend/__tests__/CrudForm.render.test.tsx +30 -0
  224. package/src/backend/__tests__/DataTable.render.test.tsx +48 -0
  225. package/src/backend/__tests__/custom-field-filters.test.ts +72 -0
  226. package/src/backend/__tests__/custom-field-forms.test.ts +54 -0
  227. package/src/backend/__tests__/serverErrors.test.ts +83 -0
  228. package/src/backend/custom-fields/FieldDefinitionsEditor.tsx +1292 -0
  229. package/src/backend/custom-fields/FieldDefinitionsManager.tsx +381 -0
  230. package/src/backend/dashboard/DashboardScreen.tsx +684 -0
  231. package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +112 -0
  232. package/src/backend/dashboard/index.ts +1 -0
  233. package/src/backend/dashboard/widgetRegistry.ts +68 -0
  234. package/src/backend/detail/ActivitiesSection.tsx +1284 -0
  235. package/src/backend/detail/AddressEditor.tsx +472 -0
  236. package/src/backend/detail/AddressTiles.tsx +587 -0
  237. package/src/backend/detail/AddressesSection.tsx +346 -0
  238. package/src/backend/detail/AttachmentDeleteDialog.tsx +56 -0
  239. package/src/backend/detail/AttachmentMetadataDialog.tsx +672 -0
  240. package/src/backend/detail/AttachmentsSection.tsx +414 -0
  241. package/src/backend/detail/CustomDataSection.tsx +530 -0
  242. package/src/backend/detail/DetailFieldsSection.tsx +147 -0
  243. package/src/backend/detail/ErrorMessage.tsx +32 -0
  244. package/src/backend/detail/InlineEditors.tsx +877 -0
  245. package/src/backend/detail/LoadingMessage.tsx +14 -0
  246. package/src/backend/detail/NotesSection.tsx +1275 -0
  247. package/src/backend/detail/TabEmptyState.tsx +48 -0
  248. package/src/backend/detail/TagsSection.tsx +314 -0
  249. package/src/backend/detail/addressFormat.tsx +121 -0
  250. package/src/backend/detail/index.ts +44 -0
  251. package/src/backend/fields/registry.generated.ts +8 -0
  252. package/src/backend/fields/registry.ts +38 -0
  253. package/src/backend/indexes/PartialIndexBanner.tsx +68 -0
  254. package/src/backend/indexes/store.ts +88 -0
  255. package/src/backend/injection/InjectionSpot.tsx +236 -0
  256. package/src/backend/injection/PageInjectionBoundary.tsx +31 -0
  257. package/src/backend/injection/helpers.ts +35 -0
  258. package/src/backend/injection/widgetRegistry.ts +68 -0
  259. package/src/backend/inputs/ComboboxInput.tsx +269 -0
  260. package/src/backend/inputs/LookupSelect.tsx +247 -0
  261. package/src/backend/inputs/PhoneNumberField.tsx +129 -0
  262. package/src/backend/inputs/SwitchableMarkdownInput.tsx +128 -0
  263. package/src/backend/inputs/TagsInput.tsx +259 -0
  264. package/src/backend/inputs/index.ts +5 -0
  265. package/src/backend/operations/LastOperationBanner.tsx +85 -0
  266. package/src/backend/operations/__tests__/LastOperationBanner.test.tsx +99 -0
  267. package/src/backend/operations/store.ts +230 -0
  268. package/src/backend/schedule/ScheduleAgenda.tsx +136 -0
  269. package/src/backend/schedule/ScheduleGrid.tsx +136 -0
  270. package/src/backend/schedule/ScheduleToolbar.tsx +178 -0
  271. package/src/backend/schedule/ScheduleView.tsx +198 -0
  272. package/src/backend/schedule/index.ts +5 -0
  273. package/src/backend/schedule/recurrence.ts +99 -0
  274. package/src/backend/schedule/types.ts +26 -0
  275. package/src/backend/upgrades/UpgradeActionBanner.tsx +128 -0
  276. package/src/backend/utils/__tests__/apiCall.test.ts +109 -0
  277. package/src/backend/utils/__tests__/crud.test.ts +87 -0
  278. package/src/backend/utils/__tests__/customFieldDefs.test.ts +25 -0
  279. package/src/backend/utils/__tests__/customFieldValues.test.ts +35 -0
  280. package/src/backend/utils/api.ts +149 -0
  281. package/src/backend/utils/apiCall.ts +96 -0
  282. package/src/backend/utils/crud.ts +174 -0
  283. package/src/backend/utils/customFieldColumns.ts +71 -0
  284. package/src/backend/utils/customFieldDefs.ts +245 -0
  285. package/src/backend/utils/customFieldFilters.ts +145 -0
  286. package/src/backend/utils/customFieldForms.ts +196 -0
  287. package/src/backend/utils/customFieldValues.ts +41 -0
  288. package/src/backend/utils/flash.ts +17 -0
  289. package/src/backend/utils/nav.ts +238 -0
  290. package/src/backend/utils/serverErrors.ts +302 -0
  291. package/src/frontend/AuthFooter.tsx +29 -0
  292. package/src/frontend/LanguageSwitcher.tsx +66 -0
  293. package/src/frontend/Layout.tsx +13 -0
  294. package/src/index.ts +32 -0
  295. package/src/primitives/DataLoader.tsx +92 -0
  296. package/src/primitives/ErrorNotice.tsx +26 -0
  297. package/src/primitives/alert.tsx +52 -0
  298. package/src/primitives/badge.tsx +31 -0
  299. package/src/primitives/button.tsx +47 -0
  300. package/src/primitives/card.tsx +92 -0
  301. package/src/primitives/checkbox.tsx +28 -0
  302. package/src/primitives/dialog.tsx +110 -0
  303. package/src/primitives/input.tsx +20 -0
  304. package/src/primitives/label.tsx +18 -0
  305. package/src/primitives/separator.tsx +7 -0
  306. package/src/primitives/spinner.tsx +27 -0
  307. package/src/primitives/switch.tsx +86 -0
  308. package/src/primitives/table.tsx +27 -0
  309. package/src/primitives/tabs.tsx +128 -0
  310. package/src/primitives/textarea.tsx +20 -0
  311. package/src/primitives/tooltip.tsx +85 -0
  312. package/src/theme/QueryProvider.tsx +46 -0
  313. package/src/theme/ThemeProvider.tsx +120 -0
  314. package/src/theme/ThemeToggle.tsx +88 -0
  315. package/src/theme/index.ts +3 -0
  316. package/src/types/react-big-calendar.d.ts +16 -0
  317. package/tsconfig.build.json +11 -0
  318. package/tsconfig.json +9 -0
  319. package/watch.mjs +6 -0
@@ -0,0 +1,877 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import dynamic from 'next/dynamic'
5
+ import { FileCode, Loader2, Mail, Pencil, Phone, X } from 'lucide-react'
6
+ import type { PluggableList } from 'unified'
7
+ import { PhoneNumberField } from '@open-mercato/ui/backend/inputs/PhoneNumberField'
8
+ import { Button } from '@open-mercato/ui/primitives/button'
9
+ import { Textarea } from '@open-mercato/ui/primitives/textarea'
10
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
11
+ import { cn } from '@open-mercato/shared/lib/utils'
12
+ import { LoadingMessage } from './LoadingMessage'
13
+ import { mapCrudServerErrorToFormErrors } from '../utils/serverErrors'
14
+
15
+ function resolveInlineErrorMessage(err: unknown, fallbackMessage: string): string {
16
+ const { message, fieldErrors } = mapCrudServerErrorToFormErrors(err)
17
+ const firstFieldError = fieldErrors
18
+ ? Object.values(fieldErrors).find((text) => typeof text === 'string' && text.trim().length)
19
+ : null
20
+ if (typeof firstFieldError === 'string' && firstFieldError.trim().length) {
21
+ return firstFieldError.trim()
22
+ }
23
+ if (typeof message === 'string' && message.trim().length) {
24
+ return message.trim()
25
+ }
26
+ if (err instanceof Error && typeof err.message === 'string' && err.message.trim().length) {
27
+ return err.message.trim()
28
+ }
29
+ if (typeof err === 'string' && err.trim().length) {
30
+ return err.trim()
31
+ }
32
+ return fallbackMessage
33
+ }
34
+
35
+ type EditorVariant = 'default' | 'muted' | 'plain'
36
+
37
+ export type InlineFieldType = 'text' | 'email' | 'tel' | 'url'
38
+
39
+ export type InlineTextEditorProps = {
40
+ label: string
41
+ value: string | null | undefined
42
+ placeholder?: string
43
+ emptyLabel: string
44
+ onSave: (value: string | null) => Promise<void>
45
+ type?: InlineFieldType
46
+ inputType?: React.HTMLInputTypeAttribute
47
+ validator?: (value: string) => string | null
48
+ variant?: EditorVariant
49
+ activateOnClick?: boolean
50
+ containerClassName?: string
51
+ triggerClassName?: string
52
+ hideLabel?: boolean
53
+ renderDisplay?: (params: { value: string | null | undefined; emptyLabel: string; type: InlineFieldType }) => React.ReactNode
54
+ onEditingChange?: (editing: boolean) => void
55
+ renderActions?: React.ReactNode
56
+ saveLabel?: string
57
+ recordId?: string
58
+ onDraftChange?: (draft: string) => void
59
+ renderBelowInput?: (params: {
60
+ draft: string
61
+ resolvedType: InlineFieldType
62
+ error: string | null
63
+ saving: boolean
64
+ }) => React.ReactNode
65
+ }
66
+
67
+ export function InlineTextEditor({
68
+ label,
69
+ value,
70
+ placeholder,
71
+ emptyLabel,
72
+ onSave,
73
+ type = 'text',
74
+ inputType,
75
+ validator,
76
+ variant = 'default',
77
+ activateOnClick = false,
78
+ containerClassName,
79
+ triggerClassName,
80
+ hideLabel = false,
81
+ renderDisplay,
82
+ onEditingChange,
83
+ renderActions,
84
+ saveLabel,
85
+ onDraftChange,
86
+ renderBelowInput,
87
+ }: InlineTextEditorProps) {
88
+ const t = useT()
89
+ const [editing, setEditing] = React.useState(false)
90
+ const [draft, setDraft] = React.useState(value ?? '')
91
+ const [error, setError] = React.useState<string | null>(null)
92
+ const [saving, setSaving] = React.useState(false)
93
+ const computedSaveLabel = saveLabel ?? t('ui.detail.inline.saveShortcut', 'Save ⌘⏎ / Ctrl+Enter')
94
+ const fallbackError = React.useMemo(
95
+ () => t('ui.detail.inline.error', 'Failed to save value.'),
96
+ [t],
97
+ )
98
+ const resolvedType = React.useMemo<InlineFieldType>(() => {
99
+ if (type && typeof type === 'string') return type
100
+ if (inputType && typeof inputType === 'string') {
101
+ const normalized = inputType.toLowerCase()
102
+ if (normalized === 'email' || normalized === 'tel' || normalized === 'url') {
103
+ return normalized as InlineFieldType
104
+ }
105
+ }
106
+ return 'text'
107
+ }, [inputType, type])
108
+
109
+ React.useEffect(() => {
110
+ if (!editing) setDraft(value ?? '')
111
+ }, [editing, value])
112
+
113
+ React.useEffect(() => {
114
+ if (onDraftChange) onDraftChange(draft)
115
+ }, [draft, onDraftChange])
116
+
117
+ const containerClasses = cn(
118
+ 'group overflow-hidden',
119
+ variant === 'muted'
120
+ ? 'relative rounded border bg-muted/20 p-3'
121
+ : variant === 'plain'
122
+ ? 'relative flex items-center gap-3 rounded-none border-0 p-0'
123
+ : 'rounded-lg border p-4',
124
+ activateOnClick && !editing ? 'cursor-pointer' : null,
125
+ containerClassName ?? null,
126
+ )
127
+ const readOnlyWrapperClasses = cn(
128
+ 'flex-1 min-w-0',
129
+ activateOnClick && !editing ? 'cursor-pointer' : null,
130
+ variant === 'plain' ? 'flex items-center gap-2' : null,
131
+ )
132
+ const triggerClasses = cn(
133
+ 'shrink-0 transition-opacity duration-150',
134
+ editing
135
+ ? 'opacity-100'
136
+ : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100',
137
+ variant === 'muted' ? 'h-8 w-8' : null,
138
+ triggerClassName ?? null,
139
+ )
140
+ const triggerSize = variant === 'plain' ? 'icon' : 'sm'
141
+
142
+ const setEditingSafe = React.useCallback(
143
+ (next: boolean) => {
144
+ setEditing(next)
145
+ if (onEditingChange) onEditingChange(next)
146
+ },
147
+ [onEditingChange],
148
+ )
149
+
150
+ const handleActivate = React.useCallback(() => {
151
+ if (!editing) setEditingSafe(true)
152
+ }, [editing, setEditingSafe])
153
+
154
+ const handleInteractiveClick = React.useCallback(
155
+ (event: React.MouseEvent<HTMLDivElement>) => {
156
+ if (!activateOnClick || editing) return
157
+ const target = event.target as HTMLElement
158
+ const interactiveElement = target.closest('button, input, select, textarea, a, [role="link"]')
159
+ if (interactiveElement) {
160
+ if (interactiveElement.tagName.toLowerCase() === 'a') {
161
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
162
+ return
163
+ }
164
+ event.preventDefault()
165
+ // let the link click toggle edit mode instead of navigating away
166
+ } else {
167
+ return
168
+ }
169
+ }
170
+ handleActivate()
171
+ },
172
+ [activateOnClick, editing, handleActivate],
173
+ )
174
+
175
+ const handleContainerKeyDown = React.useCallback(
176
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
177
+ if (!activateOnClick || editing) return
178
+ if (event.key === 'Enter' || event.key === ' ') {
179
+ event.preventDefault()
180
+ handleActivate()
181
+ }
182
+ },
183
+ [activateOnClick, editing, handleActivate],
184
+ )
185
+
186
+ const handleSave = React.useCallback(async () => {
187
+ const trimmed = draft.trim()
188
+ if (validator) {
189
+ const validationError = validator(trimmed)
190
+ if (validationError) {
191
+ setError(validationError)
192
+ return
193
+ }
194
+ }
195
+ setError(null)
196
+ setSaving(true)
197
+ try {
198
+ await onSave(trimmed.length ? trimmed : null)
199
+ setEditingSafe(false)
200
+ } catch (err) {
201
+ setError(resolveInlineErrorMessage(err, fallbackError))
202
+ } finally {
203
+ setSaving(false)
204
+ }
205
+ }, [draft, fallbackError, onSave, setEditingSafe, validator])
206
+
207
+ const interactiveProps: React.HTMLAttributes<HTMLDivElement> =
208
+ activateOnClick && !editing
209
+ ? {
210
+ role: 'button' as const,
211
+ tabIndex: 0,
212
+ onKeyDown: handleContainerKeyDown,
213
+ }
214
+ : {}
215
+
216
+ const displayContent = React.useMemo(() => {
217
+ if (renderDisplay) {
218
+ return renderDisplay({ value, emptyLabel, type: resolvedType })
219
+ }
220
+ const baseValue = value && typeof value === 'string' ? value : ''
221
+ const anchorClass =
222
+ variant === 'plain'
223
+ ? 'inline-flex max-w-full min-w-0 items-center gap-2 text-xl font-semibold leading-tight text-primary hover:text-primary/90 hover:underline'
224
+ : 'flex max-w-full min-w-0 items-center gap-2 text-sm text-primary hover:text-primary/90 hover:underline'
225
+ const textClass = variant === 'plain' ? 'text-2xl font-semibold leading-tight' : 'text-sm break-words'
226
+ if (resolvedType === 'email') {
227
+ if (!baseValue.length) {
228
+ return (
229
+ <p className={variant === 'plain' ? 'text-base text-muted-foreground' : 'text-sm text-muted-foreground'}>
230
+ {emptyLabel}
231
+ </p>
232
+ )
233
+ }
234
+ return (
235
+ <a className={anchorClass} href={`mailto:${baseValue}`}>
236
+ <Mail aria-hidden className={variant === 'plain' ? 'h-5 w-5' : 'h-4 w-4'} />
237
+ <span className="truncate min-w-0">{baseValue}</span>
238
+ </a>
239
+ )
240
+ }
241
+ if (!baseValue.length) {
242
+ return (
243
+ <p className={variant === 'plain' ? 'text-base text-muted-foreground' : 'text-sm text-muted-foreground'}>
244
+ {emptyLabel}
245
+ </p>
246
+ )
247
+ }
248
+ if (resolvedType === 'tel') {
249
+ const sanitizedValue = baseValue.replace(/[^+\d]/g, '')
250
+ const hrefValue = sanitizedValue.length ? sanitizedValue : baseValue
251
+ return (
252
+ <a className={anchorClass} href={`tel:${hrefValue}`}>
253
+ <Phone aria-hidden className={variant === 'plain' ? 'h-5 w-5' : 'h-4 w-4'} />
254
+ <span className="truncate">{baseValue}</span>
255
+ </a>
256
+ )
257
+ }
258
+ if (resolvedType === 'url') {
259
+ return (
260
+ <a className={textClass} href={baseValue} target="_blank" rel="noreferrer">
261
+ {baseValue}
262
+ </a>
263
+ )
264
+ }
265
+ return <p className={textClass}>{baseValue}</p>
266
+ }, [emptyLabel, renderDisplay, resolvedType, value, variant])
267
+
268
+ const editingContainerClass = variant === 'plain' ? 'mt-0 w-full max-w-sm space-y-3' : 'mt-2 space-y-3'
269
+
270
+ return (
271
+ <div className={containerClasses} onClick={handleInteractiveClick}>
272
+ <div className="flex items-start justify-between gap-2 min-w-0">
273
+ <div className={readOnlyWrapperClasses} {...interactiveProps}>
274
+ {hideLabel ? null : <p className="text-xs uppercase tracking-wide text-muted-foreground">{label}</p>}
275
+ {editing ? (
276
+ <form
277
+ className={editingContainerClass}
278
+ onSubmit={(event) => {
279
+ event.preventDefault()
280
+ if (!saving) void handleSave()
281
+ }}
282
+ onKeyDown={(event) => {
283
+ if (event.key === 'Escape') {
284
+ event.preventDefault()
285
+ setEditingSafe(false)
286
+ setError(null)
287
+ return
288
+ }
289
+ if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
290
+ event.preventDefault()
291
+ if (!saving) void handleSave()
292
+ }
293
+ }}
294
+ >
295
+ {resolvedType === 'tel' ? (
296
+ <PhoneNumberField
297
+ value={draft.length ? draft : undefined}
298
+ onValueChange={(next) => {
299
+ if (error) setError(null)
300
+ setDraft(next ?? '')
301
+ }}
302
+ placeholder={placeholder}
303
+ autoFocus
304
+ disabled={saving}
305
+ minDigits={7}
306
+ />
307
+ ) : (
308
+ <input
309
+ className="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
310
+ value={draft}
311
+ onChange={(event) => {
312
+ if (error) setError(null)
313
+ setDraft(event.target.value)
314
+ }}
315
+ placeholder={placeholder}
316
+ type={inputType ?? resolvedType}
317
+ autoFocus
318
+ />
319
+ )}
320
+ {error ? <p className="text-xs text-destructive">{error}</p> : null}
321
+ {renderBelowInput ? renderBelowInput({ draft, resolvedType, error, saving }) : null}
322
+ <div className="flex items-center gap-2">
323
+ <Button type="submit" size="sm" disabled={saving}>
324
+ {saving ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
325
+ {computedSaveLabel}
326
+ </Button>
327
+ <Button type="button" size="sm" variant="ghost" onClick={() => setEditingSafe(false)} disabled={saving}>
328
+ {t('ui.detail.inline.cancel', 'Cancel')}
329
+ </Button>
330
+ </div>
331
+ </form>
332
+ ) : (
333
+ <div className={variant === 'plain' ? '' : 'mt-1'}>{displayContent}</div>
334
+ )}
335
+ </div>
336
+ {renderActions ? <div className="flex items-center gap-2">{renderActions}</div> : null}
337
+ <Button
338
+ type="button"
339
+ variant="ghost"
340
+ size={triggerSize}
341
+ className={triggerClasses}
342
+ onClick={(event) => {
343
+ event.stopPropagation()
344
+ const next = !editing
345
+ setEditingSafe(next)
346
+ }}
347
+ >
348
+ {editing ? <X className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
349
+ </Button>
350
+ </div>
351
+ </div>
352
+ )
353
+ }
354
+
355
+ export type InlineMultilineEditorProps = {
356
+ label: string
357
+ value: string | null | undefined
358
+ placeholder?: string
359
+ emptyLabel: string
360
+ onSave: (value: string | null) => Promise<void>
361
+ validator?: (value: string) => string | null
362
+ variant?: EditorVariant
363
+ activateOnClick?: boolean
364
+ containerClassName?: string
365
+ triggerClassName?: string
366
+ renderDisplay?: (params: { value: string | null | undefined; emptyLabel: string }) => React.ReactNode
367
+ }
368
+
369
+ type UiMarkdownEditorProps = {
370
+ value?: string
371
+ height?: number
372
+ onChange?: (value?: string) => void
373
+ previewOptions?: { remarkPlugins?: unknown[] }
374
+ }
375
+
376
+ type MarkdownPreviewProps = {
377
+ children: string
378
+ className?: string
379
+ remarkPlugins?: PluggableList
380
+ }
381
+
382
+ const isTestEnv = typeof process !== 'undefined' && process.env.NODE_ENV === 'test'
383
+
384
+ function MarkdownEditorFallback() {
385
+ const t = useT()
386
+ return (
387
+ <LoadingMessage label={t('ui.detail.inline.editorLoading', 'Loading editor…')} className="min-h-[200px] justify-center" />
388
+ )
389
+ }
390
+
391
+ const MarkdownEditorTestStub: React.ComponentType<UiMarkdownEditorProps> = ({ value, onChange }) => (
392
+ <Textarea
393
+ data-testid="markdown-editor"
394
+ rows={8}
395
+ value={value ?? ''}
396
+ onChange={(event) => onChange?.(event.target.value)}
397
+ />
398
+ )
399
+
400
+ const MarkdownEditorComponent: React.ComponentType<UiMarkdownEditorProps> = isTestEnv
401
+ ? MarkdownEditorTestStub
402
+ : (dynamic(() => import('@uiw/react-md-editor'), {
403
+ ssr: false,
404
+ loading: () => <MarkdownEditorFallback />,
405
+ }) as unknown as React.ComponentType<UiMarkdownEditorProps>)
406
+
407
+ const MarkdownPreviewComponent: React.ComponentType<MarkdownPreviewProps> = isTestEnv
408
+ ? ({ children, className }) => <div className={className}>{children}</div>
409
+ : (dynamic(() => import('react-markdown').then((mod) => mod.default as React.ComponentType<MarkdownPreviewProps>), {
410
+ ssr: false,
411
+ loading: () => null,
412
+ }) as unknown as React.ComponentType<MarkdownPreviewProps>)
413
+
414
+ let markdownPluginsPromise: Promise<PluggableList> | null = null
415
+
416
+ async function loadMarkdownPlugins(): Promise<PluggableList> {
417
+ if (isTestEnv) return []
418
+ if (!markdownPluginsPromise) {
419
+ markdownPluginsPromise = import('remark-gfm')
420
+ .then((mod) => [mod.default ?? mod] as PluggableList)
421
+ .catch(() => [])
422
+ }
423
+ return markdownPluginsPromise
424
+ }
425
+
426
+ export function InlineMultilineEditor({
427
+ label,
428
+ value,
429
+ placeholder,
430
+ emptyLabel,
431
+ onSave,
432
+ validator,
433
+ variant = 'default',
434
+ activateOnClick = true,
435
+ containerClassName,
436
+ triggerClassName,
437
+ renderDisplay,
438
+ }: InlineMultilineEditorProps) {
439
+ const t = useT()
440
+ const [editing, setEditing] = React.useState(false)
441
+ const [draft, setDraft] = React.useState(value ?? '')
442
+ const [error, setError] = React.useState<string | null>(null)
443
+ const [saving, setSaving] = React.useState(false)
444
+ const [isMarkdownEnabled, setIsMarkdownEnabled] = React.useState(true)
445
+ const textareaRef = React.useRef<HTMLTextAreaElement | null>(null)
446
+ const markdownEditorRef = React.useRef<HTMLDivElement | null>(null)
447
+ const [markdownPlugins, setMarkdownPlugins] = React.useState<PluggableList>([])
448
+ const fallbackError = React.useMemo(
449
+ () => t('ui.detail.inline.error', 'Failed to save value.'),
450
+ [t],
451
+ )
452
+ React.useEffect(() => {
453
+ if (isTestEnv) return
454
+ let mounted = true
455
+ void loadMarkdownPlugins().then((plugins) => {
456
+ if (!mounted) return
457
+ setMarkdownPlugins(plugins)
458
+ })
459
+ return () => {
460
+ mounted = false
461
+ }
462
+ }, [])
463
+
464
+ const adjustTextareaSize = React.useCallback((element: HTMLTextAreaElement | null) => {
465
+ if (!element) return
466
+ element.style.height = 'auto'
467
+ element.style.height = `${element.scrollHeight}px`
468
+ }, [])
469
+
470
+ React.useEffect(() => {
471
+ adjustTextareaSize(textareaRef.current)
472
+ }, [adjustTextareaSize, draft, isMarkdownEnabled])
473
+
474
+ React.useEffect(() => {
475
+ if (!editing) return
476
+ if (isMarkdownEnabled) {
477
+ const element = markdownEditorRef.current?.querySelector('textarea')
478
+ if (!element) return
479
+ window.requestAnimationFrame(() => {
480
+ element.focus()
481
+ })
482
+ return
483
+ }
484
+ const element = textareaRef.current
485
+ if (!element) return
486
+ window.requestAnimationFrame(() => {
487
+ adjustTextareaSize(element)
488
+ element.focus()
489
+ })
490
+ }, [adjustTextareaSize, editing, isMarkdownEnabled])
491
+
492
+ const handleMarkdownToggle = React.useCallback(() => {
493
+ setIsMarkdownEnabled((prev) => !prev)
494
+ }, [])
495
+
496
+ React.useEffect(() => {
497
+ if (!editing) {
498
+ setDraft(value ?? '')
499
+ setError(null)
500
+ }
501
+ }, [editing, value])
502
+
503
+ const handleActivate = React.useCallback(() => {
504
+ if (!editing) setEditing(true)
505
+ }, [editing])
506
+
507
+ const handleInteractiveClick = React.useCallback(
508
+ (event: React.MouseEvent<HTMLDivElement>) => {
509
+ if (!activateOnClick || editing) return
510
+ const target = event.target as HTMLElement
511
+ const interactiveElement = target.closest('button, input, select, textarea, a, [role="link"]')
512
+ if (interactiveElement) {
513
+ if (interactiveElement.tagName.toLowerCase() === 'a') {
514
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
515
+ return
516
+ }
517
+ event.preventDefault()
518
+ // Links should not block activation; let the click toggle edit mode
519
+ } else {
520
+ return
521
+ }
522
+ }
523
+ handleActivate()
524
+ },
525
+ [activateOnClick, editing, handleActivate],
526
+ )
527
+
528
+ const handleContainerKeyDown = React.useCallback(
529
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
530
+ if (!activateOnClick || editing) return
531
+ if (event.key === 'Enter' || event.key === ' ') {
532
+ event.preventDefault()
533
+ handleActivate()
534
+ }
535
+ },
536
+ [activateOnClick, editing, handleActivate],
537
+ )
538
+
539
+ const adjustError = React.useCallback(
540
+ (nextValue: string) => {
541
+ if (!validator) return null
542
+ const trimmed = nextValue.trim()
543
+ return validator(trimmed)
544
+ },
545
+ [validator],
546
+ )
547
+
548
+ const containerClasses = cn(
549
+ 'group rounded-lg border p-4',
550
+ variant === 'muted' ? 'bg-muted/20' : null,
551
+ activateOnClick && !editing ? 'cursor-pointer' : null,
552
+ containerClassName ?? null,
553
+ )
554
+ const triggerClasses = cn(
555
+ 'transition-opacity duration-150',
556
+ editing
557
+ ? 'opacity-100'
558
+ : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100',
559
+ triggerClassName ?? null,
560
+ )
561
+
562
+ const handleSave = React.useCallback(async () => {
563
+ const trimmed = draft.trim()
564
+ const validationError = adjustError(draft)
565
+ if (validationError) {
566
+ setError(validationError)
567
+ return
568
+ }
569
+ setSaving(true)
570
+ try {
571
+ await onSave(trimmed.length ? trimmed : null)
572
+ setEditing(false)
573
+ } catch (err) {
574
+ setError(resolveInlineErrorMessage(err, fallbackError))
575
+ } finally {
576
+ setSaving(false)
577
+ }
578
+ }, [adjustError, draft, fallbackError, onSave])
579
+
580
+ return (
581
+ <div className={containerClasses} onClick={handleInteractiveClick}>
582
+ <div className="flex items-start justify-between gap-2">
583
+ <div
584
+ className={cn('flex-1 min-w-0', activateOnClick && !editing ? 'cursor-pointer' : null)}
585
+ {...(activateOnClick && !editing
586
+ ? { role: 'button' as const, tabIndex: 0, onKeyDown: handleContainerKeyDown }
587
+ : {})}
588
+ >
589
+ <p className="text-xs uppercase tracking-wide text-muted-foreground">{label}</p>
590
+ {editing ? (
591
+ <form
592
+ className="mt-2 space-y-3"
593
+ onSubmit={(event) => {
594
+ event.preventDefault()
595
+ if (!saving) void handleSave()
596
+ }}
597
+ onKeyDown={(event) => {
598
+ if (event.key === 'Escape') {
599
+ event.preventDefault()
600
+ setEditing(false)
601
+ setError(null)
602
+ return
603
+ }
604
+ if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
605
+ event.preventDefault()
606
+ if (!saving) void handleSave()
607
+ }
608
+ }}
609
+ >
610
+ {isMarkdownEnabled ? (
611
+ <div
612
+ ref={markdownEditorRef}
613
+ className={cn(
614
+ 'w-full rounded-md border border-muted-foreground/30 bg-background p-2',
615
+ saving ? 'pointer-events-none opacity-75' : null,
616
+ )}
617
+ >
618
+ <div data-color-mode="light" className="w-full">
619
+ <MarkdownEditorComponent
620
+ value={draft}
621
+ height={220}
622
+ onChange={(nextValue) => {
623
+ if (error) setError(null)
624
+ setDraft(typeof nextValue === 'string' ? nextValue : '')
625
+ }}
626
+ previewOptions={{ remarkPlugins: markdownPlugins }}
627
+ />
628
+ </div>
629
+ </div>
630
+ ) : (
631
+ <Textarea
632
+ ref={textareaRef}
633
+ rows={3}
634
+ className="w-full resize-none overflow-hidden rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
635
+ placeholder={placeholder}
636
+ value={draft}
637
+ onChange={(event) => {
638
+ if (error) setError(null)
639
+ setDraft(event.target.value)
640
+ }}
641
+ onInput={(event) => adjustTextareaSize(event.currentTarget)}
642
+ autoFocus
643
+ disabled={saving}
644
+ />
645
+ )}
646
+ {error ? <p className="text-xs text-destructive">{error}</p> : null}
647
+ <div className="flex items-center gap-2">
648
+ <Button type="submit" size="sm" disabled={saving}>
649
+ {saving ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
650
+ {t('ui.detail.inline.saveShortcut', 'Save ⌘⏎ / Ctrl+Enter')}
651
+ </Button>
652
+ <Button type="button" size="sm" variant="ghost" onClick={() => setEditing(false)} disabled={saving}>
653
+ {t('ui.detail.inline.cancel', 'Cancel')}
654
+ </Button>
655
+ <Button
656
+ type="button"
657
+ variant="ghost"
658
+ size="icon"
659
+ onClick={handleMarkdownToggle}
660
+ aria-pressed={isMarkdownEnabled}
661
+ title={
662
+ isMarkdownEnabled
663
+ ? t('ui.detail.inline.markdownDisable', 'Disable Markdown')
664
+ : t('ui.detail.inline.markdownEnable', 'Enable Markdown')
665
+ }
666
+ aria-label={
667
+ isMarkdownEnabled
668
+ ? t('ui.detail.inline.markdownDisable', 'Disable Markdown')
669
+ : t('ui.detail.inline.markdownEnable', 'Enable Markdown')
670
+ }
671
+ className={cn('h-8 w-8', isMarkdownEnabled ? 'text-primary' : undefined)}
672
+ disabled={saving}
673
+ >
674
+ <FileCode className="h-4 w-4" aria-hidden />
675
+ <span className="sr-only">
676
+ {isMarkdownEnabled
677
+ ? t('ui.detail.inline.markdownDisable', 'Disable Markdown')
678
+ : t('ui.detail.inline.markdownEnable', 'Enable Markdown')}
679
+ </span>
680
+ </Button>
681
+ </div>
682
+ </form>
683
+ ) : (
684
+ <div
685
+ className={cn(
686
+ 'mt-1 text-sm break-words',
687
+ renderDisplay ? null : 'whitespace-pre-wrap',
688
+ activateOnClick && !editing ? 'cursor-pointer' : null,
689
+ )}
690
+ >
691
+ {renderDisplay ? (
692
+ renderDisplay({ value, emptyLabel })
693
+ ) : value && value.length ? (
694
+ <MarkdownPreviewComponent
695
+ remarkPlugins={markdownPlugins}
696
+ className="prose prose-sm max-w-none text-foreground [&>*]:my-2 [&>*:last-child]:mb-0 [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5"
697
+ >
698
+ {value}
699
+ </MarkdownPreviewComponent>
700
+ ) : (
701
+ <span className="text-muted-foreground">{emptyLabel}</span>
702
+ )}
703
+ </div>
704
+ )}
705
+ </div>
706
+ <Button
707
+ type="button"
708
+ variant="ghost"
709
+ size="sm"
710
+ className={triggerClasses}
711
+ onClick={(event) => {
712
+ event.stopPropagation()
713
+ setEditing((state) => !state)
714
+ }}
715
+ >
716
+ {editing ? <X className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
717
+ </Button>
718
+ </div>
719
+ </div>
720
+ )
721
+ }
722
+
723
+ export type InlineSelectOption = { value: string; label: string; description?: string }
724
+
725
+ export type InlineSelectEditorProps = {
726
+ label: string
727
+ value: string | null | undefined
728
+ emptyLabel: string
729
+ options: InlineSelectOption[]
730
+ onSave: (value: string | null) => Promise<void>
731
+ variant?: EditorVariant
732
+ activateOnClick?: boolean
733
+ containerClassName?: string
734
+ triggerClassName?: string
735
+ hideLabel?: boolean
736
+ renderEditor?: (params: { value: string; onChange: (next: string) => void }) => React.ReactNode
737
+ renderDisplay?: (params: { value: string | null | undefined; emptyLabel: string }) => React.ReactNode
738
+ }
739
+
740
+ export function InlineSelectEditor({
741
+ label,
742
+ value,
743
+ emptyLabel,
744
+ options,
745
+ onSave,
746
+ variant = 'default',
747
+ activateOnClick = false,
748
+ containerClassName,
749
+ triggerClassName,
750
+ hideLabel = false,
751
+ renderEditor,
752
+ renderDisplay,
753
+ }: InlineSelectEditorProps) {
754
+ const t = useT()
755
+ const [editing, setEditing] = React.useState(false)
756
+ const [draft, setDraft] = React.useState<string>(value ?? '')
757
+ const [saving, setSaving] = React.useState(false)
758
+
759
+ React.useEffect(() => {
760
+ if (!editing) setDraft(value ?? '')
761
+ }, [editing, value])
762
+
763
+ const containerClasses = cn(
764
+ 'group',
765
+ variant === 'muted'
766
+ ? 'relative rounded border bg-muted/30 p-3'
767
+ : variant === 'plain'
768
+ ? 'relative flex flex-col gap-1 rounded-none border-0 p-0'
769
+ : 'rounded-lg border bg-card p-4',
770
+ activateOnClick && !editing ? 'cursor-pointer' : null,
771
+ containerClassName ?? null,
772
+ )
773
+ const triggerClasses = cn(
774
+ 'shrink-0 transition-opacity duration-150',
775
+ editing
776
+ ? 'opacity-100'
777
+ : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100',
778
+ variant === 'muted' ? 'h-8 w-8' : null,
779
+ triggerClassName ?? null,
780
+ )
781
+
782
+ const handleSave = React.useCallback(async () => {
783
+ setSaving(true)
784
+ try {
785
+ await onSave(draft.length ? draft : null)
786
+ setEditing(false)
787
+ } catch (err) {
788
+ const message = err instanceof Error ? err.message : t('ui.detail.inline.error', 'Failed to save value.')
789
+ console.error(message, err)
790
+ } finally {
791
+ setSaving(false)
792
+ }
793
+ }, [draft, onSave, t])
794
+
795
+ const selected = options.find((option) => option.value === value)
796
+
797
+ const interactiveProps: React.HTMLAttributes<HTMLDivElement> =
798
+ activateOnClick && !editing
799
+ ? {
800
+ role: 'button' as const,
801
+ tabIndex: 0,
802
+ onClick: () => setEditing(true),
803
+ onKeyDown: (event) => {
804
+ if (event.key === 'Enter' || event.key === ' ') {
805
+ event.preventDefault()
806
+ setEditing(true)
807
+ }
808
+ },
809
+ }
810
+ : {}
811
+
812
+ return (
813
+ <div className={containerClasses}>
814
+ <div className="flex items-start justify-between gap-2">
815
+ <div className="flex-1 min-w-0" {...interactiveProps}>
816
+ {hideLabel ? null : <p className="text-xs uppercase tracking-wide text-muted-foreground">{label}</p>}
817
+ {editing ? (
818
+ <div className={variant === 'plain' ? 'space-y-2 pt-1' : 'mt-2 space-y-2'}>
819
+ {renderEditor ? (
820
+ renderEditor({ value: draft, onChange: setDraft })
821
+ ) : (
822
+ <select
823
+ className="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
824
+ value={draft}
825
+ onChange={(event) => setDraft(event.target.value)}
826
+ >
827
+ <option value="">{t('ui.detail.inline.select.placeholder', 'Not set')}</option>
828
+ {options.map((option) => (
829
+ <option key={option.value} value={option.value}>
830
+ {option.label}
831
+ </option>
832
+ ))}
833
+ </select>
834
+ )}
835
+ <div className="flex items-center gap-2">
836
+ <Button type="button" size="sm" onClick={() => void handleSave()} disabled={saving}>
837
+ {saving ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
838
+ {t('ui.detail.inline.saveShortcut', 'Save ⌘⏎ / Ctrl+Enter')}
839
+ </Button>
840
+ <Button type="button" size="sm" variant="ghost" onClick={() => setEditing(false)} disabled={saving}>
841
+ {t('ui.detail.inline.cancel', 'Cancel')}
842
+ </Button>
843
+ </div>
844
+ </div>
845
+ ) : (
846
+ <div className={variant === 'plain' ? 'flex items-center gap-2' : 'mt-1 text-sm'}>
847
+ {renderDisplay ? (
848
+ renderDisplay({ value, emptyLabel })
849
+ ) : selected ? (
850
+ <div className="space-y-0.5">
851
+ <p className="font-medium leading-tight">{selected.label}</p>
852
+ {selected.description ? (
853
+ <p className="text-xs text-muted-foreground">{selected.description}</p>
854
+ ) : null}
855
+ </div>
856
+ ) : (
857
+ <span className="text-muted-foreground">{emptyLabel}</span>
858
+ )}
859
+ </div>
860
+ )}
861
+ </div>
862
+ <Button
863
+ type="button"
864
+ variant="ghost"
865
+ size={variant === 'plain' ? 'icon' : 'sm'}
866
+ className={triggerClasses}
867
+ onClick={(event) => {
868
+ event.stopPropagation()
869
+ setEditing((state) => !state)
870
+ }}
871
+ >
872
+ {editing ? <X className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
873
+ </Button>
874
+ </div>
875
+ </div>
876
+ )
877
+ }