@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,672 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { z } from 'zod'
5
+ import { Button } from '@open-mercato/ui/primitives/button'
6
+ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@open-mercato/ui/primitives/dialog'
7
+ import { Input } from '@open-mercato/ui/primitives/input'
8
+ import { TagsInput } from '@open-mercato/ui/backend/inputs/TagsInput'
9
+ import { CrudForm, type CrudField, type CrudFormGroup } from '@open-mercato/ui/backend/CrudForm'
10
+ import { collectCustomFieldValues } from '@open-mercato/ui/backend/utils/customFieldValues'
11
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
12
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
13
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
14
+ import { cn } from '@open-mercato/shared/lib/utils'
15
+ import { Copy, Download, Trash2 } from 'lucide-react'
16
+ import { AttachmentContentPreview } from '@open-mercato/core/modules/attachments/components/AttachmentContentPreview'
17
+ import { buildAttachmentFileUrl, buildAttachmentImageUrl, slugifyAttachmentFileName } from '@open-mercato/core/modules/attachments/lib/imageUrls'
18
+ import { E } from '@open-mercato/core/generated-shims/entities.ids.generated'
19
+
20
+ export type AttachmentAssignment = {
21
+ type: string
22
+ id: string
23
+ href?: string | null
24
+ label?: string | null
25
+ }
26
+
27
+ export type AttachmentItem = {
28
+ id: string
29
+ fileName: string
30
+ fileSize: number
31
+ mimeType?: string | null
32
+ partitionCode?: string | null
33
+ partitionTitle?: string | null
34
+ url?: string | null
35
+ createdAt?: string | null
36
+ tags?: string[]
37
+ assignments?: AttachmentAssignment[]
38
+ thumbnailUrl?: string
39
+ content?: string | null
40
+ }
41
+
42
+ export type AssignmentDraft = {
43
+ type: string
44
+ id: string
45
+ href?: string
46
+ label?: string
47
+ }
48
+
49
+ export type AttachmentMetadataSavePayload = {
50
+ tags: string[]
51
+ assignments: AssignmentDraft[]
52
+ customFields?: Record<string, unknown>
53
+ }
54
+
55
+ type AttachmentMetadataResponse = {
56
+ item: {
57
+ id: string
58
+ fileName?: string
59
+ fileSize?: number
60
+ mimeType?: string | null
61
+ partitionCode?: string
62
+ partitionTitle?: string | null
63
+ tags?: string[]
64
+ content?: string | null
65
+ assignments?: AttachmentAssignment[]
66
+ customFields?: Record<string, unknown>
67
+ }
68
+ error?: string
69
+ }
70
+
71
+ type AttachmentMetadataFormValues = {
72
+ id: string
73
+ tags?: string[]
74
+ assignments?: AssignmentDraft[]
75
+ } & Record<string, unknown>
76
+
77
+ type AssignmentEditorLabels = {
78
+ title: string
79
+ description: string
80
+ type: string
81
+ id: string
82
+ href: string
83
+ label: string
84
+ add: string
85
+ remove: string
86
+ }
87
+
88
+ type AttachmentMetadataDialogProps = {
89
+ open: boolean
90
+ onOpenChange: (next: boolean) => void
91
+ item: AttachmentItem | null
92
+ availableTags: string[]
93
+ onSave: (id: string, payload: AttachmentMetadataSavePayload) => Promise<void>
94
+ }
95
+
96
+ function formatFileSize(value: number): string {
97
+ if (!Number.isFinite(value)) return '—'
98
+ if (value <= 0) return '0 B'
99
+ const units = ['B', 'KB', 'MB', 'GB', 'TB']
100
+ let idx = 0
101
+ let current = value
102
+ while (current >= 1024 && idx < units.length - 1) {
103
+ current /= 1024
104
+ idx += 1
105
+ }
106
+ return `${current.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`
107
+ }
108
+
109
+ const ENV_APP_URL = (process.env.NEXT_PUBLIC_APP_URL || '').replace(/\/$/, '')
110
+
111
+ function resolveAbsoluteUrl(path: string): string {
112
+ if (!path) return path
113
+ if (/^https?:\/\//i.test(path)) return path
114
+ const base =
115
+ ENV_APP_URL ||
116
+ (typeof window !== 'undefined' && window.location?.origin ? window.location.origin : '')
117
+ if (!base) return path
118
+ const normalizedBase = base.replace(/\/$/, '')
119
+ return `${normalizedBase}${path.startsWith('/') ? path : `/${path}`}`
120
+ }
121
+
122
+ function normalizeCustomFieldSubmitValue(value: unknown): unknown {
123
+ if (Array.isArray(value)) {
124
+ return value.filter((entry) => entry !== undefined)
125
+ }
126
+ if (value === undefined) return null
127
+ return value
128
+ }
129
+
130
+ function prepareAssignmentsForForm(assignments?: AttachmentAssignment[] | null): AssignmentDraft[] {
131
+ return (assignments ?? []).map((assignment) => ({
132
+ type: assignment.type,
133
+ id: assignment.id,
134
+ href: assignment.href ?? '',
135
+ label: assignment.label ?? '',
136
+ }))
137
+ }
138
+
139
+ function prefixCustomFieldValues(values?: Record<string, unknown> | null): Record<string, unknown> {
140
+ if (!values) return {}
141
+ const prefixed: Record<string, unknown> = {}
142
+ Object.entries(values).forEach(([key, value]) => {
143
+ if (!key) return
144
+ if (key.startsWith('cf_')) {
145
+ prefixed[key] = value
146
+ } else if (key.startsWith('cf:')) {
147
+ const normalized = key.slice(3)
148
+ if (normalized) prefixed[`cf_${normalized}`] = value
149
+ } else {
150
+ prefixed[`cf_${key}`] = value
151
+ }
152
+ })
153
+ return prefixed
154
+ }
155
+
156
+ function AssignmentInputRow({
157
+ value,
158
+ onChange,
159
+ labels,
160
+ disabled,
161
+ onRemove,
162
+ }: {
163
+ value: AssignmentDraft
164
+ onChange: (next: AssignmentDraft) => void
165
+ labels: AssignmentEditorLabels
166
+ disabled?: boolean
167
+ onRemove: () => void
168
+ }) {
169
+ return (
170
+ <div className="grid gap-2 rounded-md border border-border/70 bg-background p-3 md:grid-cols-[1.2fr_1.2fr_1.6fr_1fr_auto]">
171
+ <div className="space-y-1">
172
+ <label className="text-xs font-medium">{labels.type}</label>
173
+ <Input
174
+ value={value.type}
175
+ onChange={(event) => onChange({ ...value, type: event.target.value })}
176
+ placeholder="catalog.product"
177
+ disabled={disabled}
178
+ />
179
+ </div>
180
+ <div className="space-y-1">
181
+ <label className="text-xs font-medium">{labels.id}</label>
182
+ <Input
183
+ value={value.id}
184
+ onChange={(event) => onChange({ ...value, id: event.target.value })}
185
+ placeholder="Record ID"
186
+ disabled={disabled}
187
+ />
188
+ </div>
189
+ <div className="space-y-1">
190
+ <label className="text-xs font-medium">{labels.href}</label>
191
+ <Input
192
+ value={value.href ?? ''}
193
+ onChange={(event) => onChange({ ...value, href: event.target.value })}
194
+ placeholder="https://"
195
+ disabled={disabled}
196
+ />
197
+ </div>
198
+ <div className="space-y-1">
199
+ <label className="text-xs font-medium">{labels.label}</label>
200
+ <Input
201
+ value={value.label ?? ''}
202
+ onChange={(event) => onChange({ ...value, label: event.target.value })}
203
+ placeholder="Optional label"
204
+ disabled={disabled}
205
+ />
206
+ </div>
207
+ <div className="flex items-end">
208
+ <Button type="button" variant="ghost" size="icon" onClick={onRemove} disabled={disabled}>
209
+ <Trash2 className="h-4 w-4" />
210
+ </Button>
211
+ </div>
212
+ </div>
213
+ )
214
+ }
215
+
216
+ function AttachmentAssignmentsEditor({
217
+ value,
218
+ onChange,
219
+ labels,
220
+ disabled,
221
+ }: {
222
+ value: AssignmentDraft[]
223
+ onChange: (next: AssignmentDraft[]) => void
224
+ labels: AssignmentEditorLabels
225
+ disabled?: boolean
226
+ }) {
227
+ const handleAdd = React.useCallback(() => {
228
+ onChange([...value, { type: '', id: '', href: '', label: '' }])
229
+ }, [onChange, value])
230
+
231
+ const handleChange = React.useCallback(
232
+ (index: number, next: AssignmentDraft) => {
233
+ const draft = [...value]
234
+ draft[index] = next
235
+ onChange(draft)
236
+ },
237
+ [onChange, value],
238
+ )
239
+
240
+ const handleRemove = React.useCallback(
241
+ (index: number) => {
242
+ const next = value.filter((_, idx) => idx !== index)
243
+ onChange(next)
244
+ },
245
+ [onChange, value],
246
+ )
247
+
248
+ return (
249
+ <div className="space-y-2">
250
+ <div className="space-y-1">
251
+ <div className="text-sm font-medium">{labels.title}</div>
252
+ <p className="text-xs text-muted-foreground">{labels.description}</p>
253
+ </div>
254
+ <div className="space-y-2">
255
+ {value.length ? value.map((entry, idx) => (
256
+ <AssignmentInputRow
257
+ key={`${entry.type}-${entry.id}-${idx}`}
258
+ value={entry}
259
+ labels={labels}
260
+ disabled={disabled}
261
+ onChange={(next) => handleChange(idx, next)}
262
+ onRemove={() => handleRemove(idx)}
263
+ />
264
+ )) : (
265
+ <div className="rounded-md border border-dashed border-border/70 px-3 py-4 text-xs text-muted-foreground">
266
+ {labels.description}
267
+ </div>
268
+ )}
269
+ </div>
270
+ <Button type="button" variant="outline" size="sm" onClick={handleAdd} disabled={disabled}>
271
+ {labels.add}
272
+ </Button>
273
+ </div>
274
+ )
275
+ }
276
+
277
+ export function AttachmentMetadataDialog({ open, onOpenChange, item, availableTags, onSave }: AttachmentMetadataDialogProps) {
278
+ const t = useT()
279
+ const [sizeWidth, setSizeWidth] = React.useState<string>('')
280
+ const [sizeHeight, setSizeHeight] = React.useState<string>('')
281
+ const [imageTab, setImageTab] = React.useState<'preview' | 'resize'>('preview')
282
+ const [initialValues, setInitialValues] = React.useState<Partial<AttachmentMetadataFormValues> | null>(null)
283
+ const [loading, setLoading] = React.useState(false)
284
+ const [loadError, setLoadError] = React.useState<string | null>(null)
285
+ const [extractedContent, setExtractedContent] = React.useState<string | null>(null)
286
+
287
+ React.useEffect(() => {
288
+ if (!open || !item) {
289
+ setInitialValues(null)
290
+ setLoadError(null)
291
+ setLoading(false)
292
+ setImageTab('preview')
293
+ setExtractedContent(null)
294
+ return
295
+ }
296
+ let cancelled = false
297
+ setLoading(true)
298
+ setLoadError(null)
299
+ setSizeWidth('')
300
+ setSizeHeight('')
301
+ setImageTab('preview')
302
+ setInitialValues({
303
+ id: item.id,
304
+ tags: item.tags ?? [],
305
+ assignments: prepareAssignmentsForForm(item.assignments),
306
+ })
307
+ setExtractedContent(item.content ?? null)
308
+ const loadDetails = async () => {
309
+ try {
310
+ const call = await apiCall<AttachmentMetadataResponse>(`/api/attachments/library/${encodeURIComponent(item.id)}`)
311
+ if (!call.ok || !call.result?.item) {
312
+ const message = call.result?.error || t('attachments.library.metadata.error', 'Failed to update metadata.')
313
+ throw new Error(message)
314
+ }
315
+ const payload = call.result.item
316
+ const prefixedCustom = prefixCustomFieldValues(payload.customFields)
317
+ if (!cancelled) {
318
+ setInitialValues({
319
+ id: payload.id,
320
+ tags: Array.isArray(payload.tags) ? payload.tags : [],
321
+ assignments: prepareAssignmentsForForm(payload.assignments ?? item.assignments),
322
+ ...prefixedCustom,
323
+ })
324
+ const nextContent = typeof payload.content === 'string' ? payload.content : null
325
+ setExtractedContent(nextContent)
326
+ }
327
+ } catch (err: any) {
328
+ if (!cancelled) {
329
+ const message =
330
+ err?.message || t('attachments.library.metadata.loadError', 'Failed to load attachment metadata.')
331
+ setLoadError(message)
332
+ }
333
+ } finally {
334
+ if (!cancelled) setLoading(false)
335
+ }
336
+ }
337
+ void loadDetails()
338
+ return () => {
339
+ cancelled = true
340
+ }
341
+ }, [item, open, t])
342
+
343
+ const isImage = React.useMemo(() => Boolean(item?.mimeType?.toLowerCase().startsWith('image/')), [item])
344
+ const previewUrl = React.useMemo(() => {
345
+ if (!item) return null
346
+ return (
347
+ item.thumbnailUrl ??
348
+ buildAttachmentImageUrl(item.id, {
349
+ width: 320,
350
+ height: 320,
351
+ slug: slugifyAttachmentFileName(item.fileName),
352
+ })
353
+ )
354
+ }, [item])
355
+ const downloadUrl = React.useMemo(() => {
356
+ if (!item) return null
357
+ const original = buildAttachmentFileUrl(item.id, { download: true })
358
+ return resolveAbsoluteUrl(original)
359
+ }, [item])
360
+
361
+ const assignmentLabels = React.useMemo(
362
+ () => ({
363
+ title: t('attachments.library.metadata.assignments.title', 'Assignments'),
364
+ description: t(
365
+ 'attachments.library.metadata.assignments.description',
366
+ 'Add the records this attachment belongs to with optional links.',
367
+ ),
368
+ type: t('attachments.library.metadata.assignments.type', 'Type'),
369
+ id: t('attachments.library.metadata.assignments.id', 'Record ID'),
370
+ href: t('attachments.library.metadata.assignments.href', 'Link'),
371
+ label: t('attachments.library.metadata.assignments.label', 'Label'),
372
+ add: t('attachments.library.metadata.assignments.add', 'Add assignment'),
373
+ remove: t('attachments.library.metadata.assignments.remove', 'Remove'),
374
+ }),
375
+ [t],
376
+ )
377
+
378
+ const metadataFields = React.useMemo<CrudField[]>(() => {
379
+ return [
380
+ {
381
+ id: 'tags',
382
+ label: t('attachments.library.table.tags', 'Tags'),
383
+ type: 'custom',
384
+ component: ({ value, setValue, disabled }) => (
385
+ <TagsInput
386
+ value={Array.isArray(value) ? (value as string[]) : []}
387
+ onChange={(next) => setValue(next)}
388
+ suggestions={availableTags}
389
+ placeholder={t('attachments.library.metadata.tagsPlaceholder', 'Add tags')}
390
+ disabled={Boolean(disabled) || loading}
391
+ />
392
+ ),
393
+ },
394
+ {
395
+ id: 'assignments',
396
+ label: '',
397
+ type: 'custom',
398
+ component: ({ value, setValue, disabled }) => (
399
+ <AttachmentAssignmentsEditor
400
+ value={Array.isArray(value) ? (value as AssignmentDraft[]) : []}
401
+ onChange={(next) => setValue(next)}
402
+ labels={assignmentLabels}
403
+ disabled={Boolean(disabled) || loading}
404
+ />
405
+ ),
406
+ },
407
+ ]
408
+ }, [assignmentLabels, availableTags, loading, t])
409
+
410
+ const metadataGroups = React.useMemo<CrudFormGroup[]>(() => {
411
+ return [
412
+ {
413
+ id: 'details',
414
+ title: t('attachments.library.metadata.details', 'Details'),
415
+ column: 1,
416
+ fields: ['tags', 'assignments'],
417
+ },
418
+ {
419
+ id: 'customFields',
420
+ title: t('entities.customFields.title', 'Custom attributes'),
421
+ column: 2,
422
+ kind: 'customFields',
423
+ },
424
+ ]
425
+ }, [t])
426
+
427
+ const metadataSchema = React.useMemo(
428
+ () =>
429
+ z
430
+ .object({
431
+ id: z.string().min(1),
432
+ tags: z.array(z.string()).optional(),
433
+ assignments: z
434
+ .array(
435
+ z.object({
436
+ type: z.string().min(1),
437
+ id: z.string().min(1),
438
+ href: z.string().optional(),
439
+ label: z.string().optional(),
440
+ }),
441
+ )
442
+ .optional(),
443
+ })
444
+ .passthrough(),
445
+ [],
446
+ )
447
+
448
+ const handleSubmit = React.useCallback(
449
+ async (values: AttachmentMetadataFormValues) => {
450
+ if (!item) return
451
+ const tags = Array.isArray(values.tags)
452
+ ? values.tags.map((tag) => (typeof tag === 'string' ? tag.trim() : '')).filter((tag) => tag.length > 0)
453
+ : []
454
+ const assignments = Array.isArray(values.assignments)
455
+ ? values.assignments
456
+ .map((assignment) => ({
457
+ type: assignment.type?.trim() ?? '',
458
+ id: assignment.id?.trim() ?? '',
459
+ href: assignment.href?.trim() || undefined,
460
+ label: assignment.label?.trim() || undefined,
461
+ }))
462
+ .filter((assignment) => assignment.type && assignment.id)
463
+ : []
464
+ const customFields = collectCustomFieldValues(values, {
465
+ transform: (value) => normalizeCustomFieldSubmitValue(value),
466
+ })
467
+ const payload: AttachmentMetadataSavePayload = {
468
+ tags,
469
+ assignments,
470
+ }
471
+ if (Object.keys(customFields).length) {
472
+ payload.customFields = customFields
473
+ }
474
+ await onSave(item.id, payload)
475
+ },
476
+ [item, onSave],
477
+ )
478
+
479
+ const handleKeyDown = React.useCallback(
480
+ (event: React.KeyboardEvent) => {
481
+ if (event.key === 'Escape') {
482
+ event.preventDefault()
483
+ onOpenChange(false)
484
+ }
485
+ },
486
+ [onOpenChange],
487
+ )
488
+
489
+ const handleCopyResizedUrl = React.useCallback(async () => {
490
+ if (!item) return
491
+ const width = sizeWidth ? Number(sizeWidth) : undefined
492
+ const height = sizeHeight ? Number(sizeHeight) : undefined
493
+ if (!width && !height) {
494
+ flash(
495
+ t('attachments.library.metadata.resizeTool.missing', 'Enter width or height to generate the URL.'),
496
+ 'error',
497
+ )
498
+ return
499
+ }
500
+ const url = buildAttachmentImageUrl(item.id, {
501
+ width: width && width > 0 ? width : undefined,
502
+ height: height && height > 0 ? height : undefined,
503
+ slug: slugifyAttachmentFileName(item.fileName),
504
+ })
505
+ const absolute = resolveAbsoluteUrl(url)
506
+ try {
507
+ await navigator.clipboard.writeText(absolute)
508
+ flash(
509
+ t('attachments.library.metadata.resizeTool.copied', 'Image URL copied.'),
510
+ 'success',
511
+ )
512
+ } catch {
513
+ flash(
514
+ t('attachments.library.metadata.resizeTool.copyError', 'Unable to copy URL.'),
515
+ 'error',
516
+ )
517
+ }
518
+ }, [item, sizeHeight, sizeWidth, t])
519
+
520
+ const loadMessage = t('attachments.library.metadata.loading', 'Loading attachment details…')
521
+
522
+ return (
523
+ <Dialog open={open} onOpenChange={onOpenChange}>
524
+ <DialogContent className="sm:max-w-2xl" onKeyDown={handleKeyDown}>
525
+ <DialogHeader>
526
+ <DialogTitle>{t('attachments.library.metadata.title', 'Edit attachment metadata')}</DialogTitle>
527
+ </DialogHeader>
528
+ {item ? (
529
+ <div className="space-y-4">
530
+ <div className="flex items-start justify-between gap-3">
531
+ <div className="space-y-1 min-w-0">
532
+ <div className="truncate text-sm font-medium" title={item.fileName}>
533
+ {item.fileName}
534
+ </div>
535
+ <div className="text-xs text-muted-foreground">
536
+ {formatFileSize(item.fileSize)} • {item.partitionTitle ?? item.partitionCode}
537
+ </div>
538
+ </div>
539
+ {downloadUrl ? (
540
+ <Button variant="outline" size="sm" asChild className="shrink-0">
541
+ <a href={downloadUrl} download>
542
+ <Download className="mr-2 h-4 w-4" />
543
+ {t('attachments.library.metadata.download', 'Download')}
544
+ </a>
545
+ </Button>
546
+ ) : null}
547
+ </div>
548
+ {isImage ? (
549
+ <div className="rounded border">
550
+ <div className="flex flex-wrap gap-4 border-b px-3 py-2 text-sm font-medium" role="tablist">
551
+ {(['preview', 'resize'] as const).map((tab) => (
552
+ <button
553
+ key={tab}
554
+ type="button"
555
+ role="tab"
556
+ aria-selected={imageTab === tab}
557
+ onClick={() => setImageTab(tab)}
558
+ className={cn(
559
+ 'border-b-2 px-0 py-1 transition-colors',
560
+ imageTab === tab
561
+ ? 'border-primary text-foreground'
562
+ : 'border-transparent text-muted-foreground hover:text-foreground',
563
+ )}
564
+ >
565
+ {tab === 'preview'
566
+ ? t('attachments.library.metadata.preview', 'Preview')
567
+ : t('attachments.library.metadata.resizeTool.title', 'Generate resized URL')}
568
+ </button>
569
+ ))}
570
+ </div>
571
+ <div className="space-y-3 p-3">
572
+ {imageTab === 'preview' ? (
573
+ previewUrl ? (
574
+ <img
575
+ src={previewUrl}
576
+ alt={item.fileName}
577
+ className="h-48 w-full rounded-md bg-muted object-contain"
578
+ />
579
+ ) : (
580
+ <div className="text-sm text-muted-foreground">
581
+ {t('attachments.library.metadata.previewUnavailable', 'Preview unavailable.')}
582
+ </div>
583
+ )
584
+ ) : (
585
+ <div className="space-y-2">
586
+ <div className="grid gap-2 sm:grid-cols-2">
587
+ <div className="space-y-1">
588
+ <label className="text-xs font-medium" htmlFor="resize-width">
589
+ {t('attachments.library.metadata.resizeTool.width', 'Width (px)')}
590
+ </label>
591
+ <Input
592
+ id="resize-width"
593
+ type="number"
594
+ min={0}
595
+ value={sizeWidth}
596
+ onChange={(event) => setSizeWidth(event.target.value)}
597
+ disabled={loading}
598
+ />
599
+ </div>
600
+ <div className="space-y-1">
601
+ <label className="text-xs font-medium" htmlFor="resize-height">
602
+ {t('attachments.library.metadata.resizeTool.height', 'Height (px)')}
603
+ </label>
604
+ <Input
605
+ id="resize-height"
606
+ type="number"
607
+ min={0}
608
+ value={sizeHeight}
609
+ onChange={(event) => setSizeHeight(event.target.value)}
610
+ disabled={loading}
611
+ />
612
+ </div>
613
+ </div>
614
+ <Button
615
+ type="button"
616
+ variant="outline"
617
+ size="sm"
618
+ className="inline-flex items-center gap-2"
619
+ onClick={() => void handleCopyResizedUrl()}
620
+ disabled={loading}
621
+ >
622
+ <Copy className="h-4 w-4" />
623
+ {t('attachments.library.metadata.resizeTool.copy', 'Copy URL')}
624
+ </Button>
625
+ </div>
626
+ )}
627
+ </div>
628
+ </div>
629
+ ) : null}
630
+ {loadError ? (
631
+ <div className="rounded border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
632
+ {loadError}
633
+ </div>
634
+ ) : null}
635
+ <div className="rounded border border-border/60 bg-muted/30 px-3 py-2">
636
+ <div className="text-xs font-semibold text-muted-foreground">
637
+ {t('attachments.library.metadata.extractedTitle', 'Extracted text')}
638
+ </div>
639
+ <AttachmentContentPreview
640
+ content={extractedContent}
641
+ emptyLabel={t('attachments.library.metadata.noContent', 'No text extracted')}
642
+ showMoreLabel={t('attachments.library.metadata.showMore', 'Show more')}
643
+ showLessLabel={t('attachments.library.metadata.showLess', 'Show less')}
644
+ />
645
+ </div>
646
+ <CrudForm<AttachmentMetadataFormValues>
647
+ embedded
648
+ schema={metadataSchema}
649
+ entityId={E.attachments.attachment}
650
+ fields={metadataFields}
651
+ groups={metadataGroups}
652
+ initialValues={initialValues ?? undefined}
653
+ isLoading={!initialValues || loading}
654
+ loadingMessage={loadMessage}
655
+ submitLabel={t('attachments.library.metadata.save', 'Save')}
656
+ extraActions={
657
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
658
+ {t('attachments.library.metadata.cancel', 'Cancel')}
659
+ </Button>
660
+ }
661
+ onSubmit={handleSubmit}
662
+ />
663
+ </div>
664
+ ) : (
665
+ <div className="py-8 text-center text-sm text-muted-foreground">
666
+ {t('attachments.library.metadata.noSelection', 'Select an attachment to edit.')}
667
+ </div>
668
+ )}
669
+ </DialogContent>
670
+ </Dialog>
671
+ )
672
+ }