@byline/admin 5.1.4 → 5.2.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 (78) hide show
  1. package/package.json +14 -14
  2. package/dist/exports-parity.test.node.js +0 -37
  3. package/dist/fields/array/array-field.jsx +0 -209
  4. package/dist/fields/blocks/blocks-field.jsx +0 -248
  5. package/dist/fields/checkbox/checkbox-field.jsx +0 -24
  6. package/dist/fields/code/code-editor.jsx +0 -211
  7. package/dist/fields/code/code-field.jsx +0 -109
  8. package/dist/fields/column-formatter.jsx +0 -25
  9. package/dist/fields/date-time-formatter.jsx +0 -18
  10. package/dist/fields/datetime/datetime-field.jsx +0 -22
  11. package/dist/fields/draggable-context-menu.jsx +0 -50
  12. package/dist/fields/field-admin.test.node.js +0 -40
  13. package/dist/fields/field-renderer.jsx +0 -136
  14. package/dist/fields/field-services-context.jsx +0 -17
  15. package/dist/fields/file/file-field.jsx +0 -176
  16. package/dist/fields/file/file-upload-field.jsx +0 -89
  17. package/dist/fields/group/group-field.jsx +0 -46
  18. package/dist/fields/image/image-field.jsx +0 -183
  19. package/dist/fields/image/image-upload-field.jsx +0 -157
  20. package/dist/fields/local-date-time.jsx +0 -69
  21. package/dist/fields/locale-badge.jsx +0 -19
  22. package/dist/fields/numerical/numerical-field.jsx +0 -93
  23. package/dist/fields/relation/relation-column-formatter.jsx +0 -74
  24. package/dist/fields/relation/relation-display.jsx +0 -118
  25. package/dist/fields/relation/relation-field.jsx +0 -108
  26. package/dist/fields/relation/relation-many-field.jsx +0 -142
  27. package/dist/fields/relation/relation-picker.jsx +0 -240
  28. package/dist/fields/relation/relation-summary.jsx +0 -45
  29. package/dist/fields/select/select-field.jsx +0 -27
  30. package/dist/fields/sortable-item.jsx +0 -54
  31. package/dist/fields/text/text-field.jsx +0 -79
  32. package/dist/fields/text-area/text-area-field.jsx +0 -79
  33. package/dist/forms/available-locales-reconcile.test.node.js +0 -43
  34. package/dist/forms/available-locales-widget.jsx +0 -63
  35. package/dist/forms/document-actions.jsx +0 -486
  36. package/dist/forms/form-context.jsx +0 -598
  37. package/dist/forms/form-modals.jsx +0 -128
  38. package/dist/forms/form-renderer.jsx +0 -532
  39. package/dist/forms/form-status-display.jsx +0 -69
  40. package/dist/forms/navigation-guard.jsx +0 -68
  41. package/dist/forms/nested-path.test.node.js +0 -115
  42. package/dist/forms/path-widget.jsx +0 -125
  43. package/dist/forms/pending-uploads.test.node.js +0 -19
  44. package/dist/forms/repeating-items.test.node.js +0 -29
  45. package/dist/forms/scheduled-publication-control.jsx +0 -389
  46. package/dist/forms/scheduled-publication-state.test.node.js +0 -121
  47. package/dist/forms/scheduled-publication-time.test.node.js +0 -70
  48. package/dist/forms/status-transitions.test.node.js +0 -76
  49. package/dist/forms/tree-placement-widget.jsx +0 -160
  50. package/dist/forms/upload-executor.test.node.js +0 -368
  51. package/dist/forms/use-form-layout.test.node.js +0 -69
  52. package/dist/modules/admin-account/components/change-password.jsx +0 -169
  53. package/dist/modules/admin-account/components/container.jsx +0 -164
  54. package/dist/modules/admin-account/components/preferences.jsx +0 -148
  55. package/dist/modules/admin-account/components/theme-switch.jsx +0 -52
  56. package/dist/modules/admin-account/components/update.jsx +0 -186
  57. package/dist/modules/admin-permissions/components/inspector.jsx +0 -200
  58. package/dist/modules/admin-preferences/schemas.test.node.js +0 -48
  59. package/dist/modules/admin-roles/components/create.jsx +0 -155
  60. package/dist/modules/admin-roles/components/permissions.jsx +0 -239
  61. package/dist/modules/admin-roles/components/update.jsx +0 -148
  62. package/dist/modules/admin-users/components/create.jsx +0 -206
  63. package/dist/modules/admin-users/components/roles.jsx +0 -126
  64. package/dist/modules/admin-users/components/set-password.jsx +0 -133
  65. package/dist/modules/admin-users/components/update.jsx +0 -215
  66. package/dist/modules/analytics/components/dashboard.jsx +0 -208
  67. package/dist/modules/analytics/components/dashboard.test.node.js +0 -162
  68. package/dist/modules/analytics/components/timeseries.jsx +0 -193
  69. package/dist/modules/auth/components/sign-in-form.jsx +0 -89
  70. package/dist/modules/auth/safe-redirect.test.node.js +0 -40
  71. package/dist/modules/auth/sign-in-form-props.test.node.js +0 -7
  72. package/dist/presentation/group.jsx +0 -26
  73. package/dist/presentation/row.jsx +0 -23
  74. package/dist/presentation/tabs.jsx +0 -39
  75. package/dist/services/admin-services-context.jsx +0 -17
  76. package/dist/widgets/diff-viewer/diff-modal.jsx +0 -110
  77. package/dist/widgets/source-locale-badge/source-locale-badge.jsx +0 -28
  78. package/dist/widgets/status-badge/status-badge.jsx +0 -46
@@ -1,176 +0,0 @@
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
- import { isPendingStoredFileValue, } from '@byline/core';
9
- import { useTranslation } from '@byline/i18n/react';
10
- import { CloseIcon, DocumentIcon, DownloadIcon, ErrorText, HelpText, IconButton, Label, LoaderRing, VideoIcon, } from '@byline/ui/react';
11
- import cx from 'clsx';
12
- import { useFieldError, useFieldValue, useFormContext, useIsDirty, useIsFieldUploading, } from '../../forms/form-context';
13
- import { useFieldChangeHandler } from '../use-field-change-handler';
14
- import styles from './file-field.module.css';
15
- import { FileUploadField } from './file-upload-field';
16
- /**
17
- * Trigger a download via a temporary anchor. Mirrors the helper in
18
- * `image-lightbox.tsx`: same-origin URLs respect the `download` attribute and
19
- * save with the suggested filename; cross-origin URLs without CORS headers
20
- * fall through to navigation in a new tab, where the user can right-click
21
- * Save As.
22
- */
23
- function triggerDownload(url, filename) {
24
- if (typeof document === 'undefined')
25
- return;
26
- const a = document.createElement('a');
27
- a.href = url;
28
- if (filename)
29
- a.download = filename;
30
- a.target = '_blank';
31
- a.rel = 'noreferrer';
32
- document.body.appendChild(a);
33
- a.click();
34
- document.body.removeChild(a);
35
- }
36
- export const FileField = ({ field, value, defaultValue, onChange: _onChange, path, }) => {
37
- const fieldPath = path ?? field.name;
38
- const { t } = useTranslation('byline-admin');
39
- const fieldError = useFieldError(fieldPath);
40
- const isDirty = useIsDirty(fieldPath);
41
- const fieldValue = useFieldValue(fieldPath);
42
- const isUploading = useIsFieldUploading(fieldPath);
43
- // `collectionPath` comes from form context rather than a prop: it is
44
- // constant for the form, and prop-drilling it meant any container that
45
- // forgot to forward it silently rendered this widget read-only.
46
- const { removePendingUpload, documentId, collectionPath } = useFormContext();
47
- const handleChange = useFieldChangeHandler(field, fieldPath);
48
- // Mirror the image-field rule: once the field has been touched, the form
49
- // value is authoritative (even when null, so a click-to-remove sticks);
50
- // otherwise fall back to props.
51
- const incomingValue = isDirty
52
- ? (fieldValue ?? null)
53
- : (value ?? fieldValue ?? defaultValue ?? null);
54
- const isPending = isPendingStoredFileValue(incomingValue);
55
- // Legacy placeholder shape — kept for backwards compatibility with older
56
- // seed data, matching the image-field check.
57
- const isOldPlaceholder = (v) => {
58
- if (!v || typeof v !== 'object')
59
- return false;
60
- const maybe = v;
61
- return maybe.storageProvider === 'placeholder' && maybe.storagePath === 'pending';
62
- };
63
- const showUploadWidget = incomingValue == null || isOldPlaceholder(incomingValue);
64
- // `upload.requireSavedDocument` gate: until the document is persisted,
65
- // render a "save first" notice in place of the upload zone. Server-side
66
- // upload hooks that depend on save-time state (counters, document id)
67
- // rely on this; existing stored values still render normally below.
68
- const uploadGated = field.upload?.requireSavedDocument === true && documentId == null;
69
- const handleRemove = () => {
70
- if (isPending) {
71
- removePendingUpload(fieldPath);
72
- }
73
- handleChange(null);
74
- };
75
- // MIME-driven glyph dispatch. Until a dedicated VideoField primitive lands,
76
- // the FileField is the canonical home for video uploads — the schema's
77
- // `upload.allowedMimeTypes` decides what gets in, and we swap the glyph
78
- // here based on the resolved MIME so the tile reads as "video" rather
79
- // than "generic document".
80
- const isVideo = incomingValue?.mimeType?.startsWith('video/') === true;
81
- const FileGlyph = isVideo ? VideoIcon : DocumentIcon;
82
- const htmlId = fieldPath;
83
- return (<div className={`byline-field-file ${field.name}`}>
84
- <div className={cx('byline-field-file-header', styles.header)}>
85
- <Label id={htmlId} htmlFor={htmlId} label={field.label ?? field.name} required={!field.optional}/>
86
- </div>
87
-
88
- {showUploadWidget ? (uploadGated ? (<div className={cx('byline-field-file-empty', styles.empty)} role="note" data-testid="upload-require-saved-document">
89
- {t('fields.upload.requireSavedDocument')}
90
- </div>) : collectionPath ? (<FileUploadField field={field} collectionPath={collectionPath} fieldPath={fieldPath} onUploaded={(uploaded) => {
91
- handleChange(uploaded);
92
- }}/>) : (<div className={cx('byline-field-file-empty', styles.empty)}>
93
- {t('fields.file.empty')}
94
- </div>)) : (<div className={cx('byline-field-file-tile', styles.tile)}>
95
- {isUploading && (<div className={cx('byline-field-file-uploading', styles.uploading)} aria-live="polite" aria-busy="true">
96
- <LoaderRing />
97
- </div>)}
98
- {collectionPath && (<div className={cx('byline-field-file-actions', styles.actions)}>
99
- {!isPending && incomingValue?.storageUrl && (<IconButton type="button" intent="noeffect" onClick={() => triggerDownload(incomingValue.storageUrl, incomingValue.originalFilename ?? incomingValue.filename)} size="xs" disabled={isUploading} aria-label={t('fields.file.downloadAriaLabel')}>
100
- <DownloadIcon width="15px" height="15px"/>
101
- </IconButton>)}
102
- <IconButton type="button" intent="noeffect" onClick={handleRemove} size="xs" disabled={isUploading} aria-label={t('fields.file.removeAriaLabel')}>
103
- <CloseIcon width="15px" height="15px"/>
104
- </IconButton>
105
- </div>)}
106
- {/* Document icon + (optional) pending badge — mirrors the
107
- image-field's preview-wrap so the file tile has the same
108
- visual hierarchy: glyph on the left, metadata on the right.
109
- When the file is stored (non-pending and resolvable storageUrl),
110
- the wrap is rendered as an anchor that opens the asset in a new
111
- tab — browser-native viewer dispatch (PDFs render inline,
112
- non-renderable types fall through to download). */}
113
- {!isPending && incomingValue?.storageUrl ? (<a href={incomingValue.storageUrl} target="_blank" rel="noreferrer" aria-label={t('fields.file.openInNewTabAriaLabel', {
114
- filename: incomingValue.originalFilename ?? incomingValue.filename,
115
- })} className={cx('byline-field-file-icon-wrap', styles['icon-wrap'])}>
116
- <FileGlyph width="48px" height="48px" className={cx('byline-field-file-icon', styles.icon)}/>
117
- </a>) : (<div className={cx('byline-field-file-icon-wrap', styles['icon-wrap'])}>
118
- <FileGlyph width="48px" height="48px" className={cx('byline-field-file-icon', styles.icon)}/>
119
- {isPending && (<div className={cx('byline-field-file-pending', styles.pending)}>
120
- {t('fields.fileMeta.pendingUpload')}
121
- </div>)}
122
- </div>)}
123
- <div className={cx('byline-field-file-meta', styles.meta)}>
124
- <div>
125
- <span className={cx('byline-field-file-meta-key', styles['meta-key'])}>
126
- {t('fields.fileMeta.filename')}
127
- </span>{' '}
128
- {incomingValue?.filename}
129
- </div>
130
- <div>
131
- <span className={cx('byline-field-file-meta-key', styles['meta-key'])}>
132
- {t('fields.fileMeta.original')}
133
- </span>{' '}
134
- {incomingValue?.originalFilename}
135
- </div>
136
- <div>
137
- <span className={cx('byline-field-file-meta-key', styles['meta-key'])}>
138
- {t('fields.fileMeta.type')}
139
- </span>{' '}
140
- {incomingValue?.mimeType}
141
- </div>
142
- <div>
143
- <span className={cx('byline-field-file-meta-key', styles['meta-key'])}>
144
- {t('fields.fileMeta.size')}
145
- </span>{' '}
146
- {incomingValue?.fileSize}
147
- </div>
148
- {isPending ? (<div>
149
- <span className={cx('byline-field-file-meta-key', styles['meta-key'])}>
150
- {t('fields.fileMeta.status')}
151
- </span>{' '}
152
- <span className={cx('byline-field-file-meta-pending', styles['meta-pending'])}>
153
- {t('fields.fileMeta.willUploadOnSave')}
154
- </span>
155
- </div>) : (<>
156
- <div>
157
- <span className={cx('byline-field-file-meta-key', styles['meta-key'])}>
158
- {t('fields.fileMeta.storage')}
159
- </span>{' '}
160
- {incomingValue?.storageProvider}
161
- </div>
162
- <div>
163
- <span className={cx('byline-field-file-meta-key', styles['meta-key'])}>
164
- {t('fields.fileMeta.path')}
165
- </span>{' '}
166
- {incomingValue?.storagePath}
167
- </div>
168
- </>)}
169
- </div>
170
- </div>)}
171
-
172
- {field.helpText && <HelpText text={field.helpText}/>}
173
-
174
- {fieldError && <ErrorText id={`${field.name}-error`} text={fieldError}/>}
175
- </div>);
176
- };
@@ -1,89 +0,0 @@
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
- import { useCallback, useRef, useState } from 'react';
9
- import { createPendingStoredFileValue, } from '@byline/core';
10
- import { useTranslation } from '@byline/i18n/react';
11
- import cx from 'clsx';
12
- import { useFormContext } from '../../forms/form-context';
13
- import styles from './file-upload-field.module.css';
14
- export const FileUploadField = ({ field: _field, collectionPath, fieldPath, onUploaded, accept, }) => {
15
- const inputRef = useRef(null);
16
- const { t } = useTranslation('byline-admin');
17
- const [status, setStatus] = useState('idle');
18
- const [errorMessage, setErrorMessage] = useState(null);
19
- const [isDragOver, setIsDragOver] = useState(false);
20
- const { addPendingUpload } = useFormContext();
21
- const handleFileSelected = useCallback((file) => {
22
- setStatus('processing');
23
- setErrorMessage(null);
24
- // Blob URL is created so the form orchestrator can revoke it on cleanup
25
- // alongside image-field uploads; it isn't surfaced in the UI here.
26
- const previewUrl = URL.createObjectURL(file);
27
- const pendingValue = createPendingStoredFileValue(file, previewUrl);
28
- if (!addPendingUpload(fieldPath, {
29
- file,
30
- previewUrl,
31
- collectionPath,
32
- })) {
33
- return;
34
- }
35
- setStatus('idle');
36
- onUploaded(pendingValue);
37
- }, [collectionPath, fieldPath, addPendingUpload, onUploaded]);
38
- const handleFileChange = useCallback((e) => {
39
- const file = e.target.files?.[0];
40
- if (file)
41
- handleFileSelected(file);
42
- // Reset so re-selecting the same file fires the event again.
43
- e.target.value = '';
44
- }, [handleFileSelected]);
45
- const handleBrowseClick = useCallback(() => {
46
- inputRef.current?.click();
47
- }, []);
48
- const handleDragOver = useCallback((e) => {
49
- e.preventDefault();
50
- setIsDragOver(true);
51
- }, []);
52
- const handleDragLeave = useCallback((e) => {
53
- e.preventDefault();
54
- setIsDragOver(false);
55
- }, []);
56
- const handleDrop = useCallback((e) => {
57
- e.preventDefault();
58
- setIsDragOver(false);
59
- const file = e.dataTransfer.files?.[0];
60
- if (file)
61
- handleFileSelected(file);
62
- }, [handleFileSelected]);
63
- const isProcessing = status === 'processing';
64
- return (<div className={cx('byline-field-file-upload', styles.root)}>
65
- <input ref={inputRef} type="file" accept={accept} className={cx('byline-field-file-upload-input', styles.input)} onChange={handleFileChange} disabled={isProcessing} aria-hidden="true" tabIndex={-1}/>
66
-
67
- <div role="button" tabIndex={0} aria-label={t('fields.file.upload.zoneAriaLabel')} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} onClick={handleBrowseClick} onKeyDown={(e) => {
68
- if (e.key === 'Enter' || e.key === ' ') {
69
- e.preventDefault();
70
- handleBrowseClick();
71
- }
72
- }} className={cx('byline-field-file-upload-zone', styles.zone, isDragOver &&
73
- !isProcessing && ['byline-field-file-upload-zone-active', styles['zone-active']], isProcessing && ['byline-field-file-upload-zone-busy', styles['zone-busy']])}>
74
- <svg xmlns="http://www.w3.org/2000/svg" className={cx('byline-field-file-upload-icon', styles.icon)} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
75
- <path strokeLinecap="round" strokeLinejoin="round" 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"/>
76
- </svg>
77
- <span className={cx('byline-field-file-upload-label', styles.label)}>
78
- {t('fields.file.upload.label')}{' '}
79
- <span className={cx('byline-field-file-upload-action', styles.action)}>
80
- {t('fields.file.upload.browse')}
81
- </span>
82
- </span>
83
- </div>
84
-
85
- {status === 'error' && errorMessage && (<p className={cx('byline-field-file-upload-error', styles.error)} role="alert">
86
- {errorMessage}
87
- </p>)}
88
- </div>);
89
- };
@@ -1,46 +0,0 @@
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
- import { useMemo } from 'react';
9
- import { ErrorText } from '@byline/ui/react';
10
- import cx from 'clsx';
11
- import { sliceFieldAdmin } from '../../fields/field-admin';
12
- import { placeholderForField } from '../../fields/field-helpers';
13
- import { FieldRenderer } from '../../fields/field-renderer';
14
- import { useFieldError } from '../../forms/form-context';
15
- import styles from './group-field.module.css';
16
- export const GroupField = ({ field, defaultValue, path, disableSorting = true, contentLocale, fieldAdmin, }) => {
17
- const fieldError = useFieldError(field.name);
18
- // Default value for a group field is a plain object: { rating: 5, comment: '...' }
19
- // Normalize to a plain object if not already one.
20
- const groupData = useMemo(() => {
21
- if (defaultValue && typeof defaultValue === 'object' && !Array.isArray(defaultValue)) {
22
- return defaultValue;
23
- }
24
- // Fallback: build a placeholder object from child field definitions
25
- const placeholder = {};
26
- for (const childField of field.fields) {
27
- placeholder[childField.name] = placeholderForField(childField);
28
- }
29
- return placeholder;
30
- }, [defaultValue, field.fields]);
31
- return (<div className={`byline-field-group ${field.name}`}>
32
- {field.label && (<div className={cx('byline-field-group-header', styles.header)}>
33
- <h3 className={cx('byline-field-group-title', styles.title)}>
34
- {field.label}{' '}
35
- {!field.optional && (<span className={cx('byline-field-group-required', styles.required)}>*</span>)}
36
- </h3>
37
- {field.helpText && (<p className={cx('byline-field-group-help', styles.help)}>{field.helpText}</p>)}
38
- </div>)}
39
- <div className={cx('byline-field-group-body', styles.body)}>
40
- {field.fields.map((innerField) => {
41
- return (<FieldRenderer key={innerField.name} field={innerField} defaultValue={groupData[innerField.name]} basePath={path} disableSorting={disableSorting} contentLocale={contentLocale} components={fieldAdmin?.[innerField.name]?.components} editor={fieldAdmin?.[innerField.name]?.editor} fieldAdmin={sliceFieldAdmin(fieldAdmin, innerField.name)}/>);
42
- })}
43
- </div>
44
- {fieldError && <ErrorText id={`${field.name}-error`} text={fieldError}/>}
45
- </div>);
46
- };
@@ -1,183 +0,0 @@
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
- import { useState } from 'react';
9
- import { isPendingStoredFileValue, } from '@byline/core';
10
- import { useTranslation } from '@byline/i18n/react';
11
- import { CloseIcon, ErrorText, HelpText, IconButton, ImageLightbox, Label, LoaderRing, } from '@byline/ui/react';
12
- import cx from 'clsx';
13
- import { useFieldError, useFieldValue, useFormContext, useIsDirty, useIsFieldUploading, } from '../../forms/form-context';
14
- import { useFieldChangeHandler } from '../use-field-change-handler';
15
- import styles from './image-field.module.css';
16
- import { ImageUploadField } from './image-upload-field';
17
- export const ImageField = ({ field, value, defaultValue, onChange: _onChange, path, }) => {
18
- const fieldPath = path ?? field.name;
19
- const fieldError = useFieldError(fieldPath);
20
- const isDirty = useIsDirty(fieldPath);
21
- const fieldValue = useFieldValue(fieldPath);
22
- const isUploading = useIsFieldUploading(fieldPath);
23
- // `collectionPath` comes from form context rather than a prop: it is
24
- // constant for the form, and prop-drilling it meant any container that
25
- // forgot to forward it silently rendered this widget read-only.
26
- const { removePendingUpload, documentId, collectionPath } = useFormContext();
27
- const { t } = useTranslation('byline-admin');
28
- // Re-use the standard field change handler so patches are emitted correctly.
29
- const handleChange = useFieldChangeHandler(field, fieldPath);
30
- // When the field has been explicitly set (dirty), use the field value from
31
- // form state — even if it's null (user clicked Remove). Only fall back to
32
- // the prop / defaultValue when the field hasn't been touched yet.
33
- const incomingValue = isDirty
34
- ? (fieldValue ?? null)
35
- : (value ?? fieldValue ?? defaultValue ?? null);
36
- // Check if this is a pending upload (selected but not yet uploaded)
37
- const isPending = isPendingStoredFileValue(incomingValue);
38
- // Old placeholder check for backwards compatibility
39
- const isOldPlaceholder = (v) => {
40
- if (!v || typeof v !== 'object')
41
- return false;
42
- const maybe = v;
43
- return maybe.storageProvider === 'placeholder' && maybe.storagePath === 'pending';
44
- };
45
- // Show upload widget only if no value or old placeholder
46
- const showUploadWidget = incomingValue == null || isOldPlaceholder(incomingValue);
47
- // `upload.requireSavedDocument` gate: until the document is persisted,
48
- // render a "save first" notice in place of the upload zone. Server-side
49
- // upload hooks that depend on save-time state (counters, document id)
50
- // rely on this; existing stored values still render normally below.
51
- const uploadGated = field.upload?.requireSavedDocument === true && documentId == null;
52
- // Prefer the generated thumbnail variant for the preview tile. SVGs and
53
- // other bypass types have no variants — fall back to the original.
54
- const thumbVariant = incomingValue && !isPendingStoredFileValue(incomingValue)
55
- ? incomingValue.variants?.find((v) => v.name === 'thumbnail')
56
- : undefined;
57
- const previewUrl = thumbVariant?.storageUrl ?? incomingValue?.storageUrl;
58
- // Handle remove, including cleanup of pending uploads
59
- const handleRemove = () => {
60
- if (isPending) {
61
- removePendingUpload(fieldPath);
62
- }
63
- handleChange(null);
64
- };
65
- // Lightbox state — only enabled for stored (non-pending) images that have a
66
- // resolvable original storageUrl.
67
- const [lightboxOpen, setLightboxOpen] = useState(false);
68
- const canOpenLightbox = !isPending && !!incomingValue?.storageUrl;
69
- const htmlId = fieldPath;
70
- return (<div className={`byline-field-image ${field.name}`}>
71
- <div className={cx('byline-field-image-header', styles.header)}>
72
- <Label id={htmlId} htmlFor={htmlId} label={field.label ?? field.name} required={!field.optional}/>
73
- </div>
74
-
75
- {showUploadWidget ? (uploadGated ? (<div className={cx('byline-field-image-empty', styles.empty)} role="note" data-testid="upload-require-saved-document">
76
- {t('fields.upload.requireSavedDocument')}
77
- </div>) : collectionPath ? (<ImageUploadField field={field} collectionPath={collectionPath} fieldPath={fieldPath} onUploaded={(uploaded) => {
78
- handleChange(uploaded);
79
- }}/>) : (<div className={cx('byline-field-image-empty', styles.empty)}>
80
- {t('fields.image.empty')}
81
- </div>)) : (<div className={cx('byline-field-image-tile', styles.tile)}>
82
- {isUploading && (<div className={cx('byline-field-image-uploading', styles.uploading)} aria-live="polite" aria-busy="true">
83
- <LoaderRing />
84
- </div>)}
85
- {/* Remove button — shown when an image is set (including pending) */}
86
- {collectionPath && (<div className={cx('byline-field-image-remove', styles.remove)}>
87
- <IconButton type="button" intent="noeffect" onClick={handleRemove} size="xs" disabled={isUploading} aria-label={t('fields.image.removeAriaLabel')}>
88
- <CloseIcon width="15px" height="15px"/>
89
- </IconButton>
90
- </div>)}
91
- {/* Preview */}
92
- {previewUrl && (<div className={cx('byline-field-image-preview-wrap', styles['preview-wrap'])}>
93
- {canOpenLightbox ? (<button type="button" onClick={() => setLightboxOpen(true)} aria-label={t('fields.image.openLightboxAriaLabel')} className={cx('byline-field-image-preview-button', styles['preview-button'])}>
94
- <img src={previewUrl} alt={incomingValue.originalFilename ?? incomingValue.filename} className={cx('byline-field-image-preview', styles.preview, incomingValue.mimeType === 'image/svg+xml' && [
95
- 'byline-field-image-preview-svg',
96
- styles['preview-svg'],
97
- ])}/>
98
- </button>) : (<img src={previewUrl} alt={incomingValue.originalFilename ?? incomingValue.filename} className={cx('byline-field-image-preview', styles.preview, incomingValue.mimeType === 'image/svg+xml' && [
99
- 'byline-field-image-preview-svg',
100
- styles['preview-svg'],
101
- ])}/>)}
102
- {/* Pending upload badge */}
103
- {isPending && (<div className={cx('byline-field-image-pending', styles.pending)}>
104
- {t('fields.fileMeta.pendingUpload')}
105
- </div>)}
106
- </div>)}
107
- {/* Metadata */}
108
- <div className={cx('byline-field-image-meta', styles.meta)}>
109
- <div>
110
- <span className={cx('byline-field-image-meta-key', styles['meta-key'])}>
111
- {t('fields.fileMeta.filename')}
112
- </span>{' '}
113
- {incomingValue?.filename}
114
- </div>
115
- <div>
116
- <span className={cx('byline-field-image-meta-key', styles['meta-key'])}>
117
- {t('fields.fileMeta.original')}
118
- </span>{' '}
119
- {incomingValue?.originalFilename}
120
- </div>
121
- <div>
122
- <span className={cx('byline-field-image-meta-key', styles['meta-key'])}>
123
- {t('fields.fileMeta.type')}
124
- </span>{' '}
125
- {incomingValue?.mimeType}
126
- </div>
127
- <div>
128
- <span className={cx('byline-field-image-meta-key', styles['meta-key'])}>
129
- {t('fields.fileMeta.size')}
130
- </span>{' '}
131
- {incomingValue?.fileSize}
132
- </div>
133
- {isPending ? (<div>
134
- <span className={cx('byline-field-image-meta-key', styles['meta-key'])}>
135
- {t('fields.fileMeta.status')}
136
- </span>{' '}
137
- <span className={cx('byline-field-image-meta-pending', styles['meta-pending'])}>
138
- {t('fields.fileMeta.willUploadOnSave')}
139
- </span>
140
- </div>) : (<>
141
- <div>
142
- <span className={cx('byline-field-image-meta-key', styles['meta-key'])}>
143
- {t('fields.fileMeta.storage')}
144
- </span>{' '}
145
- {incomingValue?.storageProvider}
146
- </div>
147
- {incomingValue?.imageWidth != null && (<div>
148
- <span className={cx('byline-field-image-meta-key', styles['meta-key'])}>
149
- {t('fields.imageMeta.dimensions')}
150
- </span>{' '}
151
- {incomingValue.imageWidth}
152
- {incomingValue.imageHeight != null ? `×${incomingValue.imageHeight}` : ''}
153
- </div>)}
154
- {incomingValue?.imageFormat != null && (<div>
155
- <span className={cx('byline-field-image-meta-key', styles['meta-key'])}>
156
- {t('fields.imageMeta.format')}
157
- </span>{' '}
158
- {incomingValue.imageFormat}
159
- </div>)}
160
- <div>
161
- <span className={cx('byline-field-image-meta-key', styles['meta-key'])}>
162
- {t('fields.imageMeta.thumbnail')}
163
- </span>{' '}
164
- {thumbVariant
165
- ? t('fields.imageMeta.thumbnailGenerated')
166
- : t('fields.imageMeta.thumbnailPending')}
167
- </div>
168
- </>)}
169
- </div>
170
- </div>)}
171
-
172
- {field.helpText && <HelpText text={field.helpText}/>}
173
-
174
- {fieldError && <ErrorText id={`${field.name}-error`} text={fieldError}/>}
175
-
176
- {canOpenLightbox && incomingValue?.storageUrl && (<ImageLightbox isOpen={lightboxOpen} onDismiss={() => setLightboxOpen(false)} src={incomingValue.storageUrl} alt={incomingValue.originalFilename ?? incomingValue.filename} downloadFilename={incomingValue.originalFilename ?? incomingValue.filename} title={incomingValue.originalFilename ?? incomingValue.filename} meta={{
177
- width: incomingValue.imageWidth,
178
- height: incomingValue.imageHeight,
179
- fileSize: incomingValue.fileSize,
180
- mimeType: incomingValue.mimeType,
181
- }}/>)}
182
- </div>);
183
- };
@@ -1,157 +0,0 @@
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
- import { useCallback, useEffect, useRef, useState } from 'react';
9
- import { createPendingStoredFileValue, } from '@byline/core';
10
- import { useTranslation } from '@byline/i18n/react';
11
- import cx from 'clsx';
12
- import { useFormContext } from '../../forms/form-context';
13
- import styles from './image-upload-field.module.css';
14
- // ---------------------------------------------------------------------------
15
- // Component
16
- // ---------------------------------------------------------------------------
17
- export const ImageUploadField = ({ field: _field, collectionPath, fieldPath, onUploaded, accept = 'image/*', }) => {
18
- const inputRef = useRef(null);
19
- const mountedRef = useRef(true);
20
- const [status, setStatus] = useState('idle');
21
- const [errorMessage, setErrorMessage] = useState(null);
22
- const [isDragOver, setIsDragOver] = useState(false);
23
- const { addPendingUpload } = useFormContext();
24
- const { t } = useTranslation('byline-admin');
25
- useEffect(() => {
26
- mountedRef.current = true;
27
- return () => {
28
- mountedRef.current = false;
29
- };
30
- }, []);
31
- // -------------------------------------------------------------------------
32
- // Core file selection logic (deferred upload)
33
- // -------------------------------------------------------------------------
34
- const handleFileSelected = useCallback((file) => {
35
- setStatus('processing');
36
- setErrorMessage(null);
37
- // Basic client-side validation
38
- if (!file.type.startsWith('image/')) {
39
- setStatus('error');
40
- setErrorMessage(t('fields.image.upload.errors.notAnImage'));
41
- return;
42
- }
43
- // Create a blob URL for immediate preview
44
- const previewUrl = URL.createObjectURL(file);
45
- // Extract image dimensions for the pending value
46
- const img = new Image();
47
- img.onload = () => {
48
- if (!mountedRef.current) {
49
- URL.revokeObjectURL(previewUrl);
50
- return;
51
- }
52
- // SVGs without explicit width/height attrs (viewBox-only) report naturalWidth/Height = 0.
53
- // Skip dimensions when zero so they are stored as null (scalable, no fixed size).
54
- const w = img.naturalWidth;
55
- const h = img.naturalHeight;
56
- const dimensions = w > 0 && h > 0 ? { width: w, height: h } : undefined;
57
- // Create the pending stored file value
58
- const pendingValue = createPendingStoredFileValue(file, previewUrl, dimensions);
59
- // Register the pending upload in form context
60
- if (!addPendingUpload(fieldPath, {
61
- file,
62
- previewUrl,
63
- collectionPath,
64
- })) {
65
- return;
66
- }
67
- setStatus('idle');
68
- onUploaded(pendingValue);
69
- };
70
- img.onerror = () => {
71
- URL.revokeObjectURL(previewUrl);
72
- if (!mountedRef.current)
73
- return;
74
- setStatus('error');
75
- setErrorMessage(t('fields.image.upload.errors.cannotRead'));
76
- };
77
- img.src = previewUrl;
78
- }, [collectionPath, fieldPath, addPendingUpload, onUploaded, t]);
79
- // -------------------------------------------------------------------------
80
- // File input
81
- // -------------------------------------------------------------------------
82
- const handleFileChange = useCallback((e) => {
83
- const file = e.target.files?.[0];
84
- if (file)
85
- handleFileSelected(file);
86
- // Reset so re-selecting the same file fires the event again.
87
- e.target.value = '';
88
- }, [handleFileSelected]);
89
- const handleBrowseClick = useCallback(() => {
90
- inputRef.current?.click();
91
- }, []);
92
- // -------------------------------------------------------------------------
93
- // Drag and drop
94
- // -------------------------------------------------------------------------
95
- const handleDragOver = useCallback((e) => {
96
- e.preventDefault();
97
- setIsDragOver(true);
98
- }, []);
99
- const handleDragLeave = useCallback((e) => {
100
- e.preventDefault();
101
- setIsDragOver(false);
102
- }, []);
103
- const handleDrop = useCallback((e) => {
104
- e.preventDefault();
105
- setIsDragOver(false);
106
- const file = e.dataTransfer.files?.[0];
107
- if (file)
108
- handleFileSelected(file);
109
- }, [handleFileSelected]);
110
- // -------------------------------------------------------------------------
111
- // Render
112
- // -------------------------------------------------------------------------
113
- const isProcessing = status === 'processing';
114
- return (<div className={cx('byline-field-image-upload', styles.root)}>
115
- {/* Hidden native file input */}
116
- <input ref={inputRef} type="file" accept={accept} className={cx('byline-field-image-upload-input', styles.input)} onChange={handleFileChange} disabled={isProcessing} aria-hidden="true" tabIndex={-1}/>
117
-
118
- {/* Drop zone */}
119
- <div role="button" tabIndex={0} aria-label={t('fields.image.upload.zoneAriaLabel')} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} onClick={handleBrowseClick} onKeyDown={(e) => {
120
- if (e.key === 'Enter' || e.key === ' ') {
121
- e.preventDefault();
122
- handleBrowseClick();
123
- }
124
- }} className={cx('byline-field-image-upload-zone', styles.zone, isDragOver &&
125
- !isProcessing && ['byline-field-image-upload-zone-active', styles['zone-active']], isProcessing && ['byline-field-image-upload-zone-busy', styles['zone-busy']])}>
126
- {isProcessing ? (<>
127
- {/* Spinner */}
128
- <svg className={cx('byline-field-image-upload-spinner', styles.spinner)} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
129
- <circle style={{ opacity: 0.25 }} cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
130
- <path style={{ opacity: 0.75 }} fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
131
- </svg>
132
- <span className={cx('byline-field-image-upload-label', styles.label)}>
133
- {t('fields.image.upload.processing')}
134
- </span>
135
- </>) : (<>
136
- {/* Upload icon */}
137
- <svg xmlns="http://www.w3.org/2000/svg" className={cx('byline-field-image-upload-icon', styles.icon)} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
138
- <path strokeLinecap="round" strokeLinejoin="round" 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"/>
139
- </svg>
140
- <span className={cx('byline-field-image-upload-label', styles.label)}>
141
- {t('fields.image.upload.label')}{' '}
142
- <span className={cx('byline-field-image-upload-action', styles.action)}>
143
- {t('fields.image.upload.browse')}
144
- </span>
145
- </span>
146
- <span className={cx('byline-field-image-upload-hint', styles.hint)}>
147
- {t('fields.image.upload.hint')}
148
- </span>
149
- </>)}
150
- </div>
151
-
152
- {/* Error message */}
153
- {status === 'error' && errorMessage && (<p className={cx('byline-field-image-upload-error', styles.error)} role="alert">
154
- {errorMessage}
155
- </p>)}
156
- </div>);
157
- };