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 { isPathFavs, isPhotoFav } from '@/tag';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { BiTrash } from 'react-icons/bi';
|
import { BiTrash } from 'react-icons/bi';
|
||||||
import MoreMenu from '@/components/MoreMenu';
|
import MoreMenu from '@/components/more/MoreMenu';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/state/AppState';
|
||||||
import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
|
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"
|
spinnerColor="dim"
|
||||||
prefetch={prefetch}
|
prefetch={prefetch}
|
||||||
shouldScroll={shouldScroll}
|
shouldScroll={shouldScroll}
|
||||||
|
styleAs="link"
|
||||||
shouldReplace
|
shouldReplace
|
||||||
styleAsLink
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -60,7 +60,7 @@ export default function SubmitButtonWithStatus({
|
|||||||
)}
|
)}
|
||||||
icon={icon}
|
icon={icon}
|
||||||
spinnerColor={spinnerColor}
|
spinnerColor={spinnerColor}
|
||||||
styleAsLink={styleAsLink}
|
styleAs={styleAsLink ? 'link' : undefined}
|
||||||
isLoading={pending}
|
isLoading={pending}
|
||||||
{...buttonProps}
|
{...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: {
|
export default function LoaderButton(props: {
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
icon?: JSX.Element
|
icon?: ReactNode
|
||||||
spinnerColor?: SpinnerColor
|
spinnerColor?: SpinnerColor
|
||||||
styleAsLink?: boolean
|
styleAs?: 'button' | 'link' | 'link-without-hover'
|
||||||
|
hideTextOnMobile?: boolean
|
||||||
} & ButtonHTMLAttributes<HTMLButtonElement>) {
|
} & ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||||
const {
|
const {
|
||||||
children,
|
children,
|
||||||
isLoading,
|
isLoading,
|
||||||
icon,
|
icon,
|
||||||
spinnerColor,
|
spinnerColor,
|
||||||
styleAsLink,
|
styleAs = 'button',
|
||||||
|
hideTextOnMobile = true,
|
||||||
type = 'button',
|
type = 'button',
|
||||||
disabled,
|
disabled,
|
||||||
className,
|
className,
|
||||||
...rest
|
...rest
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
{...rest}
|
{...rest}
|
||||||
type={type}
|
type={type}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
className,
|
...(styleAs !== 'button'
|
||||||
...(styleAsLink
|
|
||||||
? [
|
? [
|
||||||
'link h-4 hover:text-dim active:text-medium',
|
'link h-4 active:text-medium',
|
||||||
'disabled:!bg-transparent',
|
'disabled:!bg-transparent',
|
||||||
]
|
]
|
||||||
: ['h-9']),
|
: ['h-9']),
|
||||||
|
styleAs === 'link' && 'hover:text-dim',
|
||||||
|
styleAs === 'link-without-hover' && 'hover:text-main',
|
||||||
'inline-flex items-center gap-2 self-start',
|
'inline-flex items-center gap-2 self-start',
|
||||||
|
className,
|
||||||
)}
|
)}
|
||||||
disabled={isLoading || disabled}
|
disabled={isLoading || disabled}
|
||||||
>
|
>
|
||||||
{(icon || isLoading) &&
|
{(icon || isLoading) &&
|
||||||
<span className={clsx(
|
<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',
|
'inline-flex justify-center',
|
||||||
)}>
|
)}>
|
||||||
{isLoading
|
{isLoading
|
||||||
? <Spinner
|
? <Spinner
|
||||||
size={14}
|
size={14}
|
||||||
color={spinnerColor}
|
color={spinnerColor}
|
||||||
className="translate-y-[2px]"
|
className={styleAs === 'button'
|
||||||
|
? 'translate-y-[2px]'
|
||||||
|
: 'translate-y-[0.5px]'}
|
||||||
/>
|
/>
|
||||||
: icon}
|
: icon}
|
||||||
</span>}
|
</span>}
|
||||||
{children && <span className={clsx(
|
{children && <span className={clsx(
|
||||||
icon !== undefined && 'hidden sm:inline-block',
|
styleAs !== 'button' && isLoading && 'text-dim',
|
||||||
|
hideTextOnMobile && icon !== undefined && 'hidden sm:inline-block',
|
||||||
)}>
|
)}>
|
||||||
{children}
|
{children}
|
||||||
</span>}
|
</span>}
|
||||||
|
|||||||
@ -13,18 +13,22 @@ export default function PathLoaderButton({
|
|||||||
shouldScroll = true,
|
shouldScroll = true,
|
||||||
shouldReplace,
|
shouldReplace,
|
||||||
spinnerColor,
|
spinnerColor,
|
||||||
styleAsLink,
|
shouldPreventDefault,
|
||||||
|
styleAs,
|
||||||
|
hideTextOnMobile,
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
path: string
|
path: string
|
||||||
icon?: JSX.Element
|
icon?: ReactNode
|
||||||
prefetch?: boolean
|
prefetch?: boolean
|
||||||
loaderDelay?: number
|
loaderDelay?: number
|
||||||
shouldScroll?: boolean
|
shouldScroll?: boolean
|
||||||
shouldReplace?: boolean
|
shouldReplace?: boolean
|
||||||
spinnerColor?: SpinnerColor
|
spinnerColor?: SpinnerColor
|
||||||
styleAsLink?: boolean
|
shouldPreventDefault?: boolean
|
||||||
|
styleAs?: 'button' | 'link' | 'link-without-hover'
|
||||||
|
hideTextOnMobile?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
@ -55,16 +59,20 @@ export default function PathLoaderButton({
|
|||||||
<LoaderButton
|
<LoaderButton
|
||||||
icon={icon}
|
icon={icon}
|
||||||
className={className}
|
className={className}
|
||||||
onClick={() => startTransition(() => {
|
onClick={e => {
|
||||||
if (shouldReplace) {
|
if (shouldPreventDefault) { e.preventDefault(); }
|
||||||
router.replace(path, { scroll: shouldScroll });
|
startTransition(() => {
|
||||||
} else {
|
if (shouldReplace) {
|
||||||
router.push(path, { scroll: shouldScroll });
|
router.replace(path, { scroll: shouldScroll });
|
||||||
}
|
} else {
|
||||||
})}
|
router.push(path, { scroll: shouldScroll });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
isLoading={shouldShowLoader}
|
isLoading={shouldShowLoader}
|
||||||
spinnerColor={spinnerColor}
|
spinnerColor={spinnerColor}
|
||||||
styleAsLink={styleAsLink}
|
styleAs={styleAs}
|
||||||
|
hideTextOnMobile={hideTextOnMobile}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</LoaderButton>
|
</LoaderButton>
|
||||||
|
|||||||
@ -108,7 +108,7 @@ export default function SiteChecklistClient({
|
|||||||
navigator.clipboard.writeText(text);
|
navigator.clipboard.writeText(text);
|
||||||
toastSuccess(`${label} copied to clipboard`);
|
toastSuccess(`${label} copied to clipboard`);
|
||||||
}}
|
}}
|
||||||
styleAsLink
|
styleAs="link"
|
||||||
/>;
|
/>;
|
||||||
|
|
||||||
const renderEnvVar = (
|
const renderEnvVar = (
|
||||||
@ -253,7 +253,7 @@ export default function SiteChecklistClient({
|
|||||||
onClick={refreshSecret}
|
onClick={refreshSecret}
|
||||||
isLoading={isPendingSecret}
|
isLoading={isPendingSecret}
|
||||||
spinnerColor="text"
|
spinnerColor="text"
|
||||||
styleAsLink
|
styleAs="link"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user