Refactor admin AI/sync buttons
This commit is contained in:
parent
2e3d92885c
commit
2da60e68c1
@ -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)}
|
||||||
|
|||||||
58
src/admin/PhotoSyncButton.tsx
Normal file
58
src/admin/PhotoSyncButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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'
|
||||||
? [
|
? [
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -69,5 +69,4 @@ export const cleanUpAiTextResponse = (text?: string) => text
|
|||||||
.replaceAll('\n', ' ')
|
.replaceAll('\n', ' ')
|
||||||
.replaceAll('"', '')
|
.replaceAll('"', '')
|
||||||
.replace(/\.$/, '')
|
.replace(/\.$/, '')
|
||||||
.trim()
|
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user