@byline/admin 2.5.2 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (260) hide show
  1. package/dist/fields/array/array-field.d.ts +14 -0
  2. package/dist/fields/array/array-field.js +177 -0
  3. package/dist/fields/array/array-field.module.js +11 -0
  4. package/dist/fields/array/array-field_module.css +32 -0
  5. package/dist/fields/blocks/blocks-field.d.ts +13 -0
  6. package/dist/fields/blocks/blocks-field.js +245 -0
  7. package/dist/fields/blocks/blocks-field.module.js +26 -0
  8. package/dist/fields/blocks/blocks-field_module.css +107 -0
  9. package/dist/fields/checkbox/checkbox-field.d.ts +16 -0
  10. package/dist/fields/checkbox/checkbox-field.js +28 -0
  11. package/dist/fields/checkbox/checkbox-field.module.js +6 -0
  12. package/dist/fields/checkbox/checkbox-field_module.css +4 -0
  13. package/dist/fields/column-formatter.d.ts +20 -0
  14. package/dist/fields/column-formatter.js +15 -0
  15. package/dist/fields/date-time-formatter.d.ts +16 -0
  16. package/dist/fields/date-time-formatter.js +8 -0
  17. package/dist/fields/datetime/datetime-field.d.ts +16 -0
  18. package/dist/fields/datetime/datetime-field.js +37 -0
  19. package/dist/fields/datetime/datetime-field.module.js +5 -0
  20. package/dist/fields/datetime/datetime-field_module.css +4 -0
  21. package/dist/fields/draggable-context-menu.d.ts +6 -0
  22. package/dist/fields/draggable-context-menu.js +85 -0
  23. package/dist/fields/draggable-context-menu.module.js +15 -0
  24. package/dist/fields/draggable-context-menu_module.css +91 -0
  25. package/dist/fields/field-helpers.d.ts +26 -0
  26. package/dist/fields/field-helpers.js +50 -0
  27. package/dist/fields/field-renderer.d.ts +37 -0
  28. package/dist/fields/field-renderer.js +206 -0
  29. package/dist/fields/field-renderer.module.js +8 -0
  30. package/dist/fields/field-renderer_module.css +11 -0
  31. package/dist/fields/field-services-context.d.ts +16 -0
  32. package/dist/fields/field-services-context.js +13 -0
  33. package/dist/fields/field-services-types.d.ts +63 -0
  34. package/dist/fields/field-services-types.js +1 -0
  35. package/dist/fields/file/file-field.d.ts +19 -0
  36. package/dist/fields/file/file-field.js +225 -0
  37. package/dist/fields/file/file-field.module.js +18 -0
  38. package/dist/fields/file/file-field_module.css +131 -0
  39. package/dist/fields/file/file-upload-field.d.ts +21 -0
  40. package/dist/fields/file/file-upload-field.js +130 -0
  41. package/dist/fields/file/file-upload-field.module.js +15 -0
  42. package/dist/fields/file/file-upload-field_module.css +74 -0
  43. package/dist/fields/group/group-field.d.ts +15 -0
  44. package/dist/fields/group/group-field.js +59 -0
  45. package/dist/fields/group/group-field.module.js +9 -0
  46. package/dist/fields/group/group-field_module.css +27 -0
  47. package/dist/fields/image/image-field.d.ts +19 -0
  48. package/dist/fields/image/image-field.js +241 -0
  49. package/dist/fields/image/image-field.module.js +22 -0
  50. package/dist/fields/image/image-field_module.css +121 -0
  51. package/dist/fields/image/image-upload-field.d.ts +21 -0
  52. package/dist/fields/image/image-upload-field.js +190 -0
  53. package/dist/fields/image/image-upload-field.module.js +19 -0
  54. package/dist/fields/image/image-upload-field_module.css +92 -0
  55. package/dist/fields/local-date-time.d.ts +27 -0
  56. package/dist/fields/local-date-time.js +49 -0
  57. package/dist/fields/locale-badge.d.ts +18 -0
  58. package/dist/fields/locale-badge.js +10 -0
  59. package/dist/fields/locale-badge.module.js +5 -0
  60. package/dist/fields/locale-badge_module.css +27 -0
  61. package/dist/fields/numerical/numerical-field.d.ts +18 -0
  62. package/dist/fields/numerical/numerical-field.js +74 -0
  63. package/dist/fields/relation/relation-display.d.ts +40 -0
  64. package/dist/fields/relation/relation-display.js +58 -0
  65. package/dist/fields/relation/relation-display.module.js +9 -0
  66. package/dist/fields/relation/relation-display_module.css +21 -0
  67. package/dist/fields/relation/relation-field.d.ts +18 -0
  68. package/dist/fields/relation/relation-field.js +138 -0
  69. package/dist/fields/relation/relation-field.module.js +13 -0
  70. package/dist/fields/relation/relation-field_module.css +62 -0
  71. package/dist/fields/relation/relation-picker.d.ts +49 -0
  72. package/dist/fields/relation/relation-picker.js +236 -0
  73. package/dist/fields/relation/relation-picker.module.js +26 -0
  74. package/dist/fields/relation/relation-picker_module.css +124 -0
  75. package/dist/fields/relation/relation-summary.d.ts +31 -0
  76. package/dist/fields/relation/relation-summary.js +50 -0
  77. package/dist/fields/relation/relation-summary.module.js +11 -0
  78. package/dist/fields/relation/relation-summary_module.css +37 -0
  79. package/dist/fields/select/select-field.d.ts +16 -0
  80. package/dist/fields/select/select-field.js +50 -0
  81. package/dist/fields/select/select-field.module.js +5 -0
  82. package/dist/fields/select/select-field_module.css +4 -0
  83. package/dist/fields/sortable-item.d.ts +15 -0
  84. package/dist/fields/sortable-item.js +81 -0
  85. package/dist/fields/sortable-item.module.js +22 -0
  86. package/dist/fields/sortable-item_module.css +124 -0
  87. package/dist/fields/text/text-field.d.ts +20 -0
  88. package/dist/fields/text/text-field.js +104 -0
  89. package/dist/fields/text/text-field.module.js +6 -0
  90. package/dist/fields/text/text-field_module.css +5 -0
  91. package/dist/fields/text-area/text-area-field.d.ts +20 -0
  92. package/dist/fields/text-area/text-area-field.js +105 -0
  93. package/dist/fields/text-area/text-area-field.module.js +6 -0
  94. package/dist/fields/text-area/text-area-field_module.css +5 -0
  95. package/dist/fields/use-field-change-handler.d.ts +23 -0
  96. package/dist/fields/use-field-change-handler.js +52 -0
  97. package/dist/forms/document-actions.d.ts +48 -0
  98. package/dist/forms/document-actions.js +475 -0
  99. package/dist/forms/document-actions.module.js +34 -0
  100. package/dist/forms/document-actions_module.css +118 -0
  101. package/dist/forms/form-context.d.ts +89 -0
  102. package/dist/forms/form-context.js +466 -0
  103. package/dist/forms/form-renderer.d.ts +98 -0
  104. package/dist/forms/form-renderer.js +597 -0
  105. package/dist/forms/form-renderer.module.js +46 -0
  106. package/dist/forms/form-renderer_module.css +245 -0
  107. package/dist/forms/navigation-guard.d.ts +54 -0
  108. package/dist/forms/navigation-guard.js +22 -0
  109. package/dist/forms/path-widget.d.ts +36 -0
  110. package/dist/forms/path-widget.js +116 -0
  111. package/dist/forms/path-widget.module.js +8 -0
  112. package/dist/forms/path-widget_module.css +29 -0
  113. package/dist/forms/upload-executor.d.ts +57 -0
  114. package/dist/forms/upload-executor.js +94 -0
  115. package/dist/lib/translate-validation-error.d.ts +36 -0
  116. package/dist/lib/translate-validation-error.js +11 -0
  117. package/dist/modules/admin-account/commands.d.ts +2 -1
  118. package/dist/modules/admin-account/commands.js +13 -2
  119. package/dist/modules/admin-account/components/change-password.js +45 -36
  120. package/dist/modules/admin-account/components/container.js +185 -134
  121. package/dist/modules/admin-account/components/preferences.d.ts +8 -0
  122. package/dist/modules/admin-account/components/preferences.js +152 -0
  123. package/dist/modules/admin-account/components/preferences.module.js +11 -0
  124. package/dist/modules/admin-account/components/preferences_module.css +41 -0
  125. package/dist/modules/admin-account/components/update.js +50 -31
  126. package/dist/modules/admin-account/index.d.ts +3 -3
  127. package/dist/modules/admin-account/index.js +2 -2
  128. package/dist/modules/admin-account/schemas.d.ts +4 -0
  129. package/dist/modules/admin-account/schemas.js +4 -1
  130. package/dist/modules/admin-account/service.d.ts +1 -0
  131. package/dist/modules/admin-account/service.js +8 -0
  132. package/dist/modules/admin-permissions/components/inspector.js +31 -41
  133. package/dist/modules/admin-roles/components/create.js +43 -26
  134. package/dist/modules/admin-roles/components/permissions.js +26 -35
  135. package/dist/modules/admin-roles/components/update.js +26 -16
  136. package/dist/modules/admin-users/components/create.js +60 -40
  137. package/dist/modules/admin-users/components/roles.js +9 -15
  138. package/dist/modules/admin-users/components/set-password.js +30 -31
  139. package/dist/modules/admin-users/components/update.js +58 -39
  140. package/dist/modules/admin-users/dto.js +1 -0
  141. package/dist/modules/admin-users/repository.d.ts +17 -0
  142. package/dist/modules/admin-users/schemas.d.ts +4 -0
  143. package/dist/modules/admin-users/schemas.js +6 -2
  144. package/dist/modules/auth/components/sign-in-form.js +10 -8
  145. package/dist/presentation/group.d.ts +27 -0
  146. package/dist/presentation/group.js +14 -0
  147. package/dist/presentation/group.module.js +6 -0
  148. package/dist/presentation/group_module.css +19 -0
  149. package/dist/presentation/row.d.ts +25 -0
  150. package/dist/presentation/row.js +8 -0
  151. package/dist/presentation/row.module.js +5 -0
  152. package/dist/presentation/row_module.css +18 -0
  153. package/dist/presentation/tabs.d.ts +25 -0
  154. package/dist/presentation/tabs.js +39 -0
  155. package/dist/presentation/tabs.module.js +10 -0
  156. package/dist/presentation/tabs_module.css +68 -0
  157. package/dist/react.d.ts +66 -0
  158. package/dist/react.js +36 -0
  159. package/dist/services/admin-services-types.d.ts +16 -0
  160. package/dist/widgets/diff-viewer/diff-modal.d.ts +22 -0
  161. package/dist/widgets/diff-viewer/diff-modal.js +149 -0
  162. package/dist/widgets/diff-viewer/diff-modal.module.js +14 -0
  163. package/dist/widgets/diff-viewer/diff-modal_module.css +56 -0
  164. package/dist/widgets/status-badge/status-badge.d.ts +25 -0
  165. package/dist/widgets/status-badge/status-badge.js +37 -0
  166. package/dist/widgets/status-badge/status-badge.module.js +7 -0
  167. package/dist/widgets/status-badge/status-badge_module.css +20 -0
  168. package/package.json +14 -4
  169. package/src/fields/array/array-field.module.css +48 -0
  170. package/src/fields/array/array-field.tsx +267 -0
  171. package/src/fields/blocks/blocks-field.module.css +148 -0
  172. package/src/fields/blocks/blocks-field.tsx +323 -0
  173. package/src/fields/checkbox/checkbox-field.module.css +4 -0
  174. package/src/fields/checkbox/checkbox-field.tsx +54 -0
  175. package/src/fields/column-formatter.tsx +31 -0
  176. package/src/fields/date-time-formatter.tsx +22 -0
  177. package/src/fields/datetime/datetime-field.module.css +13 -0
  178. package/src/fields/datetime/datetime-field.tsx +54 -0
  179. package/src/fields/draggable-context-menu.module.css +127 -0
  180. package/src/fields/draggable-context-menu.tsx +87 -0
  181. package/src/fields/field-helpers.ts +69 -0
  182. package/src/fields/field-renderer.module.css +22 -0
  183. package/src/fields/field-renderer.tsx +288 -0
  184. package/src/fields/field-services-context.tsx +35 -0
  185. package/src/fields/field-services-types.ts +68 -0
  186. package/src/fields/file/file-field.module.css +153 -0
  187. package/src/fields/file/file-field.tsx +286 -0
  188. package/src/fields/file/file-upload-field.module.css +101 -0
  189. package/src/fields/file/file-upload-field.tsx +187 -0
  190. package/src/fields/group/group-field.module.css +43 -0
  191. package/src/fields/group/group-field.tsx +84 -0
  192. package/src/fields/image/image-field.module.css +155 -0
  193. package/src/fields/image/image-field.tsx +306 -0
  194. package/src/fields/image/image-upload-field.module.css +123 -0
  195. package/src/fields/image/image-upload-field.tsx +276 -0
  196. package/src/fields/local-date-time.tsx +88 -0
  197. package/src/fields/locale-badge.module.css +37 -0
  198. package/src/fields/locale-badge.tsx +32 -0
  199. package/src/fields/numerical/numerical-field.tsx +114 -0
  200. package/src/fields/relation/relation-display.module.css +36 -0
  201. package/src/fields/relation/relation-display.tsx +130 -0
  202. package/src/fields/relation/relation-field.module.css +83 -0
  203. package/src/fields/relation/relation-field.tsx +211 -0
  204. package/src/fields/relation/relation-picker.module.css +168 -0
  205. package/src/fields/relation/relation-picker.tsx +326 -0
  206. package/src/fields/relation/relation-summary.module.css +55 -0
  207. package/src/fields/relation/relation-summary.tsx +123 -0
  208. package/src/fields/select/select-field.module.css +13 -0
  209. package/src/fields/select/select-field.tsx +61 -0
  210. package/src/fields/sortable-item.module.css +167 -0
  211. package/src/fields/sortable-item.tsx +106 -0
  212. package/src/fields/text/text-field.module.css +13 -0
  213. package/src/fields/text/text-field.tsx +146 -0
  214. package/src/fields/text-area/text-area-field.module.css +13 -0
  215. package/src/fields/text-area/text-area-field.tsx +147 -0
  216. package/src/fields/use-field-change-handler.ts +112 -0
  217. package/src/forms/document-actions.module.css +160 -0
  218. package/src/forms/document-actions.tsx +482 -0
  219. package/src/forms/form-context.tsx +704 -0
  220. package/src/forms/form-renderer.module.css +321 -0
  221. package/src/forms/form-renderer.tsx +891 -0
  222. package/src/forms/navigation-guard.tsx +98 -0
  223. package/src/forms/path-widget.module.css +41 -0
  224. package/src/forms/path-widget.test.tsx +217 -0
  225. package/src/forms/path-widget.tsx +183 -0
  226. package/src/forms/upload-executor.ts +192 -0
  227. package/src/lib/translate-validation-error.ts +56 -0
  228. package/src/modules/admin-account/commands.ts +13 -0
  229. package/src/modules/admin-account/components/change-password.tsx +46 -31
  230. package/src/modules/admin-account/components/container.tsx +83 -38
  231. package/src/modules/admin-account/components/preferences.module.css +60 -0
  232. package/src/modules/admin-account/components/preferences.tsx +203 -0
  233. package/src/modules/admin-account/components/update.tsx +53 -27
  234. package/src/modules/admin-account/index.ts +3 -0
  235. package/src/modules/admin-account/schemas.ts +13 -0
  236. package/src/modules/admin-account/service.ts +12 -0
  237. package/src/modules/admin-permissions/components/inspector.tsx +22 -14
  238. package/src/modules/admin-roles/components/create.tsx +51 -23
  239. package/src/modules/admin-roles/components/permissions.tsx +25 -21
  240. package/src/modules/admin-roles/components/update.tsx +37 -19
  241. package/src/modules/admin-users/components/create.tsx +63 -34
  242. package/src/modules/admin-users/components/roles.tsx +9 -8
  243. package/src/modules/admin-users/components/set-password.tsx +34 -28
  244. package/src/modules/admin-users/components/update.tsx +58 -36
  245. package/src/modules/admin-users/dto.ts +1 -0
  246. package/src/modules/admin-users/repository.ts +17 -0
  247. package/src/modules/admin-users/schemas.ts +12 -0
  248. package/src/modules/auth/components/sign-in-form.tsx +14 -8
  249. package/src/presentation/group.module.css +41 -0
  250. package/src/presentation/group.tsx +40 -0
  251. package/src/presentation/row.module.css +32 -0
  252. package/src/presentation/row.tsx +33 -0
  253. package/src/presentation/tabs.module.css +107 -0
  254. package/src/presentation/tabs.tsx +84 -0
  255. package/src/react.ts +84 -0
  256. package/src/services/admin-services-types.ts +18 -0
  257. package/src/widgets/diff-viewer/diff-modal.module.css +79 -0
  258. package/src/widgets/diff-viewer/diff-modal.tsx +186 -0
  259. package/src/widgets/status-badge/status-badge.module.css +31 -0
  260. package/src/widgets/status-badge/status-badge.tsx +71 -0
@@ -0,0 +1,306 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { useState } from 'react'
10
+
11
+ import {
12
+ type ImageField as FieldType,
13
+ isPendingStoredFileValue,
14
+ type StoredFileValue,
15
+ } from '@byline/core'
16
+ import { useTranslation } from '@byline/i18n/react'
17
+ import {
18
+ CloseIcon,
19
+ ErrorText,
20
+ HelpText,
21
+ IconButton,
22
+ ImageLightbox,
23
+ Label,
24
+ LoaderRing,
25
+ } from '@byline/ui/react'
26
+ import cx from 'classnames'
27
+
28
+ import {
29
+ useFieldError,
30
+ useFieldValue,
31
+ useFormContext,
32
+ useIsDirty,
33
+ useIsFieldUploading,
34
+ } from '../../forms/form-context'
35
+ import { useFieldChangeHandler } from '../use-field-change-handler'
36
+ import styles from './image-field.module.css'
37
+ import { ImageUploadField } from './image-upload-field'
38
+
39
+ interface ImageFieldProps {
40
+ field: FieldType
41
+ /** Collection path required to call the /upload endpoint. */
42
+ collectionPath?: string
43
+ // Stored value is currently a plain object with file/image metadata
44
+ // coming from the seed data / storage layer.
45
+ value?: StoredFileValue | null
46
+ defaultValue?: StoredFileValue | null
47
+ onChange?: (value: StoredFileValue | null) => void
48
+ path?: string
49
+ }
50
+
51
+ export const ImageField = ({
52
+ field,
53
+ collectionPath,
54
+ value,
55
+ defaultValue,
56
+ onChange: _onChange,
57
+ path,
58
+ }: ImageFieldProps) => {
59
+ const fieldPath = path ?? field.name
60
+ const fieldError = useFieldError(fieldPath)
61
+ const isDirty = useIsDirty(fieldPath)
62
+ const fieldValue = useFieldValue<StoredFileValue | null | undefined>(fieldPath)
63
+ const isUploading = useIsFieldUploading(fieldPath)
64
+ const { removePendingUpload } = useFormContext()
65
+ const { t } = useTranslation('byline-admin')
66
+
67
+ // Re-use the standard field change handler so patches are emitted correctly.
68
+ const handleChange = useFieldChangeHandler(field, fieldPath)
69
+
70
+ // When the field has been explicitly set (dirty), use the field value from
71
+ // form state — even if it's null (user clicked Remove). Only fall back to
72
+ // the prop / defaultValue when the field hasn't been touched yet.
73
+ const incomingValue = isDirty
74
+ ? (fieldValue ?? null)
75
+ : (value ?? fieldValue ?? defaultValue ?? null)
76
+
77
+ // Check if this is a pending upload (selected but not yet uploaded)
78
+ const isPending = isPendingStoredFileValue(incomingValue)
79
+
80
+ // Old placeholder check for backwards compatibility
81
+ const isOldPlaceholder = (v: unknown): boolean => {
82
+ if (!v || typeof v !== 'object') return false
83
+ const maybe = v as Partial<StoredFileValue>
84
+ return maybe.storageProvider === 'placeholder' && maybe.storagePath === 'pending'
85
+ }
86
+
87
+ // Show upload widget only if no value or old placeholder
88
+ const showUploadWidget = incomingValue == null || isOldPlaceholder(incomingValue)
89
+
90
+ // Prefer the generated thumbnail variant for the preview tile. SVGs and
91
+ // other bypass types have no variants — fall back to the original.
92
+ const thumbVariant =
93
+ incomingValue && !isPendingStoredFileValue(incomingValue)
94
+ ? incomingValue.variants?.find((v) => v.name === 'thumbnail')
95
+ : undefined
96
+ const previewUrl = thumbVariant?.storageUrl ?? incomingValue?.storageUrl
97
+
98
+ // Handle remove, including cleanup of pending uploads
99
+ const handleRemove = () => {
100
+ if (isPending) {
101
+ removePendingUpload(fieldPath)
102
+ }
103
+ handleChange(null)
104
+ }
105
+
106
+ // Lightbox state — only enabled for stored (non-pending) images that have a
107
+ // resolvable original storageUrl.
108
+ const [lightboxOpen, setLightboxOpen] = useState(false)
109
+ const canOpenLightbox = !isPending && !!incomingValue?.storageUrl
110
+
111
+ const htmlId = fieldPath
112
+
113
+ return (
114
+ <div className={`byline-field-image ${field.name}`}>
115
+ <div className={cx('byline-field-image-header', styles.header)}>
116
+ <Label
117
+ id={htmlId}
118
+ htmlFor={htmlId}
119
+ label={field.label ?? field.name}
120
+ required={!field.optional}
121
+ />
122
+ </div>
123
+
124
+ {showUploadWidget ? (
125
+ collectionPath ? (
126
+ <ImageUploadField
127
+ field={field}
128
+ collectionPath={collectionPath}
129
+ fieldPath={fieldPath}
130
+ onUploaded={(uploaded) => {
131
+ handleChange(uploaded)
132
+ }}
133
+ />
134
+ ) : (
135
+ <div className={cx('byline-field-image-empty', styles.empty)}>
136
+ {t('fields.image.empty')}
137
+ </div>
138
+ )
139
+ ) : (
140
+ <div className={cx('byline-field-image-tile', styles.tile)}>
141
+ {isUploading && (
142
+ <div
143
+ className={cx('byline-field-image-uploading', styles.uploading)}
144
+ aria-live="polite"
145
+ aria-busy="true"
146
+ >
147
+ <LoaderRing />
148
+ </div>
149
+ )}
150
+ {/* Remove button — shown when an image is set (including pending) */}
151
+ {collectionPath && (
152
+ <div className={cx('byline-field-image-remove', styles.remove)}>
153
+ <IconButton
154
+ type="button"
155
+ intent="noeffect"
156
+ onClick={handleRemove}
157
+ size="xs"
158
+ disabled={isUploading}
159
+ aria-label={t('fields.image.removeAriaLabel')}
160
+ >
161
+ <CloseIcon width="15px" height="15px" />
162
+ </IconButton>
163
+ </div>
164
+ )}
165
+ {/* Preview */}
166
+ {previewUrl && (
167
+ <div className={cx('byline-field-image-preview-wrap', styles['preview-wrap'])}>
168
+ {canOpenLightbox ? (
169
+ <button
170
+ type="button"
171
+ onClick={() => setLightboxOpen(true)}
172
+ aria-label={t('fields.image.openLightboxAriaLabel')}
173
+ className={cx('byline-field-image-preview-button', styles['preview-button'])}
174
+ >
175
+ <img
176
+ src={previewUrl}
177
+ alt={incomingValue.originalFilename ?? incomingValue.filename}
178
+ className={cx(
179
+ 'byline-field-image-preview',
180
+ styles.preview,
181
+ incomingValue.mimeType === 'image/svg+xml' && [
182
+ 'byline-field-image-preview-svg',
183
+ styles['preview-svg'],
184
+ ]
185
+ )}
186
+ />
187
+ </button>
188
+ ) : (
189
+ <img
190
+ src={previewUrl}
191
+ alt={incomingValue.originalFilename ?? incomingValue.filename}
192
+ className={cx(
193
+ 'byline-field-image-preview',
194
+ styles.preview,
195
+ incomingValue.mimeType === 'image/svg+xml' && [
196
+ 'byline-field-image-preview-svg',
197
+ styles['preview-svg'],
198
+ ]
199
+ )}
200
+ />
201
+ )}
202
+ {/* Pending upload badge */}
203
+ {isPending && (
204
+ <div className={cx('byline-field-image-pending', styles.pending)}>
205
+ {t('fields.fileMeta.pendingUpload')}
206
+ </div>
207
+ )}
208
+ </div>
209
+ )}
210
+ {/* Metadata */}
211
+ <div className={cx('byline-field-image-meta', styles.meta)}>
212
+ <div>
213
+ <span className={cx('byline-field-image-meta-key', styles['meta-key'])}>
214
+ {t('fields.fileMeta.filename')}
215
+ </span>{' '}
216
+ {incomingValue?.filename}
217
+ </div>
218
+ <div>
219
+ <span className={cx('byline-field-image-meta-key', styles['meta-key'])}>
220
+ {t('fields.fileMeta.original')}
221
+ </span>{' '}
222
+ {incomingValue?.originalFilename}
223
+ </div>
224
+ <div>
225
+ <span className={cx('byline-field-image-meta-key', styles['meta-key'])}>
226
+ {t('fields.fileMeta.type')}
227
+ </span>{' '}
228
+ {incomingValue?.mimeType}
229
+ </div>
230
+ <div>
231
+ <span className={cx('byline-field-image-meta-key', styles['meta-key'])}>
232
+ {t('fields.fileMeta.size')}
233
+ </span>{' '}
234
+ {incomingValue?.fileSize}
235
+ </div>
236
+ {isPending ? (
237
+ <div>
238
+ <span className={cx('byline-field-image-meta-key', styles['meta-key'])}>
239
+ {t('fields.fileMeta.status')}
240
+ </span>{' '}
241
+ <span className={cx('byline-field-image-meta-pending', styles['meta-pending'])}>
242
+ {t('fields.fileMeta.willUploadOnSave')}
243
+ </span>
244
+ </div>
245
+ ) : (
246
+ <>
247
+ <div>
248
+ <span className={cx('byline-field-image-meta-key', styles['meta-key'])}>
249
+ {t('fields.fileMeta.storage')}
250
+ </span>{' '}
251
+ {incomingValue?.storageProvider}
252
+ </div>
253
+ {incomingValue?.imageWidth != null && (
254
+ <div>
255
+ <span className={cx('byline-field-image-meta-key', styles['meta-key'])}>
256
+ {t('fields.imageMeta.dimensions')}
257
+ </span>{' '}
258
+ {incomingValue.imageWidth}
259
+ {incomingValue.imageHeight != null ? `×${incomingValue.imageHeight}` : ''}
260
+ </div>
261
+ )}
262
+ {incomingValue?.imageFormat != null && (
263
+ <div>
264
+ <span className={cx('byline-field-image-meta-key', styles['meta-key'])}>
265
+ {t('fields.imageMeta.format')}
266
+ </span>{' '}
267
+ {incomingValue.imageFormat}
268
+ </div>
269
+ )}
270
+ <div>
271
+ <span className={cx('byline-field-image-meta-key', styles['meta-key'])}>
272
+ {t('fields.imageMeta.thumbnail')}
273
+ </span>{' '}
274
+ {thumbVariant
275
+ ? t('fields.imageMeta.thumbnailGenerated')
276
+ : t('fields.imageMeta.thumbnailPending')}
277
+ </div>
278
+ </>
279
+ )}
280
+ </div>
281
+ </div>
282
+ )}
283
+
284
+ {field.helpText && <HelpText text={field.helpText} />}
285
+
286
+ {fieldError && <ErrorText id={`${field.name}-error`} text={fieldError} />}
287
+
288
+ {canOpenLightbox && incomingValue?.storageUrl && (
289
+ <ImageLightbox
290
+ isOpen={lightboxOpen}
291
+ onDismiss={() => setLightboxOpen(false)}
292
+ src={incomingValue.storageUrl}
293
+ alt={incomingValue.originalFilename ?? incomingValue.filename}
294
+ downloadFilename={incomingValue.originalFilename ?? incomingValue.filename}
295
+ title={incomingValue.originalFilename ?? incomingValue.filename}
296
+ meta={{
297
+ width: incomingValue.imageWidth,
298
+ height: incomingValue.imageHeight,
299
+ fileSize: incomingValue.fileSize,
300
+ mimeType: incomingValue.mimeType,
301
+ }}
302
+ />
303
+ )}
304
+ </div>
305
+ )
306
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * ImageUploadField — drag-and-drop image picker that registers a deferred
3
+ * upload in form context.
4
+ *
5
+ * Override handles:
6
+ * .byline-field-image-upload — root wrapper
7
+ * .byline-field-image-upload-input — visually-hidden file input
8
+ * .byline-field-image-upload-zone — clickable / drop target
9
+ * .byline-field-image-upload-zone-active — drag-hovered state
10
+ * .byline-field-image-upload-zone-busy — processing state
11
+ * .byline-field-image-upload-spinner — animated spinner svg
12
+ * .byline-field-image-upload-icon — upload icon svg
13
+ * .byline-field-image-upload-label — primary text inside the zone
14
+ * .byline-field-image-upload-action — "browse" link inside the label
15
+ * .byline-field-image-upload-hint — secondary "JPEG, PNG …" hint
16
+ * .byline-field-image-upload-error — error message paragraph
17
+ */
18
+
19
+ .root,
20
+ :global(.byline-field-image-upload) {
21
+ margin-top: 0.25rem;
22
+ }
23
+
24
+ .input,
25
+ :global(.byline-field-image-upload-input) {
26
+ position: absolute;
27
+ width: 1px;
28
+ height: 1px;
29
+ padding: 0;
30
+ margin: -1px;
31
+ overflow: hidden;
32
+ clip: rect(0, 0, 0, 0);
33
+ white-space: nowrap;
34
+ border: 0;
35
+ }
36
+
37
+ .zone,
38
+ :global(.byline-field-image-upload-zone) {
39
+ display: flex;
40
+ flex-direction: column;
41
+ align-items: center;
42
+ justify-content: center;
43
+ gap: var(--spacing-8);
44
+ padding: 1.5rem 1rem;
45
+ border: 2px dashed var(--gray-600);
46
+ border-radius: var(--border-radius-lg);
47
+ color: var(--gray-400);
48
+ text-align: center;
49
+ cursor: pointer;
50
+ user-select: none;
51
+ transition:
52
+ color 150ms ease,
53
+ background-color 150ms ease,
54
+ border-color 150ms ease;
55
+ }
56
+
57
+ .zone:hover,
58
+ :global(.byline-field-image-upload-zone):hover {
59
+ border-color: var(--primary-500);
60
+ background-color: oklch(from var(--primary-900) l c h / 0.1);
61
+ }
62
+
63
+ .zone-active,
64
+ :global(.byline-field-image-upload-zone-active) {
65
+ border-color: var(--primary-400);
66
+ background-color: oklch(from var(--primary-900) l c h / 0.2);
67
+ color: var(--primary-300);
68
+ }
69
+
70
+ .zone-busy,
71
+ :global(.byline-field-image-upload-zone-busy) {
72
+ border-color: var(--gray-700);
73
+ background-color: oklch(from var(--canvas-800) l c h / 0.5);
74
+ color: var(--gray-600);
75
+ cursor: not-allowed;
76
+ }
77
+
78
+ .spinner,
79
+ :global(.byline-field-image-upload-spinner) {
80
+ width: 1.5rem;
81
+ height: 1.5rem;
82
+ color: var(--primary-400);
83
+ animation: byline-image-upload-spin 1s linear infinite;
84
+ }
85
+
86
+ @keyframes byline-image-upload-spin {
87
+ to {
88
+ transform: rotate(360deg);
89
+ }
90
+ }
91
+
92
+ .icon,
93
+ :global(.byline-field-image-upload-icon) {
94
+ width: 1.75rem;
95
+ height: 1.75rem;
96
+ opacity: 0.6;
97
+ }
98
+
99
+ .label,
100
+ :global(.byline-field-image-upload-label) {
101
+ font-size: var(--font-size-xs);
102
+ font-weight: var(--font-weight-medium);
103
+ }
104
+
105
+ .action,
106
+ :global(.byline-field-image-upload-action) {
107
+ color: var(--primary-400);
108
+ text-decoration: underline;
109
+ text-underline-offset: 2px;
110
+ }
111
+
112
+ .hint,
113
+ :global(.byline-field-image-upload-hint) {
114
+ color: var(--gray-500);
115
+ font-size: 0.65rem;
116
+ }
117
+
118
+ .error,
119
+ :global(.byline-field-image-upload-error) {
120
+ margin-top: 0.375rem;
121
+ color: var(--red-400);
122
+ font-size: var(--font-size-xs);
123
+ }
@@ -0,0 +1,276 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ /**
10
+ * ImageUploadField
11
+ *
12
+ * A drag-and-drop / click-to-browse file picker that prepares an image for
13
+ * upload. The actual upload is deferred until form submission — this component
14
+ * stores the file in the form context's pending uploads and emits a placeholder
15
+ * StoredFileValue with a blob URL for immediate preview.
16
+ *
17
+ * Prototype: no chunk upload, no resumable uploads, single file only.
18
+ */
19
+
20
+ import type { ChangeEvent, DragEvent } from 'react'
21
+ import { useCallback, useRef, useState } from 'react'
22
+
23
+ import {
24
+ createPendingStoredFileValue,
25
+ type ImageField as FieldType,
26
+ type PendingStoredFileValue,
27
+ type StoredFileValue,
28
+ } from '@byline/core'
29
+ import { useTranslation } from '@byline/i18n/react'
30
+ import cx from 'classnames'
31
+
32
+ import { useFormContext } from '../../forms/form-context'
33
+ import styles from './image-upload-field.module.css'
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Types
37
+ // ---------------------------------------------------------------------------
38
+
39
+ interface ImageUploadFieldProps {
40
+ field: FieldType
41
+ /** Collection path used to build the upload URL (e.g. `'media'`). */
42
+ collectionPath: string
43
+ /** Field path in the form (e.g. `'image'` or `'content.0.image'`). */
44
+ fieldPath: string
45
+ /** Called with the PendingStoredFileValue for immediate preview. */
46
+ onUploaded: (value: StoredFileValue | PendingStoredFileValue) => void
47
+ /** Optional accepted-file MIME types string for the native file input. */
48
+ accept?: string
49
+ }
50
+
51
+ type SelectionStatus = 'idle' | 'processing' | 'error'
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Component
55
+ // ---------------------------------------------------------------------------
56
+
57
+ export const ImageUploadField = ({
58
+ field: _field,
59
+ collectionPath,
60
+ fieldPath,
61
+ onUploaded,
62
+ accept = 'image/*',
63
+ }: ImageUploadFieldProps) => {
64
+ const inputRef = useRef<HTMLInputElement>(null)
65
+ const [status, setStatus] = useState<SelectionStatus>('idle')
66
+ const [errorMessage, setErrorMessage] = useState<string | null>(null)
67
+ const [isDragOver, setIsDragOver] = useState(false)
68
+ const { addPendingUpload } = useFormContext()
69
+ const { t } = useTranslation('byline-admin')
70
+
71
+ // -------------------------------------------------------------------------
72
+ // Core file selection logic (deferred upload)
73
+ // -------------------------------------------------------------------------
74
+
75
+ const handleFileSelected = useCallback(
76
+ (file: File) => {
77
+ setStatus('processing')
78
+ setErrorMessage(null)
79
+
80
+ // Basic client-side validation
81
+ if (!file.type.startsWith('image/')) {
82
+ setStatus('error')
83
+ setErrorMessage(t('fields.image.upload.errors.notAnImage'))
84
+ return
85
+ }
86
+
87
+ // Create a blob URL for immediate preview
88
+ const previewUrl = URL.createObjectURL(file)
89
+
90
+ // Extract image dimensions for the pending value
91
+ const img = new Image()
92
+ img.onload = () => {
93
+ // SVGs without explicit width/height attrs (viewBox-only) report naturalWidth/Height = 0.
94
+ // Skip dimensions when zero so they are stored as null (scalable, no fixed size).
95
+ const w = img.naturalWidth
96
+ const h = img.naturalHeight
97
+ const dimensions = w > 0 && h > 0 ? { width: w, height: h } : undefined
98
+
99
+ // Create the pending stored file value
100
+ const pendingValue = createPendingStoredFileValue(file, previewUrl, dimensions)
101
+
102
+ // Register the pending upload in form context
103
+ addPendingUpload(fieldPath, {
104
+ file,
105
+ previewUrl,
106
+ collectionPath,
107
+ })
108
+
109
+ setStatus('idle')
110
+ onUploaded(pendingValue)
111
+ }
112
+
113
+ img.onerror = () => {
114
+ URL.revokeObjectURL(previewUrl)
115
+ setStatus('error')
116
+ setErrorMessage(t('fields.image.upload.errors.cannotRead'))
117
+ }
118
+
119
+ img.src = previewUrl
120
+ },
121
+ [collectionPath, fieldPath, addPendingUpload, onUploaded, t]
122
+ )
123
+
124
+ // -------------------------------------------------------------------------
125
+ // File input
126
+ // -------------------------------------------------------------------------
127
+
128
+ const handleFileChange = useCallback(
129
+ (e: ChangeEvent<HTMLInputElement>) => {
130
+ const file = e.target.files?.[0]
131
+ if (file) handleFileSelected(file)
132
+ // Reset so re-selecting the same file fires the event again.
133
+ e.target.value = ''
134
+ },
135
+ [handleFileSelected]
136
+ )
137
+
138
+ const handleBrowseClick = useCallback(() => {
139
+ inputRef.current?.click()
140
+ }, [])
141
+
142
+ // -------------------------------------------------------------------------
143
+ // Drag and drop
144
+ // -------------------------------------------------------------------------
145
+
146
+ const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
147
+ e.preventDefault()
148
+ setIsDragOver(true)
149
+ }, [])
150
+
151
+ const handleDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
152
+ e.preventDefault()
153
+ setIsDragOver(false)
154
+ }, [])
155
+
156
+ const handleDrop = useCallback(
157
+ (e: DragEvent<HTMLDivElement>) => {
158
+ e.preventDefault()
159
+ setIsDragOver(false)
160
+ const file = e.dataTransfer.files?.[0]
161
+ if (file) handleFileSelected(file)
162
+ },
163
+ [handleFileSelected]
164
+ )
165
+
166
+ // -------------------------------------------------------------------------
167
+ // Render
168
+ // -------------------------------------------------------------------------
169
+
170
+ const isProcessing = status === 'processing'
171
+
172
+ return (
173
+ <div className={cx('byline-field-image-upload', styles.root)}>
174
+ {/* Hidden native file input */}
175
+ <input
176
+ ref={inputRef}
177
+ type="file"
178
+ accept={accept}
179
+ className={cx('byline-field-image-upload-input', styles.input)}
180
+ onChange={handleFileChange}
181
+ disabled={isProcessing}
182
+ aria-hidden="true"
183
+ tabIndex={-1}
184
+ />
185
+
186
+ {/* Drop zone */}
187
+ <div
188
+ role="button"
189
+ tabIndex={0}
190
+ aria-label={t('fields.image.upload.zoneAriaLabel')}
191
+ onDragOver={handleDragOver}
192
+ onDragLeave={handleDragLeave}
193
+ onDrop={handleDrop}
194
+ onClick={handleBrowseClick}
195
+ onKeyDown={(e) => {
196
+ if (e.key === 'Enter' || e.key === ' ') {
197
+ e.preventDefault()
198
+ handleBrowseClick()
199
+ }
200
+ }}
201
+ className={cx(
202
+ 'byline-field-image-upload-zone',
203
+ styles.zone,
204
+ isDragOver &&
205
+ !isProcessing && ['byline-field-image-upload-zone-active', styles['zone-active']],
206
+ isProcessing && ['byline-field-image-upload-zone-busy', styles['zone-busy']]
207
+ )}
208
+ >
209
+ {isProcessing ? (
210
+ <>
211
+ {/* Spinner */}
212
+ <svg
213
+ className={cx('byline-field-image-upload-spinner', styles.spinner)}
214
+ xmlns="http://www.w3.org/2000/svg"
215
+ fill="none"
216
+ viewBox="0 0 24 24"
217
+ aria-hidden="true"
218
+ >
219
+ <circle
220
+ style={{ opacity: 0.25 }}
221
+ cx="12"
222
+ cy="12"
223
+ r="10"
224
+ stroke="currentColor"
225
+ strokeWidth="4"
226
+ />
227
+ <path
228
+ style={{ opacity: 0.75 }}
229
+ fill="currentColor"
230
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
231
+ />
232
+ </svg>
233
+ <span className={cx('byline-field-image-upload-label', styles.label)}>
234
+ {t('fields.image.upload.processing')}
235
+ </span>
236
+ </>
237
+ ) : (
238
+ <>
239
+ {/* Upload icon */}
240
+ <svg
241
+ xmlns="http://www.w3.org/2000/svg"
242
+ className={cx('byline-field-image-upload-icon', styles.icon)}
243
+ fill="none"
244
+ viewBox="0 0 24 24"
245
+ stroke="currentColor"
246
+ strokeWidth={1.5}
247
+ aria-hidden="true"
248
+ >
249
+ <path
250
+ strokeLinecap="round"
251
+ strokeLinejoin="round"
252
+ d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
253
+ />
254
+ </svg>
255
+ <span className={cx('byline-field-image-upload-label', styles.label)}>
256
+ {t('fields.image.upload.label')}{' '}
257
+ <span className={cx('byline-field-image-upload-action', styles.action)}>
258
+ {t('fields.image.upload.browse')}
259
+ </span>
260
+ </span>
261
+ <span className={cx('byline-field-image-upload-hint', styles.hint)}>
262
+ {t('fields.image.upload.hint')}
263
+ </span>
264
+ </>
265
+ )}
266
+ </div>
267
+
268
+ {/* Error message */}
269
+ {status === 'error' && errorMessage && (
270
+ <p className={cx('byline-field-image-upload-error', styles.error)} role="alert">
271
+ {errorMessage}
272
+ </p>
273
+ )}
274
+ </div>
275
+ )
276
+ }