Merge pull request #232 from sambecker/shrink-client-bundle

Shrink client bundle 30%
This commit is contained in:
Sam Becker 2025-04-04 20:21:05 -05:00 committed by GitHub
commit bf953699fd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 312 additions and 287 deletions

View File

@ -1 +1 @@
export { GET, POST } from '@/auth';
export { GET, POST } from '@/auth/server';

View File

@ -1,4 +1,4 @@
import { auth } from '@/auth';
import { auth } from '@/auth/server';
import {
awsS3Client,
awsS3PutObjectCommandForKey,

View File

@ -1,4 +1,4 @@
import { auth } from '@/auth';
import { auth } from '@/auth/server';
import { revalidateAdminPaths, revalidatePhotosKey } from '@/photo/cache';
import {
ACCEPTED_PHOTO_FILE_TYPES,

View File

@ -9,8 +9,8 @@ import { cache } from 'react';
import { getPhotos, getPhotosMeta } from '@/photo/db/query';
import { GRID_HOMEPAGE_ENABLED } from '@/app/config';
import { getDataForCategories } from '@/category/data';
import PhotoGridPage from '@/photo/PhotoGridPage';
import PhotoFeedPage from '@/photo/PhotoFeedPage';
import PhotoGridPage from '@/photo/PhotoGridPage';
export const dynamic = 'force-static';
export const maxDuration = 60;

View File

@ -1,4 +1,4 @@
import { auth } from '@/auth';
import { auth } from '@/auth/server';
import SignInForm from '@/auth/SignInForm';
import { PATH_ADMIN, PATH_ROOT } from '@/app/paths';
import { clsx } from 'clsx/lite';

View File

@ -1,4 +1,4 @@
import { auth } from './src/auth';
import { auth } from './src/auth/server';
import { NextRequest, NextResponse } from 'next/server';
import type { NextApiRequest, NextApiResponse } from 'next';
import {

View File

@ -14,7 +14,7 @@ import {
} from 'react-icons/bi';
import { HiOutlineCog } from 'react-icons/hi';
import ChecklistGroup from '@/components/ChecklistGroup';
import { ConfigChecklistStatus } from '../app/config';
import { AppConfiguration } from '../app/config';
import StatusIcon from '@/components/StatusIcon';
import { labelForStorage } from '@/platforms/storage';
import { HiSparkles } from 'react-icons/hi';
@ -109,7 +109,7 @@ export default function AdminAppConfigurationClient({
// Component props
simplifiedView,
isAnalyzingConfiguration,
}: ConfigChecklistStatus &
}: AppConfiguration &
Partial<Awaited<ReturnType<typeof testConnectionsAction>>> & {
simplifiedView?: boolean
isAnalyzingConfiguration?: boolean

View File

@ -1,6 +1,6 @@
'use server';
import { runAuthenticatedAdminServerAction } from '@/auth';
import { runAuthenticatedAdminServerAction } from '@/auth/server';
import { testRedisConnection } from '@/platforms/redis';
import { testOpenAiConnection } from '@/platforms/openai';
import { testDatabaseConnection } from '@/platforms/postgres';

View File

@ -317,9 +317,7 @@ export default function AdminAppInsightsClient({
/>}
content="Speed up page load times"
expandContent={<>
Improve load times by enabling static optimization
{' '}
on:
Improve load times by enabling static optimization:
<div className="flex flex-col gap-y-4 mt-3">
{renderLabeledEnvVar(
'Photo pages',

View File

@ -399,11 +399,11 @@ export const APP_CONFIGURATION = {
commitUrl: VERCEL_GIT_COMMIT_URL,
};
export type ConfigChecklistStatus = typeof APP_CONFIGURATION;
export const IS_SITE_READY =
APP_CONFIGURATION.hasDatabase &&
APP_CONFIGURATION.hasStorageProvider &&
APP_CONFIGURATION.hasAuthSecret &&
APP_CONFIGURATION.hasAdminUser;
export type AppConfiguration = typeof APP_CONFIGURATION;

View File

@ -1,18 +1,20 @@
'use server';
import {
auth,
signIn,
signOut,
} from '@/auth/server';
import type { Session } from 'next-auth';
import { redirect } from 'next/navigation';
import {
generateAuthSecret,
KEY_CALLBACK_URL,
KEY_CREDENTIALS_CALLBACK_ROUTE_ERROR_URL,
KEY_CREDENTIALS_SIGN_IN_ERROR,
KEY_CREDENTIALS_SIGN_IN_ERROR_URL,
KEY_CREDENTIALS_SUCCESS,
auth,
generateAuthSecret,
signIn,
signOut,
} from '@/auth';
import type { Session } from 'next-auth';
import { redirect } from 'next/navigation';
} from '.';
export const signInAction = async (
_prevState: string | undefined,

View File

@ -1,4 +1,4 @@
import { cache } from 'react';
import { auth } from '@/auth';
import { auth } from '@/auth/server';
export const authCachedSafe = cache(() => auth().catch(() => null));

View File

@ -1,12 +0,0 @@
import { deleteCookie, getCookie, storeCookie } from '@/utility/cookie';
const KEY_AUTH_EMAIL = 'authjs.email';
export const storeAuthEmailCookie = (email: string) =>
storeCookie(KEY_AUTH_EMAIL, email);
export const clearAuthEmailCookie = () =>
deleteCookie(KEY_AUTH_EMAIL);
export const hasAuthEmailCookie = () =>
Boolean(getCookie(KEY_AUTH_EMAIL));

View File

@ -1,6 +1,4 @@
import { isPathProtected } from '@/app/paths';
import NextAuth, { User } from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import { deleteCookie, getCookie, storeCookie } from '@/utility/cookie';
export const KEY_CREDENTIALS_SIGN_IN_ERROR = 'CredentialsSignin';
export const KEY_CREDENTIALS_SIGN_IN_ERROR_URL =
@ -10,53 +8,16 @@ export const KEY_CREDENTIALS_CALLBACK_ROUTE_ERROR_URL =
export const KEY_CREDENTIALS_SUCCESS = 'success';
export const KEY_CALLBACK_URL = 'callbackUrl';
export const {
handlers: { GET, POST },
signIn,
signOut,
auth,
} = NextAuth({
providers: [
Credentials({
async authorize({ email, password }) {
if (
process.env.ADMIN_EMAIL && process.env.ADMIN_EMAIL === email &&
process.env.ADMIN_PASSWORD && process.env.ADMIN_PASSWORD === password
) {
const user: User = { email, name: 'Admin User' };
return user;
} else {
return null;
}
},
}),
],
callbacks: {
authorized({ auth, request }) {
const { pathname } = request.nextUrl;
const KEY_AUTH_EMAIL = 'authjs.email';
const isUrlProtected = isPathProtected(pathname);
const isUserLoggedIn = !!auth?.user;
const isRequestAuthorized = !isUrlProtected || isUserLoggedIn;
export const storeAuthEmailCookie = (email: string) =>
storeCookie(KEY_AUTH_EMAIL, email);
return isRequestAuthorized;
},
},
pages: {
signIn: '/sign-in',
},
});
export const clearAuthEmailCookie = () =>
deleteCookie(KEY_AUTH_EMAIL);
export const runAuthenticatedAdminServerAction = async <T>(
callback: () => T,
): Promise<T> => {
const session = await auth();
if (session?.user) {
return callback();
} else {
throw new Error('Unauthorized server action request');
}
};
export const hasAuthEmailCookie = () =>
Boolean(getCookie(KEY_AUTH_EMAIL));
export const generateAuthSecret = () => fetch(
'https://generate-secret.vercel.app/32',

51
src/auth/server.ts Normal file
View File

@ -0,0 +1,51 @@
import { isPathProtected } from '@/app/paths';
import NextAuth, { User } from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
export const {
handlers: { GET, POST },
signIn,
signOut,
auth,
} = NextAuth({
providers: [
Credentials({
async authorize({ email, password }) {
if (
process.env.ADMIN_EMAIL && process.env.ADMIN_EMAIL === email &&
process.env.ADMIN_PASSWORD && process.env.ADMIN_PASSWORD === password
) {
const user: User = { email, name: 'Admin User' };
return user;
} else {
return null;
}
},
}),
],
callbacks: {
authorized({ auth, request }) {
const { pathname } = request.nextUrl;
const isUrlProtected = isPathProtected(pathname);
const isUserLoggedIn = !!auth?.user;
const isRequestAuthorized = !isUrlProtected || isUserLoggedIn;
return isRequestAuthorized;
},
},
pages: {
signIn: '/sign-in',
},
});
export const runAuthenticatedAdminServerAction = async <T>(
callback: () => T,
): Promise<T> => {
const session = await auth();
if (session?.user) {
return callback();
} else {
throw new Error('Unauthorized server action request');
}
};

View File

@ -12,8 +12,11 @@ import {
FUJIFILM_SIMULATION_FORM_INPUT_OPTIONS,
labelForFujifilmSimulation,
} from '@/platforms/fujifilm/simulation';
import { deparameterize, formatCount } from '@/utility/string';
import { formatCountDescriptive } from '@/utility/string';
import {
deparameterize,
formatCount,
formatCountDescriptive,
} from '@/utility/string';
import { AnnotatedTag } from '@/photo/form';
import PhotoFilmIcon from './PhotoFilmIcon';

View File

@ -1,58 +1,24 @@
'use client';
import { ComponentProps } from 'react';
import PhotoGridPageClient from './PhotoGridPageClient';
import {
htmlHasBrParagraphBreaks,
safelyParseFormattedHtml,
} from '@/utility/html';
import { PAGE_ABOUT } from '@/app/config';
import { Photo } from '.';
import { PATH_GRID_INFERRED } from '@/app/paths';
import PhotoGridSidebar from './PhotoGridSidebar';
import PhotoGridContainer from './PhotoGridContainer';
import { useEffect, useRef } from 'react';
import { useAppState } from '@/state/AppState';
import clsx from 'clsx/lite';
import { PhotoSetCategories } from '@/category';
import useElementHeight from '@/utility/useElementHeight';
import MaskedScroll from '@/components/MaskedScroll';
export default function PhotoGridPage(
props: ComponentProps<typeof PhotoGridPageClient>,
) {
const aboutTextSafelyParsedHtml = PAGE_ABOUT
? safelyParseFormattedHtml(PAGE_ABOUT)
: undefined;
const aboutTextHasBrParagraphBreaks = PAGE_ABOUT
? htmlHasBrParagraphBreaks(PAGE_ABOUT)
: false;
export default function PhotoGridPage({
photos,
photosCount,
...categories
}: PhotoSetCategories & {
photos: Photo[]
photosCount: number
}) {
const ref = useRef<HTMLDivElement>(null);
const { setSelectedPhotoIds } = useAppState();
useEffect(
() => () => setSelectedPhotoIds?.(undefined),
[setSelectedPhotoIds],
);
const containerHeight = useElementHeight(ref);
return (
<PhotoGridContainer
cacheKey={`page-${PATH_GRID_INFERRED}`}
photos={photos}
count={photosCount}
sidebar={
<MaskedScroll
className={clsx(
'sticky top-0 -mb-5 -mt-5',
'max-h-screen py-4',
)}
fadeHeight={36}
hideScrollbar
>
<PhotoGridSidebar {...{
...categories,
photosCount,
containerHeight,
}}
/>
</MaskedScroll>
}
canSelect
/>
);
return <PhotoGridPageClient {...{
...props,
aboutTextSafelyParsedHtml,
aboutTextHasBrParagraphBreaks,
}} />;
}

View File

@ -0,0 +1,56 @@
'use client';
import { Photo } from '.';
import { PATH_GRID_INFERRED } from '@/app/paths';
import PhotoGridSidebar from './PhotoGridSidebar';
import PhotoGridContainer from './PhotoGridContainer';
import { ComponentProps, useEffect, useRef } from 'react';
import { useAppState } from '@/state/AppState';
import clsx from 'clsx/lite';
import useElementHeight from '@/utility/useElementHeight';
import MaskedScroll from '@/components/MaskedScroll';
export default function PhotoGridPageClient({
photos,
photosCount,
...categories
}: ComponentProps<typeof PhotoGridSidebar> & {
photos: Photo[]
photosCount: number
}) {
const ref = useRef<HTMLDivElement>(null);
const { setSelectedPhotoIds } = useAppState();
useEffect(
() => () => setSelectedPhotoIds?.(undefined),
[setSelectedPhotoIds],
);
const containerHeight = useElementHeight(ref);
return (
<PhotoGridContainer
cacheKey={`page-${PATH_GRID_INFERRED}`}
photos={photos}
count={photosCount}
sidebar={
<MaskedScroll
className={clsx(
'sticky top-0 -mb-5 -mt-5',
'max-h-screen py-4',
)}
fadeHeight={36}
hideScrollbar
>
<PhotoGridSidebar {...{
...categories,
photosCount,
containerHeight,
}} />
</MaskedScroll>
}
canSelect
/>
);
}

View File

@ -10,11 +10,7 @@ import FavsTag from '../tag/FavsTag';
import { useAppState } from '@/state/AppState';
import { useMemo, useRef } from 'react';
import HiddenTag from '@/tag/HiddenTag';
import { CATEGORY_VISIBILITY, PAGE_ABOUT } from '@/app/config';
import {
htmlHasBrParagraphBreaks,
safelyParseFormattedHtml,
} from '@/utility/html';
import { CATEGORY_VISIBILITY } from '@/app/config';
import { clsx } from 'clsx/lite';
import PhotoRecipe from '@/recipe/PhotoRecipe';
import IconCamera from '@/components/icons/IconCamera';
@ -38,11 +34,15 @@ export default function PhotoGridSidebar({
photosCount,
photosDateRange,
containerHeight,
aboutTextSafelyParsedHtml,
aboutTextHasBrParagraphBreaks,
...categories
}: PhotoSetCategories & {
photosCount: number
photosDateRange?: PhotoDateRange
containerHeight?: number
aboutTextSafelyParsedHtml?: string
aboutTextHasBrParagraphBreaks?: boolean
}) {
const {
cameras,
@ -245,16 +245,16 @@ export default function PhotoGridSidebar({
return (
<div className="space-y-4">
{PAGE_ABOUT && <HeaderList
{aboutTextSafelyParsedHtml && <HeaderList
items={[<p
key="about"
ref={aboutRef}
className={clsx(
'max-w-60 normal-case text-dim',
htmlHasBrParagraphBreaks(PAGE_ABOUT) && 'pb-2',
aboutTextHasBrParagraphBreaks && 'pb-2',
)}
dangerouslySetInnerHTML={{
__html: safelyParseFormattedHtml(PAGE_ABOUT),
__html: aboutTextSafelyParsedHtml,
}}
/>]}
/>}

View File

@ -46,7 +46,7 @@ import {
} from './server';
import { TAG_FAVS, isTagFavs } from '@/tag';
import { convertPhotoToPhotoDbInsert, Photo } from '.';
import { runAuthenticatedAdminServerAction } from '@/auth';
import { runAuthenticatedAdminServerAction } from '@/auth/server';
import { AiImageQuery, getAiImageQuery } from './ai';
import { streamOpenAiImageQuery } from '@/platforms/openai';
import {

View File

@ -1,22 +1,13 @@
import type { ExifData } from 'ts-exif-parser';
import { DEFAULT_ASPECT_RATIO, Photo, PhotoDbInsert, PhotoExif } from '..';
import { DEFAULT_ASPECT_RATIO, Photo, PhotoDbInsert } from '..';
import {
convertTimestampToNaivePostgresString,
convertTimestampWithOffsetToPostgresString,
generateLocalNaivePostgresString,
generateLocalPostgresString,
validationMessageNaivePostgresDateString,
validationMessagePostgresDateString,
} from '@/utility/date';
import {
convertApertureValueToFNumber,
getAspectRatioFromExif,
getOffsetFromExif,
} from '@/utility/exif';
import { roundToNumber } from '@/utility/number';
import { convertStringToArray, parameterize } from '@/utility/string';
import { generateNanoid } from '@/utility/nanoid';
import { GEO_PRIVACY_ENABLED } from '@/app/config';
import { TAG_FAVS, getValidationMessageForTags } from '@/tag';
import { MAKE_FUJIFILM } from '@/platforms/fujifilm';
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
@ -272,46 +263,6 @@ export const convertPhotoToFormData = (photo: Photo): PhotoFormData => {
} as PhotoFormData);
};
// CREATE FORM DATA: FROM EXIF
export const convertExifToFormData = (
data: ExifData,
film?: FujifilmSimulation,
recipeData?: FujifilmRecipe,
): Omit<
Record<keyof PhotoExif, string | undefined>,
'takenAt' | 'takenAtNaive'
> => ({
aspectRatio: getAspectRatioFromExif(data).toString(),
make: data.tags?.Make,
model: data.tags?.Model,
focalLength: data.tags?.FocalLength?.toString(),
focalLengthIn35MmFormat: data.tags?.FocalLengthIn35mmFormat?.toString(),
lensMake: data.tags?.LensMake,
lensModel: data.tags?.LensModel,
fNumber: (
data.tags?.FNumber?.toString() ||
convertApertureValueToFNumber(data.tags?.ApertureValue)
),
iso: data.tags?.ISO?.toString() || data.tags?.ISOSpeed?.toString(),
exposureTime: data.tags?.ExposureTime?.toString(),
exposureCompensation: data.tags?.ExposureCompensation?.toString(),
latitude:
!GEO_PRIVACY_ENABLED ? data.tags?.GPSLatitude?.toString() : undefined,
longitude:
!GEO_PRIVACY_ENABLED ? data.tags?.GPSLongitude?.toString() : undefined,
film,
recipeData: JSON.stringify(recipeData),
...data.tags?.DateTimeOriginal && {
takenAt: convertTimestampWithOffsetToPostgresString(
data.tags.DateTimeOriginal,
getOffsetFromExif(data),
),
takenAtNaive:
convertTimestampToNaivePostgresString(data.tags.DateTimeOriginal),
},
});
// PREPARE FORM FOR DB INSERT
export const convertFormDataToPhotoDbInsert = (

50
src/photo/form/server.ts Normal file
View File

@ -0,0 +1,50 @@
import {
convertApertureValueToFNumber,
getAspectRatioFromExif,
} from '@/utility/exif';
import { GEO_PRIVACY_ENABLED } from '@/app/config';
import { convertTimestampWithOffsetToPostgresString } from '@/utility/date';
import { convertTimestampToNaivePostgresString } from '@/utility/date';
import { getOffsetFromExif } from '@/utility/exif';
import { PhotoExif } from '..';
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
import { FujifilmSimulation } from '@/platforms/fujifilm/simulation';
import type { ExifData } from 'ts-exif-parser';
export const convertExifToFormData = (
data: ExifData,
film?: FujifilmSimulation,
recipeData?: FujifilmRecipe,
): Omit<
Record<keyof PhotoExif, string | undefined>,
'takenAt' | 'takenAtNaive'
> => ({
aspectRatio: getAspectRatioFromExif(data).toString(),
make: data.tags?.Make,
model: data.tags?.Model,
focalLength: data.tags?.FocalLength?.toString(),
focalLengthIn35MmFormat: data.tags?.FocalLengthIn35mmFormat?.toString(),
lensMake: data.tags?.LensMake,
lensModel: data.tags?.LensModel,
fNumber: (
data.tags?.FNumber?.toString() ||
convertApertureValueToFNumber(data.tags?.ApertureValue)
),
iso: data.tags?.ISO?.toString() || data.tags?.ISOSpeed?.toString(),
exposureTime: data.tags?.ExposureTime?.toString(),
exposureCompensation: data.tags?.ExposureCompensation?.toString(),
latitude:
!GEO_PRIVACY_ENABLED ? data.tags?.GPSLatitude?.toString() : undefined,
longitude:
!GEO_PRIVACY_ENABLED ? data.tags?.GPSLongitude?.toString() : undefined,
film,
recipeData: JSON.stringify(recipeData),
...data.tags?.DateTimeOriginal && {
takenAt: convertTimestampWithOffsetToPostgresString(
data.tags.DateTimeOriginal,
getOffsetFromExif(data),
),
takenAtNaive:
convertTimestampToNaivePostgresString(data.tags.DateTimeOriginal),
},
});

View File

@ -2,10 +2,7 @@ import {
getExtensionFromStorageUrl,
getIdFromStorageUrl,
} from '@/platforms/storage';
import {
convertExifToFormData,
convertFormDataToPhotoDbInsert,
} from '@/photo/form';
import { convertFormDataToPhotoDbInsert } from '@/photo/form';
import {
FujifilmSimulation,
getFujifilmSimulationFromMakerNote,
@ -17,7 +14,7 @@ import {
GEO_PRIVACY_ENABLED,
PRESERVE_ORIGINAL_UPLOADS,
} from '@/app/config';
import { isExifForFujifilm } from '@/platforms/fujifilm';
import { isExifForFujifilm } from '@/platforms/fujifilm/server';
import {
FujifilmRecipe,
getFujifilmRecipeFromMakerNote,
@ -27,6 +24,8 @@ import {
updateAllMatchingRecipeTitles,
} from './db/query';
import { PhotoDbInsert } from '.';
import { convertExifToFormData } from './form/server';
const IMAGE_WIDTH_RESIZE = 200;
const IMAGE_WIDTH_BLUR = 200;
@ -124,7 +123,7 @@ export const extractImageDataFromBlobPath = async (
url,
},
...generateBlurData && { blurData },
...convertExifToFormData(exifData, film, recipe),
...convertExifToFormData (exifData, film, recipe),
},
},
imageResizedBase64,

View File

@ -1,84 +1,4 @@
// MakerNote tag IDs and values referenced from:
// - github.com/exiftool/exiftool/blob/master/lib/Image/ExifTool/FujiFilm.pm
// - exiftool.org/TagNames/FujiFilm.html
import type { ExifData } from 'ts-exif-parser';
export const MAKE_FUJIFILM = 'FUJIFILM';
// Makernote Offsets
const BYTE_OFFSET_TAG_COUNT = 12;
const BYTE_OFFSET_FIRST_TAG = 14;
// Tag Offsets
const BYTE_OFFSET_TAG_TYPE = 2;
const BYTE_OFFSET_TAG_SIZE = 4;
const BYTE_OFFSET_TAG_VALUE = 8;
// Tag Sizes
const BYTES_PER_TAG = 12;
const BYTES_PER_TAG_VALUE = 4;
export const isExifForFujifilm = (data: ExifData) =>
data.tags?.Make?.toLocaleUpperCase() === MAKE_FUJIFILM;
export const isMakeFujifilm = (make?: string) =>
make?.toLocaleUpperCase() === MAKE_FUJIFILM;
export const parseFujifilmMakerNote = (
bytes: Buffer,
sendTagNumbers: (tagId: number, numbers: number[]) => void,
) => {
const tagCount = bytes.readUint16LE(BYTE_OFFSET_TAG_COUNT);
for (let i = 0; i < tagCount; i++) {
const index = BYTE_OFFSET_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);
const tagValueSize = bytes.readUInt16LE(index + BYTE_OFFSET_TAG_SIZE);
const sendNumbersForDataType = (
parseNumberAtOffset: (offset: number) => number,
sizeInBytes: number,
) => {
let values: number[] = [];
if (tagValueSize * sizeInBytes <= BYTES_PER_TAG_VALUE) {
// Retrieve values if they fit in tag block
values = Array.from({ length: tagValueSize }, (_, i) =>
parseNumberAtOffset(
index + BYTE_OFFSET_TAG_VALUE + i * sizeInBytes,
),
);
} else {
// Retrieve outside values if they don't fit in tag block
const offset = bytes.readUint16LE(index + BYTE_OFFSET_TAG_VALUE);
for (let i = 0; i < tagValueSize; i++) {
values.push(parseNumberAtOffset(offset + i * sizeInBytes));
}
}
sendTagNumbers(tagId, values);
};
switch (tagType) {
// Int8 (UInt8 read as Int8 according to spec)
case 1:
sendNumbersForDataType(offset => bytes.readInt8(offset), 1);
break;
// UInt16
case 3:
sendNumbersForDataType(offset => bytes.readUInt16LE(offset), 2);
break;
// UInt32
case 4:
sendNumbersForDataType(offset => bytes.readUInt32LE(offset), 4);
break;
// Int32
case 9:
sendNumbersForDataType(offset => bytes.readInt32LE(offset), 4);
break;
}
}
}
};

View File

@ -1,4 +1,4 @@
import { parseFujifilmMakerNote } from '.';
import { parseFujifilmMakerNote } from './server';
const TAG_ID_DYNAMIC_RANGE = 0x1400;
const TAG_ID_DYNAMIC_RANGE_SETTING = 0x1402;

View File

@ -0,0 +1,80 @@
// MakerNote tag IDs and values referenced from:
// - github.com/exiftool/exiftool/blob/master/lib/Image/ExifTool/FujiFilm.pm
// - exiftool.org/TagNames/FujiFilm.html
import type { ExifData } from 'ts-exif-parser';
import { MAKE_FUJIFILM } from '.';
// Makernote Offsets
const BYTE_OFFSET_TAG_COUNT = 12;
const BYTE_OFFSET_FIRST_TAG = 14;
// Tag Offsets
const BYTE_OFFSET_TAG_TYPE = 2;
const BYTE_OFFSET_TAG_SIZE = 4;
const BYTE_OFFSET_TAG_VALUE = 8;
// Tag Sizes
const BYTES_PER_TAG = 12;
const BYTES_PER_TAG_VALUE = 4;
export const isExifForFujifilm = (data: ExifData) =>
data.tags?.Make?.toLocaleUpperCase() === MAKE_FUJIFILM;
export const parseFujifilmMakerNote = (
bytes: Buffer,
sendTagNumbers: (tagId: number, numbers: number[]) => void,
) => {
const tagCount = bytes.readUint16LE(BYTE_OFFSET_TAG_COUNT);
for (let i = 0; i < tagCount; i++) {
const index = BYTE_OFFSET_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);
const tagValueSize = bytes.readUInt16LE(index + BYTE_OFFSET_TAG_SIZE);
const sendNumbersForDataType = (
parseNumberAtOffset: (offset: number) => number,
sizeInBytes: number,
) => {
let values: number[] = [];
if (tagValueSize * sizeInBytes <= BYTES_PER_TAG_VALUE) {
// Retrieve values if they fit in tag block
values = Array.from({ length: tagValueSize }, (_, i) =>
parseNumberAtOffset(
index + BYTE_OFFSET_TAG_VALUE + i * sizeInBytes,
),
);
} else {
// Retrieve outside values if they don't fit in tag block
const offset = bytes.readUint16LE(index + BYTE_OFFSET_TAG_VALUE);
for (let i = 0; i < tagValueSize; i++) {
values.push(parseNumberAtOffset(offset + i * sizeInBytes));
}
}
sendTagNumbers(tagId, values);
};
switch (tagType) {
// Int8 (UInt8 read as Int8 according to spec)
case 1:
sendNumbersForDataType(offset => bytes.readInt8(offset), 1);
break;
// UInt16
case 3:
sendNumbersForDataType(offset => bytes.readUInt16LE(offset), 2);
break;
// UInt32
case 4:
sendNumbersForDataType(offset => bytes.readUInt32LE(offset), 4);
break;
// Int32
case 9:
sendNumbersForDataType(offset => bytes.readInt32LE(offset), 4);
break;
}
}
}
};

View File

@ -1,4 +1,4 @@
import { parseFujifilmMakerNote } from '.';
import { parseFujifilmMakerNote } from './server';
const TAG_ID_SATURATION = 0x1003;
const TAG_ID_FILM_MODE = 0x1401;

View File

@ -19,7 +19,7 @@ import {
storeAuthEmailCookie,
clearAuthEmailCookie,
hasAuthEmailCookie,
} from '@/auth/client';
} from '@/auth';
import { useRouter, usePathname } from 'next/navigation';
import { isPathAdmin, PATH_ROOT } from '@/app/paths';
import { INITIAL_UPLOAD_STATE, UploadState } from '@/admin/upload';

View File

@ -9,4 +9,4 @@ export const safelyParseFormattedHtml = (text: string) =>
// Matches two or more <br> or <br /> tags in a row
export const htmlHasBrParagraphBreaks = (text: string) =>
text.match(/(<br\s*\/?>){2}/i);
/(<br\s*\/?>){2}/i.test(text);