mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-01-06 22:40:14 -05:00
Updated AdminX file upload to support drag and drop (#18691)
refs https://github.com/TryGhost/Product/issues/3831 --- ### <samp>🤖 Generated by Copilot at f85ceff</samp> This pull request adds drag and drop functionality and custom styling options to the `FileUpload` and `ImageUpload` components, and uses them to improve the user experience of uploading themes and user images in the admin settings. It also fixes a minor issue with the background color contrast in the `IntegrationHeader` component in dark mode, and updates the corresponding tests.
This commit is contained in:
parent
b5631241c5
commit
2debf686e6
6 changed files with 69 additions and 19 deletions
|
@ -1,4 +1,5 @@
|
||||||
import React, {CSSProperties, ChangeEvent, useState} from 'react';
|
import React, {CSSProperties, ChangeEvent, useState} from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
export interface FileUploadProps {
|
export interface FileUploadProps {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -10,14 +11,16 @@ export interface FileUploadProps {
|
||||||
*/
|
*/
|
||||||
children?: string | React.ReactNode;
|
children?: string | React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
dragIndicatorClassName?: string;
|
||||||
onUpload: (file: File) => void;
|
onUpload: (file: File) => void;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
unstyled?: boolean;
|
unstyled?: boolean;
|
||||||
inputRef?: React.RefObject<HTMLInputElement>;
|
inputRef?: React.RefObject<HTMLInputElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileUpload: React.FC<FileUploadProps> = ({id, onUpload, children, style, unstyled = false, inputRef, ...props}) => {
|
const FileUpload: React.FC<FileUploadProps> = ({id, onUpload, children, style, unstyled = false, inputRef, className, dragIndicatorClassName, ...props}) => {
|
||||||
const [fileKey, setFileKey] = useState<number>(Date.now());
|
const [fileKey, setFileKey] = useState<number>(Date.now());
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
const selectedFile = event.target.files?.[0];
|
const selectedFile = event.target.files?.[0];
|
||||||
|
@ -27,8 +30,28 @@ const FileUpload: React.FC<FileUploadProps> = ({id, onUpload, children, style, u
|
||||||
setFileKey(Date.now());
|
setFileKey(Date.now());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDrop = (event: React.DragEvent<HTMLLabelElement>) => {
|
||||||
|
handleStopDragging(event);
|
||||||
|
const selectedFile = event.dataTransfer.files?.[0];
|
||||||
|
if (selectedFile) {
|
||||||
|
onUpload?.(selectedFile);
|
||||||
|
}
|
||||||
|
setFileKey(Date.now());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragging = (event: React.DragEvent<HTMLLabelElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStopDragging = (event: React.DragEvent<HTMLLabelElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label htmlFor={id} style={style} {...props}>
|
<label className={clsx('relative', className)} htmlFor={id} style={style} onDragEnter={handleDragging} onDragLeave={handleStopDragging} onDragOver={handleDragging} onDrop={handleDrop} {...props}>
|
||||||
|
{isDragging && <div className={clsx('absolute inset-1 rounded border-2 border-dashed border-grey-400/25', dragIndicatorClassName)} />}
|
||||||
<input key={fileKey} ref={inputRef || null} id={id} type="file" hidden onChange={handleFileChange} />
|
<input key={fileKey} ref={inputRef || null} id={id} type="file" hidden onChange={handleFileChange} />
|
||||||
{(typeof children === 'string') ?
|
{(typeof children === 'string') ?
|
||||||
<div className={!unstyled ? `inline-flex h-[34px] cursor-pointer items-center justify-center rounded px-4 text-sm font-semibold hover:bg-grey-100 dark:text-white dark:hover:bg-grey-900` : ''}>
|
<div className={!unstyled ? `inline-flex h-[34px] cursor-pointer items-center justify-center rounded px-4 text-sm font-semibold hover:bg-grey-100 dark:text-white dark:hover:bg-grey-900` : ''}>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import FileUpload from './FileUpload';
|
import FileUpload, {FileUploadProps} from './FileUpload';
|
||||||
import Icon from '../Icon';
|
import Icon from '../Icon';
|
||||||
import React, {MouseEventHandler} from 'react';
|
import React, {MouseEventHandler} from 'react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
@ -16,6 +16,7 @@ interface ImageUploadProps {
|
||||||
imageClassName?: string;
|
imageClassName?: string;
|
||||||
imageBWCheckedBg?: boolean;
|
imageBWCheckedBg?: boolean;
|
||||||
fileUploadClassName?: string;
|
fileUploadClassName?: string;
|
||||||
|
fileUploadProps?: Partial<FileUploadProps>;
|
||||||
deleteButtonClassName?: string;
|
deleteButtonClassName?: string;
|
||||||
deleteButtonContent?: React.ReactNode;
|
deleteButtonContent?: React.ReactNode;
|
||||||
deleteButtonUnstyled?: boolean;
|
deleteButtonUnstyled?: boolean;
|
||||||
|
@ -57,6 +58,7 @@ const ImageUpload: React.FC<ImageUploadProps> = ({
|
||||||
imageContainerClassName,
|
imageContainerClassName,
|
||||||
imageClassName,
|
imageClassName,
|
||||||
fileUploadClassName,
|
fileUploadClassName,
|
||||||
|
fileUploadProps,
|
||||||
deleteButtonClassName,
|
deleteButtonClassName,
|
||||||
deleteButtonContent,
|
deleteButtonContent,
|
||||||
deleteButtonUnstyled = false,
|
deleteButtonUnstyled = false,
|
||||||
|
@ -181,7 +183,7 @@ const ImageUpload: React.FC<ImageUploadProps> = ({
|
||||||
width: (unstyled ? '' : width),
|
width: (unstyled ? '' : width),
|
||||||
height: (unstyled ? '' : height)
|
height: (unstyled ? '' : height)
|
||||||
}
|
}
|
||||||
} unstyled={unstyled} onUpload={onUpload}>
|
} unstyled={unstyled} onUpload={onUpload} {...fileUploadProps}>
|
||||||
<>
|
<>
|
||||||
<span className='text-center'>{children}</span>
|
<span className='text-center'>{children}</span>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -14,7 +14,7 @@ const IntegrationHeader: React.FC<IntegrationHeaderProps> = ({
|
||||||
extra
|
extra
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className='-mx-8 -mt-8 flex flex-col gap-4 bg-grey-75 p-8 md:flex-row'>
|
<div className='-mx-8 -mt-8 flex flex-col gap-4 bg-grey-75 p-8 dark:bg-grey-950 md:flex-row'>
|
||||||
<div className='h-14 w-14'>{icon}</div>
|
<div className='h-14 w-14'>{icon}</div>
|
||||||
<div className='mt-1.5 flex min-w-0 flex-1 flex-col'>
|
<div className='mt-1.5 flex min-w-0 flex-1 flex-col'>
|
||||||
<h3>{title}</h3>
|
<h3>{title}</h3>
|
||||||
|
|
|
@ -417,6 +417,7 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
|
||||||
deleteButtonContent={<Icon colorClass='text-white' name='trash' size='sm' />}
|
deleteButtonContent={<Icon colorClass='text-white' name='trash' size='sm' />}
|
||||||
editButtonClassName='md:invisible absolute right-[22px] -top-2 flex h-8 w-8 cursor-pointer items-center justify-center text-white group-hover:!visible z-20'
|
editButtonClassName='md:invisible absolute right-[22px] -top-2 flex h-8 w-8 cursor-pointer items-center justify-center text-white group-hover:!visible z-20'
|
||||||
fileUploadClassName='rounded-full bg-black flex items-center justify-center opacity-80 transition hover:opacity-100 -ml-2 cursor-pointer h-[80px] w-[80px]'
|
fileUploadClassName='rounded-full bg-black flex items-center justify-center opacity-80 transition hover:opacity-100 -ml-2 cursor-pointer h-[80px] w-[80px]'
|
||||||
|
fileUploadProps={{dragIndicatorClassName: 'rounded-full'}}
|
||||||
id='avatar'
|
id='avatar'
|
||||||
imageClassName='w-full h-full object-cover rounded-full shrink-0'
|
imageClassName='w-full h-full object-cover rounded-full shrink-0'
|
||||||
imageContainerClassName='relative group bg-cover bg-center -ml-2 h-[80px] w-[80px] shrink-0'
|
imageContainerClassName='relative group bg-cover bg-center -ml-2 h-[80px] w-[80px] shrink-0'
|
||||||
|
|
|
@ -37,6 +37,24 @@ interface ThemeModalContentProps {
|
||||||
themes: Theme[];
|
themes: Theme[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const UploadModalContent: React.FC<{onUpload: (file: File) => void}> = ({onUpload}) => {
|
||||||
|
const modal = useModal();
|
||||||
|
|
||||||
|
return <div className="-mb-6">
|
||||||
|
<FileUpload
|
||||||
|
id="theme-upload"
|
||||||
|
onUpload={(file) => {
|
||||||
|
modal.remove();
|
||||||
|
onUpload(file);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="cursor-pointer bg-grey-75 p-10 text-center dark:bg-grey-950">
|
||||||
|
Click to select or drag & drop zip file
|
||||||
|
</div>
|
||||||
|
</FileUpload>
|
||||||
|
</div>;
|
||||||
|
};
|
||||||
|
|
||||||
const ThemeToolbar: React.FC<ThemeToolbarProps> = ({
|
const ThemeToolbar: React.FC<ThemeToolbarProps> = ({
|
||||||
currentTab,
|
currentTab,
|
||||||
setCurrentTab,
|
setCurrentTab,
|
||||||
|
@ -205,6 +223,22 @@ const ThemeToolbar: React.FC<ThemeToolbarProps> = ({
|
||||||
onBack={onClose}
|
onBack={onClose}
|
||||||
/>;
|
/>;
|
||||||
|
|
||||||
|
const handleUpload = () => {
|
||||||
|
if (uploadConfig?.enabled) {
|
||||||
|
NiceModal.show(ConfirmationModal, {
|
||||||
|
title: 'Upload theme',
|
||||||
|
prompt: <UploadModalContent onUpload={onThemeUpload} />,
|
||||||
|
okLabel: '',
|
||||||
|
formSheet: false
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
NiceModal.show(LimitModal, {
|
||||||
|
title: 'Upgrade to enable custom themes',
|
||||||
|
prompt: uploadConfig?.error || <>Your current plan only supports official themes. You can install them from the <a href="https://ghost.org/marketplace/">Ghost theme marketplace</a>.</>
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const right =
|
const right =
|
||||||
<div className='flex items-center gap-14'>
|
<div className='flex items-center gap-14'>
|
||||||
<div className='hidden md:!visible md:!block'>
|
<div className='hidden md:!visible md:!block'>
|
||||||
|
@ -220,19 +254,7 @@ const ThemeToolbar: React.FC<ThemeToolbarProps> = ({
|
||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
<div className='flex items-center gap-3'>
|
<div className='flex items-center gap-3'>
|
||||||
{uploadConfig && (
|
<Button color='black' label='Upload theme' loading={isUploading} onClick={handleUpload} />
|
||||||
uploadConfig.enabled ?
|
|
||||||
<FileUpload id='theme-upload' inputRef={fileInputRef} onUpload={onThemeUpload}>
|
|
||||||
<Button color='black' label='Upload theme' loading={isUploading} tag='div' />
|
|
||||||
</FileUpload> :
|
|
||||||
// for when user's plan does not support custom themes
|
|
||||||
<Button color='black' label='Upload theme' onClick={() => {
|
|
||||||
NiceModal.show(LimitModal, {
|
|
||||||
title: 'Upgrade to enable custom themes',
|
|
||||||
prompt: uploadConfig?.error || <>Your current plan only supports official themes. You can install them from the <a href="https://ghost.org/marketplace/">Ghost theme marketplace</a>.</>
|
|
||||||
});
|
|
||||||
}} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>;
|
</div>;
|
||||||
|
|
||||||
|
|
|
@ -151,9 +151,11 @@ test.describe('Theme settings', async () => {
|
||||||
|
|
||||||
const modal = page.getByTestId('theme-modal');
|
const modal = page.getByTestId('theme-modal');
|
||||||
|
|
||||||
|
await modal.getByRole('button', {name: 'Upload theme'}).click();
|
||||||
|
|
||||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||||
|
|
||||||
await modal.locator('label[for=theme-upload]').click();
|
await page.getByTestId('confirmation-modal').locator('label[for=theme-upload]').click();
|
||||||
|
|
||||||
const fileChooser = await fileChooserPromise;
|
const fileChooser = await fileChooserPromise;
|
||||||
await fileChooser.setFiles(`${__dirname}/../../utils/responses/theme.zip`);
|
await fileChooser.setFiles(`${__dirname}/../../utils/responses/theme.zip`);
|
||||||
|
|
Loading…
Reference in a new issue