@byline/admin 5.1.3 → 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.
- package/dist/modules/analytics/components/dashboard.d.ts +13 -3
- package/dist/modules/analytics/components/dashboard.js +86 -68
- package/dist/modules/analytics/components/dashboard.module.js +10 -1
- package/dist/modules/analytics/components/dashboard_module.css +57 -0
- package/dist/modules/analytics/components/ranked-list-modal.d.ts +32 -0
- package/dist/modules/analytics/components/ranked-list-modal.js +145 -0
- package/dist/modules/analytics/components/ranking.d.ts +45 -0
- package/dist/modules/analytics/components/ranking.js +71 -0
- package/dist/modules/analytics/components/ranking.test.node.d.ts +8 -0
- package/package.json +14 -14
- package/src/modules/analytics/components/dashboard.module.css +69 -0
- package/src/modules/analytics/components/dashboard.tsx +105 -83
- package/src/modules/analytics/components/ranked-list-modal.test.tsx +154 -0
- package/src/modules/analytics/components/ranked-list-modal.tsx +159 -0
- package/src/modules/analytics/components/ranking.test.node.ts +43 -0
- package/src/modules/analytics/components/ranking.tsx +122 -0
- package/dist/exports-parity.test.node.js +0 -37
- package/dist/fields/array/array-field.jsx +0 -209
- package/dist/fields/blocks/blocks-field.jsx +0 -248
- package/dist/fields/checkbox/checkbox-field.jsx +0 -24
- package/dist/fields/code/code-editor.jsx +0 -211
- package/dist/fields/code/code-field.jsx +0 -109
- package/dist/fields/column-formatter.jsx +0 -25
- package/dist/fields/date-time-formatter.jsx +0 -18
- package/dist/fields/datetime/datetime-field.jsx +0 -22
- package/dist/fields/draggable-context-menu.jsx +0 -50
- package/dist/fields/field-admin.test.node.js +0 -40
- package/dist/fields/field-renderer.jsx +0 -136
- package/dist/fields/field-services-context.jsx +0 -17
- package/dist/fields/file/file-field.jsx +0 -176
- package/dist/fields/file/file-upload-field.jsx +0 -89
- package/dist/fields/group/group-field.jsx +0 -46
- package/dist/fields/image/image-field.jsx +0 -183
- package/dist/fields/image/image-upload-field.jsx +0 -157
- package/dist/fields/local-date-time.jsx +0 -69
- package/dist/fields/locale-badge.jsx +0 -19
- package/dist/fields/numerical/numerical-field.jsx +0 -93
- package/dist/fields/relation/relation-column-formatter.jsx +0 -74
- package/dist/fields/relation/relation-display.jsx +0 -118
- package/dist/fields/relation/relation-field.jsx +0 -108
- package/dist/fields/relation/relation-many-field.jsx +0 -142
- package/dist/fields/relation/relation-picker.jsx +0 -240
- package/dist/fields/relation/relation-summary.jsx +0 -45
- package/dist/fields/select/select-field.jsx +0 -27
- package/dist/fields/sortable-item.jsx +0 -54
- package/dist/fields/text/text-field.jsx +0 -79
- package/dist/fields/text-area/text-area-field.jsx +0 -79
- package/dist/forms/available-locales-reconcile.test.node.js +0 -43
- package/dist/forms/available-locales-widget.jsx +0 -63
- package/dist/forms/document-actions.jsx +0 -486
- package/dist/forms/form-context.jsx +0 -598
- package/dist/forms/form-modals.jsx +0 -128
- package/dist/forms/form-renderer.jsx +0 -532
- package/dist/forms/form-status-display.jsx +0 -69
- package/dist/forms/navigation-guard.jsx +0 -68
- package/dist/forms/nested-path.test.node.js +0 -115
- package/dist/forms/path-widget.jsx +0 -125
- package/dist/forms/pending-uploads.test.node.js +0 -19
- package/dist/forms/repeating-items.test.node.js +0 -29
- package/dist/forms/scheduled-publication-control.jsx +0 -389
- package/dist/forms/scheduled-publication-state.test.node.js +0 -121
- package/dist/forms/scheduled-publication-time.test.node.js +0 -70
- package/dist/forms/status-transitions.test.node.js +0 -76
- package/dist/forms/tree-placement-widget.jsx +0 -160
- package/dist/forms/upload-executor.test.node.js +0 -368
- package/dist/forms/use-form-layout.test.node.js +0 -69
- package/dist/modules/admin-account/components/change-password.jsx +0 -169
- package/dist/modules/admin-account/components/container.jsx +0 -164
- package/dist/modules/admin-account/components/preferences.jsx +0 -148
- package/dist/modules/admin-account/components/theme-switch.jsx +0 -52
- package/dist/modules/admin-account/components/update.jsx +0 -186
- package/dist/modules/admin-permissions/components/inspector.jsx +0 -200
- package/dist/modules/admin-preferences/schemas.test.node.js +0 -48
- package/dist/modules/admin-roles/components/create.jsx +0 -155
- package/dist/modules/admin-roles/components/permissions.jsx +0 -239
- package/dist/modules/admin-roles/components/update.jsx +0 -148
- package/dist/modules/admin-users/components/create.jsx +0 -206
- package/dist/modules/admin-users/components/roles.jsx +0 -126
- package/dist/modules/admin-users/components/set-password.jsx +0 -133
- package/dist/modules/admin-users/components/update.jsx +0 -215
- package/dist/modules/analytics/components/dashboard.jsx +0 -208
- package/dist/modules/analytics/components/dashboard.test.node.js +0 -162
- package/dist/modules/analytics/components/timeseries.jsx +0 -193
- package/dist/modules/auth/components/sign-in-form.jsx +0 -89
- package/dist/modules/auth/safe-redirect.test.node.js +0 -40
- package/dist/modules/auth/sign-in-form-props.test.node.js +0 -7
- package/dist/presentation/group.jsx +0 -26
- package/dist/presentation/row.jsx +0 -23
- package/dist/presentation/tabs.jsx +0 -39
- package/dist/services/admin-services-context.jsx +0 -17
- package/dist/widgets/diff-viewer/diff-modal.jsx +0 -110
- package/dist/widgets/source-locale-badge/source-locale-badge.jsx +0 -28
- package/dist/widgets/status-badge/status-badge.jsx +0 -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
|
-
};
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
import { useEffect, useState } from 'react';
|
|
3
|
-
// undefined will use the user's locale
|
|
4
|
-
const dateTimeFormatter = new Intl.DateTimeFormat(undefined, {
|
|
5
|
-
year: 'numeric',
|
|
6
|
-
month: 'short', // <- short month text (locale-aware)
|
|
7
|
-
day: '2-digit',
|
|
8
|
-
hour: 'numeric',
|
|
9
|
-
minute: 'numeric',
|
|
10
|
-
});
|
|
11
|
-
const dateFormatter = new Intl.DateTimeFormat(undefined, {
|
|
12
|
-
year: 'numeric',
|
|
13
|
-
month: 'short', // <- short month text (locale-aware)
|
|
14
|
-
day: '2-digit',
|
|
15
|
-
});
|
|
16
|
-
/**
|
|
17
|
-
* Client-side date/time formatter that displays dates in the user's local timezone.
|
|
18
|
-
*
|
|
19
|
-
* This component avoids hydration mismatches by:
|
|
20
|
-
* 1. Rendering a placeholder on the server/initial render
|
|
21
|
-
* 2. Updating to the correctly formatted date after hydration
|
|
22
|
-
*
|
|
23
|
-
* Use this instead of formatDateTime when you need to display dates
|
|
24
|
-
* in the user's local timezone rather than UTC.
|
|
25
|
-
*/
|
|
26
|
-
export function LocalDateTime({ value, mode = 'datetime', className, fallback = '—', }) {
|
|
27
|
-
const [formattedDate, setFormattedDate] = useState(null);
|
|
28
|
-
useEffect(() => {
|
|
29
|
-
if (value == null) {
|
|
30
|
-
setFormattedDate(null);
|
|
31
|
-
return;
|
|
32
|
-
}
|
|
33
|
-
const date = typeof value === 'string' ? Date.parse(value) : value.getTime();
|
|
34
|
-
if (Number.isNaN(date)) {
|
|
35
|
-
setFormattedDate('Error');
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
|
-
const formatter = mode === 'datetime' ? dateTimeFormatter : dateFormatter;
|
|
39
|
-
setFormattedDate(formatter.format(date));
|
|
40
|
-
}, [value, mode]);
|
|
41
|
-
// Show fallback during SSR and before useEffect runs
|
|
42
|
-
if (formattedDate === null) {
|
|
43
|
-
return <span className={className}>{value ? '...' : fallback}</span>;
|
|
44
|
-
}
|
|
45
|
-
return <span className={className}>{formattedDate}</span>;
|
|
46
|
-
}
|
|
47
|
-
/**
|
|
48
|
-
* Formats a date string to the user's local timezone.
|
|
49
|
-
*
|
|
50
|
-
* WARNING: Only use this in client components where hydration
|
|
51
|
-
* mismatches are acceptable or managed. For most cases, prefer
|
|
52
|
-
* the <LocalDateTime /> component.
|
|
53
|
-
*/
|
|
54
|
-
export function formatLocalDateTime(value) {
|
|
55
|
-
if (value == null)
|
|
56
|
-
return '—';
|
|
57
|
-
const date = typeof value === 'string' ? Date.parse(value) : value.getTime();
|
|
58
|
-
if (Number.isNaN(date))
|
|
59
|
-
return 'Error';
|
|
60
|
-
return dateTimeFormatter.format(date);
|
|
61
|
-
}
|
|
62
|
-
export function formatLocalDate(value) {
|
|
63
|
-
if (value == null)
|
|
64
|
-
return '—';
|
|
65
|
-
const date = typeof value === 'string' ? Date.parse(value) : value.getTime();
|
|
66
|
-
if (Number.isNaN(date))
|
|
67
|
-
return 'Error';
|
|
68
|
-
return dateFormatter.format(date);
|
|
69
|
-
}
|
|
@@ -1,19 +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 cx from 'clsx';
|
|
9
|
-
import styles from './locale-badge.module.css';
|
|
10
|
-
/**
|
|
11
|
-
* Small inline badge shown next to the label of a field that has
|
|
12
|
-
* `localized: true` in its schema definition. Indicates which locale
|
|
13
|
-
* the editor is currently working in.
|
|
14
|
-
*
|
|
15
|
-
* Stable override handle: `.byline-locale-badge`.
|
|
16
|
-
*/
|
|
17
|
-
export const LocaleBadge = ({ locale }) => (<span aria-hidden="true" title={`Localised — editing ${locale.toUpperCase()} content`} className={cx('byline-locale-badge', styles.badge)}>
|
|
18
|
-
{locale}
|
|
19
|
-
</span>);
|
|
@@ -1,93 +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 { useEffect, useState } from 'react';
|
|
9
|
-
import { normalizeNumericValue } from '@byline/core';
|
|
10
|
-
import { Input } from '@byline/ui/react';
|
|
11
|
-
import { useFieldError, useFieldValue } from '../../forms/form-context';
|
|
12
|
-
const COMPLETE_NUMERIC_RE = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
|
|
13
|
-
export const NumericalField = ({ field, value, defaultValue, onChange, id, path, components, }) => {
|
|
14
|
-
const fieldPath = path ?? field.name;
|
|
15
|
-
const fieldError = useFieldError(fieldPath);
|
|
16
|
-
const fieldValue = useFieldValue(fieldPath);
|
|
17
|
-
const incomingValue = value !== undefined ? value : fieldValue !== undefined ? fieldValue : (defaultValue ?? null);
|
|
18
|
-
const htmlId = id ?? fieldPath;
|
|
19
|
-
const canonicalDisplay = incomingValue == null ? '' : String(incomingValue);
|
|
20
|
-
const [displayValue, setDisplayValue] = useState(canonicalDisplay);
|
|
21
|
-
const [isEditing, setIsEditing] = useState(false);
|
|
22
|
-
useEffect(() => {
|
|
23
|
-
if (!isEditing)
|
|
24
|
-
setDisplayValue(canonicalDisplay);
|
|
25
|
-
}, [canonicalDisplay, isEditing]);
|
|
26
|
-
const canonicalize = (nextValue) => {
|
|
27
|
-
if (nextValue == null || (typeof nextValue === 'string' && nextValue.trim() === ''))
|
|
28
|
-
return null;
|
|
29
|
-
if (field.type === 'counter')
|
|
30
|
-
return undefined;
|
|
31
|
-
if (typeof nextValue === 'string' && !COMPLETE_NUMERIC_RE.test(nextValue.trim())) {
|
|
32
|
-
return undefined;
|
|
33
|
-
}
|
|
34
|
-
try {
|
|
35
|
-
return normalizeNumericValue(field.type, nextValue, fieldPath);
|
|
36
|
-
}
|
|
37
|
-
catch {
|
|
38
|
-
return undefined;
|
|
39
|
-
}
|
|
40
|
-
};
|
|
41
|
-
const commit = (nextValue) => {
|
|
42
|
-
const canonical = canonicalize(nextValue);
|
|
43
|
-
if (canonical !== undefined)
|
|
44
|
-
onChange?.(canonical);
|
|
45
|
-
return canonical;
|
|
46
|
-
};
|
|
47
|
-
// Custom component slots (from admin config)
|
|
48
|
-
const slots = components;
|
|
49
|
-
const CustomLabel = slots?.Label;
|
|
50
|
-
const CustomHelpText = slots?.HelpText;
|
|
51
|
-
const CustomField = slots?.Field;
|
|
52
|
-
const BeforeField = slots?.beforeField;
|
|
53
|
-
const AfterField = slots?.afterField;
|
|
54
|
-
// Shared props available to every slot component
|
|
55
|
-
const slotBaseProps = {
|
|
56
|
-
field: field,
|
|
57
|
-
path: fieldPath,
|
|
58
|
-
value: incomingValue,
|
|
59
|
-
error: fieldError,
|
|
60
|
-
id: htmlId,
|
|
61
|
-
};
|
|
62
|
-
const hasCustomLabel = !!CustomLabel;
|
|
63
|
-
const suppressInputLabel = hasCustomLabel;
|
|
64
|
-
const suppressInputHelpText = !!CustomHelpText;
|
|
65
|
-
// ── Label rendering ──────────────────────────────────────────
|
|
66
|
-
const renderLabel = () => {
|
|
67
|
-
if (hasCustomLabel) {
|
|
68
|
-
return <CustomLabel {...slotBaseProps} label={field.label} required={!field.optional}/>;
|
|
69
|
-
}
|
|
70
|
-
return null;
|
|
71
|
-
};
|
|
72
|
-
// ── Field input rendering ────────────────────────────────────
|
|
73
|
-
const renderInput = () => {
|
|
74
|
-
if (CustomField) {
|
|
75
|
-
return (<CustomField {...slotBaseProps} onChange={(nextValue) => commit(nextValue)} defaultValue={defaultValue} placeholder={field.placeholder}/>);
|
|
76
|
-
}
|
|
77
|
-
return (<Input type="text" inputMode={field.type === 'integer' || field.type === 'counter' ? 'numeric' : 'decimal'} step={field.type === 'integer' || field.type === 'counter' ? 1 : 'any'} id={htmlId} name={field.name} label={suppressInputLabel ? undefined : field.label} required={!field.optional} readOnly={field.readOnly} helpText={suppressInputHelpText ? undefined : field.helpText} value={displayValue} onFocus={() => setIsEditing(true)} onChange={(e) => {
|
|
78
|
-
setDisplayValue(e.target.value);
|
|
79
|
-
commit(e.target.value);
|
|
80
|
-
}} onBlur={() => {
|
|
81
|
-
setIsEditing(false);
|
|
82
|
-
const canonical = commit(displayValue);
|
|
83
|
-
setDisplayValue(canonical === undefined ? canonicalDisplay : (canonical?.toString() ?? ''));
|
|
84
|
-
}} error={fieldError != null} errorText={fieldError}/>);
|
|
85
|
-
};
|
|
86
|
-
return (<div className={`byline-field-${field.type} ${field.name}`}>
|
|
87
|
-
{renderLabel()}
|
|
88
|
-
{BeforeField && <BeforeField {...slotBaseProps}/>}
|
|
89
|
-
{renderInput()}
|
|
90
|
-
{AfterField && <AfterField {...slotBaseProps}/>}
|
|
91
|
-
{CustomHelpText && <CustomHelpText {...slotBaseProps} helpText={field.helpText}/>}
|
|
92
|
-
</div>);
|
|
93
|
-
};
|
|
@@ -1,74 +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 { getCollectionDefinition } from '@byline/core';
|
|
9
|
-
import { resolveFallbackDisplayField, resolveRowLabel } from './relation-display';
|
|
10
|
-
/** Max titles shown for a hasMany cell before collapsing to "+N more". */
|
|
11
|
-
const MAX_SHOWN = 3;
|
|
12
|
-
function labelFor(env, displayField) {
|
|
13
|
-
// Unresolved (deleted / out-of-scope target) or not populated → no title.
|
|
14
|
-
if (env == null || env._resolved === false || env.document == null)
|
|
15
|
-
return null;
|
|
16
|
-
return resolveRowLabel(env.document, displayField);
|
|
17
|
-
}
|
|
18
|
-
/**
|
|
19
|
-
* Pure text projection of a relation cell: the target's title for a single
|
|
20
|
-
* relation, or "A, B, +N more" for `hasMany`. Unresolved targets are skipped;
|
|
21
|
-
* an all-empty cell yields `null` (the formatter renders a muted dash).
|
|
22
|
-
*
|
|
23
|
-
* `displayField` is the resolved chain
|
|
24
|
-
* (`field.displayField` → target `useAsTitle` → first text field).
|
|
25
|
-
*/
|
|
26
|
-
export function formatRelationCellText(field, value, displayField) {
|
|
27
|
-
const envelopes = field.hasMany
|
|
28
|
-
? Array.isArray(value)
|
|
29
|
-
? value
|
|
30
|
-
: []
|
|
31
|
-
: value
|
|
32
|
-
? [value]
|
|
33
|
-
: [];
|
|
34
|
-
const labels = envelopes
|
|
35
|
-
.map((env) => labelFor(env, displayField))
|
|
36
|
-
.filter((l) => l != null && l.length > 0);
|
|
37
|
-
if (labels.length === 0)
|
|
38
|
-
return null;
|
|
39
|
-
if (!field.hasMany)
|
|
40
|
-
return labels[0] ?? null;
|
|
41
|
-
const shown = labels.slice(0, MAX_SHOWN);
|
|
42
|
-
const extra = labels.length - shown.length;
|
|
43
|
-
return extra > 0 ? `${shown.join(', ')}, +${extra} more` : shown.join(', ');
|
|
44
|
-
}
|
|
45
|
-
/**
|
|
46
|
-
* The built-in `{ component }` column formatter for a `relation` field. Resolves
|
|
47
|
-
* the target collection's `useAsTitle` at render time and renders the cell text.
|
|
48
|
-
*/
|
|
49
|
-
export function relationColumnFormatter(field) {
|
|
50
|
-
return {
|
|
51
|
-
component: ({ value }) => {
|
|
52
|
-
const targetDef = getCollectionDefinition(field.targetCollection);
|
|
53
|
-
const displayField = field.displayField ?? targetDef?.useAsTitle ?? resolveFallbackDisplayField(targetDef);
|
|
54
|
-
const text = formatRelationCellText(field, value, displayField);
|
|
55
|
-
return text != null ? (<span className="byline-relation-column">{text}</span>) : (<span className="byline-relation-column-empty muted">—</span>);
|
|
56
|
-
},
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
/**
|
|
60
|
-
* Return a copy of `columns` with {@link relationColumnFormatter} applied to any
|
|
61
|
-
* `relation`-typed column that doesn't already declare its own formatter, so
|
|
62
|
-
* relation cells render target titles by default. Other columns are untouched.
|
|
63
|
-
*/
|
|
64
|
-
export function applyRelationColumnFormatters(columns, definition) {
|
|
65
|
-
return columns.map((col) => {
|
|
66
|
-
if (col.formatter)
|
|
67
|
-
return col;
|
|
68
|
-
const field = definition.fields.find((f) => f.name === String(col.fieldName));
|
|
69
|
-
if (field?.type === 'relation') {
|
|
70
|
-
return { ...col, formatter: relationColumnFormatter(field) };
|
|
71
|
-
}
|
|
72
|
-
return col;
|
|
73
|
-
});
|
|
74
|
-
}
|