0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-01-20 22:42:53 -05:00

AdminX Portal links (#17227)

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

Static version of links page in AdminX Portal settings.
This commit is contained in:
Peter Zimon 2023-07-07 11:36:31 +02:00 committed by GitHub
parent 31ff544e7a
commit 2642941be6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 256 additions and 58 deletions

View file

@ -15,9 +15,10 @@ export interface ButtonProps {
fullWidth?: boolean;
link?: boolean;
disabled?: boolean;
unstyled?: boolean;
className?: string;
tag?: string;
onClick?: () => void;
onClick?: (e?:React.MouseEvent<HTMLElement>) => void;
}
const Button: React.FC<ButtonProps> = ({
@ -30,6 +31,7 @@ const Button: React.FC<ButtonProps> = ({
fullWidth,
link,
disabled,
unstyled = false,
className = '',
tag = 'button',
onClick,
@ -41,33 +43,36 @@ const Button: React.FC<ButtonProps> = ({
let styles = '';
styles += ' transition whitespace-nowrap flex items-center justify-center rounded-sm text-sm';
styles += ((link && color !== 'clear' && color !== 'black') || (!link && color !== 'clear')) ? ' font-bold' : ' font-semibold';
styles += !link ? `${size === 'sm' ? ' px-3 h-7 ' : ' px-4 h-[34px] '}` : '';
if (!unstyled) {
styles += ' transition whitespace-nowrap flex items-center justify-center rounded-sm text-sm';
styles += ((link && color !== 'clear' && color !== 'black') || (!link && color !== 'clear')) ? ' font-bold' : ' font-semibold';
styles += !link ? `${size === 'sm' ? ' px-3 h-7 ' : ' px-4 h-[34px] '}` : '';
switch (color) {
case 'black':
styles += link ? ' text-black hover:text-grey-800' : ' bg-black text-white hover:bg-grey-900';
break;
case 'grey':
styles += link ? ' text-black hover:text-grey-800' : ' bg-grey-100 text-black hover:!bg-grey-300';
break;
case 'green':
styles += link ? ' text-green hover:text-green-400' : ' bg-green text-white hover:bg-green-400';
break;
case 'red':
styles += link ? ' text-red hover:text-red-400' : ' bg-red text-white hover:bg-red-400';
break;
case 'white':
styles += link ? ' text-white hover:text-white' : ' bg-white text-black';
break;
default:
styles += link ? ' text-black hover:text-grey-800' : ' text-black hover:bg-grey-200';
break;
switch (color) {
case 'black':
styles += link ? ' text-black hover:text-grey-800' : ' bg-black text-white hover:bg-grey-900';
break;
case 'grey':
styles += link ? ' text-black hover:text-grey-800' : ' bg-grey-100 text-black hover:!bg-grey-300';
break;
case 'green':
styles += link ? ' text-green hover:text-green-400' : ' bg-green text-white hover:bg-green-400';
break;
case 'red':
styles += link ? ' text-red hover:text-red-400' : ' bg-red text-white hover:bg-red-400';
break;
case 'white':
styles += link ? ' text-white hover:text-white' : ' bg-white text-black';
break;
default:
styles += link ? ' text-black hover:text-grey-800' : ' text-black hover:bg-grey-200';
break;
}
styles += (fullWidth && !link) ? ' w-full' : '';
styles += (disabled) ? ' opacity-40' : ' cursor-pointer';
}
styles += (fullWidth && !link) ? ' w-full' : '';
styles += (disabled) ? ' opacity-40' : ' cursor-pointer';
styles += ` ${className}`;
const buttonChildren = <>

View file

@ -16,27 +16,42 @@ interface ListProps {
title?: React.ReactNode;
titleSeparator?: boolean;
children?: React.ReactNode;
actions?: React.ReactNode;
hint?: React.ReactNode;
hintSeparator?: boolean;
borderTop?: boolean;
className?: string;
}
const List: React.FC<ListProps> = ({title, titleSeparator, children, hint, hintSeparator, borderTop, pageTitle}) => {
const List: React.FC<ListProps> = ({title, titleSeparator, children, actions, hint, hintSeparator, borderTop, pageTitle, className}) => {
titleSeparator = (titleSeparator === undefined) ? true : titleSeparator;
hintSeparator = (hintSeparator === undefined) ? true : hintSeparator;
const listClasses = clsx(
(borderTop || pageTitle) && 'border-t border-grey-300',
pageTitle && 'mt-14'
pageTitle && 'mt-14',
className
);
let heading;
if (title) {
const headingTitle = <Heading grey={true} level={6}>{title}</Heading>;
heading = actions ? (
<div className='flex items-end justify-between gap-2'>
{headingTitle}
{actions}
</div>
) : headingTitle;
}
return (
<>
{pageTitle && <Heading>{pageTitle}</Heading>}
<section className={listClasses}>
{(!pageTitle && title) &&
<div className='flex flex-col gap-1'>
<Heading grey={true} level={6}>{title}</Heading>
<div className='flex flex-col items-stretch gap-1'>
{heading}
{titleSeparator && <Separator />}
</div>
}

View file

@ -18,9 +18,10 @@ interface ListItemProps {
bgOnHover?: boolean;
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
children?: React.ReactNode;
}
const ListItem: React.FC<ListItemProps> = ({id, title, detail, action, hideActions, avatar, className, testId, separator, bgOnHover = true, onClick}) => {
const ListItem: React.FC<ListItemProps> = ({id, title, detail, action, hideActions, avatar, className, testId, separator, bgOnHover = true, onClick, children}) => {
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
onClick?.(e);
};
@ -29,19 +30,21 @@ const ListItem: React.FC<ListItemProps> = ({id, title, detail, action, hideActio
const listItemClasses = clsx(
'group flex items-center justify-between',
bgOnHover && 'hover:bg-gradient-to-r hover:from-white hover:to-grey-50',
separator ? 'border-y border-grey-100 last-of-type:border-t 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 hover:border-grey-200 first-of-type:hover:border-t-transparent',
className
);
return (
<div className={listItemClasses} data-testid={testId}>
<div className={`flex grow items-center gap-3 ${onClick && 'cursor-pointer'}`} onClick={handleClick}>
{avatar && avatar}
<div className={`flex grow flex-col py-3 pr-6`} id={id}>
<span>{title}</span>
{detail && <span className='text-xs text-grey-700'>{detail}</span>}
{children ? children :
<div className={`flex grow items-center gap-3 ${onClick && 'cursor-pointer'}`} onClick={handleClick}>
{avatar && avatar}
<div className={`flex grow flex-col py-3 pr-6`} id={id}>
<span>{title}</span>
{detail && <span className='text-xs text-grey-700'>{detail}</span>}
</div>
</div>
</div>
}
{action &&
<div className={`px-6 py-3 ${hideActions ? 'invisible group-hover:visible' : ''}`}>
{action}

View file

@ -51,7 +51,7 @@ const DesktopChromeHeader: React.FC<DesktopChromeHeaderProps & React.HTMLAttribu
);
return (
<header className={`relative flex items-center justify-center bg-grey-50 ${containerSize} ${toolbarClasses}`} {...props}>
<header className={`relative flex items-center justify-center ${containerSize} ${toolbarClasses}`} {...props}>
{toolbarLeft ?
<div className='absolute left-5 flex h-full items-center'>
{toolbarLeft}

View file

@ -96,10 +96,10 @@ const Select: React.FC<SelectProps> = ({
);
return (
unstyled ? select :
unstyled ? select : (title || hint ? (
<div>
{select}
</div>
</div>) : select)
);
};

View file

@ -20,6 +20,7 @@ export type TextFieldProps = React.InputHTMLAttributes<HTMLInputElement> & {
containerClassName?: string;
hintClassName?: string;
unstyled?: boolean;
disabled?: boolean;
}
const TextField: React.FC<TextFieldProps> = ({
@ -39,6 +40,7 @@ const TextField: React.FC<TextFieldProps> = ({
containerClassName = '',
hintClassName = '',
unstyled = false,
disabled,
...props
}) => {
const id = useId();
@ -46,14 +48,16 @@ const TextField: React.FC<TextFieldProps> = ({
const textFieldClasses = !unstyled && clsx(
'h-10 border-b py-2',
clearBg ? 'bg-transparent' : 'bg-grey-75 px-[10px]',
error ? `border-red` : `border-grey-500 hover:border-grey-700 focus:border-black`,
error ? `border-red` : `${disabled ? 'border-grey-300' : 'border-grey-500 hover:border-grey-700 focus:border-black'}`,
(title && !hideTitle && !clearBg) && `mt-2`,
(disabled ? 'text-grey-700' : ''),
className
);
const field = <input
ref={inputRef}
className={textFieldClasses || className}
disabled={disabled}
id={id}
maxLength={maxLength}
placeholder={placeholder}
@ -63,13 +67,17 @@ const TextField: React.FC<TextFieldProps> = ({
onChange={onChange}
{...props} />;
return (
<div className={`flex flex-col ${containerClassName}`}>
{title && <Heading className={hideTitle ? 'sr-only' : ''} grey={value ? true : false} htmlFor={id} useLabelTag={true}>{title}</Heading>}
{field}
{hint && <Hint className={hintClassName} color={error ? 'red' : ''}>{hint}</Hint>}
</div>
);
if (title || hint) {
return (
<div className={`flex flex-col ${containerClassName}`}>
{title && <Heading className={hideTitle ? 'sr-only' : ''} grey={value ? true : false} htmlFor={id} useLabelTag={true}>{title}</Heading>}
{field}
{hint && <Hint className={hintClassName} color={error ? 'red' : ''}>{hint}</Hint>}
</div>
);
} else {
return field;
}
};
export default TextField;

View file

@ -2,6 +2,7 @@ import type {Meta, StoryObj} from '@storybook/react';
import Modal from './Modal';
import ModalContainer from './ModalContainer';
import ModalPage from './ModalPage';
import NiceModal from '@ebay/nice-modal-react';
const meta = {
@ -75,7 +76,7 @@ export const ExtraLarge: Story = {
}
};
export const full: Story = {
export const Full: Story = {
args: {
size: 'full',
onOk: () => {
@ -97,6 +98,19 @@ export const Bleed: Story = {
}
};
export const CompletePage: Story = {
args: {
size: 'full',
footer: <></>,
noPadding: true,
children: <>
<ModalPage heading='Hey there full page'>
<p>This is a full page in a modal</p>
</ModalPage>
</>
}
};
export const CustomButtons: Story = {
args: {
leftButtonLabel: 'Extra action',

View file

@ -0,0 +1,21 @@
import type {Meta, StoryObj} from '@storybook/react';
import ModalPage from './ModalPage';
const meta = {
title: 'Global / Modal / Modal page contents',
component: ModalPage,
tags: ['autodocs']
} satisfies Meta<typeof ModalPage>;
export default meta;
type Story = StoryObj<typeof ModalPage>;
export const Default: Story = {
args: {
heading: 'Here\'s a modal page',
children: <>
<p>Use this component to in full-width or bleed modals in which you build a complete page (e.g. Theme grid)</p>
</>
}
};

View file

@ -0,0 +1,24 @@
import Heading from '../Heading';
import React from 'react';
import clsx from 'clsx';
interface ModalPageProps {
heading?: string;
children?: React.ReactNode;
className?: string;
}
const ModalPage: React.FC<ModalPageProps> = ({heading, children, className}) => {
className = clsx(
'min-h-full min-w-full p-[8vmin] pt-5',
className
);
return (
<div className={className}>
{heading && <Heading className='mb-8'>{heading}</Heading>}
{children}
</div>
);
};
export default ModalPage;

View file

@ -27,6 +27,7 @@ export interface PreviewModalProps {
rightToolbar?: boolean;
deviceSelector?: boolean;
previewToolbarURLs?: SelectOption[];
previewBgColor?: 'grey' | 'white';
selectedURL?: string;
previewToolbarTabs?: Tab[];
defaultTab?: string;
@ -58,6 +59,7 @@ export const PreviewModalContent: React.FC<PreviewModalProps> = ({
rightToolbar = true,
deviceSelector = true,
previewToolbarURLs,
previewBgColor = 'grey',
selectedURL,
previewToolbarTabs,
buttonsDisabled,
@ -137,7 +139,7 @@ export const PreviewModalContent: React.FC<PreviewModalProps> = ({
);
preview = (
<>
<div className={`min-h-100 min-w-100 flex grow flex-col ${previewBgColor === 'grey' ? 'bg-grey-50' : 'bg-white'}`}>
<DesktopChromeHeader
data-testid="design-toolbar"
size='lg'
@ -145,10 +147,10 @@ export const PreviewModalContent: React.FC<PreviewModalProps> = ({
toolbarLeft={leftToolbar && toolbarLeft}
toolbarRight={rightToolbar && toolbarRight}
/>
<div className='flex h-full grow items-center justify-center bg-grey-50 text-sm text-grey-400'>
<div className='flex h-full grow items-center justify-center text-sm text-grey-400'>
{preview}
</div>
</>
</div>
);
}

View file

@ -149,6 +149,7 @@ const PortalModal: React.FC = () => {
dirty={saveState === 'unsaved'}
okLabel='Save & close'
preview={preview}
previewBgColor={selectedPreviewTab === 'links' ? 'white' : 'grey'}
previewToolbarTabs={previewTabs}
selectedURL={selectedPreviewTab}
sidebar={sidebar}

View file

@ -0,0 +1,103 @@
import Button from '../../../../admin-x-ds/global/Button';
import List from '../../../../admin-x-ds/global/List';
import ListItem from '../../../../admin-x-ds/global/ListItem';
import ModalPage from '../../../../admin-x-ds/global/modal/ModalPage';
import React, {useContext, useState} from 'react';
import Select from '../../../../admin-x-ds/global/form/Select';
import TextField from '../../../../admin-x-ds/global/form/TextField';
import {SettingsContext} from '../../../providers/SettingsProvider';
import {getHomepageUrl} from '../../../../utils/helpers';
interface PortalLinksPrefs {
}
interface PortalLinkPrefs {
name: string;
value: string;
}
const PortalLink: React.FC<PortalLinkPrefs> = ({name, value}) => {
return (
<ListItem
action={<Button color='black' label='Copy' link onClick={(e) => {
navigator.clipboard.writeText(value);
const button = e?.target as HTMLButtonElement;
button.innerText = 'Copied';
setTimeout(() => {
button.innerText = 'Copy';
}, 1000);
}}/>}
hideActions
separator
>
<div className='flex w-full grow items-center gap-5 py-3'>
<span className='inline-block w-[200px] whitespace-nowrap'>{name}</span>
<TextField className='border-b-500 grow bg-transparent p-1 text-grey-700' value={value} disabled unstyled />
</div>
</ListItem>
);
};
const PortalLinks: React.FC<PortalLinksPrefs> = () => {
const [isDataAttributes, setIsDataAttributes] = useState(false);
const {siteData} = useContext(SettingsContext);
const toggleIsDataAttributes = () => {
setIsDataAttributes(!isDataAttributes);
};
const homePageURL = getHomepageUrl(siteData!);
return (
<ModalPage className='text-base text-black' heading='Links'>
<p className='-mt-6 mb-16'>Use these {isDataAttributes ? 'data attributes' : 'links'} in your theme to show pages of Portal.</p>
<List actions={<Button color='green' label={isDataAttributes ? 'Links' : 'Data attributes'} link onClick={toggleIsDataAttributes}/>} title='Generic'>
<PortalLink name='Default' value={isDataAttributes ? 'data-portal' : `${homePageURL}/#/portal`} />
<PortalLink name='Sign in' value={isDataAttributes ? 'data-portal="signin"' : `${homePageURL}/#/portal/signin`} />
<PortalLink name='Sign up' value={isDataAttributes ? 'data-portal="signup"' : `${homePageURL}/#/portal/signup`} />
</List>
<List className='mt-14' title='Tiers'>
<ListItem
hideActions
separator
>
<div className='flex w-full items-center gap-5 py-3 pr-6'>
<span className='inline-block w-[200px] shrink-0 font-bold'>Tier</span>
<Select
containerClassName='max-w-[400px]'
options={[
{
label: 'Tier one',
value: 'tier-one'
},
{
label: 'Tier two',
value: 'tier-two'
}
]}
onSelect={() => {
}}
/>
</div>
</ListItem>
<PortalLink name='Signup / Monthly' value={isDataAttributes ? 'data-portal="signup/abc123/monthly"' : `${homePageURL}/#/portal/signup/abc123/monthly`} />
<PortalLink name='Signup / Yearly' value={isDataAttributes ? 'data-portal="signup/abc123/yearly"' : `${homePageURL}/#/portal/signup/abc123/yearly`} />
<PortalLink name='Signup / Free' value={isDataAttributes ? 'data-portal="signup/free"' : `${homePageURL}/#/portal/signup/free`} />
</List>
<List className='mt-14' title='Account'>
<PortalLink name='Account' value={isDataAttributes ? 'data-portal="account"' : `${homePageURL}/#/portal/account`} />
<PortalLink name='Account / Plans' value={isDataAttributes ? 'data-portal="account/plans"' : `${homePageURL}/#/portal/account/plans`} />
<PortalLink name='Account / Profile' value={isDataAttributes ? 'data-portal="account/profile"' : `${homePageURL}/#/portal/account/profile`} />
<PortalLink name='Account / Newsletters' value={isDataAttributes ? 'data-portal="account/newsletters"' : `${homePageURL}/#/portal/account/newsletters`} />
</List>
</ModalPage>
);
};
export default PortalLinks;

View file

@ -1,4 +1,5 @@
import PortalFrame from './PortalFrame';
import PortalLinks from './PortalLinks';
import React from 'react';
import {Setting, Tier} from '../../../../types/api';
@ -24,7 +25,7 @@ const PortalPreview: React.FC<PortalPreviewProps> = ({
);
break;
case 'links':
tabContents = <>Links</>;
tabContents = <PortalLinks />;
break;
default:
tabContents = (

View file

@ -3,6 +3,7 @@ import ConfirmationModal from '../../../../admin-x-ds/global/modal/ConfirmationM
import List from '../../../../admin-x-ds/global/List';
import ListItem from '../../../../admin-x-ds/global/ListItem';
import Menu from '../../../../admin-x-ds/global/Menu';
import ModalPage from '../../../../admin-x-ds/global/modal/ModalPage';
import NiceModal from '@ebay/nice-modal-react';
import React from 'react';
import {Theme} from '../../../../types/api';
@ -196,12 +197,12 @@ const AdvancedThemeSettings: React.FC<ThemeSettingProps> = ({
setThemes
}) => {
return (
<div className='p-[8vmin] pt-5'>
<ModalPage>
<ThemeList
setThemes={setThemes}
themes={themes}
/>
</div>
</ModalPage>
);
};

View file

@ -1,4 +1,5 @@
import Heading from '../../../../admin-x-ds/global/Heading';
import ModalPage from '../../../../admin-x-ds/global/modal/ModalPage';
import React from 'react';
import {OfficialTheme} from '../../../../models/themes';
import {getGhostPaths} from '../../../../utils/helpers';
@ -13,8 +14,7 @@ const OfficialThemes: React.FC<{
const officialThemes = useOfficialThemes();
return (
<div className='h-[calc(100vh-74px-40px)] overflow-y-auto overflow-x-hidden p-[8vmin] pt-5'>
<Heading>Themes</Heading>
<ModalPage heading='Themes'>
<div className='mt-[6vmin] grid grid-cols-1 gap-[6vmin] sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4'>
{officialThemes.map((theme) => {
return (
@ -37,7 +37,7 @@ const OfficialThemes: React.FC<{
);
})}
</div>
</div>
</ModalPage>
);
};