Refactor photo edit page data handling
This commit is contained in:
parent
a0d7048cf9
commit
d6adce8e27
@ -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 }} />
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
|
||||
31
src/photo/PhotoEditPageClient.tsx
Normal file
31
src/photo/PhotoEditPageClient.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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}
|
||||
>
|
||||
|
||||
@ -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
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 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),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
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';
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user