/** * 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 { type FileField as FieldType, isPendingStoredFileValue, type StoredFileValue, } from '@byline/core' import { useTranslation } from '@byline/i18n/react' import { CloseIcon, DocumentIcon, DownloadIcon, ErrorText, HelpText, IconButton, Label, LoaderRing, VideoIcon, } 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 './file-field.module.css' import { FileUploadField } from './file-upload-field' /** * Trigger a download via a temporary anchor. Mirrors the helper in * `image-lightbox.tsx`: same-origin URLs respect the `download` attribute and * save with the suggested filename; cross-origin URLs without CORS headers * fall through to navigation in a new tab, where the user can right-click * Save As. */ function triggerDownload(url: string, filename?: string) { if (typeof document === 'undefined') return const a = document.createElement('a') a.href = url if (filename) a.download = filename a.target = '_blank' a.rel = 'noreferrer' document.body.appendChild(a) a.click() document.body.removeChild(a) } interface FileFieldProps { field: FieldType value?: StoredFileValue | null defaultValue?: StoredFileValue | null onChange?: (value: StoredFileValue | null) => void path?: string } export const FileField = ({ field, value, defaultValue, onChange: _onChange, path, }: FileFieldProps) => { const fieldPath = path ?? field.name const { t } = useTranslation('byline-admin') 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 handleChange = useFieldChangeHandler(field, fieldPath) // Mirror the image-field rule: once the field has been touched, the form // value is authoritative (even when null, so a click-to-remove sticks); // otherwise fall back to props. const incomingValue = isDirty ? (fieldValue ?? null) : (value ?? fieldValue ?? defaultValue ?? null) const isPending = isPendingStoredFileValue(incomingValue) // Legacy placeholder shape — kept for backwards compatibility with older // seed data, matching the image-field check. const isOldPlaceholder = (v: unknown): boolean => { if (!v || typeof v !== 'object') return false const maybe = v as Partial return maybe.storageProvider === 'placeholder' && maybe.storagePath === 'pending' } 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 const handleRemove = () => { if (field.readOnly) return if (isPending) { removePendingUpload(fieldPath) } handleChange(null) } // MIME-driven glyph dispatch. Until a dedicated VideoField primitive lands, // the FileField is the canonical home for video uploads — the schema's // `upload.allowedMimeTypes` decides what gets in, and we swap the glyph // here based on the resolved MIME so the tile reads as "video" rather // than "generic document". const isVideo = incomingValue?.mimeType?.startsWith('video/') === true const FileGlyph = isVideo ? VideoIcon : DocumentIcon const htmlId = useScopedDomId(fieldPath) return (
{showUploadWidget ? ( uploadGated ? (
{t('fields.upload.requireSavedDocument')}
) : collectionPath && !field.readOnly ? ( { handleChange(uploaded) }} /> ) : (
{t('fields.file.empty')}
) ) : (
{isUploading && (
)} {collectionPath && (
{!isPending && incomingValue?.storageUrl && ( triggerDownload( incomingValue.storageUrl as string, incomingValue.originalFilename ?? incomingValue.filename ) } size="xs" disabled={isUploading} aria-label={t('fields.file.downloadAriaLabel')} > )}
)} {/* Document icon + (optional) pending badge — mirrors the image-field's preview-wrap so the file tile has the same visual hierarchy: glyph on the left, metadata on the right. When the file is stored (non-pending and resolvable storageUrl), the wrap is rendered as an anchor that opens the asset in a new tab — browser-native viewer dispatch (PDFs render inline, non-renderable types fall through to download). */} {!isPending && incomingValue?.storageUrl ? ( ) : (
{isPending && (
{t('fields.fileMeta.pendingUpload')}
)}
)}
{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}
{t('fields.fileMeta.path')} {' '} {incomingValue?.storagePath}
)}
)} {field.helpText && } {fieldError && }
) }