Finalize exif syncing behaviors
This commit is contained in:
parent
0f87bd3b5c
commit
af693b9141
@ -16,7 +16,7 @@ export default function AdminGrid ({
|
||||
<div className="min-w-[14rem] overflow-x-scroll">
|
||||
<div className={cc(
|
||||
'w-full',
|
||||
'grid grid-cols-[auto_1fr_auto_auto] ',
|
||||
'grid grid-cols-[auto_1fr_auto] ',
|
||||
'gap-2 sm:gap-3 items-center',
|
||||
)}>
|
||||
{children}
|
||||
|
||||
@ -41,25 +41,30 @@ export default function BlobUrls({
|
||||
>
|
||||
{pathForBlobUrl(url)}
|
||||
</Link>
|
||||
<EditButton href={href} label="Setup" />
|
||||
<FormWithConfirm
|
||||
action={deleteBlobPhotoAction}
|
||||
confirmText="Are you sure you want to delete this upload?"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="redirectToPhotos"
|
||||
value={urls.length < 2 ? 'true' : 'false'}
|
||||
readOnly
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="url"
|
||||
value={url}
|
||||
readOnly
|
||||
/>
|
||||
<DeleteButton />
|
||||
</FormWithConfirm>
|
||||
<div className={cc(
|
||||
'flex flex-nowrap',
|
||||
'gap-2 sm:gap-3 items-center',
|
||||
)}>
|
||||
<EditButton href={href} label="Setup" />
|
||||
<FormWithConfirm
|
||||
action={deleteBlobPhotoAction}
|
||||
confirmText="Are you sure you want to delete this upload?"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="redirectToPhotos"
|
||||
value={urls.length < 2 ? 'true' : 'false'}
|
||||
readOnly
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="url"
|
||||
value={url}
|
||||
readOnly
|
||||
/>
|
||||
<DeleteButton />
|
||||
</FormWithConfirm>
|
||||
</div>
|
||||
</Fragment>;})}
|
||||
</AdminGrid>
|
||||
);
|
||||
|
||||
@ -1,11 +1,16 @@
|
||||
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
|
||||
import { FaTimes } from 'react-icons/fa';
|
||||
import { cc } from '@/utility/css';
|
||||
import { BiTrash } from 'react-icons/bi';
|
||||
|
||||
export default function DeleteButton () {
|
||||
return <SubmitButtonWithStatus
|
||||
title="Delete"
|
||||
icon={<FaTimes size={13} className="translate-y-[1px]" />}
|
||||
>
|
||||
Delete
|
||||
</SubmitButtonWithStatus>;
|
||||
icon={<BiTrash size={16} className="translate-y-[-1.5px]" />}
|
||||
className={cc(
|
||||
'text-red-500 dark:text-red-600',
|
||||
'active:!bg-red-100/50 active:dark:!bg-red-950/50',
|
||||
'!border-red-200 hover:!border-red-300',
|
||||
'dark:!border-red-900/75 dark:hover:!border-red-900',
|
||||
)}
|
||||
/>;
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ import PhotoTiny from '@/photo/PhotoTiny';
|
||||
import { cc } from '@/utility/css';
|
||||
import FormWithConfirm from '@/components/FormWithConfirm';
|
||||
import SiteGrid from '@/components/SiteGrid';
|
||||
import { deletePhotoAction } from '@/photo/actions';
|
||||
import { deletePhotoAction, syncPhotoExifDataAction } from '@/photo/actions';
|
||||
import {
|
||||
pathForAdminPhotos,
|
||||
pathForPhoto,
|
||||
@ -28,6 +28,8 @@ import DeleteButton from '@/admin/DeleteButton';
|
||||
import EditButton from '@/admin/EditButton';
|
||||
import BlobUrls from '@/admin/BlobUrls';
|
||||
import { PRO_MODE_ENABLED } from '@/site/config';
|
||||
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
|
||||
import { GrSync } from 'react-icons/gr';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
@ -76,11 +78,11 @@ export default async function AdminTagsPage({
|
||||
)}
|
||||
photo={photo}
|
||||
/>
|
||||
<div className="flex flex-col md:flex-row">
|
||||
<div className="flex flex-col lg:flex-row">
|
||||
<Link
|
||||
key={photo.id}
|
||||
href={pathForPhoto(photo)}
|
||||
className="sm:w-[50%] flex items-center gap-2"
|
||||
className="lg:w-[50%] flex items-center gap-2"
|
||||
>
|
||||
<span className={cc(
|
||||
'inline-flex items-center gap-2',
|
||||
@ -103,23 +105,42 @@ export default async function AdminTagsPage({
|
||||
</span>}
|
||||
</Link>
|
||||
<div className={cc(
|
||||
'sm:w-[50%] uppercase',
|
||||
'lg:w-[50%] uppercase',
|
||||
'text-dim',
|
||||
)}>
|
||||
{photo.takenAtNaive}
|
||||
</div>
|
||||
</div>
|
||||
<EditButton href={pathForAdminPhotoEdit(photo)} />
|
||||
<FormWithConfirm
|
||||
action={deletePhotoAction}
|
||||
confirmText={
|
||||
// eslint-disable-next-line max-len
|
||||
`Are you sure you want to delete "${titleForPhoto(photo)}?"`}
|
||||
>
|
||||
<input type="hidden" name="id" value={photo.id} />
|
||||
<input type="hidden" name="url" value={photo.url} />
|
||||
<DeleteButton />
|
||||
</FormWithConfirm>
|
||||
<div className={cc(
|
||||
'flex flex-nowrap',
|
||||
'gap-2 sm:gap-3 items-center',
|
||||
)}>
|
||||
<EditButton href={pathForAdminPhotoEdit(photo)} />
|
||||
<FormWithConfirm
|
||||
action={syncPhotoExifDataAction}
|
||||
confirmText={
|
||||
'Are you sure you want to overwrite EXIF data ' +
|
||||
`for "${titleForPhoto(photo)}" from source file? ` +
|
||||
'This action cannot be undone.'
|
||||
}
|
||||
>
|
||||
<input type="hidden" name="id" value={photo.id} />
|
||||
<SubmitButtonWithStatus icon={<GrSync
|
||||
size={15}
|
||||
className="translate-y-[-0.5px]"
|
||||
/>} />
|
||||
</FormWithConfirm>
|
||||
<FormWithConfirm
|
||||
action={deletePhotoAction}
|
||||
confirmText={
|
||||
// eslint-disable-next-line max-len
|
||||
`Are you sure you want to delete "${titleForPhoto(photo)}?"`}
|
||||
>
|
||||
<input type="hidden" name="id" value={photo.id} />
|
||||
<input type="hidden" name="url" value={photo.url} />
|
||||
<DeleteButton />
|
||||
</FormWithConfirm>
|
||||
</div>
|
||||
</Fragment>)}
|
||||
</AdminGrid>
|
||||
{showMorePhotos &&
|
||||
|
||||
@ -10,6 +10,7 @@ import PhotoTag from '@/tag/PhotoTag';
|
||||
import { formatTag } from '@/tag';
|
||||
import EditButton from '@/admin/EditButton';
|
||||
import { pathForAdminTagEdit } from '@/site/paths';
|
||||
import { cc } from '@/utility/css';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
@ -30,16 +31,21 @@ export default async function AdminPhotosPage() {
|
||||
<div className="text-dim uppercase">
|
||||
{photoQuantityText(count, false)}
|
||||
</div>
|
||||
<EditButton href={pathForAdminTagEdit(tag)} />
|
||||
<FormWithConfirm
|
||||
action={deletePhotoTagGloballyAction}
|
||||
confirmText={
|
||||
// eslint-disable-next-line max-len
|
||||
`Are you sure you want to remove "${formatTag(tag)}?" from ${photoQuantityText(count, false).toLowerCase()}?`}
|
||||
>
|
||||
<input type="hidden" name="tag" value={tag} />
|
||||
<DeleteButton />
|
||||
</FormWithConfirm>
|
||||
<div className={cc(
|
||||
'flex flex-nowrap',
|
||||
'gap-2 sm:gap-3 items-center',
|
||||
)}>
|
||||
<EditButton href={pathForAdminTagEdit(tag)} />
|
||||
<FormWithConfirm
|
||||
action={deletePhotoTagGloballyAction}
|
||||
confirmText={
|
||||
// eslint-disable-next-line max-len
|
||||
`Are you sure you want to remove "${formatTag(tag)}?" from ${photoQuantityText(count, false).toLowerCase()}?`}
|
||||
>
|
||||
<input type="hidden" name="tag" value={tag} />
|
||||
<DeleteButton />
|
||||
</FormWithConfirm>
|
||||
</div>
|
||||
</Fragment>)}
|
||||
</AdminGrid>
|
||||
</div>
|
||||
|
||||
@ -6,7 +6,7 @@ import { BASE_URL, SITE_DESCRIPTION, SITE_TITLE } from '@/site/config';
|
||||
import StateProvider from '@/state/AppStateProvider';
|
||||
import ThemeProviderClient from '@/site/ThemeProviderClient';
|
||||
import Nav from '@/site/Nav';
|
||||
import ToasterWithThemes from '@/components/ToasterWithThemes';
|
||||
import ToasterWithThemes from '@/toast/ToasterWithThemes';
|
||||
import PhotoEscapeHandler from '@/photo/PhotoEscapeHandler';
|
||||
|
||||
import '../site/globals.css';
|
||||
|
||||
@ -4,10 +4,9 @@ import Modal from '@/components/Modal';
|
||||
import { TbPhotoShare } from 'react-icons/tb';
|
||||
import { cc } from '@/utility/css';
|
||||
import { BiCopy } from 'react-icons/bi';
|
||||
import { toast } from 'sonner';
|
||||
import { FiCheckSquare } from 'react-icons/fi';
|
||||
import { ReactNode } from 'react';
|
||||
import { shortenUrl } from '@/utility/url';
|
||||
import { toastSuccess } from '@/toast';
|
||||
|
||||
export default function ShareModal({
|
||||
title = 'Share',
|
||||
@ -52,10 +51,7 @@ export default function ShareModal({
|
||||
)}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(pathShare);
|
||||
toast(
|
||||
'Link to photo copied',
|
||||
{ icon: <FiCheckSquare size={16} /> },
|
||||
);
|
||||
toastSuccess('Link to photo copied');
|
||||
}}
|
||||
>
|
||||
<BiCopy size={18} />
|
||||
|
||||
@ -46,11 +46,11 @@ export default function SubmitButtonWithStatus(props: Props) {
|
||||
? <Spinner size={14} />
|
||||
: icon}
|
||||
</span>}
|
||||
<span className={cc(
|
||||
{children && <span className={cc(
|
||||
icon !== undefined && 'hidden sm:inline-block',
|
||||
)}>
|
||||
{children}
|
||||
</span>
|
||||
</span>}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@ -4,22 +4,32 @@ import AdminChildPage from '@/components/AdminChildPage';
|
||||
import { Photo } from '.';
|
||||
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
|
||||
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
|
||||
import { BiRefresh } from 'react-icons/bi';
|
||||
import { PhotoFormData, convertPhotoToFormData } from './form';
|
||||
import PhotoForm from './PhotoForm';
|
||||
import { useFormState } from 'react-dom';
|
||||
import { getExifDataAction } from './actions';
|
||||
import { GrSync } from 'react-icons/gr';
|
||||
import { areSimpleObjectsEqual } from '@/utility/object';
|
||||
|
||||
export default function PhotoEditPageClient({
|
||||
photo,
|
||||
}: {
|
||||
photo: Photo
|
||||
}) {
|
||||
const seedExifData = { url: photo.url };
|
||||
|
||||
const [updatedExifData, action] = useFormState<Partial<PhotoFormData>>(
|
||||
getExifDataAction,
|
||||
{ url: photo.url},
|
||||
seedExifData,
|
||||
);
|
||||
|
||||
const hasExifDataBeenFound = !areSimpleObjectsEqual(
|
||||
updatedExifData,
|
||||
seedExifData,
|
||||
);
|
||||
|
||||
console.log({ hasExifDataBeenFound });
|
||||
|
||||
return (
|
||||
<AdminChildPage
|
||||
backPath={PATH_ADMIN_PHOTOS}
|
||||
@ -29,16 +39,21 @@ export default function PhotoEditPageClient({
|
||||
<form action={action}>
|
||||
<input name="photoUrl" value={photo.url} hidden readOnly />
|
||||
<SubmitButtonWithStatus
|
||||
icon={<BiRefresh size={18} className="translate-y-[-1.5px]" />}
|
||||
icon={<GrSync
|
||||
size={15}
|
||||
className="translate-y-[0.5px] mr-[4px]"
|
||||
/>}
|
||||
>
|
||||
Refresh EXIF
|
||||
EXIF
|
||||
</SubmitButtonWithStatus>
|
||||
</form>}
|
||||
>
|
||||
<PhotoForm
|
||||
type="edit"
|
||||
initialPhotoForm={convertPhotoToFormData(photo)}
|
||||
updatedExifData={updatedExifData}
|
||||
updatedExifData={hasExifDataBeenFound
|
||||
? updatedExifData
|
||||
: undefined}
|
||||
/>
|
||||
</AdminChildPage>
|
||||
);
|
||||
|
||||
@ -17,6 +17,7 @@ import {
|
||||
generateLocalNaivePostgresString,
|
||||
generateLocalPostgresString,
|
||||
} from '@/utility/date';
|
||||
import { toastSuccess, toastWarning } from '@/toast';
|
||||
|
||||
const THUMBNAIL_WIDTH = 300;
|
||||
const THUMBNAIL_HEIGHT = 200;
|
||||
@ -35,12 +36,35 @@ export default function PhotoForm({
|
||||
const [formData, setFormData] =
|
||||
useState<Partial<PhotoFormData>>(initialPhotoForm);
|
||||
|
||||
// Update form when EXIF data
|
||||
// is refreshed by parent
|
||||
useEffect(() => {
|
||||
// Update form when EXIF data is refreshed by parent
|
||||
setFormData(currentForm => ({
|
||||
...currentForm,
|
||||
...updatedExifData,
|
||||
}));
|
||||
if (Object.keys(updatedExifData ?? {}).length > 0) {
|
||||
const changedKeys: string[] = [];
|
||||
|
||||
setFormData(currentForm => {
|
||||
Object.entries(updatedExifData ?? {})
|
||||
.forEach(([key, value]) => {
|
||||
if (currentForm[key as keyof PhotoFormData] !== value) {
|
||||
changedKeys.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...currentForm,
|
||||
...updatedExifData,
|
||||
};
|
||||
});
|
||||
|
||||
if (changedKeys.length > 0) {
|
||||
toastSuccess(
|
||||
`Updated EXIF fields: ${changedKeys.join(', ')}`,
|
||||
8000,
|
||||
);
|
||||
} else {
|
||||
toastWarning('No new EXIF data found');
|
||||
}
|
||||
}
|
||||
}, [updatedExifData]);
|
||||
|
||||
// Generate local date strings when
|
||||
|
||||
@ -106,7 +106,7 @@ export async function getExifDataAction(
|
||||
}
|
||||
|
||||
export async function syncPhotoExifDataAction(formData: FormData) {
|
||||
const photoId = formData.get('photoId') as string;
|
||||
const photoId = formData.get('id') as string;
|
||||
if (photoId) {
|
||||
const photo = await getPhoto(photoId);
|
||||
if (photo) {
|
||||
|
||||
@ -4,7 +4,7 @@ import { useTransition } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { cc } from '@/utility/css';
|
||||
import ChecklistRow from '../components/ChecklistRow';
|
||||
import { FiCheckSquare, FiExternalLink } from 'react-icons/fi';
|
||||
import { FiExternalLink } from 'react-icons/fi';
|
||||
import {
|
||||
BiCog,
|
||||
BiCopy,
|
||||
@ -14,9 +14,9 @@ import {
|
||||
BiRefresh,
|
||||
} from 'react-icons/bi';
|
||||
import IconButton from '@/components/IconButton';
|
||||
import { toast } from 'sonner';
|
||||
import InfoBlock from '@/components/InfoBlock';
|
||||
import Checklist from '@/components/Checklist';
|
||||
import { toastSuccess } from '@/toast';
|
||||
|
||||
export default function SiteChecklistClient({
|
||||
hasPostgres,
|
||||
@ -84,12 +84,7 @@ export default function SiteChecklistClient({
|
||||
className={cc(subtle && 'text-gray-300 dark:text-gray-700')}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast(
|
||||
`${label} copied to clipboard`, {
|
||||
icon: <FiCheckSquare size={16} />,
|
||||
duration: 4000,
|
||||
},
|
||||
);
|
||||
toastSuccess(`${label} copied to clipboard`);
|
||||
}}
|
||||
/>;
|
||||
|
||||
|
||||
@ -25,7 +25,7 @@
|
||||
bg-white dark:bg-black
|
||||
border-gray-200 dark:border-gray-700
|
||||
font-mono text-base leading-tight
|
||||
min-h-[2.25rem]
|
||||
min-h-[2.4rem]
|
||||
}
|
||||
input[type=text], input[type=email], input[type=password], select {
|
||||
@apply
|
||||
|
||||
@ -3,6 +3,7 @@ import PhotoTag from './PhotoTag';
|
||||
import { descriptionForTaggedPhotos } from '.';
|
||||
import { pathForTagShare } from '@/site/paths';
|
||||
import PhotoHeader from '@/photo/PhotoHeader';
|
||||
import AnimateItems from '@/components/AnimateItems';
|
||||
|
||||
export default function TagHeader({
|
||||
tag,
|
||||
@ -16,14 +17,19 @@ export default function TagHeader({
|
||||
count?: number
|
||||
}) {
|
||||
return (
|
||||
<PhotoHeader
|
||||
entity={<PhotoTag tag={tag} />}
|
||||
entityVerb="Tagged"
|
||||
entityDescription={descriptionForTaggedPhotos(photos, undefined, count)}
|
||||
photos={photos}
|
||||
selectedPhoto={selectedPhoto}
|
||||
sharePath={pathForTagShare(tag)}
|
||||
count={count}
|
||||
<AnimateItems
|
||||
type="bottom"
|
||||
distanceOffset={10}
|
||||
items={[<PhotoHeader
|
||||
key="PhotoHeader"
|
||||
entity={<PhotoTag tag={tag} />}
|
||||
entityVerb="Tagged"
|
||||
entityDescription={descriptionForTaggedPhotos(photos, undefined, count)}
|
||||
photos={photos}
|
||||
selectedPhoto={selectedPhoto}
|
||||
sharePath={pathForTagShare(tag)}
|
||||
count={count}
|
||||
/>]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
26
src/toast/index.tsx
Normal file
26
src/toast/index.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { AiOutlineWarning } from 'react-icons/ai';
|
||||
import { FiCheckSquare } from 'react-icons/fi';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const DEFAULT_DURATION = 4000;
|
||||
|
||||
export const toastSuccess = (
|
||||
message: ReactNode,
|
||||
duration = DEFAULT_DURATION,
|
||||
) => toast(
|
||||
message, {
|
||||
icon: <FiCheckSquare size={16} />,
|
||||
duration,
|
||||
},
|
||||
);
|
||||
|
||||
export const toastWarning = (
|
||||
message: ReactNode,
|
||||
duration = DEFAULT_DURATION,
|
||||
) => toast(
|
||||
message, {
|
||||
icon: <AiOutlineWarning size={16} />,
|
||||
duration,
|
||||
},
|
||||
);
|
||||
13
src/utility/object.ts
Normal file
13
src/utility/object.ts
Normal file
@ -0,0 +1,13 @@
|
||||
type SimpleObject = Record<string, string>;
|
||||
|
||||
export const areSimpleObjectsEqual = (
|
||||
obj1: SimpleObject,
|
||||
obj2: SimpleObject,
|
||||
): boolean => {
|
||||
const obj1Keys = Object.keys(obj1);
|
||||
const obj2Keys = Object.keys(obj2);
|
||||
|
||||
return obj1Keys.length === obj2Keys.length
|
||||
? obj1Keys.every((key) => obj1[key] === obj2[key])
|
||||
: false;
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user