/** * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * * Copyright (c) Infonomic Company Limited */ import { useState } from 'react' import { type ImageField as FieldType, isPendingStoredFileValue, type StoredFileValue, } from '@byline/core' import { useTranslation } from '@byline/i18n/react' import { CloseIcon, ErrorText, HelpText, IconButton, ImageLightbox, Label, LoaderRing, } from '@byline/ui/react' import cx from 'clsx' import { useFieldError, useFieldValue, useFormContext, useIsDirty, useIsFieldUploading, } from '../../forms/form-context' import { useScopedDomId } from '../../forms/form-dom-scope' import { useFieldChangeHandler } from '../use-field-change-handler' import styles from './image-field.module.css' import { ImageUploadField } from './image-upload-field' interface ImageFieldProps { field: FieldType // Stored value is currently a plain object with file/image metadata // coming from the seed data / storage layer. value?: StoredFileValue | null defaultValue?: StoredFileValue | null onChange?: (value: StoredFileValue | null) => void path?: string } export const ImageField = ({ field, value, defaultValue, onChange: _onChange, path, }: ImageFieldProps) => { const fieldPath = path ?? field.name const fieldError = useFieldError(fieldPath) const isDirty = useIsDirty(fieldPath) const fieldValue = useFieldValue(fieldPath) const isUploading = useIsFieldUploading(fieldPath) // `collectionPath` comes from form context rather than a prop: it is // constant for the form, and prop-drilling it meant any container that // forgot to forward it silently rendered this widget read-only. const { removePendingUpload, documentId, collectionPath } = useFormContext() const { t } = useTranslation('byline-admin') // Re-use the standard field change handler so patches are emitted correctly. const handleChange = useFieldChangeHandler(field, fieldPath) // When the field has been explicitly set (dirty), use the field value from // form state — even if it's null (user clicked Remove). Only fall back to // the prop / defaultValue when the field hasn't been touched yet. const incomingValue = isDirty ? (fieldValue ?? null) : (value ?? fieldValue ?? defaultValue ?? null) // Check if this is a pending upload (selected but not yet uploaded) const isPending = isPendingStoredFileValue(incomingValue) // Old placeholder check for backwards compatibility const isOldPlaceholder = (v: unknown): boolean => { if (!v || typeof v !== 'object') return false const maybe = v as Partial return maybe.storageProvider === 'placeholder' && maybe.storagePath === 'pending' } // Show upload widget only if no value or old placeholder const showUploadWidget = incomingValue == null || isOldPlaceholder(incomingValue) // `upload.requireSavedDocument` gate: until the document is persisted, // render a "save first" notice in place of the upload zone. Server-side // upload hooks that depend on save-time state (counters, document id) // rely on this; existing stored values still render normally below. const uploadGated = field.upload?.requireSavedDocument === true && documentId == null // Prefer the generated thumbnail variant for the preview tile. SVGs and // other bypass types have no variants — fall back to the original. const thumbVariant = incomingValue && !isPendingStoredFileValue(incomingValue) ? incomingValue.variants?.find((v) => v.name === 'thumbnail') : undefined const previewUrl = thumbVariant?.storageUrl ?? incomingValue?.storageUrl // Handle remove, including cleanup of pending uploads const handleRemove = () => { if (field.readOnly) return if (isPending) { removePendingUpload(fieldPath) } handleChange(null) } // Lightbox state — only enabled for stored (non-pending) images that have a // resolvable original storageUrl. const [lightboxOpen, setLightboxOpen] = useState(false) const canOpenLightbox = !isPending && !!incomingValue?.storageUrl const htmlId = useScopedDomId(fieldPath) return (
{showUploadWidget ? ( uploadGated ? (
{t('fields.upload.requireSavedDocument')}
) : collectionPath && !field.readOnly ? ( { handleChange(uploaded) }} /> ) : (
{t('fields.image.empty')}
) ) : (
{isUploading && (
)} {/* Remove button — shown when an image is set (including pending) */} {collectionPath && (
)} {/* Preview */} {previewUrl && (
{canOpenLightbox ? ( ) : ( {incomingValue.originalFilename )} {/* Pending upload badge */} {isPending && (
{t('fields.fileMeta.pendingUpload')}
)}
)} {/* Metadata */}
{t('fields.fileMeta.filename')} {' '} {incomingValue?.filename}
{t('fields.fileMeta.original')} {' '} {incomingValue?.originalFilename}
{t('fields.fileMeta.type')} {' '} {incomingValue?.mimeType}
{t('fields.fileMeta.size')} {' '} {incomingValue?.fileSize}
{isPending ? (
{t('fields.fileMeta.status')} {' '} {t('fields.fileMeta.willUploadOnSave')}
) : ( <>
{t('fields.fileMeta.storage')} {' '} {incomingValue?.storageProvider}
{incomingValue?.imageWidth != null && (
{t('fields.imageMeta.dimensions')} {' '} {incomingValue.imageWidth} {incomingValue.imageHeight != null ? `×${incomingValue.imageHeight}` : ''}
)} {incomingValue?.imageFormat != null && (
{t('fields.imageMeta.format')} {' '} {incomingValue.imageFormat}
)}
{t('fields.imageMeta.thumbnail')} {' '} {thumbVariant ? t('fields.imageMeta.thumbnailGenerated') : t('fields.imageMeta.thumbnailPending')}
)}
)} {field.helpText && } {fieldError && } {canOpenLightbox && incomingValue?.storageUrl && ( setLightboxOpen(false)} src={incomingValue.storageUrl} alt={incomingValue.originalFilename ?? incomingValue.filename} downloadFilename={incomingValue.originalFilename ?? incomingValue.filename} title={incomingValue.originalFilename ?? incomingValue.filename} meta={{ width: incomingValue.imageWidth, height: incomingValue.imageHeight, fileSize: incomingValue.fileSize, mimeType: incomingValue.mimeType, }} /> )}
) }