From f10ee12dddb2780b50e9a31a78bccf827d278a37 Mon Sep 17 00:00:00 2001 From: Tale Agent Date: Wed, 24 Jun 2026 11:00:07 +0000 Subject: [PATCH 1/3] fix(platform): polish logo/favicon upload button hover and feedback (#1961) --- .../components/image-upload-field.stories.tsx | 16 ++++ .../components/image-upload-field.tsx | 94 ++++++++++++++----- 2 files changed, 84 insertions(+), 26 deletions(-) diff --git a/services/platform/app/features/settings/branding/components/image-upload-field.stories.tsx b/services/platform/app/features/settings/branding/components/image-upload-field.stories.tsx index fb1515ba2..c46985ef6 100644 --- a/services/platform/app/features/settings/branding/components/image-upload-field.stories.tsx +++ b/services/platform/app/features/settings/branding/components/image-upload-field.stories.tsx @@ -50,6 +50,22 @@ export const EmptyWithLabel: Story = { }, }; +export const WithImage: Story = { + args: { + currentUrl: + 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Crect%20width%3D%2248%22%20height%3D%2248%22%20rx%3D%228%22%20fill%3D%22%236366f1%22/%3E%3C/svg%3E', + ariaLabel: 'Upload logo', + }, + parameters: { + docs: { + description: { + story: + 'With an uploaded image. Hover or focus the control to reveal the replace overlay; a remove button sits in the corner.', + }, + }, + }, +}; + export const SmallSize: Story = { args: { size: 'sm', diff --git a/services/platform/app/features/settings/branding/components/image-upload-field.tsx b/services/platform/app/features/settings/branding/components/image-upload-field.tsx index 24d1b09e9..b4ec70a8d 100644 --- a/services/platform/app/features/settings/branding/components/image-upload-field.tsx +++ b/services/platform/app/features/settings/branding/components/image-upload-field.tsx @@ -3,7 +3,7 @@ import { VStack } from '@tale/ui/layout'; import { Spinner } from '@tale/ui/spinner'; import { Text } from '@tale/ui/text'; -import { Plus, X } from 'lucide-react'; +import { Plus, Upload, X } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { Image } from '@/app/components/ui/data-display/image'; @@ -37,6 +37,7 @@ export function ImageUploadField({ ariaLabel, }: ImageUploadFieldProps) { const [isUploading, setIsUploading] = useState(false); + const [isDragging, setIsDragging] = useState(false); const [previewUrl, setPreviewUrl] = useState(null); const [isRemoved, setIsRemoved] = useState(false); const fileInputRef = useRef(null); @@ -65,11 +66,8 @@ export function ImageUploadField({ fileInputRef.current?.click(); }, []); - const handleFileChange = useCallback( - async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - + const uploadFile = useCallback( + async (file: File) => { if (objectUrlRef.current) { URL.revokeObjectURL(objectUrlRef.current); } @@ -113,6 +111,38 @@ export function ImageUploadField({ [organizationId, saveImage, imageType, onUpload, onPreviewUrlChange], ); + const handleFileChange = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) void uploadFile(file); + }, + [uploadFile], + ); + + const handleDragOver = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + if (!isUploading) setIsDragging(true); + }, + [isUploading], + ); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + if (isUploading) return; + const file = e.dataTransfer.files?.[0]; + if (file?.type.startsWith('image/')) void uploadFile(file); + }, + [isUploading, uploadFile], + ); + const handleRemove = useCallback(() => { setPreviewUrl(null); onPreviewUrlChange?.(null); @@ -132,10 +162,17 @@ export function ImageUploadField({ {displayUrl && !isUploading && onRemove && ( {displayUrl && !isUploading && onRemove && ( @@ -229,6 +241,7 @@ export function ImageUploadField({ onChange={handleFileChange} className="hidden" tabIndex={-1} + aria-label={ariaLabel} /> ); From 6a618b48b1d928e08616d9868ad2063596a16d68 Mon Sep 17 00:00:00 2001 From: tale-agent Date: Wed, 24 Jun 2026 13:48:00 +0000 Subject: [PATCH 3/3] test(platform): cover image-upload extension-fallback accept branch (#1961) --- .../components/image-upload-field.test.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/services/platform/app/features/settings/branding/components/image-upload-field.test.tsx b/services/platform/app/features/settings/branding/components/image-upload-field.test.tsx index 1adbfb553..0a5703588 100644 --- a/services/platform/app/features/settings/branding/components/image-upload-field.test.tsx +++ b/services/platform/app/features/settings/branding/components/image-upload-field.test.tsx @@ -104,6 +104,22 @@ describe('ImageUploadField', () => { resolveUpload({ filename: 'logo.png' }); }); + // Edge: a valid file whose browser-reported MIME is blank/non-`image/*` is + // still accepted via its extension (e.g. a `.ico` reporting empty type), + // keeping the drop path in sync with the picker's `accept` list. + it('accepts a dropped file with a non-image MIME but an accepted extension', async () => { + const onUpload = vi.fn(); + render(); + + dropFile( + screen.getByRole('button', { name: 'Upload logo' }), + makeFile('favicon.ico', ''), + ); + + await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1)); + expect(mockSaveImage).toHaveBeenCalledTimes(1); + }); + // Error: a non-image file is rejected before any upload is attempted. it('ignores a dropped non-image file', () => { const onUpload = vi.fn();