Merge branch 'main' into breadcrumb

This commit is contained in:
Sam Becker 2023-11-02 13:14:31 -05:00
commit 8495bd7d8a
25 changed files with 458 additions and 194 deletions

View File

@ -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}

View File

@ -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>
); );

View File

@ -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',
)}
/>;
} }

View File

@ -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>
); );
}; };

View File

@ -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 &&

View File

@ -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>

View File

@ -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>
); );
}; };

View File

@ -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';

View File

@ -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}

View File

@ -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';

View File

@ -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} />

View File

@ -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>
); );
}; };

View 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>
);
};

View File

@ -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}
> >

View File

@ -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();

View File

@ -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
View 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
View 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>
);
}

View File

@ -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,
},
);
}} }}
/>; />;

View File

@ -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

View File

@ -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
View 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
View 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;
};

View File

@ -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;
}
}
} }
}; };