Add loading indicators to admin photo menu
This commit is contained in:
parent
83c821f664
commit
1ae7ea12c3
@ -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';
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -28,8 +28,8 @@ export default function ShareButton({
|
||||
spinnerColor="dim"
|
||||
prefetch={prefetch}
|
||||
shouldScroll={shouldScroll}
|
||||
styleAs="link"
|
||||
shouldReplace
|
||||
styleAsLink
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -60,7 +60,7 @@ export default function SubmitButtonWithStatus({
|
||||
)}
|
||||
icon={icon}
|
||||
spinnerColor={spinnerColor}
|
||||
styleAsLink={styleAsLink}
|
||||
styleAs={styleAsLink ? 'link' : undefined}
|
||||
isLoading={pending}
|
||||
{...buttonProps}
|
||||
>
|
||||
|
||||
63
src/components/more/MoreMenu.tsx
Normal file
63
src/components/more/MoreMenu.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
67
src/components/more/MoreMenuItem.tsx
Normal file
67
src/components/more/MoreMenuItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user