Merge branch 'main' into breadcrumb
This commit is contained in:
commit
8495bd7d8a
@ -16,7 +16,7 @@ export default function AdminGrid ({
|
|||||||
<div className="min-w-[14rem] overflow-x-scroll">
|
<div className="min-w-[14rem] overflow-x-scroll">
|
||||||
<div className={cc(
|
<div className={cc(
|
||||||
'w-full',
|
'w-full',
|
||||||
'grid grid-cols-[auto_1fr_auto_auto] ',
|
'grid grid-cols-[auto_1fr_auto] ',
|
||||||
'gap-2 sm:gap-3 items-center',
|
'gap-2 sm:gap-3 items-center',
|
||||||
)}>
|
)}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -41,25 +41,30 @@ export default function BlobUrls({
|
|||||||
>
|
>
|
||||||
{pathForBlobUrl(url)}
|
{pathForBlobUrl(url)}
|
||||||
</Link>
|
</Link>
|
||||||
<EditButton href={href} label="Setup" />
|
<div className={cc(
|
||||||
<FormWithConfirm
|
'flex flex-nowrap',
|
||||||
action={deleteBlobPhotoAction}
|
'gap-2 sm:gap-3 items-center',
|
||||||
confirmText="Are you sure you want to delete this upload?"
|
)}>
|
||||||
>
|
<EditButton href={href} label="Setup" />
|
||||||
<input
|
<FormWithConfirm
|
||||||
type="hidden"
|
action={deleteBlobPhotoAction}
|
||||||
name="redirectToPhotos"
|
confirmText="Are you sure you want to delete this upload?"
|
||||||
value={urls.length < 2 ? 'true' : 'false'}
|
>
|
||||||
readOnly
|
<input
|
||||||
/>
|
type="hidden"
|
||||||
<input
|
name="redirectToPhotos"
|
||||||
type="hidden"
|
value={urls.length < 2 ? 'true' : 'false'}
|
||||||
name="url"
|
readOnly
|
||||||
value={url}
|
/>
|
||||||
readOnly
|
<input
|
||||||
/>
|
type="hidden"
|
||||||
<DeleteButton />
|
name="url"
|
||||||
</FormWithConfirm>
|
value={url}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
<DeleteButton />
|
||||||
|
</FormWithConfirm>
|
||||||
|
</div>
|
||||||
</Fragment>;})}
|
</Fragment>;})}
|
||||||
</AdminGrid>
|
</AdminGrid>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,11 +1,16 @@
|
|||||||
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
|
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 () {
|
export default function DeleteButton () {
|
||||||
return <SubmitButtonWithStatus
|
return <SubmitButtonWithStatus
|
||||||
title="Delete"
|
title="Delete"
|
||||||
icon={<FaTimes size={13} className="translate-y-[1px]" />}
|
icon={<BiTrash size={16} className="translate-y-[-1.5px]" />}
|
||||||
>
|
className={cc(
|
||||||
Delete
|
'text-red-500 dark:text-red-600',
|
||||||
</SubmitButtonWithStatus>;
|
'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',
|
||||||
|
)}
|
||||||
|
/>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,31 +1,20 @@
|
|||||||
import PhotoForm from '@/photo/PhotoForm';
|
|
||||||
import { convertPhotoToFormData } from '@/photo/form';
|
|
||||||
import AdminChildPage from '@/components/AdminChildPage';
|
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { getPhotoCached } from '@/cache';
|
import { getPhotoCached } from '@/cache';
|
||||||
import { PATH_ADMIN, PATH_ADMIN_PHOTOS } from '@/site/paths';
|
import { PATH_ADMIN } from '@/site/paths';
|
||||||
|
import PhotoEditPageClient from '@/photo/PhotoEditPageClient';
|
||||||
|
|
||||||
export const runtime = 'edge';
|
export const runtime = 'edge';
|
||||||
|
|
||||||
interface Props {
|
export default async function PhotoEditPage({
|
||||||
|
params: { photoId },
|
||||||
|
}: {
|
||||||
params: { photoId: string }
|
params: { photoId: string }
|
||||||
}
|
}) {
|
||||||
|
|
||||||
export default async function PhotoPageEdit({ params: { photoId } }: Props) {
|
|
||||||
const photo = await getPhotoCached(photoId);
|
const photo = await getPhotoCached(photoId);
|
||||||
|
|
||||||
if (!photo) { redirect(PATH_ADMIN); }
|
if (!photo) { redirect(PATH_ADMIN); }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminChildPage
|
<PhotoEditPageClient {...{ photo }} />
|
||||||
backPath={PATH_ADMIN_PHOTOS}
|
|
||||||
backLabel="Photos"
|
|
||||||
breadcrumb={photo.title || photo.id}
|
|
||||||
>
|
|
||||||
<PhotoForm
|
|
||||||
type="edit"
|
|
||||||
initialPhotoForm={convertPhotoToFormData(photo)}
|
|
||||||
/>
|
|
||||||
</AdminChildPage>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import PhotoTiny from '@/photo/PhotoTiny';
|
|||||||
import { cc } from '@/utility/css';
|
import { cc } from '@/utility/css';
|
||||||
import FormWithConfirm from '@/components/FormWithConfirm';
|
import FormWithConfirm from '@/components/FormWithConfirm';
|
||||||
import SiteGrid from '@/components/SiteGrid';
|
import SiteGrid from '@/components/SiteGrid';
|
||||||
import { deletePhotoAction } from '@/photo/actions';
|
import { deletePhotoAction, syncPhotoExifDataAction } from '@/photo/actions';
|
||||||
import {
|
import {
|
||||||
pathForAdminPhotos,
|
pathForAdminPhotos,
|
||||||
pathForPhoto,
|
pathForPhoto,
|
||||||
@ -28,6 +28,8 @@ import DeleteButton from '@/admin/DeleteButton';
|
|||||||
import EditButton from '@/admin/EditButton';
|
import EditButton from '@/admin/EditButton';
|
||||||
import BlobUrls from '@/admin/BlobUrls';
|
import BlobUrls from '@/admin/BlobUrls';
|
||||||
import { PRO_MODE_ENABLED } from '@/site/config';
|
import { PRO_MODE_ENABLED } from '@/site/config';
|
||||||
|
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
|
||||||
|
import IconGrSync from '@/site/IconGrSync';
|
||||||
|
|
||||||
export const runtime = 'edge';
|
export const runtime = 'edge';
|
||||||
|
|
||||||
@ -76,11 +78,11 @@ export default async function AdminTagsPage({
|
|||||||
)}
|
)}
|
||||||
photo={photo}
|
photo={photo}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col md:flex-row">
|
<div className="flex flex-col lg:flex-row">
|
||||||
<Link
|
<Link
|
||||||
key={photo.id}
|
key={photo.id}
|
||||||
href={pathForPhoto(photo)}
|
href={pathForPhoto(photo)}
|
||||||
className="sm:w-[50%] flex items-center gap-2"
|
className="lg:w-[50%] flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<span className={cc(
|
<span className={cc(
|
||||||
'inline-flex items-center gap-2',
|
'inline-flex items-center gap-2',
|
||||||
@ -103,23 +105,41 @@ export default async function AdminTagsPage({
|
|||||||
</span>}
|
</span>}
|
||||||
</Link>
|
</Link>
|
||||||
<div className={cc(
|
<div className={cc(
|
||||||
'sm:w-[50%] uppercase',
|
'lg:w-[50%] uppercase',
|
||||||
'text-dim',
|
'text-dim',
|
||||||
)}>
|
)}>
|
||||||
{photo.takenAtNaive}
|
{photo.takenAtNaive}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<EditButton href={pathForAdminPhotoEdit(photo)} />
|
<div className={cc(
|
||||||
<FormWithConfirm
|
'flex flex-nowrap',
|
||||||
action={deletePhotoAction}
|
'gap-2 sm:gap-3 items-center',
|
||||||
confirmText={
|
)}>
|
||||||
// eslint-disable-next-line max-len
|
<EditButton href={pathForAdminPhotoEdit(photo)} />
|
||||||
`Are you sure you want to delete "${titleForPhoto(photo)}?"`}
|
<FormWithConfirm
|
||||||
>
|
action={syncPhotoExifDataAction}
|
||||||
<input type="hidden" name="id" value={photo.id} />
|
confirmText={
|
||||||
<input type="hidden" name="url" value={photo.url} />
|
'Are you sure you want to overwrite EXIF data ' +
|
||||||
<DeleteButton />
|
`for "${titleForPhoto(photo)}" from source file? ` +
|
||||||
</FormWithConfirm>
|
'This action cannot be undone.'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={photo.id} />
|
||||||
|
<SubmitButtonWithStatus
|
||||||
|
icon={<IconGrSync 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>)}
|
</Fragment>)}
|
||||||
</AdminGrid>
|
</AdminGrid>
|
||||||
{showMorePhotos &&
|
{showMorePhotos &&
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import PhotoTag from '@/tag/PhotoTag';
|
|||||||
import { formatTag } from '@/tag';
|
import { formatTag } from '@/tag';
|
||||||
import EditButton from '@/admin/EditButton';
|
import EditButton from '@/admin/EditButton';
|
||||||
import { pathForAdminTagEdit } from '@/site/paths';
|
import { pathForAdminTagEdit } from '@/site/paths';
|
||||||
|
import { cc } from '@/utility/css';
|
||||||
|
|
||||||
export const runtime = 'edge';
|
export const runtime = 'edge';
|
||||||
|
|
||||||
@ -30,16 +31,21 @@ export default async function AdminPhotosPage() {
|
|||||||
<div className="text-dim uppercase">
|
<div className="text-dim uppercase">
|
||||||
{photoQuantityText(count, false)}
|
{photoQuantityText(count, false)}
|
||||||
</div>
|
</div>
|
||||||
<EditButton href={pathForAdminTagEdit(tag)} />
|
<div className={cc(
|
||||||
<FormWithConfirm
|
'flex flex-nowrap',
|
||||||
action={deletePhotoTagGloballyAction}
|
'gap-2 sm:gap-3 items-center',
|
||||||
confirmText={
|
)}>
|
||||||
// eslint-disable-next-line max-len
|
<EditButton href={pathForAdminTagEdit(tag)} />
|
||||||
`Are you sure you want to remove "${formatTag(tag)}?" from ${photoQuantityText(count, false).toLowerCase()}?`}
|
<FormWithConfirm
|
||||||
>
|
action={deletePhotoTagGloballyAction}
|
||||||
<input type="hidden" name="tag" value={tag} />
|
confirmText={
|
||||||
<DeleteButton />
|
// eslint-disable-next-line max-len
|
||||||
</FormWithConfirm>
|
`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>)}
|
</Fragment>)}
|
||||||
</AdminGrid>
|
</AdminGrid>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,67 +1,28 @@
|
|||||||
import PhotoForm from '@/photo/PhotoForm';
|
import PhotoForm from '@/photo/PhotoForm';
|
||||||
import { ExifData, ExifParserFactory } from 'ts-exif-parser';
|
|
||||||
import { convertExifToFormData } from '@/photo/form';
|
|
||||||
import AdminChildPage from '@/components/AdminChildPage';
|
import AdminChildPage from '@/components/AdminChildPage';
|
||||||
import { getExtensionFromBlobUrl, getIdFromBlobUrl } from '@/services/blob';
|
import { PATH_ADMIN, PATH_ADMIN_UPLOADS } from '@/site/paths';
|
||||||
import { PATH_ADMIN_UPLOADS } from '@/site/paths';
|
import { extractExifDataFromBlobPath } from '@/photo/server';
|
||||||
import {
|
import { redirect } from 'next/navigation';
|
||||||
FujifilmSimulation,
|
|
||||||
getFujifilmSimulationFromMakerNote,
|
|
||||||
isExifForFujifilm,
|
|
||||||
} from '@/vendors/fujifilm';
|
|
||||||
|
|
||||||
interface Params {
|
interface Params {
|
||||||
params: { uploadPath: string }
|
params: { uploadPath: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function UploadPage({ params: { uploadPath } }: Params) {
|
export default async function UploadPage({ params: { uploadPath } }: Params) {
|
||||||
const url = decodeURIComponent(uploadPath);
|
const {
|
||||||
|
blobId,
|
||||||
|
photoFormExif,
|
||||||
|
} = await extractExifDataFromBlobPath(uploadPath);
|
||||||
|
|
||||||
const extension = getExtensionFromBlobUrl(url);
|
if (!photoFormExif) { redirect(PATH_ADMIN); }
|
||||||
|
|
||||||
const fileBytes = uploadPath
|
|
||||||
? await fetch(url)
|
|
||||||
.then(res => res.arrayBuffer())
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
let exifDataForm: ExifData | undefined;
|
|
||||||
let filmSimulation: FujifilmSimulation | undefined;
|
|
||||||
|
|
||||||
if (fileBytes) {
|
|
||||||
const parser = ExifParserFactory.create(Buffer.from(fileBytes));
|
|
||||||
|
|
||||||
// Data for form
|
|
||||||
parser.enableBinaryFields(false);
|
|
||||||
exifDataForm = parser.parse();
|
|
||||||
|
|
||||||
// Capture film simulation for Fujifilm cameras
|
|
||||||
if (isExifForFujifilm(exifDataForm)) {
|
|
||||||
// Parse exif data again with binary fields
|
|
||||||
// in order to access MakerNote tag
|
|
||||||
parser.enableBinaryFields(true);
|
|
||||||
const exifDataBinary = parser.parse();
|
|
||||||
const makerNote = exifDataBinary.tags?.MakerNote;
|
|
||||||
if (Buffer.isBuffer(makerNote)) {
|
|
||||||
filmSimulation = getFujifilmSimulationFromMakerNote(makerNote);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminChildPage
|
<AdminChildPage
|
||||||
backPath={PATH_ADMIN_UPLOADS}
|
backPath={PATH_ADMIN_UPLOADS}
|
||||||
backLabel="Uploads"
|
backLabel="Uploads"
|
||||||
breadcrumb={getIdFromBlobUrl(url)}
|
breadcrumb={blobId}
|
||||||
>
|
>
|
||||||
{exifDataForm
|
<PhotoForm initialPhotoForm={photoFormExif} />
|
||||||
? <PhotoForm
|
|
||||||
initialPhotoForm={{
|
|
||||||
extension,
|
|
||||||
url: decodeURIComponent(uploadPath),
|
|
||||||
...convertExifToFormData(exifDataForm, filmSimulation),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
: null}
|
|
||||||
</AdminChildPage>
|
</AdminChildPage>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { BASE_URL, SITE_DESCRIPTION, SITE_TITLE } from '@/site/config';
|
|||||||
import StateProvider from '@/state/AppStateProvider';
|
import StateProvider from '@/state/AppStateProvider';
|
||||||
import ThemeProviderClient from '@/site/ThemeProviderClient';
|
import ThemeProviderClient from '@/site/ThemeProviderClient';
|
||||||
import Nav from '@/site/Nav';
|
import Nav from '@/site/Nav';
|
||||||
import ToasterWithThemes from '@/components/ToasterWithThemes';
|
import ToasterWithThemes from '@/toast/ToasterWithThemes';
|
||||||
import PhotoEscapeHandler from '@/photo/PhotoEscapeHandler';
|
import PhotoEscapeHandler from '@/photo/PhotoEscapeHandler';
|
||||||
|
|
||||||
import '../site/globals.css';
|
import '../site/globals.css';
|
||||||
|
|||||||
@ -8,39 +8,49 @@ function AdminChildPage({
|
|||||||
backPath,
|
backPath,
|
||||||
backLabel,
|
backLabel,
|
||||||
breadcrumb,
|
breadcrumb,
|
||||||
|
accessory,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
backPath?: string
|
backPath?: string
|
||||||
backLabel?: string
|
backLabel?: string
|
||||||
breadcrumb?: ReactNode
|
breadcrumb?: ReactNode
|
||||||
|
accessory?: ReactNode
|
||||||
children: ReactNode,
|
children: ReactNode,
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SiteGrid
|
<SiteGrid
|
||||||
contentMain={
|
contentMain={
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{backPath &&
|
{(backPath || breadcrumb || accessory) &&
|
||||||
<div className={cc(
|
<div className={cc(
|
||||||
'flex flex-wrap items-center gap-x-1.5 sm:gap-x-3 gap-y-1',
|
'flex flex-wrap items-center gap-x-2 gap-y-3',
|
||||||
'h-9',
|
'min-h-[2.25rem]', // min-h-9 equivalent
|
||||||
)}>
|
)}>
|
||||||
<Link
|
<div className={cc(
|
||||||
href={backPath}
|
'flex flex-wrap items-center gap-x-1.5 sm:gap-x-3 gap-y-1',
|
||||||
className="flex gap-1.5 items-center"
|
'flex-grow',
|
||||||
>
|
)}>
|
||||||
<FiArrowLeft size={16} />
|
{backPath &&
|
||||||
{backLabel || 'Back'}
|
<Link
|
||||||
</Link>
|
href={backPath}
|
||||||
{breadcrumb &&
|
className="flex gap-1.5 items-center"
|
||||||
<>
|
>
|
||||||
<span>/</span>
|
<FiArrowLeft size={16} />
|
||||||
<span className={cc(
|
{backLabel || 'Back'}
|
||||||
'py-0.5 px-2 rounded-md bg-gray-100 dark:bg-gray-900',
|
</Link>}
|
||||||
'border border-gray-200 dark:border-gray-800'
|
{breadcrumb &&
|
||||||
)}>
|
<>
|
||||||
{breadcrumb}
|
<span>/</span>
|
||||||
</span>
|
<span className={cc(
|
||||||
</>}
|
'py-0.5 px-2 rounded-md bg-gray-100 dark:bg-gray-900',
|
||||||
|
'border border-gray-200 dark:border-gray-800'
|
||||||
|
)}>
|
||||||
|
{breadcrumb}
|
||||||
|
</span>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
{accessory &&
|
||||||
|
<div>{accessory}</div>}
|
||||||
</div>}
|
</div>}
|
||||||
<div>
|
<div>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { LegacyRef } from 'react';
|
import { LegacyRef } from 'react';
|
||||||
// @ts-ignore
|
|
||||||
import { useFormStatus } from 'react-dom';
|
import { useFormStatus } from 'react-dom';
|
||||||
import Spinner from './Spinner';
|
import Spinner from './Spinner';
|
||||||
import { cc } from '@/utility/css';
|
import { cc } from '@/utility/css';
|
||||||
|
|||||||
@ -4,10 +4,9 @@ import Modal from '@/components/Modal';
|
|||||||
import { TbPhotoShare } from 'react-icons/tb';
|
import { TbPhotoShare } from 'react-icons/tb';
|
||||||
import { cc } from '@/utility/css';
|
import { cc } from '@/utility/css';
|
||||||
import { BiCopy } from 'react-icons/bi';
|
import { BiCopy } from 'react-icons/bi';
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { FiCheckSquare } from 'react-icons/fi';
|
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { shortenUrl } from '@/utility/url';
|
import { shortenUrl } from '@/utility/url';
|
||||||
|
import { toastSuccess } from '@/toast';
|
||||||
|
|
||||||
export default function ShareModal({
|
export default function ShareModal({
|
||||||
title = 'Share',
|
title = 'Share',
|
||||||
@ -52,10 +51,7 @@ export default function ShareModal({
|
|||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(pathShare);
|
navigator.clipboard.writeText(pathShare);
|
||||||
toast(
|
toastSuccess('Link to photo copied');
|
||||||
'Link to photo copied',
|
|
||||||
{ icon: <FiCheckSquare size={16} /> },
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BiCopy size={18} />
|
<BiCopy size={18} />
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { HTMLProps } from 'react';
|
import { HTMLProps } from 'react';
|
||||||
// @ts-ignore
|
|
||||||
import { useFormStatus } from 'react-dom';
|
import { useFormStatus } from 'react-dom';
|
||||||
import Spinner from './Spinner';
|
import Spinner from './Spinner';
|
||||||
import { cc } from '@/utility/css';
|
import { cc } from '@/utility/css';
|
||||||
@ -47,11 +46,11 @@ export default function SubmitButtonWithStatus(props: Props) {
|
|||||||
? <Spinner size={14} />
|
? <Spinner size={14} />
|
||||||
: icon}
|
: icon}
|
||||||
</span>}
|
</span>}
|
||||||
<span className={cc(
|
{children && <span className={cc(
|
||||||
icon !== undefined && 'hidden sm:inline-block',
|
icon !== undefined && 'hidden sm:inline-block',
|
||||||
)}>
|
)}>
|
||||||
{children}
|
{children}
|
||||||
</span>
|
</span>}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
57
src/photo/PhotoEditPageClient.tsx
Normal file
57
src/photo/PhotoEditPageClient.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import AdminChildPage from '@/components/AdminChildPage';
|
||||||
|
import { Photo } from '.';
|
||||||
|
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
|
||||||
|
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
|
||||||
|
import { PhotoFormData, convertPhotoToFormData } from './form';
|
||||||
|
import PhotoForm from './PhotoForm';
|
||||||
|
import { useFormState } from 'react-dom';
|
||||||
|
import { getExifDataAction } from './actions';
|
||||||
|
import { areSimpleObjectsEqual } from '@/utility/object';
|
||||||
|
import IconGrSync from '@/site/IconGrSync';
|
||||||
|
|
||||||
|
export default function PhotoEditPageClient({
|
||||||
|
photo,
|
||||||
|
}: {
|
||||||
|
photo: Photo
|
||||||
|
}) {
|
||||||
|
const seedExifData = { url: photo.url };
|
||||||
|
|
||||||
|
const [updatedExifData, action] = useFormState<Partial<PhotoFormData>>(
|
||||||
|
getExifDataAction,
|
||||||
|
seedExifData,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasExifDataBeenFound = !areSimpleObjectsEqual(
|
||||||
|
updatedExifData,
|
||||||
|
seedExifData,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log({ hasExifDataBeenFound });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminChildPage
|
||||||
|
backPath={PATH_ADMIN_PHOTOS}
|
||||||
|
backLabel="Photos"
|
||||||
|
breadcrumb={photo.title || photo.id}
|
||||||
|
accessory={
|
||||||
|
<form action={action}>
|
||||||
|
<input name="photoUrl" value={photo.url} hidden readOnly />
|
||||||
|
<SubmitButtonWithStatus
|
||||||
|
icon={<IconGrSync className="translate-y-[0.5px] mr-[4px]"/>}
|
||||||
|
>
|
||||||
|
EXIF
|
||||||
|
</SubmitButtonWithStatus>
|
||||||
|
</form>}
|
||||||
|
>
|
||||||
|
<PhotoForm
|
||||||
|
type="edit"
|
||||||
|
initialPhotoForm={convertPhotoToFormData(photo)}
|
||||||
|
updatedExifData={hasExifDataBeenFound
|
||||||
|
? updatedExifData
|
||||||
|
: undefined}
|
||||||
|
/>
|
||||||
|
</AdminChildPage>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -12,27 +12,61 @@ import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { cc } from '@/utility/css';
|
import { cc } from '@/utility/css';
|
||||||
import CanvasBlurCapture from '@/components/CanvasBlurCapture';
|
import CanvasBlurCapture from '@/components/CanvasBlurCapture';
|
||||||
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
|
import { PATH_ADMIN_PHOTOS, PATH_ADMIN_UPLOADS } from '@/site/paths';
|
||||||
import {
|
import {
|
||||||
generateLocalNaivePostgresString,
|
generateLocalNaivePostgresString,
|
||||||
generateLocalPostgresString,
|
generateLocalPostgresString,
|
||||||
} from '@/utility/date';
|
} from '@/utility/date';
|
||||||
|
import { toastSuccess, toastWarning } from '@/toast';
|
||||||
|
|
||||||
const THUMBNAIL_WIDTH = 300;
|
const THUMBNAIL_WIDTH = 300;
|
||||||
const THUMBNAIL_HEIGHT = 200;
|
const THUMBNAIL_HEIGHT = 200;
|
||||||
|
|
||||||
export default function PhotoForm({
|
export default function PhotoForm({
|
||||||
initialPhotoForm,
|
initialPhotoForm,
|
||||||
|
updatedExifData,
|
||||||
type = 'create',
|
type = 'create',
|
||||||
debugBlur,
|
debugBlur,
|
||||||
}: {
|
}: {
|
||||||
initialPhotoForm: Partial<PhotoFormData>
|
initialPhotoForm: Partial<PhotoFormData>
|
||||||
|
updatedExifData?: Partial<PhotoFormData>
|
||||||
type?: 'create' | 'edit'
|
type?: 'create' | 'edit'
|
||||||
debugBlur?: boolean
|
debugBlur?: boolean
|
||||||
}) {
|
}) {
|
||||||
const [formData, setFormData] =
|
const [formData, setFormData] =
|
||||||
useState<Partial<PhotoFormData>>(initialPhotoForm);
|
useState<Partial<PhotoFormData>>(initialPhotoForm);
|
||||||
|
|
||||||
|
// Update form when EXIF data
|
||||||
|
// is refreshed by parent
|
||||||
|
useEffect(() => {
|
||||||
|
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
|
// Generate local date strings when
|
||||||
// none can be harvested from EXIF
|
// none can be harvested from EXIF
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -126,13 +160,12 @@ export default function PhotoForm({
|
|||||||
type={checkbox ? 'checkbox' : undefined}
|
type={checkbox ? 'checkbox' : undefined}
|
||||||
/>)}
|
/>)}
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{type === 'edit' &&
|
<Link
|
||||||
<Link
|
className="button"
|
||||||
className="button"
|
href={type === 'edit' ? PATH_ADMIN_PHOTOS : PATH_ADMIN_UPLOADS}
|
||||||
href={PATH_ADMIN_PHOTOS}
|
>
|
||||||
>
|
Cancel
|
||||||
Cancel
|
</Link>
|
||||||
</Link>}
|
|
||||||
<SubmitButtonWithStatus
|
<SubmitButtonWithStatus
|
||||||
disabled={!isFormValid}
|
disabled={!isFormValid}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -6,8 +6,13 @@ import {
|
|||||||
sqlDeletePhotoTagGlobally,
|
sqlDeletePhotoTagGlobally,
|
||||||
sqlUpdatePhoto,
|
sqlUpdatePhoto,
|
||||||
sqlRenamePhotoTagGlobally,
|
sqlRenamePhotoTagGlobally,
|
||||||
|
getPhoto,
|
||||||
} from '@/services/postgres';
|
} from '@/services/postgres';
|
||||||
import { convertFormDataToPhoto } from './form';
|
import {
|
||||||
|
PhotoFormData,
|
||||||
|
convertFormDataToPhotoDbInsert,
|
||||||
|
convertPhotoToFormData,
|
||||||
|
} from './form';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
convertUploadToPhoto,
|
convertUploadToPhoto,
|
||||||
@ -20,9 +25,10 @@ import {
|
|||||||
revalidatePhotosKey,
|
revalidatePhotosKey,
|
||||||
} from '@/cache';
|
} from '@/cache';
|
||||||
import { PATH_ADMIN_PHOTOS, PATH_ADMIN_TAGS } from '@/site/paths';
|
import { PATH_ADMIN_PHOTOS, PATH_ADMIN_TAGS } from '@/site/paths';
|
||||||
|
import { extractExifDataFromBlobPath } from './server';
|
||||||
|
|
||||||
export async function createPhotoAction(formData: FormData) {
|
export async function createPhotoAction(formData: FormData) {
|
||||||
const photo = convertFormDataToPhoto(formData, true);
|
const photo = convertFormDataToPhotoDbInsert(formData, true);
|
||||||
|
|
||||||
const updatedUrl = await convertUploadToPhoto(photo.url, photo.id);
|
const updatedUrl = await convertUploadToPhoto(photo.url, photo.id);
|
||||||
|
|
||||||
@ -36,7 +42,7 @@ export async function createPhotoAction(formData: FormData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updatePhotoAction(formData: FormData) {
|
export async function updatePhotoAction(formData: FormData) {
|
||||||
const photo = convertFormDataToPhoto(formData);
|
const photo = convertFormDataToPhotoDbInsert(formData);
|
||||||
|
|
||||||
await sqlUpdatePhoto(photo);
|
await sqlUpdatePhoto(photo);
|
||||||
|
|
||||||
@ -84,7 +90,38 @@ export async function deleteBlobPhotoAction(formData: FormData) {
|
|||||||
if (formData.get('redirectToPhotos') === 'true') {
|
if (formData.get('redirectToPhotos') === 'true') {
|
||||||
redirect(PATH_ADMIN_PHOTOS);
|
redirect(PATH_ADMIN_PHOTOS);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
|
export async function getExifDataAction(
|
||||||
|
photoFormPrevious: Partial<PhotoFormData>,
|
||||||
|
): Promise<Partial<PhotoFormData>> {
|
||||||
|
const { url } = photoFormPrevious;
|
||||||
|
if (url) {
|
||||||
|
const { photoFormExif } = await extractExifDataFromBlobPath(url);
|
||||||
|
if (photoFormExif) {
|
||||||
|
return photoFormExif;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncPhotoExifDataAction(formData: FormData) {
|
||||||
|
const photoId = formData.get('id') as string;
|
||||||
|
if (photoId) {
|
||||||
|
const photo = await getPhoto(photoId);
|
||||||
|
if (photo) {
|
||||||
|
const { photoFormExif } = await extractExifDataFromBlobPath(photo.url);
|
||||||
|
if (photoFormExif) {
|
||||||
|
const photoFormDbInsert = convertFormDataToPhotoDbInsert({
|
||||||
|
...convertPhotoToFormData(photo),
|
||||||
|
...photoFormExif,
|
||||||
|
});
|
||||||
|
await sqlUpdatePhoto(photoFormDbInsert);
|
||||||
|
revalidatePhotosKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function syncCacheAction() {
|
export async function syncCacheAction() {
|
||||||
revalidateAllKeysAndPaths();
|
revalidateAllKeysAndPaths();
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { ExifData } from 'ts-exif-parser';
|
import type { ExifData } from 'ts-exif-parser';
|
||||||
import { Photo, PhotoDbInsert, PhotoExif } from '.';
|
import { Photo, PhotoDbInsert, PhotoExif } from '.';
|
||||||
import {
|
import {
|
||||||
convertTimestampToNaivePostgresString,
|
convertTimestampToNaivePostgresString,
|
||||||
@ -11,6 +11,7 @@ import { generateNanoid } from '@/utility/nanoid';
|
|||||||
import {
|
import {
|
||||||
FILM_SIMULATION_FORM_INPUT_OPTIONS,
|
FILM_SIMULATION_FORM_INPUT_OPTIONS,
|
||||||
FujifilmSimulation,
|
FujifilmSimulation,
|
||||||
|
MAKE_FUJIFILM,
|
||||||
} from '@/vendors/fujifilm';
|
} from '@/vendors/fujifilm';
|
||||||
|
|
||||||
export type PhotoFormData = Record<keyof PhotoDbInsert, string>;
|
export type PhotoFormData = Record<keyof PhotoDbInsert, string>;
|
||||||
@ -48,7 +49,7 @@ const FORM_METADATA: Record<keyof PhotoFormData, FormMeta> = {
|
|||||||
label: 'fujifilm simulation',
|
label: 'fujifilm simulation',
|
||||||
options: FILM_SIMULATION_FORM_INPUT_OPTIONS,
|
options: FILM_SIMULATION_FORM_INPUT_OPTIONS,
|
||||||
optionsDefaultLabel: 'Unknown',
|
optionsDefaultLabel: 'Unknown',
|
||||||
hideBasedOnCamera: make => make !== 'FUJIFILM',
|
hideBasedOnCamera: make => make !== MAKE_FUJIFILM,
|
||||||
},
|
},
|
||||||
focalLength: { label: 'focal length' },
|
focalLength: { label: 'focal length' },
|
||||||
focalLengthIn35MmFormat: { label: 'focal length 35mm-equivalent' },
|
focalLengthIn35MmFormat: { label: 'focal length 35mm-equivalent' },
|
||||||
@ -59,9 +60,9 @@ const FORM_METADATA: Record<keyof PhotoFormData, FormMeta> = {
|
|||||||
locationName: { label: 'location name', hideTemporarily: true },
|
locationName: { label: 'location name', hideTemporarily: true },
|
||||||
latitude: { label: 'latitude' },
|
latitude: { label: 'latitude' },
|
||||||
longitude: { label: 'longitude' },
|
longitude: { label: 'longitude' },
|
||||||
priorityOrder: { label: 'priority order' },
|
|
||||||
takenAt: { label: 'taken at' },
|
takenAt: { label: 'taken at' },
|
||||||
takenAtNaive: { label: 'taken at (naive)' },
|
takenAtNaive: { label: 'taken at (naive)' },
|
||||||
|
priorityOrder: { label: 'priority order' },
|
||||||
hidden: { label: 'hidden', checkbox: true },
|
hidden: { label: 'hidden', checkbox: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -122,11 +123,13 @@ export const convertExifToFormData = (
|
|||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const convertFormDataToPhoto = (
|
export const convertFormDataToPhotoDbInsert = (
|
||||||
formData: FormData,
|
formData: FormData | PhotoFormData,
|
||||||
generateId?: boolean,
|
generateId?: boolean,
|
||||||
): PhotoDbInsert => {
|
): PhotoDbInsert => {
|
||||||
const photoForm = Object.fromEntries(formData) as PhotoFormData;
|
const photoForm = formData instanceof FormData
|
||||||
|
? Object.fromEntries(formData) as PhotoFormData
|
||||||
|
: formData;
|
||||||
|
|
||||||
// Parse FormData:
|
// Parse FormData:
|
||||||
// - remove server action ID
|
// - remove server action ID
|
||||||
|
|||||||
61
src/photo/server.ts
Normal file
61
src/photo/server.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { getExtensionFromBlobUrl, getIdFromBlobUrl } from '@/services/blob';
|
||||||
|
import { convertExifToFormData } from '@/photo/form';
|
||||||
|
import {
|
||||||
|
FujifilmSimulation,
|
||||||
|
getFujifilmSimulationFromMakerNote,
|
||||||
|
isExifForFujifilm,
|
||||||
|
} from '@/vendors/fujifilm';
|
||||||
|
import { ExifData, ExifParserFactory } from 'ts-exif-parser';
|
||||||
|
import { PhotoFormData } from './form';
|
||||||
|
|
||||||
|
export const extractExifDataFromBlobPath = async (
|
||||||
|
blobPath: string
|
||||||
|
): Promise<{
|
||||||
|
blobId?: string
|
||||||
|
photoFormExif?: Partial<PhotoFormData>
|
||||||
|
}> => {
|
||||||
|
const url = decodeURIComponent(blobPath);
|
||||||
|
|
||||||
|
const blobId = getIdFromBlobUrl(url);
|
||||||
|
|
||||||
|
const extension = getExtensionFromBlobUrl(url);
|
||||||
|
|
||||||
|
const fileBytes = blobPath
|
||||||
|
? await fetch(url)
|
||||||
|
.then(res => res.arrayBuffer())
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
let exifData: ExifData | undefined;
|
||||||
|
let filmSimulation: FujifilmSimulation | undefined;
|
||||||
|
|
||||||
|
if (fileBytes) {
|
||||||
|
const parser = ExifParserFactory.create(Buffer.from(fileBytes));
|
||||||
|
|
||||||
|
// Data for form
|
||||||
|
parser.enableBinaryFields(false);
|
||||||
|
exifData = parser.parse();
|
||||||
|
|
||||||
|
// Capture film simulation for Fujifilm cameras
|
||||||
|
if (isExifForFujifilm(exifData)) {
|
||||||
|
// Parse exif data again with binary fields
|
||||||
|
// in order to access MakerNote tag
|
||||||
|
parser.enableBinaryFields(true);
|
||||||
|
const exifDataBinary = parser.parse();
|
||||||
|
const makerNote = exifDataBinary.tags?.MakerNote;
|
||||||
|
if (Buffer.isBuffer(makerNote)) {
|
||||||
|
filmSimulation = getFujifilmSimulationFromMakerNote(makerNote);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
blobId,
|
||||||
|
...exifData && {
|
||||||
|
photoFormExif: {
|
||||||
|
...convertExifToFormData(exifData, filmSimulation),
|
||||||
|
extension,
|
||||||
|
url,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
26
src/site/IconGrSync.tsx
Normal file
26
src/site/IconGrSync.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
export default function IconGrSync({
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="currentColor"
|
||||||
|
className={className}
|
||||||
|
strokeWidth="0"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
height="15"
|
||||||
|
width="15"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
// eslint-disable-next-line max-len
|
||||||
|
d="M5,19 L16,19 C19.866,19 23,15.866 23,12 L23,9 M8,15 L4,19 L8,23 M19,5 L8,5 C4.134,5 1,8.134 1,12 L1,15 M16,1 L20,5 L16,9"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,7 +4,7 @@ import { useTransition } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { cc } from '@/utility/css';
|
import { cc } from '@/utility/css';
|
||||||
import ChecklistRow from '../components/ChecklistRow';
|
import ChecklistRow from '../components/ChecklistRow';
|
||||||
import { FiCheckSquare, FiExternalLink } from 'react-icons/fi';
|
import { FiExternalLink } from 'react-icons/fi';
|
||||||
import {
|
import {
|
||||||
BiCog,
|
BiCog,
|
||||||
BiCopy,
|
BiCopy,
|
||||||
@ -14,9 +14,9 @@ import {
|
|||||||
BiRefresh,
|
BiRefresh,
|
||||||
} from 'react-icons/bi';
|
} from 'react-icons/bi';
|
||||||
import IconButton from '@/components/IconButton';
|
import IconButton from '@/components/IconButton';
|
||||||
import { toast } from 'sonner';
|
|
||||||
import InfoBlock from '@/components/InfoBlock';
|
import InfoBlock from '@/components/InfoBlock';
|
||||||
import Checklist from '@/components/Checklist';
|
import Checklist from '@/components/Checklist';
|
||||||
|
import { toastSuccess } from '@/toast';
|
||||||
|
|
||||||
export default function SiteChecklistClient({
|
export default function SiteChecklistClient({
|
||||||
hasPostgres,
|
hasPostgres,
|
||||||
@ -84,12 +84,7 @@ export default function SiteChecklistClient({
|
|||||||
className={cc(subtle && 'text-gray-300 dark:text-gray-700')}
|
className={cc(subtle && 'text-gray-300 dark:text-gray-700')}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(text);
|
navigator.clipboard.writeText(text);
|
||||||
toast(
|
toastSuccess(`${label} copied to clipboard`);
|
||||||
`${label} copied to clipboard`, {
|
|
||||||
icon: <FiCheckSquare size={16} />,
|
|
||||||
duration: 4000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
/>;
|
/>;
|
||||||
|
|
||||||
|
|||||||
@ -25,7 +25,7 @@
|
|||||||
bg-white dark:bg-black
|
bg-white dark:bg-black
|
||||||
border-gray-200 dark:border-gray-700
|
border-gray-200 dark:border-gray-700
|
||||||
font-mono text-base leading-tight
|
font-mono text-base leading-tight
|
||||||
min-h-[2.25rem]
|
min-h-[2.4rem]
|
||||||
}
|
}
|
||||||
input[type=text], input[type=email], input[type=password], select {
|
input[type=text], input[type=email], input[type=password], select {
|
||||||
@apply
|
@apply
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import PhotoTag from './PhotoTag';
|
|||||||
import { descriptionForTaggedPhotos } from '.';
|
import { descriptionForTaggedPhotos } from '.';
|
||||||
import { pathForTagShare } from '@/site/paths';
|
import { pathForTagShare } from '@/site/paths';
|
||||||
import PhotoHeader from '@/photo/PhotoHeader';
|
import PhotoHeader from '@/photo/PhotoHeader';
|
||||||
|
import AnimateItems from '@/components/AnimateItems';
|
||||||
|
|
||||||
export default function TagHeader({
|
export default function TagHeader({
|
||||||
tag,
|
tag,
|
||||||
@ -16,14 +17,19 @@ export default function TagHeader({
|
|||||||
count?: number
|
count?: number
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<PhotoHeader
|
<AnimateItems
|
||||||
entity={<PhotoTag tag={tag} />}
|
type="bottom"
|
||||||
entityVerb="Tagged"
|
distanceOffset={10}
|
||||||
entityDescription={descriptionForTaggedPhotos(photos, undefined, count)}
|
items={[<PhotoHeader
|
||||||
photos={photos}
|
key="PhotoHeader"
|
||||||
selectedPhoto={selectedPhoto}
|
entity={<PhotoTag tag={tag} />}
|
||||||
sharePath={pathForTagShare(tag)}
|
entityVerb="Tagged"
|
||||||
count={count}
|
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;
|
||||||
|
};
|
||||||
39
src/vendors/fujifilm/index.ts
vendored
39
src/vendors/fujifilm/index.ts
vendored
@ -3,11 +3,13 @@
|
|||||||
|
|
||||||
import type { ExifData } from 'ts-exif-parser';
|
import type { ExifData } from 'ts-exif-parser';
|
||||||
|
|
||||||
const MAKE_FUJIFILM = 'FUJIFILM';
|
export const MAKE_FUJIFILM = 'FUJIFILM';
|
||||||
|
|
||||||
|
const BYTE_INDEX_TAG_COUNT = 12;
|
||||||
const BYTE_INDEX_FIRST_TAG = 14;
|
const BYTE_INDEX_FIRST_TAG = 14;
|
||||||
const BYTES_PER_TAG = 12;
|
const BYTES_PER_TAG = 12;
|
||||||
const BYTE_OFFSET_FOR_INT_VALUE = 8;
|
const BYTE_OFFSET_TAG_TYPE = 2;
|
||||||
|
const BYTE_OFFSET_TAG_VALUE = 8;
|
||||||
|
|
||||||
const TAG_ID_SATURATION = 0x1003;
|
const TAG_ID_SATURATION = 0x1003;
|
||||||
const TAG_ID_FILM_MODE = 0x1401;
|
const TAG_ID_FILM_MODE = 0x1401;
|
||||||
@ -233,16 +235,31 @@ export const getLabelForFilmSimulation = (
|
|||||||
|
|
||||||
const parseFujifilmMakerNote = (
|
const parseFujifilmMakerNote = (
|
||||||
bytes: Buffer,
|
bytes: Buffer,
|
||||||
valueForTag: (tag: number, value: number) => void
|
valueForTagUInt: (tagId: number, value: number) => void
|
||||||
) => {
|
) => {
|
||||||
for (
|
const tagCount = bytes.readUint16LE(BYTE_INDEX_TAG_COUNT);
|
||||||
let i = BYTE_INDEX_FIRST_TAG;
|
for (let i = 0; i < tagCount; i++) {
|
||||||
i + BYTES_PER_TAG < bytes.length;
|
const index = BYTE_INDEX_FIRST_TAG + i * BYTES_PER_TAG;
|
||||||
i += BYTES_PER_TAG
|
if (index + BYTES_PER_TAG < bytes.length) {
|
||||||
) {
|
const tagId = bytes.readUInt16LE(index);
|
||||||
const tag = bytes.readUInt16LE(i);
|
const tagType = bytes.readUInt16LE(index + BYTE_OFFSET_TAG_TYPE);
|
||||||
const value = bytes.readUInt16LE(i + BYTE_OFFSET_FOR_INT_VALUE);
|
switch (tagType) {
|
||||||
valueForTag(tag, value);
|
// UInt16
|
||||||
|
case 3:
|
||||||
|
valueForTagUInt(
|
||||||
|
tagId,
|
||||||
|
bytes.readUInt16LE(index + BYTE_OFFSET_TAG_VALUE),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
// UInt32
|
||||||
|
case 4:
|
||||||
|
valueForTagUInt(
|
||||||
|
tagId,
|
||||||
|
bytes.readUInt32LE(index + BYTE_OFFSET_TAG_VALUE),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user