Add loading indicators to admin photo menu

This commit is contained in:
Sam Becker 2024-05-26 14:32:29 -05:00
parent 83c821f664
commit 1ae7ea12c3
9 changed files with 172 additions and 133 deletions

View File

@ -8,7 +8,7 @@ import { Photo, deleteConfirmationTextForPhoto } from '@/photo';
import { isPathFavs, isPhotoFav } from '@/tag';
import { usePathname } from 'next/navigation';
import { BiTrash } from 'react-icons/bi';
import MoreMenu from '@/components/MoreMenu';
import MoreMenu from '@/components/more/MoreMenu';
import { useAppState } from '@/state/AppState';
import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';

View File

@ -1,108 +0,0 @@
import React, { ReactNode, useState } from 'react';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { clsx } from 'clsx/lite';
import { FiMoreHorizontal } from 'react-icons/fi';
import Link from 'next/link';
export default function MoreMenu({
items,
className,
buttonClassName,
}: {
items: {
label: ReactNode
icon?: ReactNode
href?: string
prefetch?: boolean
action?: () => Promise<void> | void
}[]
className?: string
buttonClassName?: string
}){
const [isLoading, setIsLoading] = useState(false);
const renderItemContent = (
label: ReactNode,
icon?: ReactNode,
) =>
<div className="flex items-center">
<span className="w-6">{icon}</span>
<span>{label}</span>
</div>;
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
className={clsx(
buttonClassName,
'p-1 min-h-0 border-none shadow-none hover:outline-none',
'hover:bg-gray-100 active:bg-gray-100',
'hover:dark:bg-gray-800/75 active:dark:bg-gray-900',
'text-dim',
)}
aria-label={`Choose an action for photo: ${'photo'}`}
>
<FiMoreHorizontal size={18} />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
align="end"
className={clsx(
className,
'min-w-[8rem]',
'ml-2.5',
'p-1 rounded-md border',
'bg-content',
'shadow-lg dark:shadow-xl',
)}
>
{items.map(({ label, icon, href, prefetch = false, action }) =>
<DropdownMenu.Item
key={`${label}`}
disabled={isLoading}
className={clsx(
'block w-full',
'border-none min-h-0 bg-transparent',
'select-none hover:outline-none',
'text-sm text-main text-left',
'px-3 py-1.5 rounded-[3px]',
'hover:text-main',
'hover:bg-gray-50 active:bg-gray-100',
'hover:dark:bg-gray-900/75 active:dark:bg-gray-900',
'whitespace-nowrap',
'shadow-none',
isLoading
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer',
)}
onClick={e => {
const result = action?.();
if (result instanceof Promise) {
e.preventDefault();
setIsLoading(true);
result.finally(() => setIsLoading(false));
}
}}
>
<>
{href &&
<Link
href={href}
className="hover:text-main"
prefetch={prefetch}
>
{renderItemContent(label, icon)}
</Link>}
{action &&
renderItemContent(label, icon)}
</>
</DropdownMenu.Item>
)}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
};

View File

@ -28,8 +28,8 @@ export default function ShareButton({
spinnerColor="dim"
prefetch={prefetch}
shouldScroll={shouldScroll}
styleAs="link"
shouldReplace
styleAsLink
/>
);
}

View File

@ -60,7 +60,7 @@ export default function SubmitButtonWithStatus({
)}
icon={icon}
spinnerColor={spinnerColor}
styleAsLink={styleAsLink}
styleAs={styleAsLink ? 'link' : undefined}
isLoading={pending}
{...buttonProps}
>

View File

@ -0,0 +1,63 @@
import { ReactNode } from 'react';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { clsx } from 'clsx/lite';
import { FiMoreHorizontal } from 'react-icons/fi';
import MoreMenuItem from './MoreMenuItem';
export default function MoreMenu({
items,
className,
buttonClassName,
}: {
items: {
label: ReactNode
icon?: ReactNode
href?: string
action?: () => Promise<void> | void
}[]
className?: string
buttonClassName?: string
}){
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
className={clsx(
buttonClassName,
'p-1 min-h-0 border-none shadow-none hover:outline-none',
'hover:bg-gray-100 active:bg-gray-100',
'hover:dark:bg-gray-800/75 active:dark:bg-gray-900',
'text-dim',
)}
aria-label={`Choose an action for photo: ${'photo'}`}
>
<FiMoreHorizontal size={18} />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
align="end"
className={clsx(
className,
'min-w-[8rem]',
'ml-2.5',
'p-1 rounded-md border',
'bg-content',
'shadow-lg dark:shadow-xl',
)}
>
{items.map(({ label, icon, href, action }) =>
<MoreMenuItem
key={`${label}`}
label={label}
icon={icon}
href={href}
action={action}
/>
)}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
};

View File

@ -0,0 +1,67 @@
'use client';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { clsx } from 'clsx/lite';
import { ReactNode, useState } from 'react';
import LoaderButton from '../primitives/LoaderButton';
import PathLoaderButton from '../primitives/PathLoaderButton';
export default function MoreMenuItem({
label,
icon,
href,
action,
}: {
label: ReactNode
icon?: ReactNode
href?: string
action?: () => Promise<void> | void
}) {
const [isLoading, setIsLoading] = useState(false);
return (
<DropdownMenu.Item
disabled={isLoading}
className={clsx(
'px-3 py-1.5 rounded-[3px]',
'select-none hover:outline-none',
'hover:bg-gray-50 active:bg-gray-100',
'hover:dark:bg-gray-900/75 active:dark:bg-gray-900',
'whitespace-nowrap',
isLoading
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer',
)}
>
{href &&
<PathLoaderButton
path={href}
icon={icon}
hideTextOnMobile={false}
shouldPreventDefault
styleAs="link-without-hover"
>
{label}
</PathLoaderButton>}
{action &&
<LoaderButton
icon={icon}
isLoading={isLoading}
hideTextOnMobile={false}
styleAs="link-without-hover"
onClick={e => {
if (!href) {
const result = action?.();
if (result instanceof Promise) {
e.preventDefault();
setIsLoading(true);
result.finally(() => setIsLoading(false));
}
}
}}
>
{label}
</LoaderButton>}
</DropdownMenu.Item>
);
}

View File

@ -5,52 +5,61 @@ import { ButtonHTMLAttributes, ReactNode } from 'react';
export default function LoaderButton(props: {
children?: ReactNode
isLoading?: boolean
icon?: JSX.Element
icon?: ReactNode
spinnerColor?: SpinnerColor
styleAsLink?: boolean
styleAs?: 'button' | 'link' | 'link-without-hover'
hideTextOnMobile?: boolean
} & ButtonHTMLAttributes<HTMLButtonElement>) {
const {
children,
isLoading,
icon,
spinnerColor,
styleAsLink,
styleAs = 'button',
hideTextOnMobile = true,
type = 'button',
disabled,
className,
...rest
} = props;
return (
<button
{...rest}
type={type}
className={clsx(
className,
...(styleAsLink
...(styleAs !== 'button'
? [
'link h-4 hover:text-dim active:text-medium',
'link h-4 active:text-medium',
'disabled:!bg-transparent',
]
: ['h-9']),
styleAs === 'link' && 'hover:text-dim',
styleAs === 'link-without-hover' && 'hover:text-main',
'inline-flex items-center gap-2 self-start',
className,
)}
disabled={isLoading || disabled}
>
{(icon || isLoading) &&
<span className={clsx(
'min-w-[1.25rem] h-4 translate-y-[-0.5px]',
'min-w-[1.25rem] h-4',
styleAs === 'button' ? 'translate-y-[-0.5px]' : 'translate-y-[0.5px]',
'inline-flex justify-center',
)}>
{isLoading
? <Spinner
size={14}
color={spinnerColor}
className="translate-y-[2px]"
className={styleAs === 'button'
? 'translate-y-[2px]'
: 'translate-y-[0.5px]'}
/>
: icon}
</span>}
{children && <span className={clsx(
icon !== undefined && 'hidden sm:inline-block',
styleAs !== 'button' && isLoading && 'text-dim',
hideTextOnMobile && icon !== undefined && 'hidden sm:inline-block',
)}>
{children}
</span>}

View File

@ -13,18 +13,22 @@ export default function PathLoaderButton({
shouldScroll = true,
shouldReplace,
spinnerColor,
styleAsLink,
shouldPreventDefault,
styleAs,
hideTextOnMobile,
className,
children,
}: {
path: string
icon?: JSX.Element
icon?: ReactNode
prefetch?: boolean
loaderDelay?: number
shouldScroll?: boolean
shouldReplace?: boolean
spinnerColor?: SpinnerColor
styleAsLink?: boolean
shouldPreventDefault?: boolean
styleAs?: 'button' | 'link' | 'link-without-hover'
hideTextOnMobile?: boolean
className?: string
children?: ReactNode
}) {
@ -55,16 +59,20 @@ export default function PathLoaderButton({
<LoaderButton
icon={icon}
className={className}
onClick={() => startTransition(() => {
if (shouldReplace) {
router.replace(path, { scroll: shouldScroll });
} else {
router.push(path, { scroll: shouldScroll });
}
})}
onClick={e => {
if (shouldPreventDefault) { e.preventDefault(); }
startTransition(() => {
if (shouldReplace) {
router.replace(path, { scroll: shouldScroll });
} else {
router.push(path, { scroll: shouldScroll });
}
});
}}
isLoading={shouldShowLoader}
spinnerColor={spinnerColor}
styleAsLink={styleAsLink}
styleAs={styleAs}
hideTextOnMobile={hideTextOnMobile}
>
{children}
</LoaderButton>

View File

@ -108,7 +108,7 @@ export default function SiteChecklistClient({
navigator.clipboard.writeText(text);
toastSuccess(`${label} copied to clipboard`);
}}
styleAsLink
styleAs="link"
/>;
const renderEnvVar = (
@ -253,7 +253,7 @@ export default function SiteChecklistClient({
onClick={refreshSecret}
isLoading={isPendingSecret}
spinnerColor="text"
styleAsLink
styleAs="link"
/>
</div>
</div>