From c50d06f7db67828c291daefc93dfb2d3345e988c Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Mon, 15 Jun 2026 12:50:32 +0100 Subject: [PATCH 01/19] update test suite --- .../AudioFilePreview/index.client.tsx | 8 + .../components/AudioFilePreview/index.tsx | 25 ++ .../PdfFilePreview/index.client.tsx | 8 + .../components/PdfFilePreview/index.tsx | 25 ++ .../SingleFilePreview/index.client.tsx | 15 + .../components/SingleFilePreview/index.tsx | 25 ++ .../AdminUploadFilePreview/index.ts | 45 +++ .../components/FilePreview/index.tsx | 43 +++ test/uploads/collections/FilePreview/index.ts | 24 ++ test/uploads/config.ts | 189 +++++++++++ test/uploads/e2e.spec.ts | 186 ++++++++++ test/uploads/payload-types.ts | 320 +++++++++++++++++- test/uploads/seed.ts | 72 ++++ test/uploads/shared.ts | 4 + 14 files changed, 979 insertions(+), 10 deletions(-) create mode 100644 test/uploads/collections/AdminUploadFilePreview/components/AudioFilePreview/index.client.tsx create mode 100644 test/uploads/collections/AdminUploadFilePreview/components/AudioFilePreview/index.tsx create mode 100644 test/uploads/collections/AdminUploadFilePreview/components/PdfFilePreview/index.client.tsx create mode 100644 test/uploads/collections/AdminUploadFilePreview/components/PdfFilePreview/index.tsx create mode 100644 test/uploads/collections/AdminUploadFilePreview/components/SingleFilePreview/index.client.tsx create mode 100644 test/uploads/collections/AdminUploadFilePreview/components/SingleFilePreview/index.tsx create mode 100644 test/uploads/collections/AdminUploadFilePreview/index.ts create mode 100644 test/uploads/collections/FilePreview/components/FilePreview/index.tsx create mode 100644 test/uploads/collections/FilePreview/index.ts diff --git a/test/uploads/collections/AdminUploadFilePreview/components/AudioFilePreview/index.client.tsx b/test/uploads/collections/AdminUploadFilePreview/components/AudioFilePreview/index.client.tsx new file mode 100644 index 00000000000..20e0fe9938e --- /dev/null +++ b/test/uploads/collections/AdminUploadFilePreview/components/AudioFilePreview/index.client.tsx @@ -0,0 +1,8 @@ +'use client' +import type { UploadFilePreviewClientProps } from 'payload' + +import React from 'react' + +export const AudioFilePreviewClient: React.FC = ({ fileSrc }) => { + return
test{fileSrc &&
+} diff --git a/test/uploads/collections/AdminUploadFilePreview/components/AudioFilePreview/index.tsx b/test/uploads/collections/AdminUploadFilePreview/components/AudioFilePreview/index.tsx new file mode 100644 index 00000000000..c276a782aa9 --- /dev/null +++ b/test/uploads/collections/AdminUploadFilePreview/components/AudioFilePreview/index.tsx @@ -0,0 +1,25 @@ +import type { UploadFilePreviewClientProps } from 'payload' + +import React from 'react' + +import { AudioFilePreviewClient } from './index.client.js' + +export const AudioFilePreviewRSC: React.FC = ({ + filename, + filesize, + fileSrc, + height, + mimeType, + width, +}) => { + return ( + + ) +} diff --git a/test/uploads/collections/AdminUploadFilePreview/components/PdfFilePreview/index.client.tsx b/test/uploads/collections/AdminUploadFilePreview/components/PdfFilePreview/index.client.tsx new file mode 100644 index 00000000000..d6b41711e52 --- /dev/null +++ b/test/uploads/collections/AdminUploadFilePreview/components/PdfFilePreview/index.client.tsx @@ -0,0 +1,8 @@ +'use client' +import type { UploadFilePreviewClientProps } from 'payload' + +import React from 'react' + +export const PdfFilePreviewClient: React.FC = ({ fileSrc }) => { + return
{fileSrc && {fileSrc}}
+} diff --git a/test/uploads/collections/AdminUploadFilePreview/components/PdfFilePreview/index.tsx b/test/uploads/collections/AdminUploadFilePreview/components/PdfFilePreview/index.tsx new file mode 100644 index 00000000000..e6b1e49d947 --- /dev/null +++ b/test/uploads/collections/AdminUploadFilePreview/components/PdfFilePreview/index.tsx @@ -0,0 +1,25 @@ +import type { UploadFilePreviewClientProps } from 'payload' + +import React from 'react' + +import { PdfFilePreviewClient } from './index.client.js' + +export const PdfFilePreviewRSC: React.FC = ({ + filename, + filesize, + fileSrc, + height, + mimeType, + width, +}) => { + return ( + + ) +} diff --git a/test/uploads/collections/AdminUploadFilePreview/components/SingleFilePreview/index.client.tsx b/test/uploads/collections/AdminUploadFilePreview/components/SingleFilePreview/index.client.tsx new file mode 100644 index 00000000000..b809305ee8c --- /dev/null +++ b/test/uploads/collections/AdminUploadFilePreview/components/SingleFilePreview/index.client.tsx @@ -0,0 +1,15 @@ +'use client' +import type { UploadFilePreviewClientProps } from 'payload' + +import React from 'react' + +export const SingleFilePreviewClient: React.FC = ({ + fileSrc, + mimeType, +}) => { + return ( +
+ {fileSrc && {fileSrc}} +
+ ) +} diff --git a/test/uploads/collections/AdminUploadFilePreview/components/SingleFilePreview/index.tsx b/test/uploads/collections/AdminUploadFilePreview/components/SingleFilePreview/index.tsx new file mode 100644 index 00000000000..00f66568fb4 --- /dev/null +++ b/test/uploads/collections/AdminUploadFilePreview/components/SingleFilePreview/index.tsx @@ -0,0 +1,25 @@ +import type { UploadFilePreviewClientProps } from 'payload' + +import React from 'react' + +import { SingleFilePreviewClient } from './index.client.js' + +export const SingleFilePreviewRSC: React.FC = ({ + filename, + filesize, + fileSrc, + height, + mimeType, + width, +}) => { + return ( + + ) +} diff --git a/test/uploads/collections/AdminUploadFilePreview/index.ts b/test/uploads/collections/AdminUploadFilePreview/index.ts new file mode 100644 index 00000000000..a7a9b0a01c5 --- /dev/null +++ b/test/uploads/collections/AdminUploadFilePreview/index.ts @@ -0,0 +1,45 @@ +import type { CollectionConfig } from 'payload' + +import path from 'path' +import { fileURLToPath } from 'url' + +import { adminUploadFilePreviewMapSlug, adminUploadFilePreviewSingleSlug } from '../../shared.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +export const AdminUploadFilePreviewSingle: CollectionConfig = { + slug: adminUploadFilePreviewSingleSlug, + upload: { + staticDir: path.resolve(dirname, '../../media'), + admin: { + components: { + filePreview: + '/collections/AdminUploadFilePreview/components/SingleFilePreview/index.js#SingleFilePreviewRSC', + }, + }, + }, + fields: [], + versions: false, +} + +export const AdminUploadFilePreviewMap: CollectionConfig = { + slug: adminUploadFilePreviewMapSlug, + upload: { + staticDir: path.resolve(dirname, '../../media'), + admin: { + components: { + filePreview: { + 'audio/*': + '/collections/AdminUploadFilePreview/components/AudioFilePreview/index.js#AudioFilePreviewRSC', + 'application/pdf': + '/collections/AdminUploadFilePreview/components/PdfFilePreview/index.js#PdfFilePreviewRSC', + 'video/*': + '/collections/FilePreview/components/FilePreview/index.js#FilePreviewComponent', + }, + }, + }, + }, + fields: [], + versions: false, +} diff --git a/test/uploads/collections/FilePreview/components/FilePreview/index.tsx b/test/uploads/collections/FilePreview/components/FilePreview/index.tsx new file mode 100644 index 00000000000..4a64f61d34b --- /dev/null +++ b/test/uploads/collections/FilePreview/components/FilePreview/index.tsx @@ -0,0 +1,43 @@ +'use client' +import type { UploadFilePreviewClientProps } from 'payload' + +import React from 'react' + +export const FilePreviewComponent: React.FC = ({ + filename, + fileSrc, + mimeType, +}) => { + const [category] = (mimeType ?? '').split('/') + + switch (category) { + case 'audio': + return ( +
+
+ ) + case 'image': + return ( +
+ {filename} +
+ ) + case 'video': + return ( +
+
+ ) + default: + return ( +
+ {fileSrc ? {filename} : filename} +
+ ) + } +} diff --git a/test/uploads/collections/FilePreview/index.ts b/test/uploads/collections/FilePreview/index.ts new file mode 100644 index 00000000000..dc750d1eb42 --- /dev/null +++ b/test/uploads/collections/FilePreview/index.ts @@ -0,0 +1,24 @@ +import type { CollectionConfig } from 'payload' + +import path from 'path' +import { fileURLToPath } from 'url' + +import { filePreviewSlug } from '../../shared.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +export const FilePreviewCollection: CollectionConfig = { + slug: filePreviewSlug, + upload: { + staticDir: path.resolve(dirname, '../../media'), + admin: { + components: { + filePreview: + '/collections/FilePreview/components/FilePreview/index.js#FilePreviewComponent', + }, + }, + }, + fields: [], + versions: false, +} diff --git a/test/uploads/config.ts b/test/uploads/config.ts index 8049be18792..2da01b9b33c 100644 --- a/test/uploads/config.ts +++ b/test/uploads/config.ts @@ -6,11 +6,16 @@ import { AdminThumbnailFunction } from './collections/AdminThumbnailFunction/ind import { AdminThumbnailSize } from './collections/AdminThumbnailSize/index.js' import { AdminThumbnailWithSearchQueries } from './collections/AdminThumbnailWithSearchQueries/index.js' import { AdminUploadControl } from './collections/AdminUploadControl/index.js' +import { + AdminUploadFilePreviewMap, + AdminUploadFilePreviewSingle, +} from './collections/AdminUploadFilePreview/index.js' import { AnyImageTypeCollection } from './collections/AnyImageType/index.js' import { BulkUploadsCollection } from './collections/BulkUploads/index.js' import { BulkUploadsHookErrorCollection } from './collections/BulkUploadsHookError/index.js' import { CustomUploadFieldCollection } from './collections/CustomUploadField/index.js' import { FileMimeType } from './collections/FileMimeType/index.js' +import { FilePreviewCollection } from './collections/FilePreview/index.js' import { NoFilesRequired } from './collections/NoFilesRequired/index.js' import { RelationToNoFilesRequired } from './collections/RelationToNoFilesRequired/index.js' import { SimpleRelationshipCollection } from './collections/SimpleRelationship/index.js' @@ -29,6 +34,7 @@ import { imageSizesOnlySlug, listViewPreviewSlug, mediaSlug, + mediaWithFieldsSlug, mediaWithImageSizeAdminPropsSlug, mediaWithoutCacheTagsSlug, mediaWithoutDeleteAccessSlug, @@ -833,6 +839,9 @@ export default buildConfigWithDefaults({ AdminThumbnailWithSearchQueries, AdminThumbnailSize, AdminUploadControl, + AdminUploadFilePreviewSingle, + AdminUploadFilePreviewMap, + FilePreviewCollection, NoFilesRequired, RelationToNoFilesRequired, { @@ -1125,6 +1134,186 @@ export default buildConfigWithDefaults({ }, versions: false, }, + { + slug: mediaWithFieldsSlug, + fields: [ + { + name: 'title', + type: 'text', + required: true, + }, + { + name: 'description', + type: 'textarea', + }, + { + name: 'altText', + label: 'Alt Text', + type: 'text', + }, + { + name: 'caption', + type: 'text', + }, + { + name: 'credit', + label: 'Photo Credit', + type: 'text', + }, + { + name: 'source', + label: 'Source URL', + type: 'text', + }, + { + name: 'category', + type: 'select', + options: ['Nature', 'Architecture', 'People', 'Abstract', 'Technology'], + }, + { + name: 'tags', + type: 'text', + hasMany: true, + }, + { + name: 'featured', + label: 'Featured Image', + type: 'checkbox', + }, + { + name: 'photographer', + type: 'text', + admin: { + // position: 'sidebar', + }, + }, + { + name: 'priority', + type: 'select', + options: ['Low', 'Medium', 'High'], + defaultValue: 'Medium', + }, + { + name: 'shootDate', + label: 'Shoot Date', + type: 'date', + }, + { + name: 'location', + type: 'group', + fields: [ + { + name: 'city', + type: 'text', + }, + { + name: 'country', + type: 'text', + }, + ], + }, + { + name: 'dimensions', + label: 'Original Dimensions', + type: 'group', + fields: [ + { + name: 'widthCm', + label: 'Width (cm)', + type: 'number', + }, + { + name: 'heightCm', + label: 'Height (cm)', + type: 'number', + }, + ], + }, + { + name: 'colorProfile', + label: 'Color Profile', + type: 'select', + options: ['sRGB', 'Adobe RGB', 'ProPhoto RGB', 'CMYK'], + }, + { + name: 'license', + type: 'select', + options: ['All Rights Reserved', 'CC BY', 'CC BY-SA', 'CC BY-NC', 'Public Domain'], + }, + { + name: 'licenseUrl', + label: 'License URL', + type: 'text', + }, + { + name: 'notes', + label: 'Internal Notes', + type: 'textarea', + }, + { + name: 'rating', + type: 'number', + min: 1, + max: 5, + }, + { + name: 'exifData', + label: 'EXIF Data', + type: 'group', + fields: [ + { + name: 'camera', + type: 'text', + }, + { + name: 'lens', + type: 'text', + }, + { + name: 'iso', + label: 'ISO', + type: 'number', + }, + { + name: 'aperture', + type: 'text', + }, + { + name: 'shutterSpeed', + label: 'Shutter Speed', + type: 'text', + }, + ], + }, + { + name: 'published', + type: 'checkbox', + defaultValue: false, + }, + ], + upload: { + crop: true, + imageSizes: [ + { + name: 'thumbnail', + width: 300, + height: 300, + crop: 'centre', + }, + { + name: 'card', + width: 768, + height: 512, + }, + { + name: 'hero', + width: 1920, + height: 1080, + }, + ], + staticDir: path.resolve(dirname, './media'), + }, + }, ], onInit: async (payload) => { if (process.env.SEED_IN_CONFIG_ONINIT !== 'false') { diff --git a/test/uploads/e2e.spec.ts b/test/uploads/e2e.spec.ts index 050ec028a58..2d8ff78738f 100644 --- a/test/uploads/e2e.spec.ts +++ b/test/uploads/e2e.spec.ts @@ -36,6 +36,9 @@ import { adminThumbnailSizeSlug, adminThumbnailWithSearchQueries, adminUploadControlSlug, + adminUploadFilePreviewMapSlug, + adminUploadFilePreviewSingleSlug, + filePreviewSlug, animatedTypeMedia, audioSlug, bulkUploadsHookErrorSlug, @@ -122,6 +125,9 @@ let mediaWithoutDeleteAccessURL: AdminUrlUtil let mediaWithImageSizeAdminPropsURL: AdminUrlUtil let noFilesRequiredURL: AdminUrlUtil let relationToNoFilesRequiredURL: AdminUrlUtil +let adminUploadFilePreviewSingleURL: AdminUrlUtil +let adminUploadFilePreviewMapURL: AdminUrlUtil +let filePreviewURL: AdminUrlUtil describe('Uploads', () => { let page: Page @@ -167,6 +173,9 @@ describe('Uploads', () => { mediaWithImageSizeAdminPropsURL = new AdminUrlUtil(serverURL, mediaWithImageSizeAdminPropsSlug) noFilesRequiredURL = new AdminUrlUtil(serverURL, noFilesRequiredSlug) relationToNoFilesRequiredURL = new AdminUrlUtil(serverURL, relationToNoFilesRequiredSlug) + adminUploadFilePreviewSingleURL = new AdminUrlUtil(serverURL, adminUploadFilePreviewSingleSlug) + adminUploadFilePreviewMapURL = new AdminUrlUtil(serverURL, adminUploadFilePreviewMapSlug) + filePreviewURL = new AdminUrlUtil(serverURL, filePreviewSlug) const context = await browser.newContext() await context.grantPermissions(['clipboard-read', 'clipboard-write']) @@ -297,6 +306,59 @@ describe('Uploads', () => { expect(clipbaordContent).toBe(mediaDoc?.url) }) + test('should show side-by-side layout for upload collection document with file', async () => { + const mediaDoc = ( + await payload.find({ + collection: mediaSlug, + depth: 0, + limit: 1, + pagination: false, + }) + ).docs[0] + + await page.goto(mediaURL.edit(mediaDoc!.id)) + await waitForFormReady(page) + + await expect(page.locator('.collection-edit__upload-layout')).toBeVisible() + await expect(page.locator('.file-field__side-panel')).toBeVisible() + await expect(page.locator('.file-field__side-panel__preview')).toBeVisible() + await expect(page.locator('.mini-carousel')).toBeVisible() + }) + + test('should show upload dropzone in right panel for new upload collection document', async () => { + await page.goto(mediaURL.create) + await waitForFormReady(page) + + await expect(page.locator('.collection-edit__upload-layout')).toBeVisible() + await expect(page.locator('.file-field__side-panel .dropzone')).toBeVisible() + }) + + test('should switch active thumbnail when clicking mini carousel item', async () => { + const mediaDoc = ( + await payload.find({ + collection: mediaSlug, + depth: 0, + limit: 1, + pagination: false, + where: { + mimeType: { contains: 'image/' }, + }, + }) + ).docs[0] + + await page.goto(mediaURL.edit(mediaDoc!.id)) + await waitForFormReady(page) + + const carouselItems = page.locator('.mini-carousel__item') + + await expect(carouselItems.first()).toHaveClass(/mini-carousel__item--active/) + + await carouselItems.nth(1).click() + + await expect(carouselItems.nth(1)).toHaveClass(/mini-carousel__item--active/) + await expect(carouselItems.first()).not.toHaveClass(/mini-carousel__item--active/) + }) + test('should create file upload', async () => { await page.goto(mediaURL.create) await page.setInputFiles('input[type="file"]', path.resolve(dirname, './image.png')) @@ -2443,4 +2505,128 @@ describe('Uploads', () => { const response = await page.goto(`${serverURL}${href}`) expect(response?.status()).toBe(200) }) + + describe('filePreview custom components', () => { + test('should render single custom filePreview for any file type', async () => { + const imageDoc = ( + await payload.find({ + collection: adminUploadFilePreviewSingleSlug, + depth: 0, + limit: 1, + where: { mimeType: { equals: 'image/png' } }, + }) + ).docs[0] + + await page.goto(adminUploadFilePreviewSingleURL.edit(imageDoc!.id)) + await waitForFormReady(page) + + await expect(page.locator('#custom-file-preview-single')).toBeVisible() + await expect(page.locator('.file-field__side-panel__image-wrap .thumbnail')).toBeHidden() + }) + + test('should pass mimeType as clientProp to filePreview component', async () => { + const audioDoc = ( + await payload.find({ + collection: adminUploadFilePreviewSingleSlug, + depth: 0, + limit: 1, + where: { mimeType: { equals: 'audio/mpeg' } }, + }) + ).docs[0] + + await page.goto(adminUploadFilePreviewSingleURL.edit(audioDoc!.id)) + await waitForFormReady(page) + + await expect(page.locator('#custom-file-preview-single')).toHaveAttribute( + 'data-mime-type', + 'audio/mpeg', + ) + }) + + test('should render filePreview matched by exact MIME type', async () => { + const pdfDoc = ( + await payload.find({ + collection: adminUploadFilePreviewMapSlug, + depth: 0, + limit: 1, + where: { mimeType: { equals: 'application/pdf' } }, + }) + ).docs[0] + + await page.goto(adminUploadFilePreviewMapURL.edit(pdfDoc!.id)) + await waitForFormReady(page) + + await expect(page.locator('#custom-file-preview-pdf')).toBeVisible() + }) + + test('should render filePreview matched by category wildcard', async () => { + const audioDoc = ( + await payload.find({ + collection: adminUploadFilePreviewMapSlug, + depth: 0, + limit: 1, + where: { mimeType: { equals: 'audio/mpeg' } }, + }) + ).docs[0] + + await page.goto(adminUploadFilePreviewMapURL.edit(audioDoc!.id)) + await waitForFormReady(page) + + await expect(page.locator('#custom-file-preview-audio')).toBeVisible() + }) + + test('should fall back to default Thumbnail when no filePreview matches', async () => { + const imageDoc = ( + await payload.find({ + collection: adminUploadFilePreviewMapSlug, + depth: 0, + limit: 1, + where: { mimeType: { equals: 'image/png' } }, + }) + ).docs[0] + + await page.goto(adminUploadFilePreviewMapURL.edit(imageDoc!.id)) + await waitForFormReady(page) + + await expect(page.locator('.file-field__side-panel__image-wrap .thumbnail')).toBeVisible() + await expect(page.locator('#custom-file-preview-pdf')).toBeHidden() + await expect(page.locator('#custom-file-preview-audio')).toBeHidden() + }) + }) + + describe('filePreview switch-case component', () => { + test('should render image branch for image uploads', async () => { + const imageDoc = ( + await payload.find({ + collection: filePreviewSlug, + depth: 0, + limit: 1, + where: { mimeType: { equals: 'image/png' } }, + }) + ).docs[0] + + await page.goto(filePreviewURL.edit(imageDoc!.id)) + await waitForFormReady(page) + + await expect(page.locator('#file-preview[data-mime-category="image"]')).toBeVisible() + await expect(page.locator('#file-preview img')).toBeVisible() + }) + + test('should render audio branch for audio uploads', async () => { + const audioDoc = ( + await payload.find({ + collection: filePreviewSlug, + depth: 0, + limit: 1, + where: { mimeType: { equals: 'audio/mpeg' } }, + }) + ).docs[0] + + await page.goto(filePreviewURL.edit(audioDoc!.id)) + await waitForFormReady(page) + + await expect(page.locator('#file-preview[data-mime-category="audio"]')).toBeVisible() + await expect(page.locator('#file-preview audio')).toBeVisible() + }) + }) }) diff --git a/test/uploads/payload-types.ts b/test/uploads/payload-types.ts index 6bf4597ba95..3d00876a576 100644 --- a/test/uploads/payload-types.ts +++ b/test/uploads/payload-types.ts @@ -62,15 +62,15 @@ export type SupportedTimezones = | 'Pacific/Fiji'; /** * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "LexicalNodes_7A11A510". + * via the `definition` "LexicalNodes_60C9F82D". */ -export type LexicalNodes_7A11A510 = +export type LexicalNodes_60C9F82D = | SerializedTextNode | SerializedTabNode | SerializedLineBreakNode - | SerializedParagraphNode + | SerializedParagraphNode | SerializedBlockNode - | SerializedHeadingNode + | SerializedHeadingNode | SerializedUploadNode<'gif-resize'> | SerializedUploadNode<'filename-compound-index'> | SerializedUploadNode<'no-image-sizes'> @@ -105,6 +105,9 @@ export type LexicalNodes_7A11A510 = | SerializedUploadNode<'admin-thumbnail-with-search-queries'> | SerializedUploadNode<'admin-thumbnail-size'> | SerializedUploadNode<'admin-upload-control'> + | SerializedUploadNode<'admin-upload-file-preview-single'> + | SerializedUploadNode<'admin-upload-file-preview-map'> + | SerializedUploadNode<'file-preview'> | SerializedUploadNode<'no-files-required'> | SerializedUploadNode<'optional-file'> | SerializedUploadNode<'required-file'> @@ -123,11 +126,12 @@ export type LexicalNodes_7A11A510 = | SerializedUploadNode<'media-without-delete-access'> | SerializedUploadNode<'media-with-image-size-admin-props'> | SerializedUploadNode<'prefix-media'> - | SerializedQuoteNode - | SerializedListNode - | SerializedListItemNode - | SerializedAutoLinkNode - | SerializedLinkNode + | SerializedUploadNode<'media-with-fields'> + | SerializedQuoteNode + | SerializedListNode + | SerializedListItemNode + | SerializedAutoLinkNode + | SerializedLinkNode | SerializedRelationshipNode< | 'relation' | 'audio' @@ -188,6 +192,9 @@ export interface Config { 'admin-thumbnail-with-search-queries': AdminThumbnailWithSearchQuery; 'admin-thumbnail-size': AdminThumbnailSize; 'admin-upload-control': AdminUploadControl; + 'admin-upload-file-preview-single': AdminUploadFilePreviewSingle; + 'admin-upload-file-preview-map': AdminUploadFilePreviewMap; + 'file-preview': FilePreview; 'no-files-required': NoFilesRequired; 'relation-to-no-files-required': RelationToNoFilesRequired; 'optional-file': OptionalFile; @@ -211,6 +218,7 @@ export interface Config { 'media-without-delete-access': MediaWithoutDeleteAccess; 'media-with-image-size-admin-props': MediaWithImageSizeAdminProp; 'prefix-media': PrefixMedia; + 'media-with-fields': MediaWithField; users: User; 'payload-mcp-api-keys': PayloadMcpApiKey; 'payload-kv': PayloadKv; @@ -258,6 +266,9 @@ export interface Config { 'admin-thumbnail-with-search-queries': AdminThumbnailWithSearchQueriesSelect | AdminThumbnailWithSearchQueriesSelect; 'admin-thumbnail-size': AdminThumbnailSizeSelect | AdminThumbnailSizeSelect; 'admin-upload-control': AdminUploadControlSelect | AdminUploadControlSelect; + 'admin-upload-file-preview-single': AdminUploadFilePreviewSingleSelect | AdminUploadFilePreviewSingleSelect; + 'admin-upload-file-preview-map': AdminUploadFilePreviewMapSelect | AdminUploadFilePreviewMapSelect; + 'file-preview': FilePreviewSelect | FilePreviewSelect; 'no-files-required': NoFilesRequiredSelect | NoFilesRequiredSelect; 'relation-to-no-files-required': RelationToNoFilesRequiredSelect | RelationToNoFilesRequiredSelect; 'optional-file': OptionalFileSelect | OptionalFileSelect; @@ -281,6 +292,7 @@ export interface Config { 'media-without-delete-access': MediaWithoutDeleteAccessSelect | MediaWithoutDeleteAccessSelect; 'media-with-image-size-admin-props': MediaWithImageSizeAdminPropsSelect | MediaWithImageSizeAdminPropsSelect; 'prefix-media': PrefixMediaSelect | PrefixMediaSelect; + 'media-with-fields': MediaWithFieldsSelect | MediaWithFieldsSelect; users: UsersSelect | UsersSelect; 'payload-mcp-api-keys': PayloadMcpApiKeysSelect | PayloadMcpApiKeysSelect; 'payload-kv': PayloadKvSelect | PayloadKvSelect; @@ -1389,7 +1401,7 @@ export interface Uploads1 { singleUpload?: (string | null) | Uploads2; hasManyThumbnailUpload?: (string | AdminThumbnailSize)[] | null; singleThumbnailUpload?: (string | null) | AdminThumbnailSize; - richText?: LexicalRichText | null; + richText?: LexicalRichText | null; updatedAt: string; createdAt: string; url?: string | null; @@ -1530,6 +1542,60 @@ export interface AdminUploadControl { focalX?: number | null; focalY?: number | null; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "admin-upload-file-preview-single". + */ +export interface AdminUploadFilePreviewSingle { + id: string; + updatedAt: string; + createdAt: string; + url?: string | null; + thumbnailURL?: string | null; + filename?: string | null; + mimeType?: string | null; + filesize?: number | null; + width?: number | null; + height?: number | null; + focalX?: number | null; + focalY?: number | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "admin-upload-file-preview-map". + */ +export interface AdminUploadFilePreviewMap { + id: string; + updatedAt: string; + createdAt: string; + url?: string | null; + thumbnailURL?: string | null; + filename?: string | null; + mimeType?: string | null; + filesize?: number | null; + width?: number | null; + height?: number | null; + focalX?: number | null; + focalY?: number | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "file-preview". + */ +export interface FilePreview { + id: string; + updatedAt: string; + createdAt: string; + url?: string | null; + thumbnailURL?: string | null; + filename?: string | null; + mimeType?: string | null; + filesize?: number | null; + width?: number | null; + height?: number | null; + focalX?: number | null; + focalY?: number | null; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "no-files-required". @@ -1925,6 +1991,83 @@ export interface PrefixMedia { focalX?: number | null; focalY?: number | null; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "media-with-fields". + */ +export interface MediaWithField { + id: string; + title: string; + description?: string | null; + altText?: string | null; + caption?: string | null; + credit?: string | null; + source?: string | null; + category?: ('Nature' | 'Architecture' | 'People' | 'Abstract' | 'Technology') | null; + tags?: string[] | null; + featured?: boolean | null; + photographer?: string | null; + priority?: ('Low' | 'Medium' | 'High') | null; + shootDate?: string | null; + location?: { + city?: string | null; + country?: string | null; + }; + dimensions?: { + widthCm?: number | null; + heightCm?: number | null; + }; + colorProfile?: ('sRGB' | 'Adobe RGB' | 'ProPhoto RGB' | 'CMYK') | null; + license?: ('All Rights Reserved' | 'CC BY' | 'CC BY-SA' | 'CC BY-NC' | 'Public Domain') | null; + licenseUrl?: string | null; + notes?: string | null; + rating?: number | null; + exifData?: { + camera?: string | null; + lens?: string | null; + iso?: number | null; + aperture?: string | null; + shutterSpeed?: string | null; + }; + published?: boolean | null; + updatedAt: string; + createdAt: string; + url?: string | null; + thumbnailURL?: string | null; + filename?: string | null; + mimeType?: string | null; + filesize?: number | null; + width?: number | null; + height?: number | null; + focalX?: number | null; + focalY?: number | null; + sizes?: { + thumbnail?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + }; + card?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + }; + hero?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + }; + }; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "users". @@ -2151,6 +2294,18 @@ export interface PayloadLockedDocument { relationTo: 'admin-upload-control'; value: string | AdminUploadControl; } | null) + | ({ + relationTo: 'admin-upload-file-preview-single'; + value: string | AdminUploadFilePreviewSingle; + } | null) + | ({ + relationTo: 'admin-upload-file-preview-map'; + value: string | AdminUploadFilePreviewMap; + } | null) + | ({ + relationTo: 'file-preview'; + value: string | FilePreview; + } | null) | ({ relationTo: 'no-files-required'; value: string | NoFilesRequired; @@ -2243,6 +2398,10 @@ export interface PayloadLockedDocument { relationTo: 'prefix-media'; value: string | PrefixMedia; } | null) + | ({ + relationTo: 'media-with-fields'; + value: string | MediaWithField; + } | null) | ({ relationTo: 'users'; value: string | User; @@ -3563,6 +3722,57 @@ export interface AdminUploadControlSelect { focalX?: T; focalY?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "admin-upload-file-preview-single_select". + */ +export interface AdminUploadFilePreviewSingleSelect { + updatedAt?: T; + createdAt?: T; + url?: T; + thumbnailURL?: T; + filename?: T; + mimeType?: T; + filesize?: T; + width?: T; + height?: T; + focalX?: T; + focalY?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "admin-upload-file-preview-map_select". + */ +export interface AdminUploadFilePreviewMapSelect { + updatedAt?: T; + createdAt?: T; + url?: T; + thumbnailURL?: T; + filename?: T; + mimeType?: T; + filesize?: T; + width?: T; + height?: T; + focalX?: T; + focalY?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "file-preview_select". + */ +export interface FilePreviewSelect { + updatedAt?: T; + createdAt?: T; + url?: T; + thumbnailURL?: T; + filename?: T; + mimeType?: T; + filesize?: T; + width?: T; + height?: T; + focalX?: T; + focalY?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "no-files-required_select". @@ -3981,6 +4191,96 @@ export interface PrefixMediaSelect { focalX?: T; focalY?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "media-with-fields_select". + */ +export interface MediaWithFieldsSelect { + title?: T; + description?: T; + altText?: T; + caption?: T; + credit?: T; + source?: T; + category?: T; + tags?: T; + featured?: T; + photographer?: T; + priority?: T; + shootDate?: T; + location?: + | T + | { + city?: T; + country?: T; + }; + dimensions?: + | T + | { + widthCm?: T; + heightCm?: T; + }; + colorProfile?: T; + license?: T; + licenseUrl?: T; + notes?: T; + rating?: T; + exifData?: + | T + | { + camera?: T; + lens?: T; + iso?: T; + aperture?: T; + shutterSpeed?: T; + }; + published?: T; + updatedAt?: T; + createdAt?: T; + url?: T; + thumbnailURL?: T; + filename?: T; + mimeType?: T; + filesize?: T; + width?: T; + height?: T; + focalX?: T; + focalY?: T; + sizes?: + | T + | { + thumbnail?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + }; + card?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + }; + hero?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + }; + }; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "users_select". diff --git a/test/uploads/seed.ts b/test/uploads/seed.ts index 431d6163d29..84cd296911e 100644 --- a/test/uploads/seed.ts +++ b/test/uploads/seed.ts @@ -7,7 +7,10 @@ import { fileURLToPath } from 'url' import { devUser } from '../credentials.js' import { AdminThumbnailSize } from './collections/AdminThumbnailSize/index.js' import { + adminUploadFilePreviewMapSlug, + adminUploadFilePreviewSingleSlug, animatedTypeMedia, + filePreviewSlug, audioSlug, mediaSlug, mediaWithoutDeleteAccessSlug, @@ -205,4 +208,73 @@ export const seed = async (payload: Payload) => { data, }) } + + // Seed filePreview single collection — one image and one audio doc + const pdfFilePath = path.resolve(dirname, './test-pdf.pdf') + const pdfFile = await getFileByPath(pdfFilePath) + + await payload.create({ + collection: adminUploadFilePreviewSingleSlug, + data: {}, + file: { + ...imageFile, + name: `single-preview-image-${imageFile?.name}`, + } as File, + }) + + await payload.create({ + collection: adminUploadFilePreviewSingleSlug, + data: {}, + file: { + ...audioFile, + name: `single-preview-audio-${audioFile?.name}`, + } as File, + }) + + // Seed filePreview map collection — one image (no match), one PDF (exact), one audio (wildcard) + await payload.create({ + collection: adminUploadFilePreviewMapSlug, + data: {}, + file: { + ...imageFile, + name: `map-preview-image-${imageFile?.name}`, + } as File, + }) + + await payload.create({ + collection: adminUploadFilePreviewMapSlug, + data: {}, + file: { + ...pdfFile, + name: `map-preview-pdf-${pdfFile?.name}`, + } as File, + }) + + await payload.create({ + collection: adminUploadFilePreviewMapSlug, + data: {}, + file: { + ...audioFile, + name: `map-preview-audio-${audioFile?.name}`, + } as File, + }) + + // Seed file-preview collection — image and audio to exercise the switch-case component + await payload.create({ + collection: filePreviewSlug, + data: {}, + file: { + ...imageFile, + name: `file-preview-image-${imageFile?.name}`, + } as File, + }) + + await payload.create({ + collection: filePreviewSlug, + data: {}, + file: { + ...audioFile, + name: `file-preview-audio-${audioFile?.name}`, + } as File, + }) } diff --git a/test/uploads/shared.ts b/test/uploads/shared.ts index 64ec037395c..57154fce39b 100644 --- a/test/uploads/shared.ts +++ b/test/uploads/shared.ts @@ -49,3 +49,7 @@ export const uploads2Slug = 'uploads-2' export const noFilesRequiredSlug = 'no-files-required' export const relationToNoFilesRequiredSlug = 'relation-to-no-files-required' export const prefixMediaSlug = 'prefix-media' +export const mediaWithFieldsSlug = 'media-with-fields' +export const adminUploadFilePreviewSingleSlug = 'admin-upload-file-preview-single' +export const adminUploadFilePreviewMapSlug = 'admin-upload-file-preview-map' +export const filePreviewSlug = 'file-preview' From 6b10b9a3d1ca34d5cdb02814364736b31b499e18 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Mon, 15 Jun 2026 13:15:08 +0100 Subject: [PATCH 02/19] initial commit --- packages/payload/src/admin/types.ts | 1 + .../generateImportMap/iterateCollections.ts | 16 +- .../payload/src/collections/config/client.ts | 2 + packages/payload/src/exports/shared.ts | 1 + packages/payload/src/uploads/matchMimeType.ts | 20 + packages/payload/src/uploads/types.ts | 23 + packages/translations/src/clientKeys.ts | 6 + packages/translations/src/languages/en.ts | 6 + packages/ui/src/elements/AppHeader/index.tsx | 26 +- packages/ui/src/elements/Dialog/index.css | 2 +- .../src/elements/DocumentControls/index.tsx | 14 + packages/ui/src/elements/EditUpload/index.css | 255 ++++------ packages/ui/src/elements/EditUpload/index.tsx | 456 ++++++++---------- .../ui/src/elements/MiniCarousel/index.css | 50 ++ .../ui/src/elements/MiniCarousel/index.tsx | 108 +++++ .../Upload/UploadFromURLModal/index.tsx | 52 ++ .../Upload/UploadRenameModal/index.tsx | 57 +++ .../elements/Upload/UploadToolbar/index.css | 73 +++ .../elements/Upload/UploadToolbar/index.tsx | 131 +++++ packages/ui/src/elements/Upload/index.css | 143 +++++- packages/ui/src/elements/Upload/index.tsx | 338 +++++++++---- packages/ui/src/icons/Crop/index.css | 5 + packages/ui/src/icons/Crop/index.tsx | 33 ++ packages/ui/src/icons/Download/index.css | 5 + packages/ui/src/icons/Download/index.tsx | 36 ++ packages/ui/src/scss/app.scss | 5 +- packages/ui/src/views/Document/index.tsx | 1 + .../views/Document/renderDocumentSlots.tsx | 63 ++- packages/ui/src/views/Edit/index.css | 26 + packages/ui/src/views/Edit/index.tsx | 142 ++++-- 30 files changed, 1530 insertions(+), 566 deletions(-) create mode 100644 packages/payload/src/uploads/matchMimeType.ts create mode 100644 packages/ui/src/elements/MiniCarousel/index.css create mode 100644 packages/ui/src/elements/MiniCarousel/index.tsx create mode 100644 packages/ui/src/elements/Upload/UploadFromURLModal/index.tsx create mode 100644 packages/ui/src/elements/Upload/UploadRenameModal/index.tsx create mode 100644 packages/ui/src/elements/Upload/UploadToolbar/index.css create mode 100644 packages/ui/src/elements/Upload/UploadToolbar/index.tsx create mode 100644 packages/ui/src/icons/Crop/index.css create mode 100644 packages/ui/src/icons/Crop/index.tsx create mode 100644 packages/ui/src/icons/Download/index.css create mode 100644 packages/ui/src/icons/Download/index.tsx diff --git a/packages/payload/src/admin/types.ts b/packages/payload/src/admin/types.ts index 4be43d28bb9..05c27971c20 100644 --- a/packages/payload/src/admin/types.ts +++ b/packages/payload/src/admin/types.ts @@ -585,6 +585,7 @@ export type DocumentSlots = { UnpublishButton?: React.ReactNode Upload?: React.ReactNode UploadControls?: React.ReactNode + UploadFilePreview?: React.ReactNode } export type { diff --git a/packages/payload/src/bin/generateImportMap/iterateCollections.ts b/packages/payload/src/bin/generateImportMap/iterateCollections.ts index b9722cd373e..fa8f5f96390 100644 --- a/packages/payload/src/bin/generateImportMap/iterateCollections.ts +++ b/packages/payload/src/bin/generateImportMap/iterateCollections.ts @@ -1,6 +1,6 @@ import type { AdminViewConfig } from '../../admin/views/index.js' import type { SanitizedCollectionConfig } from '../../collections/config/types.js' -import type { SanitizedConfig } from '../../config/types.js' +import type { PayloadComponent, SanitizedConfig } from '../../config/types.js' import type { AddToImportMap, Imports, InternalImportMap } from './index.js' import { genImportMapIterateFields } from './iterateFields.js' @@ -52,6 +52,20 @@ export function iterateCollections({ addToImportMap(collection.upload?.admin?.components?.controls) } + const filePreview = collection.upload?.admin?.components?.filePreview + if (filePreview) { + if ( + typeof filePreview === 'string' || + (typeof filePreview === 'object' && 'path' in filePreview) + ) { + addToImportMap(filePreview) + } else { + for (const component of Object.values(filePreview as Record)) { + addToImportMap(component) + } + } + } + if (collection.admin?.components?.views?.edit) { for (const editViewConfig of Object.values(collection.admin?.components?.views?.edit)) { if ('Component' in editViewConfig) { diff --git a/packages/payload/src/collections/config/client.ts b/packages/payload/src/collections/config/client.ts index 260b780fa3f..a7a5698ac89 100644 --- a/packages/payload/src/collections/config/client.ts +++ b/packages/payload/src/collections/config/client.ts @@ -36,6 +36,7 @@ export type ServerOnlyCollectionAdminProperties = keyof Pick< export type ServerOnlyUploadProperties = keyof Pick< SanitizedCollectionConfig['upload'], + | 'admin' | 'adminThumbnail' | 'externalFileHeaderFilter' | 'handlers' @@ -90,6 +91,7 @@ const serverOnlyCollectionProperties: Partial[] ] const serverOnlyUploadProperties: Partial[] = [ + 'admin', 'adminThumbnail', 'externalFileHeaderFilter', 'handlers', diff --git a/packages/payload/src/exports/shared.ts b/packages/payload/src/exports/shared.ts index d1d3a986184..f7b51808a92 100644 --- a/packages/payload/src/exports/shared.ts +++ b/packages/payload/src/exports/shared.ts @@ -66,6 +66,7 @@ export { validOperators, validOperatorSet } from '../types/constants.js' export { formatFilesize } from '../uploads/formatFilesize.js' export { isImage } from '../uploads/isImage.js' +export { matchMimeType } from '../uploads/matchMimeType.js' export { appendDateTimezoneSelectFields } from '../utilities/appendDateTimezoneSelectFields.js' export { appendUploadSelectFields } from '../utilities/appendUploadSelectFields.js' export { applyLocaleFiltering } from '../utilities/applyLocaleFiltering.js' diff --git a/packages/payload/src/uploads/matchMimeType.ts b/packages/payload/src/uploads/matchMimeType.ts new file mode 100644 index 00000000000..77e69ab9505 --- /dev/null +++ b/packages/payload/src/uploads/matchMimeType.ts @@ -0,0 +1,20 @@ +import type { PayloadComponent } from '../config/types.js' + +/** + * Resolves a PayloadComponent from a MIME-type keyed map. + * + * Priority: exact match → category wildcard (e.g. `video/*`) → universal fallback (`*`). + */ +export function matchMimeType( + map: Record, + mimeType: string, +): PayloadComponent | undefined { + if (map[mimeType]) { + return map[mimeType] + } + const category = mimeType.split('/')[0] + if (map[`${category}/*`]) { + return map[`${category}/*`] + } + return map['*'] +} diff --git a/packages/payload/src/uploads/types.ts b/packages/payload/src/uploads/types.ts index 3fe5e79bc33..fd6806cde0a 100644 --- a/packages/payload/src/uploads/types.ts +++ b/packages/payload/src/uploads/types.ts @@ -116,12 +116,35 @@ export type FileAllowList = Array<{ mimeType: string }> +export type UploadFilePreviewClientProps = { + filename: string + filesize: number + /** Resolved URL of the file (data.url). */ + fileSrc: string + height?: number + mimeType: string + width?: number +} + +type UploadFilePreviewMap = { + [mimeTypePattern: string]: PayloadComponent +} + type Admin = { components?: { /** * The Controls component to extend the upload controls in the admin panel. */ controls?: PayloadComponent[] + /** + * A custom component to render in place of the default Thumbnail in the upload side panel. + * + * Can be a single PayloadComponent (renders for all MIME types) or a Record keyed by + * MIME type patterns. Pattern resolution priority: exact match → category wildcard + * (e.g. `video/*`) → universal fallback (`*`). Falls back to the default Thumbnail + * when nothing matches. + */ + filePreview?: PayloadComponent | UploadFilePreviewMap } } diff --git a/packages/translations/src/clientKeys.ts b/packages/translations/src/clientKeys.ts index c4f90e84857..3249dd25288 100644 --- a/packages/translations/src/clientKeys.ts +++ b/packages/translations/src/clientKeys.ts @@ -336,6 +336,7 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'general:only', 'general:or', 'general:order', + 'general:original', 'general:overwriteExistingData', 'general:pageNotFound', 'general:password', @@ -468,7 +469,12 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'upload:focalPoint', 'upload:focalPointDescription', 'upload:height', + 'upload:fromURL', + 'upload:linkToFile', 'upload:pasteURL', + 'upload:renameFile', + 'upload:replaceFile', + 'upload:uploadFile', 'upload:previewSizes', 'upload:selectCollectionToBrowse', 'upload:selectFile', diff --git a/packages/translations/src/languages/en.ts b/packages/translations/src/languages/en.ts index 900a3872c02..edc3814d6c2 100644 --- a/packages/translations/src/languages/en.ts +++ b/packages/translations/src/languages/en.ts @@ -410,6 +410,7 @@ export const enTranslations = { openInNewWindow: 'Open in new window', or: 'Or', order: 'Order', + original: 'Original', overwriteExistingData: 'Overwrite existing field data', pageNotFound: 'Page not found', password: 'Password', @@ -560,18 +561,23 @@ export const enTranslations = { focalPoint: 'Focal Point', focalPointDescription: 'Drag the focal point directly on the preview or adjust the values below.', + fromURL: 'Upload file from URL', height: 'Height', lessInfo: 'Less info', + linkToFile: 'Link to file', moreInfo: 'More info', noFile: 'No file', pasteURL: 'Paste URL', previewSizes: 'Preview Sizes', + renameFile: 'Rename file', + replaceFile: 'Replace file', selectCollectionToBrowse: 'Select a Collection to Browse', selectFile: 'Select a file', setCropArea: 'Set crop area', setFocalPoint: 'Set focal point', sizes: 'Sizes', sizesFor: 'Sizes for {{label}}', + uploadFile: 'Upload file', width: 'Width', }, validation: { diff --git a/packages/ui/src/elements/AppHeader/index.tsx b/packages/ui/src/elements/AppHeader/index.tsx index 2957a9a95c1..b43e77ac37e 100644 --- a/packages/ui/src/elements/AppHeader/index.tsx +++ b/packages/ui/src/elements/AppHeader/index.tsx @@ -34,9 +34,30 @@ export function AppHeader({ CustomAvatar, settingsItems }: Props) { config: { localization }, } = useConfig() + const headerRef = useRef(null) const customControlsRef = useRef(null) const [isScrollable, setIsScrollable] = useState(false) + useEffect(() => { + const el = headerRef.current + if (!el) { + return + } + const observer = new ResizeObserver(() => { + document.documentElement.style.setProperty('--app-header-height', `${el.offsetHeight}px`) + }) + observer.observe(el) + return () => observer.disconnect() + }, []) + + useEffect(() => { + const update = () => + document.documentElement.style.setProperty('--scroll-y', `${window.scrollY}px`) + update() + window.addEventListener('scroll', update, { passive: true }) + return () => window.removeEventListener('scroll', update) + }, []) + useEffect(() => { const checkIsScrollable = () => { const el = customControlsRef.current @@ -57,7 +78,10 @@ export function AppHeader({ CustomAvatar, settingsItems }: Props) { const ActionComponents = Actions ? Object.values(Actions) : [] return ( -
+
diff --git a/packages/ui/src/elements/Dialog/index.css b/packages/ui/src/elements/Dialog/index.css index 0cfa9c9c0f5..922751170ee 100644 --- a/packages/ui/src/elements/Dialog/index.css +++ b/packages/ui/src/elements/Dialog/index.css @@ -38,7 +38,7 @@ } .dialog--large { - --dialog-width: 640px; + --dialog-width: 840px; } .dialog__header { diff --git a/packages/ui/src/elements/DocumentControls/index.tsx b/packages/ui/src/elements/DocumentControls/index.tsx index 04a9a35e91d..61297213d4d 100644 --- a/packages/ui/src/elements/DocumentControls/index.tsx +++ b/packages/ui/src/elements/DocumentControls/index.tsx @@ -157,7 +157,20 @@ export const DocumentControls: React.FC<{ const processing = useFormProcessing() const initializing = useFormInitializing() + const rootRef = useRef(null) const i18nRef = useRef(i18n) + + useEffect(() => { + const el = rootRef.current + if (!el) { + return + } + const observer = new ResizeObserver(() => { + document.documentElement.style.setProperty('--doc-controls-height', `${el.offsetHeight}px`) + }) + observer.observe(el) + return () => observer.disconnect() + }, []) i18nRef.current = i18n const updateRelativeTime = useCallback(() => { @@ -221,6 +234,7 @@ export const DocumentControls: React.FC<{ className={[baseClass, variant !== 'default' && `${baseClass}--${variant}`] .filter(Boolean) .join(' ')} + ref={rootRef} > {variant === 'default' && (
diff --git a/packages/ui/src/elements/EditUpload/index.css b/packages/ui/src/elements/EditUpload/index.css index 171c5d99096..8da9da79adc 100644 --- a/packages/ui/src/elements/EditUpload/index.css +++ b/packages/ui/src/elements/EditUpload/index.css @@ -1,80 +1,117 @@ @layer payload-default { - .edit-upload { - --edit-upload-cell-spacing: var(--spacer-4); - --edit-upload-sidebar-width: calc(350px + var(--gutter-h)); - height: 100%; + /* Allow the ReactCrop 9999px box-shadow overlay to escape the dialog bounds. + Make the body a flex column so edit-upload__content can fill its height. */ + .edit-upload__dialog { + overflow: visible; + + .dialog__body { + overflow: visible; + display: flex; + flex-direction: column; + } + } + + .edit-upload__content { display: flex; - flex-direction: column; + flex-direction: row; + flex: 1 1 auto; + min-height: 480px; + gap: 0; } - .edit-upload__header { - border-bottom: 1px solid var(--color-border); - padding: var(--spacer-2-5); + /* Canvas area — must have a definite height for max-height: 100% to resolve on focal-wrapper */ + .edit-upload__crop { + flex: 1 1 0; + min-width: 0; + height: 100%; display: flex; - justify-content: space-between; align-items: center; + justify-content: center; + padding: var(--spacer-4); + background: var(--color-bg-secondary); + border-radius: var(--radius-small); } - .edit-upload__header h2 { - margin: 0; - text-wrap: nowrap; - overflow: hidden; - text-overflow: ellipsis; + .edit-upload .ReactCrop__selection-addon, + .edit-upload__crop-window { + height: 100%; + width: 100%; } - [dir='rtl'] .edit-upload__actions { - margin-right: auto; - margin-left: unset; + .edit-upload__focal-wrapper { + position: relative; + display: inline-flex; + max-height: 100%; + max-width: 100%; } - .edit-upload__actions { - min-width: 350px; - margin-left: auto; - padding: var(--spacer-2) 0 var(--spacer-2) var(--spacer-4); - justify-content: flex-end; + /* Sidebar */ + .edit-upload__sidebar { + flex-shrink: 0; + width: 240px; + border-left: 1px solid var(--color-border); display: flex; - gap: var(--spacer-3); - text-wrap: nowrap; + flex-direction: column; + margin-inline-start: var(--spacer-3); + padding-inline-start: var(--spacer-3); } - .edit-upload__toolWrap { + .edit-upload__section { display: flex; - justify-content: flex-end; - flex: 1; - min-height: 0; + flex-direction: column; + gap: var(--spacer-2); + padding-block: var(--spacer-3); } - .edit-upload .ReactCrop__selection-addon, - .edit-upload__crop-window { - height: 100%; - width: 100%; + .edit-upload__section + .edit-upload__section { + border-top: 1px solid var(--color-border); } - .edit-upload__focal-wrapper { - position: relative; - display: inline-flex; - max-height: 100%; + .edit-upload__section-header { + display: flex; + align-items: center; + justify-content: space-between; } - .edit-upload__draggable-container { - position: absolute; - left: 0; - right: 0; - bottom: 0; - top: 0; - pointer-events: none; + .edit-upload__section-title { + font-family: var(--text-body-medium-strong-font-family); + font-size: var(--text-body-medium-font-size); + font-weight: var(--text-body-medium-strong-font-weight); + line-height: var(--text-body-medium-strong-line-height); + color: var(--color-text); } - .edit-upload__draggable-container--dragging { - pointer-events: all; + .edit-upload__reset { + height: fit-content; + padding: 0 var(--spacer-2); + } + + .edit-upload__fieldset { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacer-2); } - .edit-upload__draggable-container--dragging .edit-upload__focalPoint { - cursor: grabbing; + .edit-upload__field { + margin-bottom: 0; + } + + .edit-upload__suffix { + font-size: var(--text-body-medium-font-size); + color: var(--color-text-secondary); + padding-inline-start: var(--spacer-1); + } + + /* Draggable focal point */ + .edit-upload__draggable-container { + position: absolute; + inset: 0; + pointer-events: none; /* let clicks pass through to the image/crop layer */ } .edit-upload__draggable { position: absolute; + pointer-events: all; /* only the handle itself is interactive */ } .edit-upload__focalPoint { @@ -94,10 +131,7 @@ .edit-upload__focalPoint svg { position: absolute; - left: 0; - right: 0; - top: 0; - bottom: 0; + inset: 0; background: rgba(0, 0, 0, 0.5); border-radius: 100%; width: var(--spacer-6); @@ -105,122 +139,23 @@ color: white; } - .edit-upload__crop, - .edit-upload__focalOnly { - padding: var(--spacer-4) var(--spacer-4) var(--spacer-4) 0; - width: 100%; - display: flex; - justify-content: center; - } - - .edit-upload__crop { - padding: var(--edit-upload-cell-spacing); - padding-left: var(--gutter-h); - display: flex; - align-items: flex-start; - height: 100%; - } - - .edit-upload__imageWrap { - position: relative; - } - - .edit-upload__point { - cursor: move; - position: absolute; - background: rgba(0, 0, 0, 0.5); - border-radius: 100%; - } - - .edit-upload__point svg { - width: var(--spacer-6); - height: var(--spacer-6); - } - - .edit-upload__sidebar { - border-left: 1px solid var(--color-border); - padding-top: var(--edit-upload-cell-spacing); - min-width: var(--edit-upload-sidebar-width); - } - - .edit-upload__sidebar > div:first-child { - margin-bottom: var(--spacer-3); - } - - .edit-upload__groupWrap { - display: flex; - flex-direction: column; - gap: var(--spacer-2); - padding-right: var(--gutter-h); - padding-left: var(--edit-upload-cell-spacing); - width: 100%; - } - - .edit-upload__groupWrap + .edit-upload__groupWrap { - padding-top: var(--edit-upload-cell-spacing); - margin-top: var(--edit-upload-cell-spacing); - border-top: 1px solid var(--color-border); - } - - .edit-upload__inputsWrap, - .edit-upload__titleWrap { - display: flex; - gap: var(--spacer-3); - } - - .edit-upload__titleWrap { - justify-content: space-between; - align-items: center; - } - - .edit-upload__titleWrap h3 { - margin: 0; - } - - .edit-upload__reset { - height: fit-content; - border-radius: var(--radius-small); - background-color: var(--color-bg-secondary); - padding: 0 var(--spacer-2); - } - - .edit-upload__input { - flex: 1; - } - - @media (max-width: 1024px) { - .edit-upload { - --edit-upload-cell-spacing: var(--gutter-h); - } - - .edit-upload__sidebar { - padding-left: 0; - border-left: 0; - width: 100%; - } - - .edit-upload__toolWrap { - flex-direction: column-reverse; - } - } - @media (max-width: 768px) { - .edit-upload { + .edit-upload__content { flex-direction: column; + min-height: unset; } - .edit-upload__focalPoint { - border-right: none; - padding: var(--spacer-3) 0; + .edit-upload__sidebar { + width: 100%; + border-left: none; + border-top: 1px solid var(--color-border); + margin-inline-start: 0; + padding-inline-start: 0; + padding-top: var(--spacer-3); } - .edit-upload__inputsWrap { + .edit-upload__fieldset { flex-direction: column; - gap: var(--spacer-3); - } - - .edit-upload__sidebar { - min-width: 0; } } } diff --git a/packages/ui/src/elements/EditUpload/index.tsx b/packages/ui/src/elements/EditUpload/index.tsx index f3180f8827f..b8374e9c872 100644 --- a/packages/ui/src/elements/EditUpload/index.tsx +++ b/packages/ui/src/elements/EditUpload/index.tsx @@ -7,40 +7,17 @@ import React, { useRef, useState } from 'react' import ReactCrop from 'react-image-crop' import { editDrawerSlug } from '../../elements/Upload/index.js' +import { NumberInput } from '../../fields/Number/Input.js' import { PlusIcon } from '../../icons/Plus/index.js' import { useTranslation } from '../../providers/Translation/index.js' import { appendCacheTag } from '../../utilities/appendCacheTag.js' import { Button } from '../Button/index.js' +import { DialogBody, DialogFooter, DialogHeader, DialogModal } from '../Dialog/index.js' import './index.css' +import './library.scss' const baseClass = 'edit-upload' -type Props = { - disabled?: boolean - name: string - onChange: (value: string) => void - ref?: React.RefObject - value: string -} - -const Input: React.FC = (props) => { - const { name, disabled, onChange, ref, value } = props - - return ( -
- {name} - onChange(e.target.value)} - ref={ref} - type="number" - value={value} - /> -
- ) -} - type FocalPosition = { x: number y: number @@ -83,10 +60,7 @@ export const EditUpload: React.FC = ({ ...(initialCrop || {}), })) - const defaultFocalPosition: FocalPosition = { - x: 50, - y: 50, - } + const defaultFocalPosition: FocalPosition = { x: 50, y: 50 } const [focalPosition, setFocalPosition] = useState(() => ({ ...defaultFocalPosition, @@ -101,13 +75,9 @@ export const EditUpload: React.FC = ({ const imageRef = useRef(undefined) const cropRef = useRef(undefined) - const heightInputRef = useRef(null) - const widthInputRef = useRef(null) - const [imageLoaded, setImageLoaded] = useState(false) const onImageLoad = (e) => { - // set the default image height/width on load setUncroppedPixelHeight(e.currentTarget.naturalHeight) setUncroppedPixelWidth(e.currentTarget.naturalWidth) setImageLoaded(true) @@ -115,18 +85,12 @@ export const EditUpload: React.FC = ({ const fineTuneCrop = ({ dimension, value }: { dimension: 'height' | 'width'; value: string }) => { const intValue = parseInt(value) - const percentage = 100 * (intValue / (dimension === 'width' ? uncroppedPixelWidth : uncroppedPixelHeight)) - if (percentage <= 0 || percentage > 100) { return null } - - setCrop((prev) => ({ - ...prev, - [dimension]: percentage, - })) + setCrop((prev) => ({ ...prev, [dimension]: percentage })) } const fineTuneFocalPosition = ({ @@ -147,8 +111,8 @@ export const EditUpload: React.FC = ({ onSave({ crop: crop ? crop : undefined, focalPoint: focalPosition, - heightInPixels: Number(heightInputRef?.current?.value ?? uncroppedPixelHeight), - widthInPixels: Number(widthInputRef?.current?.value ?? uncroppedPixelWidth), + heightInPixels: Math.round((crop.height / 100) * uncroppedPixelHeight), + widthInPixels: Math.round((crop.width / 100) * uncroppedPixelWidth), }) } closeModal(editDrawerSlug) @@ -160,145 +124,122 @@ export const EditUpload: React.FC = ({ }, []) const centerFocalPoint = () => { - const containerRect = focalWrapRef.current.getBoundingClientRect() - const boundsRect = showCrop - ? cropRef.current.getBoundingClientRect() - : imageRef.current.getBoundingClientRect() - const xCenter = - ((boundsRect.left - containerRect.left + boundsRect.width / 2) / containerRect.width) * 100 - const yCenter = - ((boundsRect.top - containerRect.top + boundsRect.height / 2) / containerRect.height) * 100 - setFocalPosition({ x: xCenter, y: yCenter }) + setFocalPosition({ x: 50, y: 50 }) } const fileSrcToUse = fileSrc ? appendCacheTag(fileSrc, imageCacheTag) : fileSrc + const cropWidthPx = ((crop.width / 100) * uncroppedPixelWidth).toFixed(0) + const cropHeightPx = ((crop.height / 100) * uncroppedPixelHeight).toFixed(0) + return ( -
-
-

- {t('general:editing')} {fileName} -

-
- - -
-
-
-
-
- {showCrop ? ( - setCrop(c)} - onComplete={() => setCheckBounds(true)} - renderSelectionAddon={() => { - return
- }} - > + + + +
+ {/* Canvas area */} +
+
+ {showCrop ? ( + setCrop(c)} + onComplete={() => setCheckBounds(true)} + renderSelectionAddon={() => ( +
+ )} + > + {t('upload:setCropArea')} + + ) : ( {t('upload:setCropArea')} - - ) : ( - {t('upload:setFocalPoint')} - )} - {showFocalPoint && ( - - - - )} + )} + {showFocalPoint && ( + + + + )} +
-
- {(showCrop || showFocalPoint) && ( -
- {showCrop && ( -
-
-
-

{t('upload:crop')}

+ + {/* Sidebar */} + {(showCrop || showFocalPoint) && ( +
+ {showCrop && ( +
+
+ {t('upload:crop')}
+
+ + fineTuneCrop({ + dimension: 'width', + value: (e as React.ChangeEvent).target.value, + }) + } + path="cropWidth" + readOnly={!imageLoaded} + value={Number(cropWidthPx)} + /> + + fineTuneCrop({ + dimension: 'height', + value: (e as React.ChangeEvent).target.value, + }) + } + path="cropHeight" + readOnly={!imageLoaded} + value={Number(cropHeightPx)} + /> +
- - {t('upload:cropToolDescription')} - -
- fineTuneCrop({ dimension: 'width', value })} - ref={widthInputRef} - value={((crop.width / 100) * uncroppedPixelWidth).toFixed(0)} - /> - fineTuneCrop({ dimension: 'height', value })} - ref={heightInputRef} - value={((crop.height / 100) * uncroppedPixelHeight).toFixed(0)} - /> -
-
- )} - - {showFocalPoint && ( -
-
-
-

{t('upload:focalPoint')}

+ )} + + {showFocalPoint && ( +
+
+ {t('upload:focalPoint')}
+
+ %} + className={`${baseClass}__field`} + label="X" + max={100} + min={0} + onChange={(e) => + fineTuneFocalPosition({ + coordinate: 'x', + value: (e as React.ChangeEvent).target.value, + }) + } + path="focalX" + value={Math.round(focalPosition.x)} + /> + %} + className={`${baseClass}__field`} + label="Y" + max={100} + min={0} + onChange={(e) => + fineTuneFocalPosition({ + coordinate: 'y', + value: (e as React.ChangeEvent).target.value, + }) + } + path="focalY" + value={Math.round(focalPosition.y)} + /> +
- - {t('upload:focalPointDescription')} - -
- fineTuneFocalPosition({ coordinate: 'x', value })} - value={focalPosition.x.toFixed(0)} - /> - fineTuneFocalPosition({ coordinate: 'y', value })} - value={focalPosition.y.toFixed(0)} - /> -
-
- )} -
- )} -
-
+ )} +
+ )} +
+ + + + + + ) } @@ -345,52 +320,18 @@ const DraggableElement = ({ const [position, setPosition] = useState({ x: initialPosition.x, y: initialPosition.y }) const [isDragging, setIsDragging] = useState(false) const dragRef = useRef(undefined) + // Keep a ref to the latest position so global mouseup handler can read it without a stale closure + const positionRef = useRef(position) + positionRef.current = position const getCoordinates = React.useCallback( - (mouseXArg?: number, mouseYArg?: number, recenter?: boolean) => { + (mouseX: number, mouseY: number) => { const containerRect = containerRef.current.getBoundingClientRect() - const boundsRect = boundsRef.current.getBoundingClientRect() - const mouseX = mouseXArg ?? boundsRect.left - const mouseY = mouseYArg ?? boundsRect.top - - const xOutOfBounds = mouseX < boundsRect.left || mouseX > boundsRect.right - const yOutOfBounds = mouseY < boundsRect.top || mouseY > boundsRect.bottom - - let x = ((mouseX - containerRect.left) / containerRect.width) * 100 - let y = ((mouseY - containerRect.top) / containerRect.height) * 100 - const xCenter = - ((boundsRect.left - containerRect.left + boundsRect.width / 2) / containerRect.width) * 100 - const yCenter = - ((boundsRect.top - containerRect.top + boundsRect.height / 2) / containerRect.height) * 100 - if (xOutOfBounds || yOutOfBounds) { - setIsDragging(false) - if (mouseX < boundsRect.left) { - x = ((boundsRect.left - containerRect.left) / containerRect.width) * 100 - } else if (mouseX > boundsRect.right) { - x = - ((containerRect.width - (containerRect.right - boundsRect.right)) / - containerRect.width) * - 100 - } - - if (mouseY < boundsRect.top) { - y = ((boundsRect.top - containerRect.top) / containerRect.height) * 100 - } else if (mouseY > boundsRect.bottom) { - y = - ((containerRect.height - (containerRect.bottom - boundsRect.bottom)) / - containerRect.height) * - 100 - } - - if (recenter) { - x = xOutOfBounds ? xCenter : x - y = yOutOfBounds ? yCenter : y - } - } - - return { x, y } + const x = ((mouseX - containerRect.left) / containerRect.width) * 100 + const y = ((mouseY - containerRect.top) / containerRect.height) * 100 + return { x: Math.max(0, Math.min(100, x)), y: Math.max(0, Math.min(100, y)) } }, - [boundsRef, containerRef], + [containerRef], ) const handleMouseDown = (event) => { @@ -398,59 +339,62 @@ const DraggableElement = ({ setIsDragging(true) } - const handleMouseMove = (event) => { + // Attach global listeners while dragging — this ensures events fire even when + // the cursor leaves the focal wrapper area during a fast drag + React.useEffect(() => { if (!isDragging) { - return null + return } - const { x, y } = getCoordinates(event.clientX, event.clientY) - setPosition({ x, y }) - } + const handleMove = (e: MouseEvent) => { + if (!containerRef.current) { + return + } + const { x, y } = getCoordinates(e.clientX, e.clientY) + setPosition({ x, y }) + } - const onDrop = () => { - setIsDragging(false) - onDragEnd(position) - } + const handleUp = () => { + setIsDragging(false) + onDragEnd(positionRef.current) + } - React.useEffect(() => { - if (isDragging || !dragRef.current) { - return + document.addEventListener('mousemove', handleMove) + document.addEventListener('mouseup', handleUp) + + return () => { + document.removeEventListener('mousemove', handleMove) + document.removeEventListener('mouseup', handleUp) } - if (checkBounds) { - const { height, left, top, width } = dragRef.current.getBoundingClientRect() - const { x, y } = getCoordinates(left + width / 2, top + height / 2, true) - onDragEnd({ x, y }) - setPosition({ x, y }) - setCheckBounds(false) + }, [isDragging, getCoordinates, onDragEnd, containerRef]) + + // Re-check position when crop changes (the crop window may have moved) + React.useEffect(() => { + if (isDragging || !checkBounds || !dragRef.current) { return } - }, [getCoordinates, isDragging, checkBounds, setCheckBounds, position.x, position.y, onDragEnd]) + const { height, left, top, width } = dragRef.current.getBoundingClientRect() + const { x, y } = getCoordinates(left + width / 2, top + height / 2) + onDragEnd({ x, y }) + setPosition({ x, y }) + setCheckBounds(false) + }, [getCoordinates, isDragging, checkBounds, setCheckBounds, onDragEnd]) React.useEffect(() => { setPosition({ x: initialPosition.x, y: initialPosition.y }) }, [initialPosition.x, initialPosition.y]) return ( -
+
-
) } diff --git a/packages/ui/src/elements/MiniCarousel/index.css b/packages/ui/src/elements/MiniCarousel/index.css new file mode 100644 index 00000000000..4614e5bca2a --- /dev/null +++ b/packages/ui/src/elements/MiniCarousel/index.css @@ -0,0 +1,50 @@ +@layer payload-default { + .mini-carousel { + display: flex; + flex-direction: column; + align-items: stretch; + gap: var(--spacer-1); + padding: var(--spacer-2-5); + width: 72px; + flex-shrink: 0; + overflow-y: auto; + background: var(--color-bg-secondary); + border-right: 1px solid var(--color-border); + align-self: stretch; + } + + .mini-carousel__item { + display: flex; + align-items: center; + justify-content: center; + aspect-ratio: 1/1; + flex-shrink: 0; + padding: var(--spacer-1); + border-radius: var(--radius-medium); + border: 1px solid var(--color-border); + background: var(--color-bg-secondary); + cursor: pointer; + overflow: hidden; + } + + .mini-carousel__item img { + width: 100%; + height: 100%; + object-fit: contain; + } + + .mini-carousel__item--active { + border-color: var(--color-border-selected); + } + + .mini-carousel__item:hover:not(.mini-carousel__item--active) { + border-color: var(--color-border-hover); + } + + .mini-carousel__divider { + width: 32px; + height: 1px; + background: var(--color-border); + flex-shrink: 0; + } +} diff --git a/packages/ui/src/elements/MiniCarousel/index.tsx b/packages/ui/src/elements/MiniCarousel/index.tsx new file mode 100644 index 00000000000..60e72456d76 --- /dev/null +++ b/packages/ui/src/elements/MiniCarousel/index.tsx @@ -0,0 +1,108 @@ +'use client' +import type { Data, FileSizes, SanitizedCollectionConfig } from 'payload' + +import React from 'react' + +import { appendCacheTag } from '../../utilities/appendCacheTag.js' +import './index.css' + +const baseClass = 'mini-carousel' + +type MiniCarouselItemProps = { + active: boolean + imageCacheTag?: string + label: string + onClick: () => void + url: string +} + +const MiniCarouselItem: React.FC = ({ + active, + imageCacheTag, + label, + onClick, + url, +}) => { + const src = appendCacheTag(url, imageCacheTag) + + return ( + + ) +} + +export type MiniCarouselProps = { + doc: { + sizes?: FileSizes + url?: string + } & Data + imageCacheTag?: string + onSelect: (sizeKey: null | string) => void + selectedSize: null | string + uploadConfig: SanitizedCollectionConfig['upload'] +} + +export const MiniCarousel: React.FC = ({ + doc, + imageCacheTag, + onSelect, + selectedSize, + uploadConfig, +}) => { + const { imageSizes } = uploadConfig + const { sizes, url: originalUrl } = doc + + const orderedSizeKeys = React.useMemo(() => { + if (!imageSizes || imageSizes.length === 0) { + return Object.keys(sizes || {}) + } + return imageSizes.map(({ name }) => name).filter((name) => sizes?.[name]?.url) + }, [imageSizes, sizes]) + + if (!originalUrl) { + return null + } + + return ( +
+ onSelect(null)} + url={originalUrl} + /> + + {orderedSizeKeys.length > 0 && ( + <> +
+ {orderedSizeKeys.map((key) => { + const sizeData = sizes?.[key] + if (!sizeData?.url) { + return null + } + return ( + onSelect(key)} + url={sizeData.url} + /> + ) + })} + + )} +
+ ) +} diff --git a/packages/ui/src/elements/Upload/UploadFromURLModal/index.tsx b/packages/ui/src/elements/Upload/UploadFromURLModal/index.tsx new file mode 100644 index 00000000000..d6ad6f51401 --- /dev/null +++ b/packages/ui/src/elements/Upload/UploadFromURLModal/index.tsx @@ -0,0 +1,52 @@ +'use client' +import React from 'react' + +import { TextInput } from '../../../fields/Text/Input.js' +import { useTranslation } from '../../../providers/Translation/index.js' +import { Button } from '../../Button/index.js' +import { DialogBody, DialogFooter, DialogHeader, DialogModal } from '../../Dialog/index.js' + +export const pasteURLDrawerSlug = 'upload-paste-url' + +type Props = { + readonly fileUrl: string + readonly handleUrlSubmit: () => Promise + readonly isValidUrl: boolean + readonly setFileUrl: (url: string) => void +} + +export const UploadFromURLModal: React.FC = ({ + fileUrl, + handleUrlSubmit, + isValidUrl, + setFileUrl, +}) => { + const { t } = useTranslation() + + return ( + + + +
{ + e.preventDefault() + void handleUrlSubmit() + }} + > + setFileUrl(e.target.value)} + path="url" + placeholder="https://" + value={fileUrl} + /> + +
+ + + +
+ ) +} diff --git a/packages/ui/src/elements/Upload/UploadRenameModal/index.tsx b/packages/ui/src/elements/Upload/UploadRenameModal/index.tsx new file mode 100644 index 00000000000..2258668b9d8 --- /dev/null +++ b/packages/ui/src/elements/Upload/UploadRenameModal/index.tsx @@ -0,0 +1,57 @@ +'use client' +import { useModal } from '@faceless-ui/modal' +import React, { useEffect, useState } from 'react' + +import { TextInput } from '../../../fields/Text/Input.js' +import { useTranslation } from '../../../providers/Translation/index.js' +import { Button } from '../../Button/index.js' +import { DialogBody, DialogFooter, DialogHeader, DialogModal } from '../../Dialog/index.js' + +export const renameFileModalSlug = 'upload-rename-file' + +type Props = { + readonly currentFilename: string + readonly onConfirm: (newName: string) => void +} + +export const UploadRenameModal: React.FC = ({ currentFilename, onConfirm }) => { + const { closeModal } = useModal() + const { t } = useTranslation() + const [newName, setNewName] = useState(currentFilename) + + useEffect(() => { + setNewName(currentFilename) + }, [currentFilename]) + + const isUnchanged = newName.trim() === currentFilename + const isEmpty = !newName.trim() + + return ( + + + + setNewName(e.target.value)} + path="filename" + value={newName} + /> + + + + + + + ) +} diff --git a/packages/ui/src/elements/Upload/UploadToolbar/index.css b/packages/ui/src/elements/Upload/UploadToolbar/index.css new file mode 100644 index 00000000000..dadb3c51a48 --- /dev/null +++ b/packages/ui/src/elements/Upload/UploadToolbar/index.css @@ -0,0 +1,73 @@ +@layer payload-default { + .upload-toolbar { + display: grid; + grid-template-columns: 1fr fit-content(100%) 1fr; + align-items: center; + padding: var(--spacer-2) var(--spacer-2-5) var(--spacer-2) var(--spacer-2); + border-bottom: 1px solid var(--color-border); + background: var(--color-bg); + flex-shrink: 0; + } + + .upload-toolbar__left { + display: flex; + align-items: flex-start; + } + + .upload-toolbar__center { + display: flex; + gap: var(--spacer-1); + align-items: center; + } + + .upload-toolbar__right { + display: flex; + gap: var(--spacer-1); + align-items: center; + justify-content: flex-end; + } + + .upload-toolbar__filename-btn { + display: flex; + align-items: center; + gap: var(--spacer-1); + padding: var(--spacer-1) var(--spacer-2); + border-radius: var(--radius-medium); + background: none; + border: none; + cursor: pointer; + color: var(--color-text); + font-family: var(--text-body-medium-strong-font-family); + font-size: var(--text-body-medium-font-size); + font-weight: var(--text-body-medium-strong-font-weight); + line-height: var(--text-body-medium-strong-line-height); + letter-spacing: var(--text-body-medium-letter-spacing); + max-width: 200px; + + &:hover { + background: var(--color-bg-transparent-hover); + } + } + + .upload-toolbar__filename-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .upload-toolbar__icon-link { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: var(--radius-medium); + color: var(--color-icon); + text-decoration: none; + + &:hover { + background: var(--color-bg-transparent-hover); + color: var(--color-icon); + } + } +} diff --git a/packages/ui/src/elements/Upload/UploadToolbar/index.tsx b/packages/ui/src/elements/Upload/UploadToolbar/index.tsx new file mode 100644 index 00000000000..44eaafb5193 --- /dev/null +++ b/packages/ui/src/elements/Upload/UploadToolbar/index.tsx @@ -0,0 +1,131 @@ +'use client' +import { useModal } from '@faceless-ui/modal' +import React, { useCallback } from 'react' + +import { ChevronIcon } from '../../../icons/Chevron/index.js' +import { CropIcon } from '../../../icons/Crop/index.js' +import { DownloadIcon } from '../../../icons/Download/index.js' +import { EditIcon } from '../../../icons/Edit/index.js' +import { LinkIcon } from '../../../icons/Link/index.js' +import { RefreshIcon } from '../../../icons/Refresh/index.js' +import { XIcon } from '../../../icons/X/index.js' +import { useTranslation } from '../../../providers/Translation/index.js' +import { Button } from '../../Button/index.js' +import { Popup } from '../../Popup/index.js' +import * as PopupList from '../../Popup/PopupButtonList/index.js' +import { renameFileModalSlug, UploadRenameModal } from '../UploadRenameModal/index.js' +import './index.css' + +const baseClass = 'upload-toolbar' + +type Props = { + readonly canRemove?: boolean + readonly filename: string + readonly fileSrc?: null | string + readonly isAdjustable?: boolean + readonly onEditImage?: () => void + readonly onRemove?: () => void + readonly onRenameConfirm: (newName: string) => void + readonly onReplace?: () => void +} + +export const UploadToolbar: React.FC = ({ + canRemove, + filename, + fileSrc, + isAdjustable, + onEditImage, + onRemove, + onRenameConfirm, + onReplace, +}) => { + const { t } = useTranslation() + const { openModal } = useModal() + + const copyURL = useCallback(() => { + if (fileSrc) { + void navigator.clipboard.writeText(fileSrc) + } + }, [fileSrc]) + + return ( + <> + +
+
+ + {filename} + + + } + buttonType="custom" + horizontalAlign="left" + size="small" + verticalAlign="bottom" + > + + } onClick={() => openModal(renameFileModalSlug)}> + {t('general:rename')} + + } onClick={onReplace}> + {t('upload:replaceFile')} + + + +
+ +
+ {isAdjustable && ( +
+ +
+ {fileSrc && ( + + + + )} + {fileSrc && ( +
+
+ + ) +} diff --git a/packages/ui/src/elements/Upload/index.css b/packages/ui/src/elements/Upload/index.css index e8a6b1e78c2..a4711291713 100644 --- a/packages/ui/src/elements/Upload/index.css +++ b/packages/ui/src/elements/Upload/index.css @@ -37,8 +37,7 @@ place-self: flex-start; } - .file-field__file-adjustments, - .file-field__remote-file-wrap { + .file-field__file-adjustments { padding: var(--spacer-3); width: 100%; display: flex; @@ -46,8 +45,7 @@ gap: var(--spacer-2); } - .file-field__filename, - .file-field__remote-file { + .file-field__filename { font-family: var(--font-body); width: 100%; border: var(--stroke-width-small) solid var(--color-border); @@ -80,14 +78,10 @@ } } - .file-field__upload-actions, - .file-field__add-file-wrap { + .file-field__upload-actions { display: flex; gap: var(--spacer-2); flex-wrap: wrap; - } - - .file-field__upload-actions { margin-top: var(--spacer-2); } @@ -104,13 +98,18 @@ .file-field .dropzone { background-color: transparent; padding: var(--spacer-2-5); + display: flex; + align-items: center; + justify-content: center; } .file-field__dropzoneContent { display: flex; + flex-direction: column; flex-wrap: wrap; + align-items: center; gap: var(--spacer-1); - justify-content: space-between; + justify-content: center; width: 100%; } @@ -133,6 +132,130 @@ color: var(--color-text-tertiary); } + .file-field--side-panel { + position: relative; + flex: 1 0 0; + width: 100%; + min-height: max(500px, calc(100dvh - var(--doc-controls-height) - var(--app-header-height))); + margin-bottom: 0; + border-radius: 0; + background: var(--color-bg-secondary); + } + + .file-field__side-panel { + position: sticky; + top: var(--doc-controls-height); + height: max( + 500px, + calc( + 100dvh - max(0px, calc(var(--app-header-height) - var(--scroll-y))) - + var(--doc-controls-height) + ) + ); + display: flex; + flex-direction: row; + overflow: hidden; + } + + .file-field__side-panel__preview { + display: flex; + flex-direction: column; + flex: 1 0 0; + min-width: 0; + background: var(--color-bg-secondary); + padding: var(--spacer-3); + gap: var(--spacer-2); + overflow: hidden; + } + + .file-field__side-panel__info { + display: flex; + align-items: baseline; + gap: var(--spacer-2); + flex-shrink: 0; + min-width: 0; + } + + .file-field__side-panel__info-label { + font-family: var(--text-body-medium-strong-font-family); + font-size: var(--text-body-medium-font-size); + font-weight: var(--text-body-medium-strong-font-weight); + line-height: var(--text-body-medium-strong-line-height); + color: var(--color-text); + white-space: nowrap; + flex-shrink: 0; + } + + .file-field__side-panel__info-meta { + font-size: var(--text-body-medium-font-size); + line-height: var(--text-body-medium-line-height); + color: var(--color-text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + } + + .file-field__side-panel__dimensions { + font-size: var(--text-body-medium-font-size); + line-height: var(--text-body-medium-line-height); + color: var(--color-text-secondary); + text-align: center; + flex-shrink: 0; + } + + .file-field__side-panel__image-wrap { + flex: 1 0 0; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + + .thumbnail { + max-width: 100%; + max-height: 100%; + + img, + svg { + object-fit: scale-down; + } + } + } + + .file-field__side-panel__upload { + display: flex; + flex: 1 0 0; + flex-direction: column; + align-items: center; + justify-content: center; + background: var(--color-bg-secondary); + padding: var(--spacer-4); + height: 100%; + + .dropzone { + width: 100%; + height: 100%; + background: transparent; + } + } + + @media (max-width: 1024px) { + .file-field--side-panel { + min-height: 400px; + } + + .file-field__side-panel { + position: static; + height: auto; + min-height: 400px; + } + + .file-field__side-panel__preview { + height: auto; + } + } + @media (max-width: 768px) { .file-field__upload { flex-wrap: wrap; diff --git a/packages/ui/src/elements/Upload/index.tsx b/packages/ui/src/elements/Upload/index.tsx index 78307c5e599..9c66ed3b292 100644 --- a/packages/ui/src/elements/Upload/index.tsx +++ b/packages/ui/src/elements/Upload/index.tsx @@ -2,7 +2,7 @@ import type { FormState, SanitizedCollectionConfig, UploadEdits } from 'payload' import { useModal } from '@faceless-ui/modal' -import { formatAdminURL, isImage } from 'payload/shared' +import { formatAdminURL, formatFilesize, isImage } from 'payload/shared' import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react' import { toast } from 'sonner' @@ -22,8 +22,13 @@ import { Dropzone } from '../Dropzone/index.js' import { EditUpload } from '../EditUpload/index.js' import './index.css' import { FileDetails } from '../FileDetails/index.js' +import { MiniCarousel } from '../MiniCarousel/index.js' import { PreviewSizes } from '../PreviewSizes/index.js' import { Thumbnail } from '../Thumbnail/index.js' +import { pasteURLDrawerSlug, UploadFromURLModal } from './UploadFromURLModal/index.js' +import { UploadToolbar } from './UploadToolbar/index.js' + +export { pasteURLDrawerSlug } const baseClass = 'file-field' export const editDrawerSlug = 'edit-upload' @@ -110,8 +115,10 @@ export type UploadProps = { readonly customActions?: React.ReactNode[] readonly initialState?: FormState readonly onChange?: (file?: File) => void + readonly sidePanel?: boolean readonly uploadConfig: SanitizedCollectionConfig['upload'] readonly UploadControls?: React.ReactNode + readonly UploadFilePreview?: React.ReactNode } export const Upload: React.FC = (props) => { @@ -141,10 +148,12 @@ export const Upload_v4: React.FC = (props) => { initialState, onChange, resetUploadEdits, + sidePanel, updateUploadEdits, uploadConfig, UploadControls, uploadEdits, + UploadFilePreview, } = props const { @@ -163,6 +172,7 @@ export const Upload_v4: React.FC = (props) => { } = useConfig() const { t } = useTranslation() + const { closeModal, openModal } = useModal() const { setModified } = useForm() const { id, data, docPermissions, setUploadStatus } = useDocumentInfo() const isFormSubmitting = useFormProcessing() @@ -174,10 +184,9 @@ export const Upload_v4: React.FC = (props) => { const [fileSrc, setFileSrc] = useState(null) const [removedFile, setRemovedFile] = useState(false) const [filename, setFilename] = useState(value?.name || '') - const [showUrlInput, setShowUrlInput] = useState(false) const [fileUrl, setFileUrl] = useState('') + const [selectedSize, setSelectedSize] = useState(null) - const urlInputRef = useRef(null) const inputRef = useRef(null) const useServerSideFetch = @@ -190,7 +199,6 @@ export const Upload_v4: React.FC = (props) => { } setValue(file) - setShowUrlInput(false) setUploadControlFileUrl('') setUploadControlFileName(null) setUploadControlFile(null) @@ -211,6 +219,17 @@ export const Upload_v4: React.FC = (props) => { return newFile } + const handleRename = React.useCallback( + (newName: string) => { + if (value) { + handleFileChange({ file: renameFile(value, newName), isNewFile: false }) + } + setFilename(newName) + setModified(true) + }, + [value, handleFileChange, setModified], + ) + const handleFileNameChange = React.useCallback( (e: React.ChangeEvent) => { const updatedFileName = e.target.value @@ -237,7 +256,6 @@ export const Upload_v4: React.FC = (props) => { setFileSrc('') setFileUrl('') resetUploadEdits() - setShowUrlInput(false) setUploadControlFileUrl('') setUploadControlFileName(null) setUploadControlFile(null) @@ -257,8 +275,10 @@ export const Upload_v4: React.FC = (props) => { [setModified, updateUploadEdits], ) + const isValidUrl = Boolean(fileUrl && URL.canParse(fileUrl)) + const handleUrlSubmit = useCallback(async () => { - if (!fileUrl || uploadConfig?.pasteURL === false) { + if (!fileUrl || !URL.canParse(fileUrl) || uploadConfig?.pasteURL === false) { return } @@ -272,11 +292,14 @@ export const Upload_v4: React.FC = (props) => { } const blob = await clientResponse.blob() - const fileName = uploadControlFileName || decodeURIComponent(fileUrl.split('/').pop() || '') + const rawSegment = fileUrl.split('/').pop() || '' + const fileName = uploadControlFileName || decodeURIComponent(rawSegment.split('?')[0]) const file = new File([blob], fileName, { type: blob.type }) handleFileChange({ file }) setUploadStatus('idle') + closeModal(pasteURLDrawerSlug) + setFileUrl('') return // Exit if client-side fetch succeeds } catch (_clientError) { if (!useServerSideFetch) { @@ -302,17 +325,21 @@ export const Upload_v4: React.FC = (props) => { } const blob = await serverResponse.blob() - const fileName = decodeURIComponent(fileUrl.split('/').pop() || '') + const rawSegment = fileUrl.split('/').pop() || '' + const fileName = decodeURIComponent(rawSegment.split('?')[0]) const file = new File([blob], fileName, { type: blob.type }) handleFileChange({ file }) setUploadStatus('idle') + closeModal(pasteURLDrawerSlug) + setFileUrl('') } catch (_serverError) { toast.error('The provided URL is not allowed.') setUploadStatus('failed') } }, [ api, + closeModal, collectionSlug, fileUrl, handleFileChange, @@ -330,12 +357,6 @@ export const Upload_v4: React.FC = (props) => { } }, [initialState]) - useEffect(() => { - if (showUrlInput && urlInputRef.current) { - // urlInputRef.current.focus() // Focus on the remote-url input field when showUrlInput is true - } - }, [showUrlInput]) - useEffect(() => { if (isFormSubmitting) { setRemovedFile(false) @@ -378,6 +399,220 @@ export const Upload_v4: React.FC = (props) => { void handleControlFile() }, [uploadControlFile, handleFileChange]) + const drawers = ( + + {(value || data?.filename) && ( + + + + )} + {data && hasImageSizes && ( + + + + )} + {uploadConfig?.pasteURL !== false && ( + + )} + + ) + + if (sidePanel) { + const selectedSizeData = selectedSize ? data?.sizes?.[selectedSize] : null + const sidePanelFileSrc = selectedSizeData?.url || data?.thumbnailURL || data?.url || null + const sidePanelMimeType = data?.mimeType as string | undefined + const fileTypeIsAdjustable = + !!sidePanelMimeType && + isImage(sidePanelMimeType) && + sidePanelMimeType !== 'image/svg+xml' && + sidePanelMimeType !== 'image/jxl' + + return ( +
+ + {data?.filename && !removedFile && ( + openModal(editDrawerSlug)} + onRemove={handleFileRemoval} + onRenameConfirm={handleRename} + onReplace={handleFileRemoval} + /> + )} +
+ {data?.filename && !removedFile ? ( + + {hasImageSizes && ( + + )} +
+
+ + {selectedSize ?? t('general:original')} + + + {formatFilesize( + (selectedSizeData?.filesize ?? (data?.filesize as number)) || 0, + )} + {data.mimeType ? ` – ${data.mimeType as string}` : null} + +
+
+ {UploadFilePreview ?? ( + + )} +
+ {(() => { + const w = (selectedSizeData?.width ?? data?.width) as number | undefined + const h = (selectedSizeData?.height ?? data?.height) as number | undefined + return typeof w === 'number' && typeof h === 'number' ? ( + + {w} × {h} + + ) : null + })()} +
+
+ ) : ( +
+ {!value && ( + +
+
+ + { + if (e.target.files && e.target.files.length > 0) { + handleFileSelection(e.target.files) + } + }} + ref={inputRef} + type="file" + /> + {uploadConfig?.pasteURL !== false && ( + + {t('general:or')} + + + )} + {UploadControls ? UploadControls : null} +
+

+ {t('general:or')} {t('upload:dragAndDrop')} +

+
+
+ )} + {value && fileSrc && ( + +
+ +
+
+ {/* eslint-disable-next-line jsx-a11y/control-has-associated-label */} + + +
+
+ )} +
+ {drawers} +
+ ) + } + return (
@@ -396,7 +631,7 @@ export const Upload_v4: React.FC = (props) => { )} {((!uploadConfig.hideFileInputOnCreate && !data?.filename) || removedFile) && (
- {!value && !showUrlInput && ( + {!value && (
@@ -430,7 +665,7 @@ export const Upload_v4: React.FC = (props) => {
)} - {showUrlInput && ( - -
- {/* eslint-disable-next-line jsx-a11y/control-has-associated-label */} - { - setFileUrl(e.target.value) - }} - ref={urlInputRef} - title={fileUrl} - type="text" - value={fileUrl} - /> -
- -
-
-
) } diff --git a/packages/ui/src/icons/Crop/index.css b/packages/ui/src/icons/Crop/index.css new file mode 100644 index 00000000000..2249338b414 --- /dev/null +++ b/packages/ui/src/icons/Crop/index.css @@ -0,0 +1,5 @@ +@layer payload-default { + .icon--crop { + /* Icon uses fill="currentColor" inline */ + } +} diff --git a/packages/ui/src/icons/Crop/index.tsx b/packages/ui/src/icons/Crop/index.tsx new file mode 100644 index 00000000000..8aee452c8fc --- /dev/null +++ b/packages/ui/src/icons/Crop/index.tsx @@ -0,0 +1,33 @@ +import React from 'react' + +import './index.css' + +export const CropIcon: React.FC<{ + readonly className?: string + readonly size?: 16 | 24 +}> = ({ className, size = 24 }) => ( + +) diff --git a/packages/ui/src/icons/Download/index.css b/packages/ui/src/icons/Download/index.css new file mode 100644 index 00000000000..4f2046d6b2e --- /dev/null +++ b/packages/ui/src/icons/Download/index.css @@ -0,0 +1,5 @@ +@layer payload-default { + .icon--download { + /* Icon uses fill="currentColor" inline */ + } +} diff --git a/packages/ui/src/icons/Download/index.tsx b/packages/ui/src/icons/Download/index.tsx new file mode 100644 index 00000000000..061c078c4c4 --- /dev/null +++ b/packages/ui/src/icons/Download/index.tsx @@ -0,0 +1,36 @@ +import React from 'react' + +import './index.css' + +export const DownloadIcon: React.FC<{ + readonly className?: string + readonly size?: 16 | 24 +}> = ({ className, size = 24 }) => ( + +) diff --git a/packages/ui/src/scss/app.scss b/packages/ui/src/scss/app.scss index 32dbb6f9ca7..8a3a4377c20 100644 --- a/packages/ui/src/scss/app.scss +++ b/packages/ui/src/scss/app.scss @@ -39,7 +39,9 @@ --gutter-h: var(--spacer-5); --spacing-view-bottom: var(--gutter-h); - --doc-controls-height: calc(var(--text-body-large-line-height) + var(--spacer-4)); + --app-header-height: 0px; + --doc-controls-height: 0px; + --scroll-y: 0px; --nav-width: 275px; @include large-break { @@ -48,7 +50,6 @@ @include mid-break { --gutter-h: var(--spacer-3); - --doc-controls-height: calc(var(--base) * 2.4); } @include small-break { diff --git a/packages/ui/src/views/Document/index.tsx b/packages/ui/src/views/Document/index.tsx index 28e935a7b4e..abe3df05e5b 100644 --- a/packages/ui/src/views/Document/index.tsx +++ b/packages/ui/src/views/Document/index.tsx @@ -365,6 +365,7 @@ export const renderDocument = async ({ const documentSlots = renderDocumentSlots({ id, collectionConfig, + doc: doc as Record, globalConfig, hasSavePermission, locale, diff --git a/packages/ui/src/views/Document/renderDocumentSlots.tsx b/packages/ui/src/views/Document/renderDocumentSlots.tsx index 7097128bb1b..90f9c4543e3 100644 --- a/packages/ui/src/views/Document/renderDocumentSlots.tsx +++ b/packages/ui/src/views/Document/renderDocumentSlots.tsx @@ -3,6 +3,7 @@ import type { DocumentSlots, EditMenuItemsServerPropsOnly, Locale, + PayloadComponent, PayloadRequest, PreviewButtonServerPropsOnly, PublishButtonServerPropsOnly, @@ -15,11 +16,12 @@ import type { ServerProps, StaticDescription, UnpublishButtonServerPropsOnly, + UploadFilePreviewClientProps, ViewDescriptionClientProps, ViewDescriptionServerPropsOnly, } from 'payload' -import { hasDraftsEnabled } from 'payload/shared' +import { hasDraftsEnabled, matchMimeType } from 'payload/shared' import { RenderServerComponent } from '../../elements/RenderServerComponent/index.js' // eslint-disable-next-line payload/no-imports-from-exports-dir -- Server component must reference exports/client bundle for proper client boundary in prod builds @@ -28,6 +30,7 @@ import { getDocumentPermissions } from '../../utilities/getDocumentPermissions.j export const renderDocumentSlots: (args: { collectionConfig?: SanitizedCollectionConfig + doc?: Record globalConfig?: SanitizedGlobalConfig hasSavePermission: boolean id?: number | string @@ -35,7 +38,8 @@ export const renderDocumentSlots: (args: { permissions: SanitizedPermissions req: PayloadRequest }) => DocumentSlots = (args) => { - const { id, collectionConfig, globalConfig, hasSavePermission, locale, permissions, req } = args + const { id, collectionConfig, doc, globalConfig, hasSavePermission, locale, permissions, req } = + args const components: DocumentSlots = {} as DocumentSlots @@ -222,6 +226,40 @@ export const renderDocumentSlots: (args: { }) } + const filePreviewConfig = collectionConfig?.upload?.admin?.components?.filePreview + if (collectionConfig?.upload && filePreviewConfig) { + const mimeType = (doc?.mimeType as string) ?? '' + let filePreviewComponent: PayloadComponent | undefined + + if ( + typeof filePreviewConfig === 'string' || + (typeof filePreviewConfig === 'object' && 'path' in filePreviewConfig) + ) { + filePreviewComponent = filePreviewConfig as PayloadComponent + } else { + filePreviewComponent = matchMimeType( + filePreviewConfig as Record, + mimeType, + ) + } + + if (filePreviewComponent) { + components.UploadFilePreview = RenderServerComponent({ + clientProps: { + filename: doc?.filename, + filesize: doc?.filesize, + fileSrc: doc?.url, + height: doc?.height, + mimeType, + width: doc?.width, + } as UploadFilePreviewClientProps, + Component: filePreviewComponent, + importMap: req.payload.importMap, + serverProps, + }) + } + } + return components } @@ -244,9 +282,30 @@ export const renderDocumentSlotsHandler: ServerFunction<{ req, }) + let doc: Record | undefined + if (id && collectionConfig.upload) { + const result = await req.payload.findByID({ + id, + collection: collectionSlug, + depth: 0, + overrideAccess: false, + req, + select: { + filename: true, + filesize: true, + height: true, + mimeType: true, + url: true, + width: true, + }, + }) + doc = result as Record + } + return renderDocumentSlots({ id, collectionConfig, + doc, hasSavePermission, locale, permissions, diff --git a/packages/ui/src/views/Edit/index.css b/packages/ui/src/views/Edit/index.css index 9100ba67b01..2c35a6b8942 100644 --- a/packages/ui/src/views/Edit/index.css +++ b/packages/ui/src/views/Edit/index.css @@ -13,6 +13,32 @@ width: 100%; } + .collection-edit__upload-layout { + display: flex; + flex-direction: row; + align-items: flex-start; + + .document-fields { + flex: 0 0 auto; + width: 347px; + border-inline-end: 1px solid var(--color-border); + } + + .document-fields:not(:has(.document-fields__sidebar-wrap)):not(:has(.document-fields__edit > :not(.document-fields__fields))):not(:has(.document-fields__fields > :not(input[type="hidden"]))) { + display: none; + } + } + + @media (max-width: 1024px) { + .collection-edit__upload-layout { + flex-direction: column; + + .document-fields { + width: 100%; + } + } + } + .collection-edit__main--is-live-previewing { width: 40%; position: relative; diff --git a/packages/ui/src/views/Edit/index.tsx b/packages/ui/src/views/Edit/index.tsx index d8d2e4016b0..c0288c4ea9d 100644 --- a/packages/ui/src/views/Edit/index.tsx +++ b/packages/ui/src/views/Edit/index.tsx @@ -68,6 +68,7 @@ export function DefaultEditView({ UnpublishButton, Upload: CustomUpload, UploadControls, + UploadFilePreview, }: DocumentViewClientProps) { const { id, @@ -781,53 +782,100 @@ export function DefaultEditView({ .filter(Boolean) .join(' ')} > - - {auth && ( - - )} - {upload && ( - - - {CustomUpload || ( - - )} - - - )} - - ) - } - Description={Description} - docPermissions={docPermissions} - fields={docConfig.fields} - forceSidebarWrap={isLivePreviewing} - isTrashed={isTrashed} - readOnly={isReadOnlyForIncomingUser || !hasSavePermission || isTrashed} - schemaPathSegments={schemaPathSegments} - /> + {upload && !BeforeFields && !isInDrawer ? ( + +
+ + ) : undefined + } + Description={Description} + docPermissions={docPermissions} + fields={docConfig.fields} + forceSidebarWrap={isLivePreviewing} + isTrashed={isTrashed} + readOnly={isReadOnlyForIncomingUser || !hasSavePermission || isTrashed} + schemaPathSegments={schemaPathSegments} + /> + {CustomUpload || ( + + )} +
+
+ ) : ( + + {auth && ( + + )} + {upload && ( + + + {CustomUpload || ( + + )} + + + )} + + ) + } + Description={Description} + docPermissions={docPermissions} + fields={docConfig.fields} + forceSidebarWrap={isLivePreviewing} + isTrashed={isTrashed} + readOnly={isReadOnlyForIncomingUser || !hasSavePermission || isTrashed} + schemaPathSegments={schemaPathSegments} + /> + )} {AfterDocument}
{isLivePreviewEnabled && !isInDrawer && livePreviewURL && ( From b28694eeb0d6caa709f891e1da5f4ac6efadb006 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Mon, 15 Jun 2026 16:10:22 +0100 Subject: [PATCH 03/19] update --- packages/translations/src/clientKeys.ts | 1 - packages/translations/src/languages/en.ts | 1 - packages/ui/src/icons/Crop/index.tsx | 20 +++++++----------- packages/ui/src/icons/Download/index.tsx | 25 ++++++++--------------- 4 files changed, 16 insertions(+), 31 deletions(-) diff --git a/packages/translations/src/clientKeys.ts b/packages/translations/src/clientKeys.ts index 3249dd25288..d3a217efe3f 100644 --- a/packages/translations/src/clientKeys.ts +++ b/packages/translations/src/clientKeys.ts @@ -474,7 +474,6 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'upload:pasteURL', 'upload:renameFile', 'upload:replaceFile', - 'upload:uploadFile', 'upload:previewSizes', 'upload:selectCollectionToBrowse', 'upload:selectFile', diff --git a/packages/translations/src/languages/en.ts b/packages/translations/src/languages/en.ts index edc3814d6c2..0a6be5c6692 100644 --- a/packages/translations/src/languages/en.ts +++ b/packages/translations/src/languages/en.ts @@ -577,7 +577,6 @@ export const enTranslations = { setFocalPoint: 'Set focal point', sizes: 'Sizes', sizesFor: 'Sizes for {{label}}', - uploadFile: 'Upload file', width: 'Width', }, validation: { diff --git a/packages/ui/src/icons/Crop/index.tsx b/packages/ui/src/icons/Crop/index.tsx index 8aee452c8fc..9e3a46eddd5 100644 --- a/packages/ui/src/icons/Crop/index.tsx +++ b/packages/ui/src/icons/Crop/index.tsx @@ -2,6 +2,12 @@ import React from 'react' import './index.css' +// Paths from Figma icon library (fpl/components/src/icons/icon-24-crop.tsx) +const paths: Record<16 | 24, string> = { + 16: 'M3 3V9H5V5H9V3H3ZM13 7H11V11H7V13H13V7Z', + 24: 'M7.5 3a.5.5 0 0 1 .5.5V16h8V8H9.5a.5.5 0 0 1 0-1h7a.5.5 0 0 1 .5.5V16h3.5a.5.5 0 0 1 0 1h-13a.5.5 0 0 1-.5-.5V8H3.5a.5.5 0 0 1 0-1H7V3.5a.5.5 0 0 1 .5-.5m9 15a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2a.5.5 0 0 1 .5-.5', +} + export const CropIcon: React.FC<{ readonly className?: string readonly size?: 16 | 24 @@ -16,18 +22,6 @@ export const CropIcon: React.FC<{ width={size} xmlns="http://www.w3.org/2000/svg" > - {size === 24 ? ( - <> - {/* Top-left L-bracket */} - - {/* Bottom-right L-bracket */} - - - ) : ( - <> - - - - )} + ) diff --git a/packages/ui/src/icons/Download/index.tsx b/packages/ui/src/icons/Download/index.tsx index 061c078c4c4..2b5017682cb 100644 --- a/packages/ui/src/icons/Download/index.tsx +++ b/packages/ui/src/icons/Download/index.tsx @@ -2,6 +2,14 @@ import React from 'react' import './index.css' +// Paths from Figma icon library +// 24px: fpl/components/src/icons/icon-24-export-small.tsx +// 16px: fpl/components/src/icons/icon-16-download.tsx +const paths: Record<16 | 24, string> = { + 16: 'M8 2.5a.5.5 0 0 0-1 0v5.793L5.354 6.646a.5.5 0 1 0-.707.708l2.5 2.5a.5.5 0 0 0 .707 0l2.5-2.5a.5.5 0 0 0-.707-.708L8 8.293zm-4 8a.5.5 0 0 0-1 0v1A1.5 1.5 0 0 0 4.5 13h6a1.5 1.5 0 0 0 1.5-1.5v-1a.5.5 0 0 0-1 0v1a.5.5 0 0 1-.5.5h-6a.5.5 0 0 1-.5-.5z', + 24: 'M12 6.5a.5.5 0 0 0-1 0v5.793l-2.146-2.147a.5.5 0 0 0-.708.708l3 3a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L12 12.293zm-5 7a.5.5 0 0 0-1 0v3A1.5 1.5 0 0 0 7.5 18h8a1.5 1.5 0 0 0 1.5-1.5v-3a.5.5 0 0 0-1 0v3a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5z', +} + export const DownloadIcon: React.FC<{ readonly className?: string readonly size?: 16 | 24 @@ -16,21 +24,6 @@ export const DownloadIcon: React.FC<{ width={size} xmlns="http://www.w3.org/2000/svg" > - {size === 24 ? ( - <> - {/* Arrow with stem */} - - {/* Bottom bar */} - - - ) : ( - <> - - - - )} + ) From 79d0fda2bb6692a35828b29314d919f4f4c71bf4 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Tue, 16 Jun 2026 18:30:33 +0100 Subject: [PATCH 04/19] commit --- .../elements/Upload/UploadToolbar/index.css | 16 +-- .../elements/Upload/UploadToolbar/index.tsx | 41 +------ packages/ui/src/elements/Upload/index.css | 82 +++++--------- packages/ui/src/elements/Upload/index.tsx | 103 +++++++++--------- packages/ui/src/views/Edit/index.tsx | 1 - 5 files changed, 85 insertions(+), 158 deletions(-) diff --git a/packages/ui/src/elements/Upload/UploadToolbar/index.css b/packages/ui/src/elements/Upload/UploadToolbar/index.css index dadb3c51a48..88c3509ecba 100644 --- a/packages/ui/src/elements/Upload/UploadToolbar/index.css +++ b/packages/ui/src/elements/Upload/UploadToolbar/index.css @@ -1,9 +1,9 @@ @layer payload-default { .upload-toolbar { - display: grid; - grid-template-columns: 1fr fit-content(100%) 1fr; + display: flex; align-items: center; - padding: var(--spacer-2) var(--spacer-2-5) var(--spacer-2) var(--spacer-2); + justify-content: space-between; + padding: var(--spacer-2) var(--spacer-4) var(--spacer-2) var(--spacer-2); border-bottom: 1px solid var(--color-border); background: var(--color-bg); flex-shrink: 0; @@ -12,19 +12,15 @@ .upload-toolbar__left { display: flex; align-items: flex-start; - } - - .upload-toolbar__center { - display: flex; - gap: var(--spacer-1); - align-items: center; + min-width: 0; + flex: 1; } .upload-toolbar__right { display: flex; gap: var(--spacer-1); align-items: center; - justify-content: flex-end; + flex-shrink: 0; } .upload-toolbar__filename-btn { diff --git a/packages/ui/src/elements/Upload/UploadToolbar/index.tsx b/packages/ui/src/elements/Upload/UploadToolbar/index.tsx index 44eaafb5193..dae3d8d4b71 100644 --- a/packages/ui/src/elements/Upload/UploadToolbar/index.tsx +++ b/packages/ui/src/elements/Upload/UploadToolbar/index.tsx @@ -1,14 +1,12 @@ 'use client' import { useModal } from '@faceless-ui/modal' -import React, { useCallback } from 'react' +import React from 'react' import { ChevronIcon } from '../../../icons/Chevron/index.js' import { CropIcon } from '../../../icons/Crop/index.js' import { DownloadIcon } from '../../../icons/Download/index.js' import { EditIcon } from '../../../icons/Edit/index.js' -import { LinkIcon } from '../../../icons/Link/index.js' import { RefreshIcon } from '../../../icons/Refresh/index.js' -import { XIcon } from '../../../icons/X/index.js' import { useTranslation } from '../../../providers/Translation/index.js' import { Button } from '../../Button/index.js' import { Popup } from '../../Popup/index.js' @@ -19,35 +17,25 @@ import './index.css' const baseClass = 'upload-toolbar' type Props = { - readonly canRemove?: boolean readonly filename: string readonly fileSrc?: null | string readonly isAdjustable?: boolean readonly onEditImage?: () => void - readonly onRemove?: () => void readonly onRenameConfirm: (newName: string) => void readonly onReplace?: () => void } export const UploadToolbar: React.FC = ({ - canRemove, filename, fileSrc, isAdjustable, onEditImage, - onRemove, onRenameConfirm, onReplace, }) => { const { t } = useTranslation() const { openModal } = useModal() - const copyURL = useCallback(() => { - if (fileSrc) { - void navigator.clipboard.writeText(fileSrc) - } - }, [fileSrc]) - return ( <> @@ -76,7 +64,7 @@ export const UploadToolbar: React.FC = ({
-
+
{isAdjustable && (
- -
{fileSrc && ( = ({ )} - {fileSrc && ( -
diff --git a/packages/ui/src/elements/Upload/index.css b/packages/ui/src/elements/Upload/index.css index a4711291713..d7d411cda68 100644 --- a/packages/ui/src/elements/Upload/index.css +++ b/packages/ui/src/elements/Upload/index.css @@ -8,6 +8,7 @@ .file-field__upload { display: flex; + position: relative; } .file-field .tooltip.error-message { @@ -33,8 +34,11 @@ } .file-field__remove { - margin: var(--spacer-4) var(--spacer-3) var(--spacer-3) 0; - place-self: flex-start; + position: absolute; + top: var(--spacer-2); + right: var(--spacer-2); + margin: 0; + z-index: 1; } .file-field__file-adjustments { @@ -45,39 +49,6 @@ gap: var(--spacer-2); } - .file-field__filename { - font-family: var(--font-body); - width: 100%; - border: var(--stroke-width-small) solid var(--color-border); - border-radius: var(--radius-small); - background: var(--color-bg-primary); - color: var(--color-text); - font-size: 1rem; - height: var(--spacer-6); - line-height: var(--spacer-3); - padding: var(--spacer-1) var(--spacer-2-5); - -webkit-appearance: none; - - &[data-rtl='true'] { - direction: rtl; - } - - &::-webkit-input-placeholder { - color: var(--color-text-tertiary); - font-weight: normal; - font-size: 1rem; - } - - &:hover { - border-color: var(--color-border-hover); - } - - &:focus { - border-color: var(--color-border-selected); - outline: none; - } - } - .file-field__upload-actions { display: flex; gap: var(--spacer-2); @@ -108,7 +79,7 @@ flex-direction: column; flex-wrap: wrap; align-items: center; - gap: var(--spacer-1); + gap: var(--spacer-2); justify-content: center; width: 100%; } @@ -142,18 +113,11 @@ background: var(--color-bg-secondary); } - .file-field__side-panel { - position: sticky; - top: var(--doc-controls-height); - height: max( - 500px, - calc( - 100dvh - max(0px, calc(var(--app-header-height) - var(--scroll-y))) - - var(--doc-controls-height) - ) - ); + .file-field__side-panel__content { display: flex; flex-direction: row; + flex: 1 1 0; + min-height: 0; overflow: hidden; } @@ -163,20 +127,28 @@ flex: 1 0 0; min-width: 0; background: var(--color-bg-secondary); - padding: var(--spacer-3); - gap: var(--spacer-2); + padding: var(--spacer-3) var(--spacer-4); + gap: var(--spacer-3); overflow: hidden; } .file-field__side-panel__info { display: flex; - align-items: baseline; + align-items: center; + align-self: center; gap: var(--spacer-2); flex-shrink: 0; min-width: 0; } + /* Badge-style label matching Figma "Original" / size-name chip */ .file-field__side-panel__info-label { + display: inline-flex; + align-items: center; + padding: 0 var(--spacer-2); + height: 16px; + border-radius: var(--radius-small); + background: var(--color-bg-secondary-hover); font-family: var(--text-body-medium-strong-font-family); font-size: var(--text-body-medium-font-size); font-weight: var(--text-body-medium-strong-font-weight); @@ -196,14 +168,6 @@ min-width: 0; } - .file-field__side-panel__dimensions { - font-size: var(--text-body-medium-font-size); - line-height: var(--text-body-medium-line-height); - color: var(--color-text-secondary); - text-align: center; - flex-shrink: 0; - } - .file-field__side-panel__image-wrap { flex: 1 0 0; min-height: 0; @@ -251,6 +215,10 @@ min-height: 400px; } + .file-field__side-panel__content { + flex-direction: column; + } + .file-field__side-panel__preview { height: auto; } diff --git a/packages/ui/src/elements/Upload/index.tsx b/packages/ui/src/elements/Upload/index.tsx index 9c66ed3b292..6467c97d0e8 100644 --- a/packages/ui/src/elements/Upload/index.tsx +++ b/packages/ui/src/elements/Upload/index.tsx @@ -8,6 +8,7 @@ import { toast } from 'sonner' import { FieldError } from '../../fields/FieldError/index.js' import { fieldBaseClass } from '../../fields/shared/index.js' +import { TextInput } from '../../fields/Text/Input.js' import { useForm, useFormProcessing } from '../../forms/Form/index.js' import { useField } from '../../forms/useField/index.js' import { useConfig } from '../../providers/Config/index.js' @@ -88,7 +89,7 @@ export const UploadActions = ({ )} {enableAdjustments && (
)}
+ {drawers}
) @@ -687,6 +690,14 @@ export const Upload_v4: React.FC = (props) => { )} {value && fileSrc && ( +
diff --git a/packages/ui/src/views/Edit/index.tsx b/packages/ui/src/views/Edit/index.tsx index c0288c4ea9d..510800d4715 100644 --- a/packages/ui/src/views/Edit/index.tsx +++ b/packages/ui/src/views/Edit/index.tsx @@ -818,7 +818,6 @@ export function DefaultEditView({ Date: Wed, 17 Jun 2026 00:25:26 +0100 Subject: [PATCH 05/19] commit --- packages/ui/src/elements/AppHeader/index.tsx | 8 - .../DocumentHeaderRoot/index.tsx | 33 ++ .../ui/src/elements/DocumentHeader/index.tsx | 6 +- packages/ui/src/elements/EditUpload/index.tsx | 2 +- .../DraggableFileDetails/index.tsx | 2 +- .../FileDetails/StaticFileDetails/index.tsx | 2 +- .../ui/src/elements/FileDetails/index.tsx | 2 +- .../FileManager/FilePreview/index.css | 79 ++++ .../FileManager/FilePreview/index.tsx | 90 ++++ .../FileManager/FileToolbar/index.css | 69 +++ .../FileManager/FileToolbar/index.tsx | 82 ++++ .../ui/src/elements/FileManager/index.css | 135 ++++++ .../ui/src/elements/FileManager/index.tsx | 420 ++++++++++++++++++ .../ui/src/elements/MiniCarousel/index.css | 28 +- .../ui/src/elements/MiniCarousel/index.tsx | 4 +- .../ui/src/elements/PreviewSizes/index.tsx | 2 +- packages/ui/src/elements/Thumbnail/index.tsx | 4 +- packages/ui/src/elements/Upload/index.tsx | 196 +------- packages/ui/src/exports/client/index.ts | 1 + packages/ui/src/scss/app.scss | 2 +- packages/ui/src/views/Edit/index.tsx | 3 +- 21 files changed, 951 insertions(+), 219 deletions(-) create mode 100644 packages/ui/src/elements/DocumentHeader/DocumentHeaderRoot/index.tsx create mode 100644 packages/ui/src/elements/FileManager/FilePreview/index.css create mode 100644 packages/ui/src/elements/FileManager/FilePreview/index.tsx create mode 100644 packages/ui/src/elements/FileManager/FileToolbar/index.css create mode 100644 packages/ui/src/elements/FileManager/FileToolbar/index.tsx create mode 100644 packages/ui/src/elements/FileManager/index.css create mode 100644 packages/ui/src/elements/FileManager/index.tsx diff --git a/packages/ui/src/elements/AppHeader/index.tsx b/packages/ui/src/elements/AppHeader/index.tsx index b43e77ac37e..c8d3a053517 100644 --- a/packages/ui/src/elements/AppHeader/index.tsx +++ b/packages/ui/src/elements/AppHeader/index.tsx @@ -50,14 +50,6 @@ export function AppHeader({ CustomAvatar, settingsItems }: Props) { return () => observer.disconnect() }, []) - useEffect(() => { - const update = () => - document.documentElement.style.setProperty('--scroll-y', `${window.scrollY}px`) - update() - window.addEventListener('scroll', update, { passive: true }) - return () => window.removeEventListener('scroll', update) - }, []) - useEffect(() => { const checkIsScrollable = () => { const el = customControlsRef.current diff --git a/packages/ui/src/elements/DocumentHeader/DocumentHeaderRoot/index.tsx b/packages/ui/src/elements/DocumentHeader/DocumentHeaderRoot/index.tsx new file mode 100644 index 00000000000..109bdee9aef --- /dev/null +++ b/packages/ui/src/elements/DocumentHeader/DocumentHeaderRoot/index.tsx @@ -0,0 +1,33 @@ +'use client' +import React, { useEffect, useRef } from 'react' + +const baseClass = 'doc-header' + +/** + * Client wrapper for the document header that publishes its rendered height as the + * `--doc-header-height` CSS variable, mirroring how `AppHeader` and `DocumentControls` + * expose their own heights for layout calculations. + * + * @internal + */ +export const DocumentHeaderRoot: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const ref = useRef(null) + + useEffect(() => { + const el = ref.current + if (!el) { + return + } + const observer = new ResizeObserver(() => { + document.documentElement.style.setProperty('--doc-header-height', `${el.offsetHeight}px`) + }) + observer.observe(el) + return () => observer.disconnect() + }, []) + + return ( +
+ {children} +
+ ) +} diff --git a/packages/ui/src/elements/DocumentHeader/index.tsx b/packages/ui/src/elements/DocumentHeader/index.tsx index e4970452e98..315581e4e97 100644 --- a/packages/ui/src/elements/DocumentHeader/index.tsx +++ b/packages/ui/src/elements/DocumentHeader/index.tsx @@ -8,7 +8,7 @@ import type { import React from 'react' // eslint-disable-next-line payload/no-imports-from-exports-dir -- Server component must reference exports dir for proper client boundary -import { Gutter, RenderTitle } from '../../exports/client/index.js' +import { DocumentHeaderRoot, Gutter, RenderTitle } from '../../exports/client/index.js' import { DocumentTabs } from './Tabs/index.js' import './index.css' @@ -28,7 +28,7 @@ export const DocumentHeader: React.FC<{ const { AfterHeader, collectionConfig, globalConfig, hideTabs, permissions, req } = props return ( -
+ {!hideTabs && ( )} {AfterHeader ?
{AfterHeader}
: null} -
+ ) } diff --git a/packages/ui/src/elements/EditUpload/index.tsx b/packages/ui/src/elements/EditUpload/index.tsx index b8374e9c872..59783e9f0cc 100644 --- a/packages/ui/src/elements/EditUpload/index.tsx +++ b/packages/ui/src/elements/EditUpload/index.tsx @@ -26,7 +26,7 @@ type FocalPosition = { export type EditUploadProps = { fileName: string fileSrc: string - imageCacheTag?: string + imageCacheTag?: false | string initialCrop?: UploadEdits['crop'] initialFocalPoint?: FocalPosition onSave?: (uploadEdits: UploadEdits) => void diff --git a/packages/ui/src/elements/FileDetails/DraggableFileDetails/index.tsx b/packages/ui/src/elements/FileDetails/DraggableFileDetails/index.tsx index 76aa0d5d21f..b4e2d853f40 100644 --- a/packages/ui/src/elements/FileDetails/DraggableFileDetails/index.tsx +++ b/packages/ui/src/elements/FileDetails/DraggableFileDetails/index.tsx @@ -24,7 +24,7 @@ export type DraggableFileDetailsProps = { hasImageSizes?: boolean hasMany: boolean hideRemoveFile?: boolean - imageCacheTag?: string + imageCacheTag?: false | string isSortable?: boolean removeItem?: (index: number) => void rowIndex: number diff --git a/packages/ui/src/elements/FileDetails/StaticFileDetails/index.tsx b/packages/ui/src/elements/FileDetails/StaticFileDetails/index.tsx index f53e9b3a371..ba5e1c7ff73 100644 --- a/packages/ui/src/elements/FileDetails/StaticFileDetails/index.tsx +++ b/packages/ui/src/elements/FileDetails/StaticFileDetails/index.tsx @@ -20,7 +20,7 @@ export type StaticFileDetailsProps = { handleRemove?: () => void hasImageSizes?: boolean hideRemoveFile?: boolean - imageCacheTag?: string + imageCacheTag?: false | string uploadConfig: SanitizedCollectionConfig['upload'] } diff --git a/packages/ui/src/elements/FileDetails/index.tsx b/packages/ui/src/elements/FileDetails/index.tsx index fcbdc61ef79..9e9df479c93 100644 --- a/packages/ui/src/elements/FileDetails/index.tsx +++ b/packages/ui/src/elements/FileDetails/index.tsx @@ -15,7 +15,7 @@ type SharedFileDetailsProps = { enableAdjustments?: boolean hasImageSizes?: boolean hideRemoveFile?: boolean - imageCacheTag?: string + imageCacheTag?: false | string uploadConfig: SanitizedCollectionConfig['upload'] } diff --git a/packages/ui/src/elements/FileManager/FilePreview/index.css b/packages/ui/src/elements/FileManager/FilePreview/index.css new file mode 100644 index 00000000000..e12a2faeef1 --- /dev/null +++ b/packages/ui/src/elements/FileManager/FilePreview/index.css @@ -0,0 +1,79 @@ +@layer payload-default { + .file-preview { + display: flex; + flex-direction: row; + flex: 1 1 0; + min-height: 0; + overflow: hidden; + } + + .file-preview__main { + display: flex; + flex-direction: column; + flex: 1 0 0; + min-width: 0; + background: var(--color-bg-secondary); + padding: var(--spacer-3) var(--spacer-4); + gap: var(--spacer-3); + overflow: hidden; + } + + .file-preview__image-wrap { + flex: 1 0 0; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + + .thumbnail { + max-width: 100%; + max-height: 100%; + + img, + svg { + object-fit: scale-down; + } + } + } + + .file-preview__info { + display: flex; + align-items: center; + align-self: center; + gap: var(--spacer-2); + flex-shrink: 0; + min-width: 0; + } + + .file-preview__info-label { + display: inline-flex; + align-items: center; + padding: 0 var(--spacer-2); + border-radius: var(--radius-small); + background: var(--color-bg-secondary-hover); + font-family: var(--text-body-medium-strong-font-family); + font-size: var(--text-body-medium-font-size); + font-weight: var(--text-body-medium-strong-font-weight); + line-height: var(--text-body-medium-strong-line-height); + color: var(--color-text); + white-space: nowrap; + flex-shrink: 0; + } + + .file-preview__info-meta { + font-size: var(--text-body-medium-font-size); + line-height: var(--text-body-medium-line-height); + color: var(--color-text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + } + + @media (max-width: 768px) { + .file-preview { + flex-direction: column; + } + } +} diff --git a/packages/ui/src/elements/FileManager/FilePreview/index.tsx b/packages/ui/src/elements/FileManager/FilePreview/index.tsx new file mode 100644 index 00000000000..302f3e2f02c --- /dev/null +++ b/packages/ui/src/elements/FileManager/FilePreview/index.tsx @@ -0,0 +1,90 @@ +'use client' +import { formatFilesize, isImage } from 'payload/shared' +import React from 'react' + +import type { FileManagerProps } from '../index.js' + +import { useTranslation } from '../../../providers/Translation/index.js' +import { MiniCarousel } from '../../MiniCarousel/index.js' +import { Thumbnail } from '../../Thumbnail/index.js' +import './index.css' + +const baseClass = 'file-preview' + +type Props = { + readonly data: Record + readonly imageCacheTag?: false | string + readonly selectedSize?: null | string + readonly selectedSizeData?: null | Record + readonly setSelectedSize: (size: null | string) => void +} & Pick + +export const FilePreview: React.FC = ({ + collectionSlug, + data, + imageCacheTag, + selectedSize, + selectedSizeData, + setSelectedSize, + uploadConfig, + UploadFilePreview, +}) => { + const { t } = useTranslation() + + const hasImageSizes = uploadConfig?.imageSizes?.length > 0 + const isImageFile = isImage((data?.mimeType as string) ?? '') + const fileSrc = (selectedSizeData?.url ?? data?.thumbnailURL ?? data?.url ?? null) as + | null + | string + + const metaString = (() => { + const w = (selectedSizeData?.width ?? data?.width) as number | undefined + const h = (selectedSizeData?.height ?? data?.height) as number | undefined + const parts: string[] = [] + if (typeof w === 'number' && typeof h === 'number') { + parts.push(`${w} × ${h}`) + } + parts.push( + formatFilesize(((selectedSizeData?.filesize ?? (data?.filesize as number)) || 0) as number), + ) + if (data?.mimeType) { + parts.push(data.mimeType as string) + } + return parts.join(' – ') + })() + + return ( +
+ {hasImageSizes && isImageFile && ( + + )} +
+
+ {UploadFilePreview ?? ( + + )} +
+
+ + {selectedSize ?? t('general:original')} + + {metaString} +
+
+
+ ) +} diff --git a/packages/ui/src/elements/FileManager/FileToolbar/index.css b/packages/ui/src/elements/FileManager/FileToolbar/index.css new file mode 100644 index 00000000000..845872f069e --- /dev/null +++ b/packages/ui/src/elements/FileManager/FileToolbar/index.css @@ -0,0 +1,69 @@ +@layer payload-default { + .file-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacer-2) var(--spacer-4) var(--spacer-2) var(--spacer-2); + border-bottom: 1px solid var(--color-border); + background: var(--color-bg); + flex-shrink: 0; + } + + .file-toolbar__left { + display: flex; + align-items: flex-start; + min-width: 0; + flex: 1; + } + + .file-toolbar__right { + display: flex; + gap: var(--spacer-1); + align-items: center; + flex-shrink: 0; + } + + .file-toolbar__filename-btn { + display: flex; + align-items: center; + gap: var(--spacer-1); + padding: var(--spacer-1) var(--spacer-2); + border-radius: var(--radius-medium); + background: none; + border: none; + cursor: pointer; + color: var(--color-text); + font-family: var(--text-body-medium-strong-font-family); + font-size: var(--text-body-medium-font-size); + font-weight: var(--text-body-medium-strong-font-weight); + line-height: var(--text-body-medium-strong-line-height); + letter-spacing: var(--text-body-medium-letter-spacing); + max-width: 200px; + + &:hover { + background: var(--color-bg-transparent-hover); + } + } + + .file-toolbar__filename-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .file-toolbar__icon-link { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: var(--radius-medium); + color: var(--color-icon); + text-decoration: none; + + &:hover { + background: var(--color-bg-transparent-hover); + color: var(--color-icon); + } + } +} diff --git a/packages/ui/src/elements/FileManager/FileToolbar/index.tsx b/packages/ui/src/elements/FileManager/FileToolbar/index.tsx new file mode 100644 index 00000000000..240ff36635f --- /dev/null +++ b/packages/ui/src/elements/FileManager/FileToolbar/index.tsx @@ -0,0 +1,82 @@ +'use client' +import React from 'react' + +import { ChevronIcon } from '../../../icons/Chevron/index.js' +import { CropIcon } from '../../../icons/Crop/index.js' +import { DownloadIcon } from '../../../icons/Download/index.js' +import { RefreshIcon } from '../../../icons/Refresh/index.js' +import { useTranslation } from '../../../providers/Translation/index.js' +import { Button } from '../../Button/index.js' +import { Popup } from '../../Popup/index.js' +import * as PopupList from '../../Popup/PopupButtonList/index.js' +import './index.css' + +const baseClass = 'file-toolbar' + +type Props = { + readonly filename: string + readonly fileSrc?: null | string + readonly isAdjustable?: boolean + readonly onEditImage?: () => void + readonly onReplace?: () => void +} + +export const FileToolbar: React.FC = ({ + filename, + fileSrc, + isAdjustable, + onEditImage, + onReplace, +}) => { + const { t } = useTranslation() + + return ( +
+
+ + {filename} + + + } + buttonType="custom" + horizontalAlign="left" + size="small" + verticalAlign="bottom" + > + + } onClick={onReplace}> + {t('upload:replaceFile')} + + + +
+ +
+ {isAdjustable && ( +
+
+ ) +} diff --git a/packages/ui/src/elements/FileManager/index.css b/packages/ui/src/elements/FileManager/index.css new file mode 100644 index 00000000000..de447f18444 --- /dev/null +++ b/packages/ui/src/elements/FileManager/index.css @@ -0,0 +1,135 @@ +@layer payload-default { + .file-manager { + --mini-carousel-width: 72px; + position: relative; + flex: 1 0 0; + align-self: stretch; + width: 100%; + min-height: max( + 500px, + calc(100dvh - var(--app-header-height) - var(--doc-controls-height) - var(--doc-header-height)) + ); + margin-bottom: 0; + border-radius: 0; + background: var(--color-bg-secondary); + } + + /* + * The carousel lives inside the sticky panel, so its border-right only spans the + * viewport-height panel. This draws the same divider down the full height of the + * (potentially taller) box so it reaches the bottom even below the sticky panel. + */ + .file-manager--has-side-carousel::before { + content: ''; + position: absolute; + inset-block: 0; + inset-inline-start: 0; + width: var(--mini-carousel-width); + border-inline-end: 1px solid var(--color-border); + pointer-events: none; + } + + .file-manager__panel { + position: sticky; + top: var(--doc-controls-height); + height: max( + 500px, + calc(100dvh - var(--app-header-height) - var(--doc-controls-height) - var(--doc-header-height)) + ); + display: flex; + flex-direction: column; + overflow: hidden; + } + + .file-manager__content { + display: flex; + flex-direction: row; + flex: 1 1 0; + min-height: 0; + overflow: hidden; + } + + .file-manager__upload { + display: flex; + flex: 1 0 0; + flex-direction: column; + align-items: center; + justify-content: center; + background: var(--color-bg-secondary); + padding: var(--spacer-4); + position: relative; + } + + .file-manager__remove { + position: absolute; + top: var(--spacer-2); + right: var(--spacer-2); + margin: 0; + z-index: 1; + } + + .file-manager__thumbnail-wrap { + position: relative; + width: 150px; + } + + .file-manager__file-adjustments { + padding: var(--spacer-3); + width: 100%; + display: flex; + flex-direction: column; + gap: var(--spacer-2); + } + + .file-manager__hidden-input { + display: none; + } + + .file-manager__dropzone-content { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacer-2); + width: 100%; + } + + .file-manager__dropzone-buttons { + display: flex; + gap: var(--spacer-2); + align-items: center; + } + + .file-manager__or-text { + color: var(--color-text-tertiary); + text-transform: lowercase; + } + + .file-manager__drag-text { + flex-shrink: 0; + margin: 0; + text-transform: lowercase; + color: var(--color-text-tertiary); + } + + @media (max-width: 1024px) { + .file-manager { + min-height: 400px; + } + + .file-manager__panel { + position: static; + height: auto; + min-height: 400px; + } + + .file-manager__content { + flex-direction: column; + } + } + + @media (max-width: 768px) { + .file-manager--has-side-carousel::before { + display: none; + } + } +} diff --git a/packages/ui/src/elements/FileManager/index.tsx b/packages/ui/src/elements/FileManager/index.tsx new file mode 100644 index 00000000000..de7d54ec68c --- /dev/null +++ b/packages/ui/src/elements/FileManager/index.tsx @@ -0,0 +1,420 @@ +'use client' +import type { SanitizedCollectionConfig, UploadEdits } from 'payload' + +import { useModal } from '@faceless-ui/modal' +import { formatAdminURL, isImage } from 'payload/shared' +import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react' +import { toast } from 'sonner' + +import { FieldError } from '../../fields/FieldError/index.js' +import { fieldBaseClass } from '../../fields/shared/index.js' +import { TextInput } from '../../fields/Text/Input.js' +import { useForm } from '../../forms/Form/index.js' +import { useField } from '../../forms/useField/index.js' +import { useConfig } from '../../providers/Config/index.js' +import { useDocumentInfo } from '../../providers/DocumentInfo/index.js' +import { EditDepthProvider } from '../../providers/EditDepth/index.js' +import { useTranslation } from '../../providers/Translation/index.js' +import { useUploadControls } from '../../providers/UploadControls/index.js' +import { useUploadEdits } from '../../providers/UploadEdits/index.js' +import { Button } from '../Button/index.js' +import { Drawer } from '../Drawer/index.js' +import { Dropzone } from '../Dropzone/index.js' +import { EditUpload } from '../EditUpload/index.js' +import { PreviewSizes } from '../PreviewSizes/index.js' +import { Thumbnail } from '../Thumbnail/index.js' +import { editDrawerSlug, sizePreviewSlug } from '../Upload/index.js' +import { pasteURLDrawerSlug, UploadFromURLModal } from '../Upload/UploadFromURLModal/index.js' +import './index.css' +import { FilePreview } from './FilePreview/index.js' +import { FileToolbar } from './FileToolbar/index.js' + +const baseClass = 'file-manager' + +const validate = (value) => { + if (!value && value !== undefined) { + return 'A file is required.' + } + if (value && (!value.name || value.name === '')) { + return 'A file name is required.' + } + return true +} + +export type FileManagerProps = { + readonly collectionSlug: string + readonly initialState?: import('payload').FormState + readonly uploadConfig: SanitizedCollectionConfig['upload'] + readonly UploadControls?: React.ReactNode + readonly UploadFilePreview?: React.ReactNode +} + +export const FileManager: React.FC = ({ + collectionSlug, + initialState, + uploadConfig, + UploadControls, + UploadFilePreview, +}) => { + const { closeModal, openModal } = useModal() + const { t } = useTranslation() + const { setModified } = useForm() + const { id, data, setUploadStatus } = useDocumentInfo() + const { errorMessage, setValue, showError, value } = useField({ + path: 'file', + validate, + }) + const { + config: { + routes: { api }, + }, + } = useConfig() + const { + setUploadControlFile, + setUploadControlFileName, + setUploadControlFileUrl, + uploadControlFile, + uploadControlFileName, + uploadControlFileUrl, + } = useUploadControls() + const { resetUploadEdits, updateUploadEdits, uploadEdits } = useUploadEdits() + + const [fileSrc, setFileSrc] = useState(null) + const [removedFile, setRemovedFile] = useState(false) + const [filename, setFilename] = useState(value?.name || '') + const [fileUrl, setFileUrl] = useState('') + const [selectedSize, setSelectedSize] = useState(null) + + const inputRef = useRef(null) + + const useServerSideFetch = + typeof uploadConfig?.pasteURL === 'object' && uploadConfig.pasteURL.allowList?.length > 0 + + const acceptMimeTypes = uploadConfig.mimeTypes?.join(', ') + const imageCacheTag = uploadConfig?.cacheTags && data?.updatedAt + + const hasImageSizes = uploadConfig?.imageSizes?.length > 0 + const hasResizeOptions = Boolean(uploadConfig?.resizeOptions) + const focalPointEnabled = uploadConfig?.focalPoint === true + const { crop: showCrop = true, focalPoint = true } = uploadConfig + const showFocalPoint = focalPoint && (hasImageSizes || hasResizeOptions || focalPointEnabled) + + const selectedSizeData = selectedSize + ? (data?.sizes?.[selectedSize] as Record) + : null + const sidePanelFileSrc = (selectedSizeData?.url ?? data?.thumbnailURL ?? data?.url ?? null) as + | null + | string + const sidePanelMimeType = data?.mimeType as string | undefined + const fileTypeIsAdjustable = + !!sidePanelMimeType && + isImage(sidePanelMimeType) && + sidePanelMimeType !== 'image/svg+xml' && + sidePanelMimeType !== 'image/jxl' + + const showSidePanelCarousel = Boolean( + data?.filename && + !removedFile && + hasImageSizes && + sidePanelMimeType && + isImage(sidePanelMimeType), + ) + + const renameFile = (fileToChange: File, newName: string): File => + new File([fileToChange], newName, { + type: fileToChange.type, + lastModified: fileToChange.lastModified, + }) + + const handleFileChange = useCallback( + ({ file, isNewFile = true }: { file: File | null; isNewFile?: boolean }) => { + if (isNewFile && file instanceof File) { + setFileSrc(URL.createObjectURL(file)) + } + setValue(file) + setUploadControlFileUrl('') + setUploadControlFileName(null) + setUploadControlFile(null) + }, + [setValue, setUploadControlFile, setUploadControlFileName, setUploadControlFileUrl], + ) + + const handleFileNameChange = useCallback( + (e: React.ChangeEvent) => { + const updatedFileName = e.target.value + if (value) { + handleFileChange({ file: renameFile(value, updatedFileName), isNewFile: false }) + setFilename(updatedFileName) + } + }, + [handleFileChange, value], + ) + + const handleFileRemoval = useCallback(() => { + setRemovedFile(true) + handleFileChange({ file: null }) + setFileSrc('') + setFileUrl('') + resetUploadEdits() + setUploadControlFileUrl('') + setUploadControlFileName(null) + setUploadControlFile(null) + }, [ + handleFileChange, + resetUploadEdits, + setUploadControlFile, + setUploadControlFileName, + setUploadControlFileUrl, + ]) + + const handleFileSelection = useCallback( + (files: FileList) => handleFileChange({ file: files?.[0] }), + [handleFileChange], + ) + + const isValidUrl = Boolean(fileUrl && URL.canParse(fileUrl)) + + const handleUrlSubmit = useCallback(async () => { + if (!fileUrl || !URL.canParse(fileUrl) || uploadConfig?.pasteURL === false) { + return + } + setUploadStatus('uploading') + try { + const clientResponse = await fetch(fileUrl) + if (!clientResponse.ok) { + throw new Error(`Fetch failed: ${clientResponse.status}`) + } + const blob = await clientResponse.blob() + const rawSegment = fileUrl.split('/').pop() || '' + const fileName = uploadControlFileName || decodeURIComponent(rawSegment.split('?')[0]) + handleFileChange({ file: new File([blob], fileName, { type: blob.type }) }) + setUploadStatus('idle') + closeModal(pasteURLDrawerSlug) + setFileUrl('') + return + } catch (_clientError) { + if (!useServerSideFetch) { + toast.error('Failed to fetch the file.') + setUploadStatus('failed') + return + } + } + try { + const pasteURL: `/${string}` = `/${collectionSlug}/paste-url${id ? `/${id}?` : '?'}src=${encodeURIComponent(fileUrl)}` + const serverResponse = await fetch(formatAdminURL({ apiRoute: api, path: pasteURL })) + if (!serverResponse.ok) { + throw new Error(`Fetch failed: ${serverResponse.status}`) + } + const blob = await serverResponse.blob() + const rawSegment = fileUrl.split('/').pop() || '' + const fileName = decodeURIComponent(rawSegment.split('?')[0]) + handleFileChange({ file: new File([blob], fileName, { type: blob.type }) }) + setUploadStatus('idle') + closeModal(pasteURLDrawerSlug) + setFileUrl('') + } catch (_serverError) { + toast.error('The provided URL is not allowed.') + setUploadStatus('failed') + } + }, [ + api, + closeModal, + collectionSlug, + fileUrl, + handleFileChange, + id, + setUploadStatus, + uploadConfig, + uploadControlFileName, + useServerSideFetch, + ]) + + const onEditsSave = useCallback( + (args: UploadEdits) => { + setModified(true) + updateUploadEdits(args) + }, + [setModified, updateUploadEdits], + ) + + useEffect(() => { + if (initialState?.file?.value instanceof File) { + setFileSrc(URL.createObjectURL(initialState.file.value)) + setRemovedFile(false) + } + }, [initialState]) + + useEffect(() => { + const handleControlFileUrl = async () => { + if (uploadControlFileUrl) { + setFileUrl(uploadControlFileUrl) + await handleUrlSubmit() + } + } + void handleControlFileUrl() + }, [uploadControlFileUrl, handleUrlSubmit]) + + useEffect(() => { + if (uploadControlFile) { + handleFileChange({ file: uploadControlFile }) + } + }, [uploadControlFile, handleFileChange]) + + const drawers = ( + + {(value || data?.filename) && ( + + + + )} + {data && hasImageSizes && ( + + + + )} + {uploadConfig?.pasteURL !== false && ( + + )} + + ) + + return ( +
+ +
+ {data?.filename && !removedFile && ( + openModal(editDrawerSlug)} + onReplace={handleFileRemoval} + /> + )} +
+ {data?.filename && !removedFile ? ( + } + imageCacheTag={imageCacheTag} + selectedSize={selectedSize} + selectedSizeData={selectedSizeData} + setSelectedSize={setSelectedSize} + uploadConfig={uploadConfig} + UploadFilePreview={UploadFilePreview} + /> + ) : ( +
+ {!value && ( + +
+
+ + { + if (e.target.files && e.target.files.length > 0) { + handleFileSelection(e.target.files) + } + }} + ref={inputRef} + type="file" + /> + {uploadConfig?.pasteURL !== false && ( + + {t('general:or')} + + + )} + {UploadControls ?? null} +
+

+ {t('general:or')} {t('upload:dragAndDrop')} +

+
+
+ )} + {value && fileSrc && ( + +
+ )} +
+
+ {drawers} +
+ ) +} diff --git a/packages/ui/src/elements/MiniCarousel/index.css b/packages/ui/src/elements/MiniCarousel/index.css index 4614e5bca2a..3d2a827b937 100644 --- a/packages/ui/src/elements/MiniCarousel/index.css +++ b/packages/ui/src/elements/MiniCarousel/index.css @@ -5,7 +5,7 @@ align-items: stretch; gap: var(--spacer-1); padding: var(--spacer-2-5); - width: 72px; + width: var(--mini-carousel-width, 72px); flex-shrink: 0; overflow-y: auto; background: var(--color-bg-secondary); @@ -42,9 +42,33 @@ } .mini-carousel__divider { - width: 32px; + width: 100%; height: 1px; background: var(--color-border); flex-shrink: 0; + margin: var(--spacer-2) 0; + } + + @media (max-width: 768px) { + .mini-carousel { + flex-direction: row; + align-items: center; + width: 100%; + overflow-x: auto; + overflow-y: hidden; + border-right: 0; + border-bottom: 1px solid var(--color-border); + } + + .mini-carousel__item { + width: 52px; + height: 52px; + } + + .mini-carousel__divider { + width: 1px; + height: 52px; + margin: 0 var(--spacer-2); + } } } diff --git a/packages/ui/src/elements/MiniCarousel/index.tsx b/packages/ui/src/elements/MiniCarousel/index.tsx index 60e72456d76..37eaab01b4e 100644 --- a/packages/ui/src/elements/MiniCarousel/index.tsx +++ b/packages/ui/src/elements/MiniCarousel/index.tsx @@ -10,7 +10,7 @@ const baseClass = 'mini-carousel' type MiniCarouselItemProps = { active: boolean - imageCacheTag?: string + imageCacheTag?: false | string label: string onClick: () => void url: string @@ -45,7 +45,7 @@ export type MiniCarouselProps = { sizes?: FileSizes url?: string } & Data - imageCacheTag?: string + imageCacheTag?: false | string onSelect: (sizeKey: null | string) => void selectedSize: null | string uploadConfig: SanitizedCollectionConfig['upload'] diff --git a/packages/ui/src/elements/PreviewSizes/index.tsx b/packages/ui/src/elements/PreviewSizes/index.tsx index c5154c81328..e6a68eb45b6 100644 --- a/packages/ui/src/elements/PreviewSizes/index.tsx +++ b/packages/ui/src/elements/PreviewSizes/index.tsx @@ -80,7 +80,7 @@ export type PreviewSizesProps = { doc: { sizes?: FilesSizesWithUrl } & Data - imageCacheTag?: string + imageCacheTag?: false | string uploadConfig: SanitizedCollectionConfig['upload'] } diff --git a/packages/ui/src/elements/Thumbnail/index.tsx b/packages/ui/src/elements/Thumbnail/index.tsx index 96efc37460b..a836f349c90 100644 --- a/packages/ui/src/elements/Thumbnail/index.tsx +++ b/packages/ui/src/elements/Thumbnail/index.tsx @@ -17,7 +17,7 @@ export type ThumbnailProps = { doc?: Record fileSrc?: string height?: number - imageCacheTag?: string + imageCacheTag?: false | string size?: 'expand' | 'large' | 'medium' | 'none' | 'small' uploadConfig?: SanitizedCollectionConfig['upload'] width?: number @@ -73,7 +73,7 @@ type ThumbnailComponentProps = { readonly className?: string readonly filename: string readonly fileSrc: string - readonly imageCacheTag?: string + readonly imageCacheTag?: false | string readonly size?: 'expand' | 'large' | 'medium' | 'none' | 'small' } export function ThumbnailComponent(props: ThumbnailComponentProps) { diff --git a/packages/ui/src/elements/Upload/index.tsx b/packages/ui/src/elements/Upload/index.tsx index 6467c97d0e8..2a25e4832cd 100644 --- a/packages/ui/src/elements/Upload/index.tsx +++ b/packages/ui/src/elements/Upload/index.tsx @@ -2,7 +2,7 @@ import type { FormState, SanitizedCollectionConfig, UploadEdits } from 'payload' import { useModal } from '@faceless-ui/modal' -import { formatAdminURL, formatFilesize, isImage } from 'payload/shared' +import { formatAdminURL, isImage } from 'payload/shared' import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react' import { toast } from 'sonner' @@ -23,11 +23,9 @@ import { Dropzone } from '../Dropzone/index.js' import { EditUpload } from '../EditUpload/index.js' import './index.css' import { FileDetails } from '../FileDetails/index.js' -import { MiniCarousel } from '../MiniCarousel/index.js' import { PreviewSizes } from '../PreviewSizes/index.js' import { Thumbnail } from '../Thumbnail/index.js' import { pasteURLDrawerSlug, UploadFromURLModal } from './UploadFromURLModal/index.js' -import { UploadToolbar } from './UploadToolbar/index.js' export { pasteURLDrawerSlug } @@ -116,10 +114,8 @@ export type UploadProps = { readonly customActions?: React.ReactNode[] readonly initialState?: FormState readonly onChange?: (file?: File) => void - readonly sidePanel?: boolean readonly uploadConfig: SanitizedCollectionConfig['upload'] readonly UploadControls?: React.ReactNode - readonly UploadFilePreview?: React.ReactNode } export const Upload: React.FC = (props) => { @@ -149,12 +145,10 @@ export const Upload_v4: React.FC = (props) => { initialState, onChange, resetUploadEdits, - sidePanel, updateUploadEdits, uploadConfig, UploadControls, uploadEdits, - UploadFilePreview, } = props const { @@ -186,7 +180,6 @@ export const Upload_v4: React.FC = (props) => { const [removedFile, setRemovedFile] = useState(false) const [filename, setFilename] = useState(value?.name || '') const [fileUrl, setFileUrl] = useState('') - const [selectedSize, setSelectedSize] = useState(null) const inputRef = useRef(null) @@ -220,17 +213,6 @@ export const Upload_v4: React.FC = (props) => { return newFile } - const handleRename = React.useCallback( - (newName: string) => { - if (value) { - handleFileChange({ file: renameFile(value, newName), isNewFile: false }) - } - setFilename(newName) - setModified(true) - }, - [value, handleFileChange, setModified], - ) - const handleFileNameChange = React.useCallback( (e: React.ChangeEvent) => { const updatedFileName = e.target.value @@ -440,182 +422,6 @@ export const Upload_v4: React.FC = (props) => { ) - if (sidePanel) { - const selectedSizeData = selectedSize ? data?.sizes?.[selectedSize] : null - const sidePanelFileSrc = selectedSizeData?.url || data?.thumbnailURL || data?.url || null - const sidePanelMimeType = data?.mimeType as string | undefined - const fileTypeIsAdjustable = - !!sidePanelMimeType && - isImage(sidePanelMimeType) && - sidePanelMimeType !== 'image/svg+xml' && - sidePanelMimeType !== 'image/jxl' - - return ( -
- - - {data?.filename && !removedFile && ( - openModal(editDrawerSlug)} - onRenameConfirm={handleRename} - onReplace={handleFileRemoval} - /> - )} -
- {data?.filename && !removedFile ? ( - - {hasImageSizes && ( - - )} -
-
- {UploadFilePreview ?? ( - - )} -
-
- - {selectedSize ?? t('general:original')} - - - {(() => { - const w = (selectedSizeData?.width ?? data?.width) as number | undefined - const h = (selectedSizeData?.height ?? data?.height) as number | undefined - const parts: string[] = [] - if (typeof w === 'number' && typeof h === 'number') { - parts.push(`${w} × ${h}`) - } - parts.push( - formatFilesize( - (selectedSizeData?.filesize ?? (data?.filesize as number)) || 0, - ), - ) - if (data.mimeType) { - parts.push(data.mimeType as string) - } - return parts.join(' – ') - })()} - -
-
-
- ) : ( -
- {!value && ( - -
-
- - { - if (e.target.files && e.target.files.length > 0) { - handleFileSelection(e.target.files) - } - }} - ref={inputRef} - type="file" - /> - {uploadConfig?.pasteURL !== false && ( - - {t('general:or')} - - - )} - {UploadControls ? UploadControls : null} -
-

- {t('general:or')} {t('upload:dragAndDrop')} -

-
-
- )} - {value && fileSrc && ( - -
- )} -
- - {drawers} -
- ) - } - return (
diff --git a/packages/ui/src/exports/client/index.ts b/packages/ui/src/exports/client/index.ts index 1629a8a73ce..90f84a6e416 100644 --- a/packages/ui/src/exports/client/index.ts +++ b/packages/ui/src/exports/client/index.ts @@ -111,6 +111,7 @@ export { CopyLocaleData } from '../../elements/CopyLocaleData/index.js' export { CopyToClipboard } from '../../elements/CopyToClipboard/index.js' export { DeleteMany } from '../../elements/DeleteMany/index.js' export { DocumentControls } from '../../elements/DocumentControls/index.js' +export { DocumentHeaderRoot } from '../../elements/DocumentHeader/DocumentHeaderRoot/index.js' export { Dropzone } from '../../elements/Dropzone/index.js' export { documentDrawerBaseClass, useDocumentDrawer } from '../../elements/DocumentDrawer/index.js' export { diff --git a/packages/ui/src/scss/app.scss b/packages/ui/src/scss/app.scss index 8a3a4377c20..0380f6a9923 100644 --- a/packages/ui/src/scss/app.scss +++ b/packages/ui/src/scss/app.scss @@ -41,7 +41,7 @@ --spacing-view-bottom: var(--gutter-h); --app-header-height: 0px; --doc-controls-height: 0px; - --scroll-y: 0px; + --doc-header-height: 0px; --nav-width: 275px; @include large-break { diff --git a/packages/ui/src/views/Edit/index.tsx b/packages/ui/src/views/Edit/index.tsx index 510800d4715..f374209b574 100644 --- a/packages/ui/src/views/Edit/index.tsx +++ b/packages/ui/src/views/Edit/index.tsx @@ -17,6 +17,7 @@ import { DocumentFields } from '../../elements/DocumentFields/index.js' import { DocumentLocked } from '../../elements/DocumentLocked/index.js' import { DocumentStaleData } from '../../elements/DocumentStaleData/index.js' import { DocumentTakeOver } from '../../elements/DocumentTakeOver/index.js' +import { FileManager } from '../../elements/FileManager/index.js' import { LeaveWithoutSaving } from '../../elements/LeaveWithoutSaving/index.js' import { LivePreviewWindow } from '../../elements/LivePreview/Window/index.js' import { Upload } from '../../elements/Upload/index.js' @@ -815,7 +816,7 @@ export function DefaultEditView({ schemaPathSegments={schemaPathSegments} /> {CustomUpload || ( - Date: Wed, 17 Jun 2026 00:25:37 +0100 Subject: [PATCH 06/19] update test --- test/uploads/config.ts | 100 ++++++++++ test/uploads/payload-types.ts | 360 ++++++++++++++++++++++++++++++++++ 2 files changed, 460 insertions(+) diff --git a/test/uploads/config.ts b/test/uploads/config.ts index 2da01b9b33c..d1f5045da8f 100644 --- a/test/uploads/config.ts +++ b/test/uploads/config.ts @@ -1310,6 +1310,106 @@ export default buildConfigWithDefaults({ width: 1920, height: 1080, }, + { + name: 'carousel1', + height: 100, + width: 100, + }, + { + name: 'carousel2', + height: 100, + width: 150, + }, + { + name: 'carousel3', + height: 150, + width: 100, + }, + { + name: 'carousel4', + height: 120, + width: 200, + }, + { + name: 'carousel5', + height: 200, + width: 120, + }, + { + name: 'carousel6', + height: 250, + width: 250, + }, + { + name: 'carousel7', + height: 180, + width: 320, + }, + { + name: 'carousel8', + height: 320, + width: 180, + }, + { + name: 'carousel9', + height: 300, + width: 400, + }, + { + name: 'carousel10', + height: 400, + width: 300, + }, + { + name: 'carousel11', + height: 200, + width: 500, + }, + { + name: 'carousel12', + height: 500, + width: 200, + }, + { + name: 'carousel13', + height: 360, + width: 640, + }, + { + name: 'carousel14', + height: 640, + width: 360, + }, + { + name: 'carousel15', + height: 128, + width: 128, + }, + { + name: 'carousel16', + height: 96, + width: 96, + }, + { + name: 'carousel17', + height: 64, + width: 64, + }, + { + name: 'carousel18', + height: 450, + width: 800, + }, + { + name: 'carousel19', + height: 800, + width: 450, + }, + { + name: 'carousel20', + height: 1000, + width: 1000, + }, ], staticDir: path.resolve(dirname, './media'), }, diff --git a/test/uploads/payload-types.ts b/test/uploads/payload-types.ts index 3d00876a576..fba307f1c2a 100644 --- a/test/uploads/payload-types.ts +++ b/test/uploads/payload-types.ts @@ -2066,6 +2066,166 @@ export interface MediaWithField { filesize?: number | null; filename?: string | null; }; + carousel1?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + }; + carousel2?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + }; + carousel3?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + }; + carousel4?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + }; + carousel5?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + }; + carousel6?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + }; + carousel7?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + }; + carousel8?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + }; + carousel9?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + }; + carousel10?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + }; + carousel11?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + }; + carousel12?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + }; + carousel13?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + }; + carousel14?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + }; + carousel15?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + }; + carousel16?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + }; + carousel17?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + }; + carousel18?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + }; + carousel19?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + }; + carousel20?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + }; }; } /** @@ -4279,6 +4439,206 @@ export interface MediaWithFieldsSelect { filesize?: T; filename?: T; }; + carousel1?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + }; + carousel2?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + }; + carousel3?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + }; + carousel4?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + }; + carousel5?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + }; + carousel6?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + }; + carousel7?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + }; + carousel8?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + }; + carousel9?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + }; + carousel10?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + }; + carousel11?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + }; + carousel12?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + }; + carousel13?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + }; + carousel14?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + }; + carousel15?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + }; + carousel16?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + }; + carousel17?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + }; + carousel18?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + }; + carousel19?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + }; + carousel20?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + }; }; } /** From fe07629d40583610d9e95e3c32b68011fe60951e Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Wed, 17 Jun 2026 00:41:48 +0100 Subject: [PATCH 07/19] progress --- .../elements/FileManager/FilePreview/index.css | 2 +- .../elements/FileManager/FileToolbar/index.css | 8 ++++++-- .../ui/src/elements/MiniCarousel/index.css | 2 +- packages/ui/src/elements/Thumbnail/index.scss | 8 -------- packages/ui/src/views/Edit/index.css | 17 +++++++++++++++-- packages/ui/src/views/Edit/index.tsx | 18 +++++++++--------- 6 files changed, 32 insertions(+), 23 deletions(-) diff --git a/packages/ui/src/elements/FileManager/FilePreview/index.css b/packages/ui/src/elements/FileManager/FilePreview/index.css index e12a2faeef1..777b2501d1d 100644 --- a/packages/ui/src/elements/FileManager/FilePreview/index.css +++ b/packages/ui/src/elements/FileManager/FilePreview/index.css @@ -73,7 +73,7 @@ @media (max-width: 768px) { .file-preview { - flex-direction: column; + flex-direction: column-reverse; } } } diff --git a/packages/ui/src/elements/FileManager/FileToolbar/index.css b/packages/ui/src/elements/FileManager/FileToolbar/index.css index 845872f069e..d0c8b230919 100644 --- a/packages/ui/src/elements/FileManager/FileToolbar/index.css +++ b/packages/ui/src/elements/FileManager/FileToolbar/index.css @@ -29,8 +29,8 @@ gap: var(--spacer-1); padding: var(--spacer-1) var(--spacer-2); border-radius: var(--radius-medium); - background: none; - border: none; + background: var(--color-bg); + border: var(--stroke-width-small) solid var(--special-border-translucent); cursor: pointer; color: var(--color-text); font-family: var(--text-body-medium-strong-font-family); @@ -43,6 +43,10 @@ &:hover { background: var(--color-bg-transparent-hover); } + + &:active { + background: var(--color-bg-transparent-pressed); + } } .file-toolbar__filename-text { diff --git a/packages/ui/src/elements/MiniCarousel/index.css b/packages/ui/src/elements/MiniCarousel/index.css index 3d2a827b937..fc42cb4dfb6 100644 --- a/packages/ui/src/elements/MiniCarousel/index.css +++ b/packages/ui/src/elements/MiniCarousel/index.css @@ -57,7 +57,7 @@ overflow-x: auto; overflow-y: hidden; border-right: 0; - border-bottom: 1px solid var(--color-border); + border-top: 1px solid var(--color-border); } .mini-carousel__item { diff --git a/packages/ui/src/elements/Thumbnail/index.scss b/packages/ui/src/elements/Thumbnail/index.scss index d73a340e0bd..f463c806891 100644 --- a/packages/ui/src/elements/Thumbnail/index.scss +++ b/packages/ui/src/elements/Thumbnail/index.scss @@ -19,14 +19,6 @@ &--size-expand { max-height: 100%; width: 100%; - padding-top: 100%; - position: relative; - - img, - svg { - position: absolute; - top: 0; - } } &--size-large { diff --git a/packages/ui/src/views/Edit/index.css b/packages/ui/src/views/Edit/index.css index 2c35a6b8942..25d54981596 100644 --- a/packages/ui/src/views/Edit/index.css +++ b/packages/ui/src/views/Edit/index.css @@ -18,9 +18,15 @@ flex-direction: row; align-items: flex-start; + /* The file manager renders before the fields in the DOM (so it stacks on top at mobile), + but on desktop it should sit after them in the row at 2fr. */ + .file-manager { + order: 2; + flex: 2; + } + .document-fields { - flex: 0 0 auto; - width: 347px; + flex: 1; border-inline-end: 1px solid var(--color-border); } @@ -32,8 +38,15 @@ @media (max-width: 1024px) { .collection-edit__upload-layout { flex-direction: column; + gap: var(--spacer-5); + + .file-manager { + order: 0; + flex: 1 0 0; + } .document-fields { + flex: 0 0 auto; width: 100%; } } diff --git a/packages/ui/src/views/Edit/index.tsx b/packages/ui/src/views/Edit/index.tsx index f374209b574..19cca21a033 100644 --- a/packages/ui/src/views/Edit/index.tsx +++ b/packages/ui/src/views/Edit/index.tsx @@ -786,6 +786,15 @@ export function DefaultEditView({ {upload && !BeforeFields && !isInDrawer ? (
+ {CustomUpload || ( + + )} - {CustomUpload || ( - - )}
) : ( From 2a094eb87ceabfe26b8aeea5be79673efc4f15fc Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Wed, 17 Jun 2026 00:59:08 +0100 Subject: [PATCH 08/19] fixes --- packages/ui/src/elements/FileManager/index.css | 5 +++-- packages/ui/src/elements/MiniCarousel/index.css | 16 +++++++++++----- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/elements/FileManager/index.css b/packages/ui/src/elements/FileManager/index.css index de447f18444..775c7050102 100644 --- a/packages/ui/src/elements/FileManager/index.css +++ b/packages/ui/src/elements/FileManager/index.css @@ -114,6 +114,7 @@ @media (max-width: 1024px) { .file-manager { min-height: 400px; + border-bottom: 1px solid var(--color-border); } .file-manager__panel { @@ -125,9 +126,9 @@ .file-manager__content { flex-direction: column; } - } - @media (max-width: 768px) { + /* The panel is static here (no stacking context), so the absolutely-positioned divider + would paint over the toolbar — and there is no sticky gap left to fill anyway. */ .file-manager--has-side-carousel::before { display: none; } diff --git a/packages/ui/src/elements/MiniCarousel/index.css b/packages/ui/src/elements/MiniCarousel/index.css index fc42cb4dfb6..0504bc46fab 100644 --- a/packages/ui/src/elements/MiniCarousel/index.css +++ b/packages/ui/src/elements/MiniCarousel/index.css @@ -3,14 +3,22 @@ display: flex; flex-direction: column; align-items: stretch; - gap: var(--spacer-1); + gap: var(--spacer-3); padding: var(--spacer-2-5); width: var(--mini-carousel-width, 72px); flex-shrink: 0; overflow-y: auto; + /* Hide the native scrollbar so it doesn't consume layout width — otherwise a classic + (non-overlay) scrollbar shrinks the stretched thumbnails below their intended size. */ + scrollbar-width: none; + -ms-overflow-style: none; background: var(--color-bg-secondary); border-right: 1px solid var(--color-border); align-self: stretch; + + &::-webkit-scrollbar { + display: none; + } } .mini-carousel__item { @@ -46,7 +54,6 @@ height: 1px; background: var(--color-border); flex-shrink: 0; - margin: var(--spacer-2) 0; } @media (max-width: 768px) { @@ -61,13 +68,12 @@ } .mini-carousel__item { - width: 52px; - height: 52px; + width: 48px; } .mini-carousel__divider { width: 1px; - height: 52px; + height: 48px; margin: 0 var(--spacer-2); } } From a58ac8c6a16f544b8afeeb69f883503bc933a77e Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Wed, 17 Jun 2026 14:43:17 +0100 Subject: [PATCH 09/19] progress --- packages/ui/src/elements/Dropzone/index.css | 17 +- .../FilePreview/AudioPreview/index.css | 6 + .../FilePreview/AudioPreview/index.tsx | 17 ++ .../FilePreview/PdfPreview/index.css | 9 + .../FilePreview/PdfPreview/index.tsx | 15 ++ .../FilePreview/VideoPreview/index.css | 8 + .../FilePreview/VideoPreview/index.tsx | 17 ++ .../FileManager/FilePreview/index.css | 2 +- .../FileManager/FilePreview/index.tsx | 49 +++-- .../FileManager/FileToolbar/index.css | 8 +- .../ui/src/elements/FileManager/index.css | 21 +- .../ui/src/elements/MiniCarousel/index.css | 2 +- packages/ui/src/views/Edit/index.css | 5 +- packages/ui/src/views/Edit/index.tsx | 59 ++---- .../christmas-mariachi-in-guadalajara.mp4 | Bin 0 -> 9017720 bytes test/uploads/e2e.spec.ts | 71 ++++++- test/uploads/seed.ts | 190 +++++++++++++++++- 17 files changed, 420 insertions(+), 76 deletions(-) create mode 100644 packages/ui/src/elements/FileManager/FilePreview/AudioPreview/index.css create mode 100644 packages/ui/src/elements/FileManager/FilePreview/AudioPreview/index.tsx create mode 100644 packages/ui/src/elements/FileManager/FilePreview/PdfPreview/index.css create mode 100644 packages/ui/src/elements/FileManager/FilePreview/PdfPreview/index.tsx create mode 100644 packages/ui/src/elements/FileManager/FilePreview/VideoPreview/index.css create mode 100644 packages/ui/src/elements/FileManager/FilePreview/VideoPreview/index.tsx create mode 100644 test/uploads/christmas-mariachi-in-guadalajara.mp4 diff --git a/packages/ui/src/elements/Dropzone/index.css b/packages/ui/src/elements/Dropzone/index.css index 514991a23be..627aac91c36 100644 --- a/packages/ui/src/elements/Dropzone/index.css +++ b/packages/ui/src/elements/Dropzone/index.css @@ -4,7 +4,6 @@ display: flex; align-items: flex-start; background: transparent; - border: var(--stroke-width-small) dashed var(--special-border-translucent); border-radius: var(--field-border-radius); overflow: clip; width: 100%; @@ -18,7 +17,6 @@ } &.dragging { - border-color: var(--color-border-selected); background: var(--color-bg-brand-tertiary); * { @@ -26,22 +24,15 @@ } } - &:focus-within, - &.focused { - border-color: var(--color-border-selected); - background: var(--color-bg-brand-tertiary); - outline: none; - } - - &.dropzone--has-error { - border-color: var(--color-border-danger-strong); - } - @media (max-width: 768px) { display: block; text-align: center; } + &.dropzone--style-default { + padding-block: var(--spacer-3); + } + &.dropzone--style-none { all: unset; } diff --git a/packages/ui/src/elements/FileManager/FilePreview/AudioPreview/index.css b/packages/ui/src/elements/FileManager/FilePreview/AudioPreview/index.css new file mode 100644 index 00000000000..a767319b78f --- /dev/null +++ b/packages/ui/src/elements/FileManager/FilePreview/AudioPreview/index.css @@ -0,0 +1,6 @@ +@layer payload-default { + .audio-preview { + width: 100%; + max-width: 480px; + } +} diff --git a/packages/ui/src/elements/FileManager/FilePreview/AudioPreview/index.tsx b/packages/ui/src/elements/FileManager/FilePreview/AudioPreview/index.tsx new file mode 100644 index 00000000000..7d61b5c94a9 --- /dev/null +++ b/packages/ui/src/elements/FileManager/FilePreview/AudioPreview/index.tsx @@ -0,0 +1,17 @@ +'use client' +import React from 'react' + +import './index.css' + +const baseClass = 'audio-preview' + +/** + * Built-in preview for native audio files, rendered inside the file manager when the + * uploaded file's mime type is `audio/*` and no custom file preview is provided. + */ +export const AudioPreview: React.FC<{ fileSrc: string }> = ({ fileSrc }) => { + return ( + // eslint-disable-next-line jsx-a11y/media-has-caption -- user-uploaded media, captions unknown +