/** * 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 */ /** * ImageUploadField * * A drag-and-drop / click-to-browse file picker that prepares an image for * upload. The actual upload is deferred until form submission — this component * stores the file in the form context's pending uploads and emits a placeholder * StoredFileValue with a blob URL for immediate preview. * * Prototype: no chunk upload, no resumable uploads, single file only. */ import type { ChangeEvent, DragEvent } from 'react' import { useCallback, useEffect, useRef, useState } from 'react' import { createPendingStoredFileValue, type ImageField as FieldType, type PendingStoredFileValue, type StoredFileValue, } from '@byline/core' import { useTranslation } from '@byline/i18n/react' import cx from 'clsx' import { useFormContext } from '../../forms/form-context' import styles from './image-upload-field.module.css' // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface ImageUploadFieldProps { field: FieldType /** Collection path used to build the upload URL (e.g. `'media'`). */ collectionPath: string /** Field path in the form (e.g. `'image'` or `'content.0.image'`). */ fieldPath: string /** Called with the PendingStoredFileValue for immediate preview. */ onUploaded: (value: StoredFileValue | PendingStoredFileValue) => void /** Optional accepted-file MIME types string for the native file input. */ accept?: string } type SelectionStatus = 'idle' | 'processing' | 'error' // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export const ImageUploadField = ({ field: _field, collectionPath, fieldPath, onUploaded, accept = 'image/*', }: ImageUploadFieldProps) => { const inputRef = useRef(null) const mountedRef = useRef(true) const [status, setStatus] = useState('idle') const [errorMessage, setErrorMessage] = useState(null) const [isDragOver, setIsDragOver] = useState(false) const { addPendingUpload } = useFormContext() const { t } = useTranslation('byline-admin') useEffect(() => { mountedRef.current = true return () => { mountedRef.current = false } }, []) // ------------------------------------------------------------------------- // Core file selection logic (deferred upload) // ------------------------------------------------------------------------- const handleFileSelected = useCallback( (file: File) => { setStatus('processing') setErrorMessage(null) // Basic client-side validation if (!file.type.startsWith('image/')) { setStatus('error') setErrorMessage(t('fields.image.upload.errors.notAnImage')) return } // Create a blob URL for immediate preview const previewUrl = URL.createObjectURL(file) // Extract image dimensions for the pending value const img = new Image() img.onload = () => { if (!mountedRef.current) { URL.revokeObjectURL(previewUrl) return } // SVGs without explicit width/height attrs (viewBox-only) report naturalWidth/Height = 0. // Skip dimensions when zero so they are stored as null (scalable, no fixed size). const w = img.naturalWidth const h = img.naturalHeight const dimensions = w > 0 && h > 0 ? { width: w, height: h } : undefined // Create the pending stored file value const pendingValue = createPendingStoredFileValue(file, previewUrl, dimensions) // Register the pending upload in form context if ( !addPendingUpload(fieldPath, { file, previewUrl, collectionPath, }) ) { return } setStatus('idle') onUploaded(pendingValue) } img.onerror = () => { URL.revokeObjectURL(previewUrl) if (!mountedRef.current) return setStatus('error') setErrorMessage(t('fields.image.upload.errors.cannotRead')) } img.src = previewUrl }, [collectionPath, fieldPath, addPendingUpload, onUploaded, t] ) // ------------------------------------------------------------------------- // File input // ------------------------------------------------------------------------- const handleFileChange = useCallback( (e: ChangeEvent) => { const file = e.target.files?.[0] if (file) handleFileSelected(file) // Reset so re-selecting the same file fires the event again. e.target.value = '' }, [handleFileSelected] ) const handleBrowseClick = useCallback(() => { inputRef.current?.click() }, []) // ------------------------------------------------------------------------- // Drag and drop // ------------------------------------------------------------------------- const handleDragOver = useCallback((e: DragEvent) => { e.preventDefault() setIsDragOver(true) }, []) const handleDragLeave = useCallback((e: DragEvent) => { e.preventDefault() setIsDragOver(false) }, []) const handleDrop = useCallback( (e: DragEvent) => { e.preventDefault() setIsDragOver(false) const file = e.dataTransfer.files?.[0] if (file) handleFileSelected(file) }, [handleFileSelected] ) // ------------------------------------------------------------------------- // Render // ------------------------------------------------------------------------- const isProcessing = status === 'processing' return (
{/* Hidden native file input */} {/* Drop zone */}
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() handleBrowseClick() } }} className={cx( 'byline-field-image-upload-zone', styles.zone, isDragOver && !isProcessing && ['byline-field-image-upload-zone-active', styles['zone-active']], isProcessing && ['byline-field-image-upload-zone-busy', styles['zone-busy']] )} > {isProcessing ? ( <> {/* Spinner */} {t('fields.image.upload.processing')} ) : ( <> {/* Upload icon */} {t('fields.image.upload.label')}{' '} {t('fields.image.upload.browse')} {t('fields.image.upload.hint')} )}
{/* Error message */} {status === 'error' && errorMessage && (

{errorMessage}

)}
) }