Refactor photo edit page data handling

This commit is contained in:
Sam Becker 2023-10-30 16:38:13 -05:00
parent a0d7048cf9
commit d6adce8e27
8 changed files with 176 additions and 108 deletions

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 { 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';
interface Props {
export default async function PhotoEditPage({
params: { photoId },
}: {
params: { photoId: string }
}
export default async function PhotoPageEdit({ params: { photoId } }: Props) {
}) {
const photo = await getPhotoCached(photoId);
if (!photo) { redirect(PATH_ADMIN); }
return (
<AdminChildPage
backPath={PATH_ADMIN_PHOTOS}
backLabel="Photos"
breadcrumb={photo.title || photo.id}
>
<PhotoForm
type="edit"
initialPhotoForm={convertPhotoToFormData(photo)}
/>
</AdminChildPage>
<PhotoEditPageClient {...{ photo }} />
);
};

View File

@ -1,66 +1,26 @@
import PhotoForm from '@/photo/PhotoForm';
import { ExifData, ExifParserFactory } from 'ts-exif-parser';
import { convertExifToFormData } from '@/photo/form';
import AdminChildPage from '@/components/AdminChildPage';
import { getExtensionFromBlobUrl, getIdFromBlobUrl } from '@/services/blob';
import { PATH_ADMIN_UPLOADS } from '@/site/paths';
import {
FujifilmSimulation,
getFujifilmSimulationFromMakerNote,
isExifForFujifilm,
} from '@/vendors/fujifilm';
import { extractFormDataFromUploadPath } from '@/photo/server';
interface Params {
params: { uploadPath: string }
}
export default async function UploadPage({ params: { uploadPath } }: Params) {
const url = decodeURIComponent(uploadPath);
const extension = getExtensionFromBlobUrl(url);
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);
}
}
}
const {
blobId,
photoForm,
} = await extractFormDataFromUploadPath(uploadPath);
return (
<AdminChildPage
backPath={PATH_ADMIN_UPLOADS}
backLabel="Uploads"
breadcrumb={getIdFromBlobUrl(url)}
breadcrumb={blobId}
>
{exifDataForm
? <PhotoForm
initialPhotoForm={{
extension,
url: decodeURIComponent(uploadPath),
...convertExifToFormData(exifDataForm, filmSimulation),
}}
/>
{photoForm
? <PhotoForm initialPhotoForm={photoForm} />
: null}
</AdminChildPage>
);

View File

@ -8,39 +8,49 @@ function AdminChildPage({
backPath,
backLabel,
breadcrumb,
accessory,
children,
}: {
backPath?: string
backLabel?: string
breadcrumb?: ReactNode
accessory?: ReactNode
children: ReactNode,
}) {
return (
<SiteGrid
contentMain={
<div className="space-y-6">
{backPath &&
{(backPath || breadcrumb || accessory) &&
<div className={cc(
'flex flex-wrap items-center gap-x-1.5 sm:gap-x-3 gap-y-1',
'h-9',
'flex flex-wrap items-center gap-x-2 gap-y-3',
'min-h-[2.25rem]', // min-h-9 equivalent
)}>
<Link
href={backPath}
className="flex gap-1.5 items-center"
>
<FiArrowLeft size={16} />
{backLabel || 'Back'}
</Link>
{breadcrumb &&
<>
<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 className={cc(
'flex flex-wrap items-center gap-x-1.5 sm:gap-x-3 gap-y-1',
'flex-grow',
)}>
{backPath &&
<Link
href={backPath}
className="flex gap-1.5 items-center"
>
<FiArrowLeft size={16} />
{backLabel || 'Back'}
</Link>}
{breadcrumb &&
<>
<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>
{children}

View File

@ -0,0 +1,31 @@
'use client';
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 { convertPhotoToFormData } from './form';
import PhotoForm from './PhotoForm';
export default function PhotoEditPageClient({
photo,
}: {
photo: Photo
}) {
return (
<AdminChildPage
backPath={PATH_ADMIN_PHOTOS}
backLabel="Photos"
breadcrumb={photo.title || photo.id}
accessory={<SubmitButtonWithStatus icon={<BiRefresh size={18} />}>
Refresh EXIF
</SubmitButtonWithStatus>}
>
<PhotoForm
type="edit"
initialPhotoForm={convertPhotoToFormData(photo)}
/>
</AdminChildPage>
);
};

View File

@ -12,7 +12,7 @@ import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import Link from 'next/link';
import { cc } from '@/utility/css';
import CanvasBlurCapture from '@/components/CanvasBlurCapture';
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
import { PATH_ADMIN_PHOTOS, PATH_ADMIN_UPLOADS } from '@/site/paths';
import {
generateLocalNaivePostgresString,
generateLocalPostgresString,
@ -126,13 +126,12 @@ export default function PhotoForm({
type={checkbox ? 'checkbox' : undefined}
/>)}
<div className="flex gap-3">
{type === 'edit' &&
<Link
className="button"
href={PATH_ADMIN_PHOTOS}
>
Cancel
</Link>}
<Link
className="button"
href={type === 'edit' ? PATH_ADMIN_PHOTOS : PATH_ADMIN_UPLOADS}
>
Cancel
</Link>
<SubmitButtonWithStatus
disabled={!isFormValid}
>

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 {
convertTimestampToNaivePostgresString,
@ -11,6 +11,7 @@ import { generateNanoid } from '@/utility/nanoid';
import {
FILM_SIMULATION_FORM_INPUT_OPTIONS,
FujifilmSimulation,
MAKE_FUJIFILM,
} from '@/vendors/fujifilm';
export type PhotoFormData = Record<keyof PhotoDbInsert, string>;
@ -48,7 +49,7 @@ const FORM_METADATA: Record<keyof PhotoFormData, FormMeta> = {
label: 'fujifilm simulation',
options: FILM_SIMULATION_FORM_INPUT_OPTIONS,
optionsDefaultLabel: 'Unknown',
hideBasedOnCamera: make => make !== 'FUJIFILM',
hideBasedOnCamera: make => make !== MAKE_FUJIFILM,
},
focalLength: { label: 'focal length' },
focalLengthIn35MmFormat: { label: 'focal length 35mm-equivalent' },
@ -59,9 +60,9 @@ const FORM_METADATA: Record<keyof PhotoFormData, FormMeta> = {
locationName: { label: 'location name', hideTemporarily: true },
latitude: { label: 'latitude' },
longitude: { label: 'longitude' },
priorityOrder: { label: 'priority order' },
takenAt: { label: 'taken at' },
takenAtNaive: { label: 'taken at (naive)' },
priorityOrder: { label: 'priority order' },
hidden: { label: 'hidden', checkbox: true },
};

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 extractFormDataFromUploadPath = async (
uploadPath: string
): Promise<{
blobId?: string
photoForm?: Partial<PhotoFormData>
}> => {
const url = decodeURIComponent(uploadPath);
const blobId = getIdFromBlobUrl(url);
const extension = getExtensionFromBlobUrl(url);
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 {
blobId,
...exifDataForm && {
photoForm: {
...convertExifToFormData(exifDataForm, filmSimulation),
extension,
url: decodeURIComponent(uploadPath),
},
},
};
};

View File

@ -3,11 +3,13 @@
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 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_FILM_MODE = 0x1401;
@ -233,16 +235,31 @@ export const getLabelForFilmSimulation = (
const parseFujifilmMakerNote = (
bytes: Buffer,
valueForTag: (tag: number, value: number) => void
valueForTagUInt: (tagId: number, value: number) => void
) => {
for (
let i = BYTE_INDEX_FIRST_TAG;
i + BYTES_PER_TAG < bytes.length;
i += BYTES_PER_TAG
) {
const tag = bytes.readUInt16LE(i);
const value = bytes.readUInt16LE(i + BYTE_OFFSET_FOR_INT_VALUE);
valueForTag(tag, value);
const tagCount = bytes.readUint16LE(BYTE_INDEX_TAG_COUNT);
for (let i = 0; i < tagCount; i++) {
const index = BYTE_INDEX_FIRST_TAG + i * BYTES_PER_TAG;
if (index + BYTES_PER_TAG < bytes.length) {
const tagId = bytes.readUInt16LE(index);
const tagType = bytes.readUInt16LE(index + BYTE_OFFSET_TAG_TYPE);
switch (tagType) {
// 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;
}
}
}
};