0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-11 02:12:21 -05:00

Admin x custom integrations UI (#17747)

refs. https://github.com/TryGhost/Product/issues/3729

- added static new custom integration modal
- added static custom integration edit modal
- refined built-in integration UI
This commit is contained in:
Peter Zimon 2023-08-17 10:12:28 +02:00 committed by GitHub
parent 9e25058934
commit efc9a53fd2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 418 additions and 48 deletions

View file

@ -0,0 +1 @@
<svg viewBox="0 0 46 43"><title>integration</title><g stroke="currentColor" fill="none" fill-rule="evenodd" stroke-width="1.5px"><path d="M-1-3h48v48H-1z" stroke="none"></path><g stroke-linecap="round" stroke-linejoin="round"><path d="M32.932 6.574c.713.428 1.069 1.057 1.068 1.888v9.278l-11 7.076-11-7.076V8.462c0-.831.355-1.46 1.068-1.888l8.8-5.28c.755-.453 1.51-.453 2.264 0l8.8 5.28zM23 13.816v11"></path><path d="M34 31.416l-11-6.6 11-7.076 10 6.426c.669.435 1.002 1.052 1 1.85v8.124c.002.798-.331 1.415-1 1.85l-8.8 5.66c-.793.51-1.587.51-2.38 0L23 35.34V24.816m11 6.6V42M23 24.816V35.34l-9.8 6.31c-.793.51-1.587.51-2.38 0l-8.8-5.66c-.678-.43-1.018-1.047-1.02-1.85v-8.124c-.002-.798.331-1.415 1-1.85l10-6.426 11 7.076-11 6.6m0 0L1.262 24.974M12 31.416V42m11-28.184L12.282 7.384m21.436 0L23 13.816m21.738 11.158L34 31.416"></path></g></g></svg>

After

Width:  |  Height:  |  Size: 848 B

View file

@ -78,8 +78,10 @@ const Button: React.FC<ButtonProps> = ({
styles += ` ${className}`;
const iconClasses = label && icon ? 'mr-1.5' : '';
const buttonChildren = <>
{icon && <Icon colorClass={iconColorClass} name={icon} size={size === 'sm' ? 'sm' : 'md'} />}
{icon && <Icon className={iconClasses} colorClass={iconColorClass} name={icon} size={size === 'sm' ? 'sm' : 'md'} />}
{(label && hideLabel) ? <span className="sr-only">{label}</span> : label}
</>;
const buttonElement = React.createElement(tag, {className: styles,

View file

@ -3,6 +3,7 @@ import type {Meta, StoryObj} from '@storybook/react';
import Table from './Table';
import TableCell from './TableCell';
import TableHead from './TableHead';
import TableRow from './TableRow';
const meta = {
@ -13,6 +14,10 @@ const meta = {
const tableRows = (
<>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
</TableRow>
<TableRow>
<TableCell>Jamie Larson</TableCell>
<TableCell>jamie@example.com</TableCell>

View file

@ -0,0 +1,19 @@
import Heading from './Heading';
import React, {HTMLProps} from 'react';
import clsx from 'clsx';
const TableHead: React.FC<HTMLProps<HTMLTableCellElement>> = ({className, children, ...props}) => {
const tableCellClasses = clsx(
'!py-3 !pl-0 !pr-6 align-top',
props.onClick && 'hover:cursor-pointer',
className
);
return (
<td className={tableCellClasses} {...props}>
<Heading level={6}>{children}</Heading>
</td>
);
};
export default TableHead;

View file

@ -28,7 +28,7 @@ const TableRow: React.FC<TableRowProps> = ({id, action, hideActions, className,
'group',
bgOnHover && 'hover:bg-gradient-to-r hover:from-white hover:to-grey-50',
onClick && 'cursor-pointer',
separator ? 'border-b border-grey-100 last-of-type:border-b-transparent hover:border-grey-200' : 'border-y border-transparent hover:border-grey-200 first-of-type:hover:border-t-transparent',
separator ? 'border-b border-grey-100 last-of-type:border-b-transparent hover:border-grey-200' : 'border-y border-transparent first-of-type:hover:border-t-transparent',
className
);

View file

@ -95,7 +95,7 @@ const Select: React.FC<SelectProps> = ({
{title && <Heading grey={selectedOption || !prompt ? true : false} htmlFor={id} useLabelTag={true}>{title}</Heading>}
<div className={containerClasses}>
<select className={selectClasses} disabled={disabled} id={id} value={selectedOption} onChange={handleOptionChange}>
{prompt && <option className={optionClasses} value="">{prompt}</option>}
{prompt && <option className={optionClasses} value="" disabled selected>{prompt}</option>}
{options.map(option => (
'options' in option ?
<optgroup key={option.label} label={option.label}>

View file

@ -186,3 +186,16 @@ export const Dirty: Story = {
children: <p>Simulates if there were unsaved changes of a form. Click on Cancel</p>
}
};
export const FormSheet: Story = {
args: {
onOk: () => {
alert('Clicked OK!');
},
onCancel: undefined,
size: 'sm',
title: 'Form sheet',
formSheet: true,
children: <p>Slightly differently styled modal that can be used to display small forms <em>inside other modals</em>. Use it sparingly!</p>
}
};

View file

@ -37,6 +37,7 @@ export interface ModalProps {
scrolling?: boolean;
dirty?: boolean;
animate?: boolean;
formSheet?: boolean;
}
const Modal: React.FC<ModalProps> = ({
@ -60,7 +61,8 @@ const Modal: React.FC<ModalProps> = ({
stickyFooter = false,
scrolling = true,
dirty = false,
animate = true
animate = true,
formSheet = false
}) => {
const modal = useModal();
const {setGlobalDirtyState} = useGlobalDirtyState();
@ -126,8 +128,10 @@ const Modal: React.FC<ModalProps> = ({
}
let modalClasses = clsx(
'relative z-50 mx-auto flex max-h-[100%] w-full flex-col justify-between overflow-x-hidden rounded bg-white shadow-xl',
animate && 'animate-modal-in',
'relative z-50 mx-auto flex max-h-[100%] w-full flex-col justify-between overflow-x-hidden rounded bg-white',
formSheet ? 'shadow-md' : 'shadow-xl',
(animate && !formSheet) && 'animate-modal-in',
formSheet && 'animate-modal-in-reverse',
scrolling ? 'overflow-y-auto' : 'overflow-y-hidden'
);
@ -237,7 +241,8 @@ const Modal: React.FC<ModalProps> = ({
<div className={backdropClasses} id='modal-backdrop' onClick={handleBackdropClick}>
<div className={clsx(
'pointer-events-none fixed inset-0 z-0',
backDrop && 'bg-[rgba(98,109,121,0.2)] backdrop-blur-[3px]'
(backDrop && !formSheet) && 'bg-[rgba(98,109,121,0.2)] backdrop-blur-[3px]',
formSheet && 'bg-[rgba(98,109,121,0.05)]'
)}></div>
<section className={modalClasses} data-testid={testId} style={modalStyles}>
<div className={contentClasses}>

View file

@ -1,6 +1,8 @@
import AddIntegrationModal from '../settings/advanced/integrations/AddIntegrationModal';
import AddNewsletterModal from '../settings/email/newsletters/AddNewsletterModal';
import AmpModal from '../settings/advanced/integrations/AmpModal';
import ChangeThemeModal from '../settings/site/ThemeModal';
import CustomIntegrationModal from '../settings/advanced/integrations/CustomIntegrationModal';
import DesignModal from '../settings/site/DesignModal';
import FirstpromoterModal from '../settings/advanced/integrations/FirstPromoterModal';
import HistoryModal from '../settings/advanced/HistoryModal';
@ -120,6 +122,10 @@ const handleNavigation = (scroll: boolean = true) => {
NiceModal.show(FirstpromoterModal);
} else if (pathName === 'integrations/pintura') {
NiceModal.show(PinturaModal);
} else if (pathName === 'integrations/add') {
NiceModal.show(AddIntegrationModal);
} else if (pathName === 'integrations/show/custom/:id') { // TODO: move this to modalRoutes
NiceModal.show(CustomIntegrationModal);
}
if (scroll) {

View file

@ -1,5 +1,6 @@
import Button from '../../../admin-x-ds/global/Button';
import ConfirmationModal from '../../../admin-x-ds/global/modal/ConfirmationModal';
import Icon from '../../../admin-x-ds/global/Icon';
import List from '../../../admin-x-ds/global/List';
import ListItem from '../../../admin-x-ds/global/ListItem';
import NiceModal from '@ebay/nice-modal-react';
@ -17,13 +18,24 @@ import {ReactComponent as ZapierIcon} from '../../../assets/icons/zapier.svg';
import {useCreateWebhook, useDeleteWebhook, useEditWebhook} from '../../../api/webhooks';
import {useGlobalData} from '../../providers/GlobalDataProvider';
const IntegrationItem: React.FC<{icon?: React.ReactNode, title: string, detail: string, action: () => void; disabled?: boolean; testId?: string}> = ({
interface IntegrationItemProps {
icon?: React.ReactNode,
title: string,
detail: string,
action: () => void;
disabled?: boolean;
testId?: string;
custom?: boolean;
}
const IntegrationItem: React.FC<IntegrationItemProps> = ({
icon,
title,
detail,
action,
disabled,
testId
testId,
custom = false
}) => {
const {updateRoute} = useRouting();
@ -35,11 +47,16 @@ const IntegrationItem: React.FC<{icon?: React.ReactNode, title: string, detail:
}
};
return <ListItem
action={disabled ?
const buttons = custom ?
<Button color='red' label='Delete' link onClick={() => {}} />
:
(disabled ?
<Button icon='lock-locked' label='Upgrade' link onClick={handleClick} /> :
<Button color='green' label='Configure' link onClick={handleClick} />
}
);
return <ListItem
action={buttons}
avatar={icon}
className={disabled ? 'opacity-50 saturate-0' : ''}
detail={detail}
@ -122,28 +139,47 @@ const CustomIntegrations: React.FC<{integrations: Integration[]}> = ({integratio
const {mutateAsync: createWebhook} = useCreateWebhook();
const {mutateAsync: editWebhook} = useEditWebhook();
const {mutateAsync: deleteWebhook} = useDeleteWebhook();
const {updateRoute} = useRouting();
const openCustomIntegrationModal = () => {
updateRoute('integrations/show/custom/:id');
};
return (
<List>
{integrations.map(integration => (
<IntegrationItem action={() => {
NiceModal.show(ConfirmationModal, {
title: 'TEST API actions',
prompt: <>
<IntegrationItem
action={() => {
NiceModal.show(ConfirmationModal, {
title: 'TEST API actions',
prompt: <>
Webhooks (will not update until you close and reopen this modal)
<pre><code>{JSON.stringify(integration.webhooks)}</code></pre>
<pre><code>{JSON.stringify(integration.webhooks)}</code></pre>
<Button label='Create integration' onClick={() => createIntegration({name: 'Test'})} />
<Button label='Update integration' onClick={() => editIntegration({...integration, name: integration.name + '*'})} />
<Button label='Delete integration' onClick={() => deleteIntegration(integration.id)} />
<Button label='Create webhook' onClick={() => createWebhook({integration_id: integration.id, event: 'post.edited', name: 'Test', target_url: 'https://test.com'})} />
<Button label='Update webhook' onClick={() => editWebhook({...integration.webhooks[0], name: integration.webhooks[0].name + '*'})} />
<Button label='Delete webhook' onClick={() => deleteWebhook(integration.webhooks[0].id)} />
</>,
onOk: modal => modal?.remove()
});
}} detail={integration.description || 'No description'} title={integration.name} />)
<Button label='Create integration' onClick={() => createIntegration({name: 'Test'})} />
<Button label='Update integration' onClick={() => editIntegration({...integration, name: integration.name + '*'})} />
<Button label='Delete integration' onClick={() => deleteIntegration(integration.id)} />
<Button label='Create webhook' onClick={() => createWebhook({integration_id: integration.id, event: 'post.edited', name: 'Test', target_url: 'https://test.com'})} />
<Button label='Update webhook' onClick={() => editWebhook({...integration.webhooks[0], name: integration.webhooks[0].name + '*'})} />
<Button label='Delete webhook' onClick={() => deleteWebhook(integration.webhooks[0].id)} />
</>,
onOk: modal => modal?.remove()
});
}}
detail={integration.description || 'No description'}
icon={<Icon className='w-8' name='integration' />}
title={integration.name}
custom
/>)
)}
<IntegrationItem
action={openCustomIntegrationModal}
detail='This is just a static placeholder to open the custom modal'
icon={<Icon className='w-8' name='integration' />} // Should be custom icon when uploaded
title='Custom integration modal'
custom
/>
</List>
);
};
@ -151,6 +187,7 @@ const CustomIntegrations: React.FC<{integrations: Integration[]}> = ({integratio
const Integrations: React.FC<{ keywords: string[] }> = ({keywords}) => {
const [selectedTab, setSelectedTab] = useState<'built-in' | 'custom'>('built-in');
const {data: {integrations} = {integrations: []}} = useBrowseIntegrations();
const {updateRoute} = useRouting();
const tabs = [
{
@ -167,7 +204,7 @@ const Integrations: React.FC<{ keywords: string[] }> = ({keywords}) => {
const buttons = (
<Button color='green' label='Add custom integration' link={true} onClick={() => {
// showInviteModal();
updateRoute('integrations/add');
}} />
);

View file

@ -18,13 +18,13 @@ const APIKeyField: React.FC<APIKeyFieldProps> = ({label, text = '', hint, onRege
};
return <>
<div className='p-0 py-1 pr-4 text-grey-600'>{label}</div>
<div className='group relative overflow-hidden rounded p-1 hover:bg-grey-100'>
<div className='p-0 py-1 pr-4 text-sm text-grey-600'>{label}</div>
<div className='group relative overflow-hidden rounded p-1 text-sm hover:bg-grey-50'>
{text}
{hint}
<div className='invisible absolute right-0 top-[50%] flex translate-y-[-50%] gap-1 group-hover:visible'>
{onRegenerate && <Button color='grey' label='Regenerate' size='sm' onClick={onRegenerate} />}
<Button color='black' label={copied ? 'Copied' : 'Copy'} size='sm' onClick={copyText} />
<div className='invisible absolute right-0 top-[50%] flex translate-y-[-50%] gap-1 bg-white pl-1 text-sm group-hover:visible'>
{onRegenerate && <Button color='outline' label='Regenerate' size='sm' onClick={onRegenerate} />}
<Button color='outline' label={copied ? 'Copied' : 'Copy'} size='sm' onClick={copyText} />
</div>
</div>
</>;

View file

@ -0,0 +1,39 @@
import Form from '../../../../admin-x-ds/global/form/Form';
import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal from '@ebay/nice-modal-react';
import React from 'react';
import TextField from '../../../../admin-x-ds/global/form/TextField';
import useRouting from '../../../../hooks/useRouting';
interface AddIntegrationModalProps {}
const AddIntegrationModal: React.FC<AddIntegrationModalProps> = () => {
// const modal = useModal();
const {updateRoute} = useRouting();
return <Modal
afterClose={() => {
updateRoute('integrations');
}}
okColor='black'
okLabel='Add'
size='sm'
testId='add-integration-modal'
title='Add integration'
onOk={async () => {}}
>
<div className='mt-5'>
<Form
marginBottom={false}
marginTop={false}
>
<TextField
placeholder='Custom integration'
title='Name'
/>
</Form>
</div>
</Modal>;
};
export default NiceModal.create(AddIntegrationModal);

View file

@ -38,9 +38,8 @@ const AmpModal = NiceModal.create(() => {
afterClose={() => {
updateRoute('integrations');
}}
cancelLabel=''
okColor='black'
okLabel='Save'
okLabel='Save & close'
testId='amp-modal'
title=''
onOk={async () => {

View file

@ -0,0 +1,128 @@
import APIKeys from './APIKeys';
import Button from '../../../../admin-x-ds/global/Button';
import Form from '../../../../admin-x-ds/global/form/Form';
import ImageUpload from '../../../../admin-x-ds/global/form/ImageUpload';
import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal from '@ebay/nice-modal-react';
import React from 'react';
import Table from '../../../../admin-x-ds/global/Table';
import TableCell from '../../../../admin-x-ds/global/TableCell';
import TableHead from '../../../../admin-x-ds/global/TableHead';
import TableRow from '../../../../admin-x-ds/global/TableRow';
import TextField from '../../../../admin-x-ds/global/form/TextField';
import WebhookModal from './WebhookModal';
import useRouting from '../../../../hooks/useRouting';
import {getGhostPaths} from '../../../../utils/helpers';
interface CustomIntegrationModalProps {}
const CustomIntegrationModal: React.FC<CustomIntegrationModalProps> = () => {
// const modal = useModal();
const {updateRoute} = useRouting();
const integrationTitle = 'A custom integration';
const regenerated = false;
return <Modal
afterClose={() => {
updateRoute('integrations');
}}
okColor='black'
okLabel='Save & close'
size='md'
testId='custom-integration-modal'
title={integrationTitle}
stickyFooter
onOk={async () => {}}
>
<div className='mt-7 flex w-full gap-7'>
<div>
<ImageUpload
height='120px'
id='custom-integration-icon'
width='120px'
onDelete={() => {}}
onImageClick={() => {}}
onUpload={() => {}}
>
Upload icon
</ImageUpload>
</div>
<div className='flex grow flex-col'>
<Form>
<TextField title='Title' />
<TextField title='Description' />
<div>
<APIKeys keys={[
{
label: 'Content API key',
text: '[content key here]',
hint: regenerated ? <div className='text-green'>Content API Key was successfully regenerated</div> : undefined
// onRegenerate: handleRegenerate
},
{
label: 'Admin API key',
text: '[api key here]',
hint: regenerated ? <div className='text-green'>Admin API Key was successfully regenerated</div> : undefined
// onRegenerate: handleRegenerate
},
{
label: 'API URL',
text: window.location.origin + getGhostPaths().subdir
}
]} />
</div>
</Form>
</div>
</div>
<div>
<Table>
<TableRow bgOnHover={false}>
<TableHead>1 webhook</TableHead>
<TableHead>Last triggered</TableHead>
<TableHead />
</TableRow>
<TableRow
action={
<Button color='red' label='Delete' link onClick={() => {}} />
}
hideActions
onClick={() => {
NiceModal.show(WebhookModal);
}}
>
<TableCell className='w-1/2'>
<div className='text-sm font-semibold'>Rebuild on post published</div>
<div className='grid grid-cols-[max-content_1fr] gap-x-1 text-xs leading-snug'>
<span className='text-grey-600'>Event:</span>
<span>Post published</span>
<span className='text-grey-600'>URL:</span>
<span>https://example.com</span>
</div>
</TableCell>
<TableCell className='w-1/2 text-sm'>
Tue Aug 15 2023 13:03:33
</TableCell>
</TableRow>
<TableRow bgOnHover={false} separator={false}>
<TableCell colSpan={3}>
<Button
color='green'
icon='add'
iconColorClass='text-green'
label='Add webhook'
size='sm'
link
onClick={() => {
NiceModal.show(WebhookModal);
}} />
</TableCell>
</TableRow>
</Table>
</div>
</Modal>;
};
export default NiceModal.create(CustomIntegrationModal);

View file

@ -13,7 +13,7 @@ import {useGlobalData} from '../../../providers/GlobalDataProvider';
const FirstpromoterModal = NiceModal.create(() => {
const {updateRoute} = useRouting();
const modal = NiceModal.useModal();
const {settings} = useGlobalData();
const {mutateAsync: editSettings} = useEditSettings();
@ -48,9 +48,8 @@ const FirstpromoterModal = NiceModal.create(() => {
afterClose={() => {
updateRoute('integrations');
}}
cancelLabel=''
okColor='black'
okLabel='Save'
okLabel='Save & close'
testId='firstpromoter-modal'
title=''
onOk={async () => {

View file

@ -5,15 +5,20 @@ import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal from '@ebay/nice-modal-react';
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import pinturaScreenshot from '../../../../assets/images/pintura-screenshot.png';
import useRouting from '../../../../hooks/useRouting';
import {ReactComponent as Icon} from '../../../../assets/icons/pintura.svg';
import {useState} from 'react';
const PinturaModal = NiceModal.create(() => {
const {updateRoute} = useRouting();
const modal = NiceModal.useModal();
const [enabled, setEnabled] = useState(false);
return (
<Modal
afterClose={() => {
updateRoute('integrations');
}}
cancelLabel=''
okColor='black'
okLabel='Save'

View file

@ -4,16 +4,20 @@ import IntegrationHeader from './IntegrationHeader';
import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal from '@ebay/nice-modal-react';
import TextField from '../../../../admin-x-ds/global/form/TextField';
import useRouting from '../../../../hooks/useRouting';
import {ReactComponent as Icon} from '../../../../assets/icons/slack.svg';
const SlackModal = NiceModal.create(() => {
const {updateRoute} = useRouting();
const modal = NiceModal.useModal();
return (
<Modal
cancelLabel=''
afterClose={() => {
updateRoute('integrations');
}}
okColor='black'
okLabel='Save'
okLabel='Save & close'
title=''
onOk={() => {
modal.remove();

View file

@ -27,9 +27,8 @@ const UnsplashModal = NiceModal.create(() => {
afterClose={() => {
updateRoute('integrations');
}}
cancelLabel=''
okColor='black'
okLabel='Close'
okLabel='Save & close'
testId='unsplash-modal'
title=''
onOk={() => {

View file

@ -0,0 +1,96 @@
import Form from '../../../../admin-x-ds/global/form/Form';
import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal from '@ebay/nice-modal-react';
import React from 'react';
import Select from '../../../../admin-x-ds/global/form/Select';
import TextField from '../../../../admin-x-ds/global/form/TextField';
interface WebhookModalProps {}
const WebhookModal: React.FC<WebhookModalProps> = () => {
return <Modal
okColor='black'
okLabel='Add'
size='sm'
testId='webhook-modal'
title='Add webhook'
formSheet
onOk={async () => {}}
>
<div className='mt-5'>
<Form
marginBottom={false}
marginTop={false}
>
<TextField
placeholder='Custom webhook'
title='Name'
/>
<Select
options={[
{
label: 'Global',
options: [{label: 'Site changed', value: ''}]
},
{
label: 'Posts',
options: [
{label: 'Post created', value: ''},
{label: 'Post deleted', value: ''},
{label: 'Post updated', value: ''},
{label: 'Post published', value: ''},
{label: 'Published post updated', value: ''},
{label: 'Post unpublished', value: ''},
{label: 'Post scheduled', value: ''},
{label: 'Post unscheduled', value: ''},
{label: 'Tag added to post', value: ''},
{label: 'Tag removed from post', value: ''}
]
},
{
label: 'Pages',
options: [
{label: 'Page created', value: ''},
{label: 'Page deleted', value: ''},
{label: 'Page updated', value: ''},
{label: 'Page published', value: ''},
{label: 'Published page updated', value: ''},
{label: 'Page unpublished', value: ''},
{label: 'Tag added to page', value: ''},
{label: 'Tag removed from page', value: ''}
]
},
{
label: 'Tags',
options: [
{label: 'Tag created', value: ''},
{label: 'Tag deleted', value: ''},
{label: 'Tag updated', value: ''}
]
},
{
label: 'Members',
options: [
{label: 'Members created', value: ''},
{label: 'Members deleted', value: ''},
{label: 'Members updated', value: ''}
]
}
]}
prompt='Select an event'
onSelect={() => {}}
/>
<TextField
placeholder='https://example.com'
title='Target URL'
/>
<TextField
placeholder='Psst...'
title='Secret'
/>
</Form>
</div>
</Modal>;
};
export default NiceModal.create(WebhookModal);

View file

@ -66,6 +66,9 @@ const ZapierModal = NiceModal.create(() => {
return (
<Modal
afterClose={() => {
updateRoute('integrations');
}}
cancelLabel=''
okColor='black'
okLabel='Close'
@ -93,15 +96,16 @@ const ZapierModal = NiceModal.create(() => {
<List className='mt-6'>
{zapierTemplates.map(template => (
<ListItem
action={<Button color='green' href={template.url} label='Use this Zap' tag='a' target='_blank' link />}
action={<Button className='whitespace-nowrap text-sm font-semibold text-[#FF4A00]' href={template.url} label='Use this Zap' tag='a' target='_blank' link unstyled />}
avatar={<>
<img className='h-10 w-10 object-contain' role='presentation' src={`${adminRoot}${template.ghostImage}`} />
<ArrowRightIcon className='h-4 w-4' />
<img className='h-10 w-10 object-contain' role='presentation' src={`${adminRoot}${template.appImage}`} />
<img className='h-8 w-8 object-contain' role='presentation' src={`${adminRoot}${template.ghostImage}`} />
<ArrowRightIcon className='h-3 w-3' />
<img className='h-8 w-8 object-contain' role='presentation' src={`${adminRoot}${template.appImage}`} />
</>}
bgOnHover={false}
className='flex items-center gap-3 py-2'
title={template.title}
title={<span className='text-sm'>{template.title}</span>}
hideActions
/>
))}
</List>

View file

@ -160,6 +160,14 @@ module.exports = {
'100%': {
transform: 'translateY(0px)'
}
},
modalInReverse: {
'0%': {
transform: 'translateY(-32px)'
},
'100%': {
transform: 'translateY(0px)'
}
}
},
animation: {
@ -170,7 +178,8 @@ module.exports = {
'fade-out': 'fadeOut 0.15s ease forwards',
'setting-highlight-fade-out': 'fadeOut 0.2s 1.4s ease forwards',
'modal-backdrop-in': 'fadeIn 0.15s ease forwards',
'modal-in': 'modalIn 0.25s ease forwards'
'modal-in': 'modalIn 0.25s ease forwards',
'modal-in-reverse': 'modalInReverse 0.25s ease forwards'
},
spacing: {
px: '1px',