Refactor admin AI/sync buttons

This commit is contained in:
Sam Becker 2024-05-29 14:13:48 -05:00
parent 2e3d92885c
commit 2da60e68c1
10 changed files with 98 additions and 63 deletions

View File

@ -11,8 +11,6 @@ import { AiOutlineEyeInvisible } from 'react-icons/ai';
import PhotoDate from '@/photo/PhotoDate'; import PhotoDate from '@/photo/PhotoDate';
import FormWithConfirm from '@/components/FormWithConfirm'; import FormWithConfirm from '@/components/FormWithConfirm';
import EditButton from './EditButton'; import EditButton from './EditButton';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import IconGrSync from '@/site/IconGrSync';
import DeleteButton from './DeleteButton'; import DeleteButton from './DeleteButton';
import { import {
deletePhotoFormAction, deletePhotoFormAction,
@ -20,6 +18,7 @@ import {
} from '@/photo/actions'; } from '@/photo/actions';
import { useAppState } from '@/state/AppState'; import { useAppState } from '@/state/AppState';
import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll'; import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
import PhotoSyncButton from './PhotoSyncButton';
export default function AdminPhotosTable({ export default function AdminPhotosTable({
photos, photos,
@ -82,25 +81,15 @@ export default function AdminPhotosTable({
'gap-2 sm:gap-3 items-center', 'gap-2 sm:gap-3 items-center',
)}> )}>
<EditButton path={pathForAdminPhotoEdit(photo)} /> <EditButton path={pathForAdminPhotoEdit(photo)} />
<FormWithConfirm <PhotoSyncButton
action={syncPhotoExifDataAction} action={syncPhotoExifDataAction}
confirmText={ photoTitle={titleForPhoto(photo)}
'Are you sure you want to overwrite EXIF data ' + formData={{ photoId: photo.id }}
`for "${titleForPhoto(photo)}" from source file? ` + onFormSubmit={invalidateSwr}
'This action cannot be undone.' includeLabel={false}
} shouldConfirm
> shouldToast
<input type="hidden" name="id" value={photo.id} /> />
<SubmitButtonWithStatus
icon={<IconGrSync
className="translate-x-[1px] translate-y-[0.5px]"
/>}
onFormSubmitToastMessage={`
"${titleForPhoto(photo)}" EXIF data synced
`}
onFormSubmit={invalidateSwr}
/>
</FormWithConfirm>
<FormWithConfirm <FormWithConfirm
action={deletePhotoFormAction} action={deletePhotoFormAction}
confirmText={deleteConfirmationTextForPhoto(photo)} confirmText={deleteConfirmationTextForPhoto(photo)}

View File

@ -0,0 +1,58 @@
import FormWithConfirm from '@/components/FormWithConfirm';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import IconGrSync from '@/site/IconGrSync';
import { clsx } from 'clsx/lite';
import { ComponentProps } from 'react';
export default function PhotoSyncButton({
action,
includeLabel = true,
onFormSubmit,
formData: { photoId, photoUrl } = {},
photoTitle,
shouldConfirm,
shouldToast,
}: {
action: (formData: FormData) => void
includeLabel?: boolean
formData?: {
photoId?: string
photoUrl?: string
}
photoTitle?: string
shouldConfirm?: boolean
shouldToast?: boolean
} & ComponentProps<typeof SubmitButtonWithStatus>) {
const confirmText =
'Are you sure you want to overwrite EXIF data ' + (photoTitle
? `for "${photoTitle}" from source file? `
: 'from source file? '
) + 'This action cannot be undone.';
return (
<FormWithConfirm
action={action}
confirmText={shouldConfirm ? confirmText : undefined}
>
{photoId &&
<input name="id" value={photoId} hidden readOnly />}
{photoUrl &&
<input name="photoUrl" value={photoUrl} hidden readOnly />}
<SubmitButtonWithStatus
title="Update photo from original file"
icon={<IconGrSync
className={clsx(
'translate-y-[0.5px] translate-x-[0.5px]',
includeLabel && 'sm:translate-x-[-0.5px]',
)} />}
onFormSubmitToastMessage={shouldToast
? photoTitle
? `"${photoTitle}" EXIF data synced`
: 'EXIF data synced'
: undefined}
onFormSubmit={onFormSubmit}
>
{includeLabel ? 'EXIF' : null}
</SubmitButtonWithStatus>
</FormWithConfirm>
);
}

View File

@ -8,8 +8,8 @@ export default function FormWithConfirm({
onSubmit, onSubmit,
children, children,
}: { }: {
action: (data: FormData) => Promise<void> action: (formData: FormData) => void
confirmText: string confirmText?: string
onSubmit?: () => void onSubmit?: () => void
children: ReactNode children: ReactNode
}) { }) {
@ -17,11 +17,11 @@ export default function FormWithConfirm({
<form <form
action={action} action={action}
onSubmit={e => { onSubmit={e => {
if (!confirm(confirmText)) { if (!confirmText || confirm(confirmText)) {
e.preventDefault();
} else {
e.currentTarget.requestSubmit(); e.currentTarget.requestSubmit();
onSubmit?.(); onSubmit?.();
} else {
e.preventDefault();
} }
}} }}
> >

View File

@ -1,16 +1,12 @@
'use client'; 'use client';
import { HTMLProps, useEffect, useRef } from 'react'; import { ComponentProps, useEffect, useRef } from 'react';
import { useFormStatus } from 'react-dom'; import { useFormStatus } from 'react-dom';
import { SpinnerColor } from './Spinner';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import { toastSuccess } from '@/toast'; import { toastSuccess } from '@/toast';
import LoaderButton from '@/components/primitives/LoaderButton'; import LoaderButton from '@/components/primitives/LoaderButton';
interface Props extends HTMLProps<HTMLButtonElement> { interface Props extends ComponentProps<typeof LoaderButton> {
icon?: JSX.Element
styleAsLink?: boolean
spinnerColor?: SpinnerColor
onFormStatusChange?: (pending: boolean) => void onFormStatusChange?: (pending: boolean) => void
onFormSubmitToastMessage?: string onFormSubmitToastMessage?: string
onFormSubmit?: () => void onFormSubmit?: () => void
@ -19,7 +15,7 @@ interface Props extends HTMLProps<HTMLButtonElement> {
export default function SubmitButtonWithStatus({ export default function SubmitButtonWithStatus({
icon, icon,
styleAsLink, styleAs,
spinnerColor, spinnerColor,
onFormStatusChange, onFormStatusChange,
onFormSubmitToastMessage, onFormSubmitToastMessage,
@ -36,7 +32,7 @@ export default function SubmitButtonWithStatus({
const pendingPrevious = useRef(pending); const pendingPrevious = useRef(pending);
useEffect(() => { useEffect(() => {
if (pending && !pendingPrevious.current) { if (!pending && pendingPrevious.current) {
if (onFormSubmitToastMessage) { if (onFormSubmitToastMessage) {
toastSuccess(onFormSubmitToastMessage); toastSuccess(onFormSubmitToastMessage);
} }
@ -60,7 +56,7 @@ export default function SubmitButtonWithStatus({
)} )}
icon={icon} icon={icon}
spinnerColor={spinnerColor} spinnerColor={spinnerColor}
styleAs={styleAsLink ? 'link' : undefined} styleAs={styleAs}
isLoading={pending} isLoading={pending}
{...buttonProps} {...buttonProps}
> >

View File

@ -3,12 +3,12 @@ import { clsx } from 'clsx/lite';
import { ButtonHTMLAttributes, ReactNode } from 'react'; import { ButtonHTMLAttributes, ReactNode } from 'react';
export default function LoaderButton(props: { export default function LoaderButton(props: {
children?: ReactNode
isLoading?: boolean isLoading?: boolean
icon?: ReactNode icon?: ReactNode
spinnerColor?: SpinnerColor spinnerColor?: SpinnerColor
styleAs?: 'button' | 'link' | 'link-without-hover' styleAs?: 'button' | 'link' | 'link-without-hover'
hideTextOnMobile?: boolean hideTextOnMobile?: boolean
shouldPreventDefault?: boolean
} & ButtonHTMLAttributes<HTMLButtonElement>) { } & ButtonHTMLAttributes<HTMLButtonElement>) {
const { const {
children, children,
@ -17,7 +17,9 @@ export default function LoaderButton(props: {
spinnerColor, spinnerColor,
styleAs = 'button', styleAs = 'button',
hideTextOnMobile = true, hideTextOnMobile = true,
shouldPreventDefault,
type = 'button', type = 'button',
onClick,
disabled, disabled,
className, className,
...rest ...rest
@ -27,6 +29,10 @@ export default function LoaderButton(props: {
<button <button
{...rest} {...rest}
type={type} type={type}
onClick={e => {
if (shouldPreventDefault) { e.preventDefault(); }
onClick?.(e);
}}
className={clsx( className={clsx(
...(styleAs !== 'button' ...(styleAs !== 'button'
? [ ? [

View File

@ -59,8 +59,7 @@ export default function PathLoaderButton({
<LoaderButton <LoaderButton
icon={icon} icon={icon}
className={className} className={className}
onClick={e => { onClick={() => {
if (shouldPreventDefault) { e.preventDefault(); }
startTransition(() => { startTransition(() => {
if (shouldReplace) { if (shouldReplace) {
router.replace(path, { scroll: shouldScroll }); router.replace(path, { scroll: shouldScroll });
@ -69,6 +68,7 @@ export default function PathLoaderButton({
} }
}); });
}} }}
shouldPreventDefault={shouldPreventDefault}
isLoading={shouldShowLoader} isLoading={shouldShowLoader}
spinnerColor={spinnerColor} spinnerColor={spinnerColor}
styleAs={styleAs} styleAs={styleAs}

View File

@ -3,7 +3,6 @@
import AdminChildPage from '@/components/AdminChildPage'; import AdminChildPage from '@/components/AdminChildPage';
import { Photo } from '.'; import { Photo } from '.';
import { PATH_ADMIN_PHOTOS } from '@/site/paths'; import { PATH_ADMIN_PHOTOS } from '@/site/paths';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import { import {
PhotoFormData, PhotoFormData,
convertPhotoToFormData, convertPhotoToFormData,
@ -11,11 +10,11 @@ import {
import PhotoForm from './form/PhotoForm'; import PhotoForm from './form/PhotoForm';
import { useFormState } from 'react-dom'; import { useFormState } from 'react-dom';
import { areSimpleObjectsEqual } from '@/utility/object'; import { areSimpleObjectsEqual } from '@/utility/object';
import IconGrSync from '@/site/IconGrSync';
import { getExifDataAction } from './actions'; import { getExifDataAction } from './actions';
import { TagsWithMeta } from '@/tag'; import { TagsWithMeta } from '@/tag';
import AiButton from './ai/AiButton'; import AiButton from './ai/AiButton';
import usePhotoFormParent from './form/usePhotoFormParent'; import usePhotoFormParent from './form/usePhotoFormParent';
import PhotoSyncButton from '@/admin/PhotoSyncButton';
export default function PhotoEditPageClient({ export default function PhotoEditPageClient({
photo, photo,
@ -69,16 +68,10 @@ export default function PhotoEditPageClient({
<div className="flex gap-2"> <div className="flex gap-2">
{hasAiTextGeneration && {hasAiTextGeneration &&
<AiButton {...{ aiContent, shouldConfirm: hasTextContent }} />} <AiButton {...{ aiContent, shouldConfirm: hasTextContent }} />}
<form action={action}> <PhotoSyncButton
<input name="photoUrl" value={photo.url} hidden readOnly /> action={action}
<SubmitButtonWithStatus formData={{ photoUrl: photo.url }}
icon={<IconGrSync />
className="translate-y-[-1px] sm:mr-[4px]"
/>}
>
EXIF
</SubmitButtonWithStatus>
</form>
</div>} </div>}
isLoading={pending} isLoading={pending}
> >

View File

@ -1,9 +1,8 @@
import Spinner from '@/components/Spinner';
import { AiContent } from './useAiImageQueries'; import { AiContent } from './useAiImageQueries';
import { HiSparkles } from 'react-icons/hi'; import { HiSparkles } from 'react-icons/hi';
import { ALL_AI_AUTO_GENERATED_FIELDS, AiAutoGeneratedField } from '.'; import { ALL_AI_AUTO_GENERATED_FIELDS, AiAutoGeneratedField } from '.';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { clsx } from 'clsx/lite'; import LoaderButton from '@/components/primitives/LoaderButton';
export default function AiButton({ export default function AiButton({
aiContent, aiContent,
@ -40,12 +39,9 @@ export default function AiButton({
]); ]);
return ( return (
<button <LoaderButton
type="button" icon={<HiSparkles size={16} />}
className={clsx( className={className}
'flex min-w-[3.25rem] min-h-9 justify-center',
className,
)}
onClick={e => { onClick={e => {
if ( if (
!shouldConfirm || !shouldConfirm ||
@ -56,9 +52,7 @@ export default function AiButton({
e.preventDefault(); e.preventDefault();
} }
}} }}
disabled={isLoading} isLoading={isLoading}
> />
{isLoading ? <Spinner /> : <HiSparkles size={16} />}
</button>
); );
} }

View File

@ -69,5 +69,4 @@ export const cleanUpAiTextResponse = (text?: string) => text
.replaceAll('\n', ' ') .replaceAll('\n', ' ')
.replaceAll('"', '') .replaceAll('"', '')
.replace(/\.$/, '') .replace(/\.$/, '')
.trim()
: undefined; : undefined;

View File

@ -50,7 +50,7 @@ export default function Footer() {
</div> </div>
<form action={() => signOutAndRedirectAction() <form action={() => signOutAndRedirectAction()
.then(() => setUserEmail?.(undefined))}> .then(() => setUserEmail?.(undefined))}>
<SubmitButtonWithStatus styleAsLink> <SubmitButtonWithStatus styleAs="link">
Sign out Sign out
</SubmitButtonWithStatus> </SubmitButtonWithStatus>
</form> </form>