Shrink client bundle
This commit is contained in:
parent
47a53a2398
commit
7dd07aac6e
@ -1 +1 @@
|
|||||||
export { GET, POST } from '@/auth';
|
export { GET, POST } from '@/auth/server';
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { auth } from '@/auth';
|
import { auth } from '@/auth/server';
|
||||||
import {
|
import {
|
||||||
awsS3Client,
|
awsS3Client,
|
||||||
awsS3PutObjectCommandForKey,
|
awsS3PutObjectCommandForKey,
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { auth } from '@/auth';
|
import { auth } from '@/auth/server';
|
||||||
import { revalidateAdminPaths, revalidatePhotosKey } from '@/photo/cache';
|
import { revalidateAdminPaths, revalidatePhotosKey } from '@/photo/cache';
|
||||||
import {
|
import {
|
||||||
ACCEPTED_PHOTO_FILE_TYPES,
|
ACCEPTED_PHOTO_FILE_TYPES,
|
||||||
|
|||||||
@ -9,8 +9,8 @@ import { cache } from 'react';
|
|||||||
import { getPhotos, getPhotosMeta } from '@/photo/db/query';
|
import { getPhotos, getPhotosMeta } from '@/photo/db/query';
|
||||||
import { GRID_HOMEPAGE_ENABLED } from '@/app/config';
|
import { GRID_HOMEPAGE_ENABLED } from '@/app/config';
|
||||||
import { getDataForCategories } from '@/category/data';
|
import { getDataForCategories } from '@/category/data';
|
||||||
import PhotoGridPage from '@/photo/PhotoGridPage';
|
|
||||||
import PhotoFeedPage from '@/photo/PhotoFeedPage';
|
import PhotoFeedPage from '@/photo/PhotoFeedPage';
|
||||||
|
import PhotoGridPage from '@/photo/PhotoGridPage';
|
||||||
|
|
||||||
export const dynamic = 'force-static';
|
export const dynamic = 'force-static';
|
||||||
export const maxDuration = 60;
|
export const maxDuration = 60;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { auth } from '@/auth';
|
import { auth } from '@/auth/server';
|
||||||
import SignInForm from '@/auth/SignInForm';
|
import SignInForm from '@/auth/SignInForm';
|
||||||
import { PATH_ADMIN, PATH_ROOT } from '@/app/paths';
|
import { PATH_ADMIN, PATH_ROOT } from '@/app/paths';
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { auth } from './src/auth';
|
import { auth } from './src/auth/server';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import {
|
|||||||
} from 'react-icons/bi';
|
} from 'react-icons/bi';
|
||||||
import { HiOutlineCog } from 'react-icons/hi';
|
import { HiOutlineCog } from 'react-icons/hi';
|
||||||
import ChecklistGroup from '@/components/ChecklistGroup';
|
import ChecklistGroup from '@/components/ChecklistGroup';
|
||||||
import { ConfigChecklistStatus } from '../app/config';
|
import { AppConfiguration } from '../app/config';
|
||||||
import StatusIcon from '@/components/StatusIcon';
|
import StatusIcon from '@/components/StatusIcon';
|
||||||
import { labelForStorage } from '@/platforms/storage';
|
import { labelForStorage } from '@/platforms/storage';
|
||||||
import { HiSparkles } from 'react-icons/hi';
|
import { HiSparkles } from 'react-icons/hi';
|
||||||
@ -109,7 +109,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
// Component props
|
// Component props
|
||||||
simplifiedView,
|
simplifiedView,
|
||||||
isAnalyzingConfiguration,
|
isAnalyzingConfiguration,
|
||||||
}: ConfigChecklistStatus &
|
}: AppConfiguration &
|
||||||
Partial<Awaited<ReturnType<typeof testConnectionsAction>>> & {
|
Partial<Awaited<ReturnType<typeof testConnectionsAction>>> & {
|
||||||
simplifiedView?: boolean
|
simplifiedView?: boolean
|
||||||
isAnalyzingConfiguration?: boolean
|
isAnalyzingConfiguration?: boolean
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { runAuthenticatedAdminServerAction } from '@/auth';
|
import { runAuthenticatedAdminServerAction } from '@/auth/server';
|
||||||
import { testRedisConnection } from '@/platforms/redis';
|
import { testRedisConnection } from '@/platforms/redis';
|
||||||
import { testOpenAiConnection } from '@/platforms/openai';
|
import { testOpenAiConnection } from '@/platforms/openai';
|
||||||
import { testDatabaseConnection } from '@/platforms/postgres';
|
import { testDatabaseConnection } from '@/platforms/postgres';
|
||||||
|
|||||||
@ -317,9 +317,7 @@ export default function AdminAppInsightsClient({
|
|||||||
/>}
|
/>}
|
||||||
content="Speed up page load times"
|
content="Speed up page load times"
|
||||||
expandContent={<>
|
expandContent={<>
|
||||||
Improve load times by enabling static optimization
|
Improve load times by enabling static optimization:
|
||||||
{' '}
|
|
||||||
on:
|
|
||||||
<div className="flex flex-col gap-y-4 mt-3">
|
<div className="flex flex-col gap-y-4 mt-3">
|
||||||
{renderLabeledEnvVar(
|
{renderLabeledEnvVar(
|
||||||
'Photo pages',
|
'Photo pages',
|
||||||
|
|||||||
@ -399,11 +399,11 @@ export const APP_CONFIGURATION = {
|
|||||||
commitUrl: VERCEL_GIT_COMMIT_URL,
|
commitUrl: VERCEL_GIT_COMMIT_URL,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ConfigChecklistStatus = typeof APP_CONFIGURATION;
|
|
||||||
|
|
||||||
export const IS_SITE_READY =
|
export const IS_SITE_READY =
|
||||||
APP_CONFIGURATION.hasDatabase &&
|
APP_CONFIGURATION.hasDatabase &&
|
||||||
APP_CONFIGURATION.hasStorageProvider &&
|
APP_CONFIGURATION.hasStorageProvider &&
|
||||||
APP_CONFIGURATION.hasAuthSecret &&
|
APP_CONFIGURATION.hasAuthSecret &&
|
||||||
APP_CONFIGURATION.hasAdminUser;
|
APP_CONFIGURATION.hasAdminUser;
|
||||||
|
|
||||||
|
export type AppConfiguration = typeof APP_CONFIGURATION;
|
||||||
|
|
||||||
@ -1,18 +1,20 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
auth,
|
||||||
|
signIn,
|
||||||
|
signOut,
|
||||||
|
} from '@/auth/server';
|
||||||
|
import type { Session } from 'next-auth';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import {
|
||||||
|
generateAuthSecret,
|
||||||
KEY_CALLBACK_URL,
|
KEY_CALLBACK_URL,
|
||||||
KEY_CREDENTIALS_CALLBACK_ROUTE_ERROR_URL,
|
KEY_CREDENTIALS_CALLBACK_ROUTE_ERROR_URL,
|
||||||
KEY_CREDENTIALS_SIGN_IN_ERROR,
|
KEY_CREDENTIALS_SIGN_IN_ERROR,
|
||||||
KEY_CREDENTIALS_SIGN_IN_ERROR_URL,
|
KEY_CREDENTIALS_SIGN_IN_ERROR_URL,
|
||||||
KEY_CREDENTIALS_SUCCESS,
|
KEY_CREDENTIALS_SUCCESS,
|
||||||
auth,
|
} from '.';
|
||||||
generateAuthSecret,
|
|
||||||
signIn,
|
|
||||||
signOut,
|
|
||||||
} from '@/auth';
|
|
||||||
import type { Session } from 'next-auth';
|
|
||||||
import { redirect } from 'next/navigation';
|
|
||||||
|
|
||||||
export const signInAction = async (
|
export const signInAction = async (
|
||||||
_prevState: string | undefined,
|
_prevState: string | undefined,
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { cache } from 'react';
|
import { cache } from 'react';
|
||||||
import { auth } from '@/auth';
|
import { auth } from '@/auth/server';
|
||||||
|
|
||||||
export const authCachedSafe = cache(() => auth().catch(() => null));
|
export const authCachedSafe = cache(() => auth().catch(() => null));
|
||||||
|
|||||||
@ -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));
|
|
||||||
@ -1,6 +1,4 @@
|
|||||||
import { isPathProtected } from '@/app/paths';
|
import { deleteCookie, getCookie, storeCookie } from '@/utility/cookie';
|
||||||
import NextAuth, { User } from 'next-auth';
|
|
||||||
import Credentials from 'next-auth/providers/credentials';
|
|
||||||
|
|
||||||
export const KEY_CREDENTIALS_SIGN_IN_ERROR = 'CredentialsSignin';
|
export const KEY_CREDENTIALS_SIGN_IN_ERROR = 'CredentialsSignin';
|
||||||
export const KEY_CREDENTIALS_SIGN_IN_ERROR_URL =
|
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_CREDENTIALS_SUCCESS = 'success';
|
||||||
export const KEY_CALLBACK_URL = 'callbackUrl';
|
export const KEY_CALLBACK_URL = 'callbackUrl';
|
||||||
|
|
||||||
export const {
|
const KEY_AUTH_EMAIL = 'authjs.email';
|
||||||
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);
|
export const storeAuthEmailCookie = (email: string) =>
|
||||||
const isUserLoggedIn = !!auth?.user;
|
storeCookie(KEY_AUTH_EMAIL, email);
|
||||||
const isRequestAuthorized = !isUrlProtected || isUserLoggedIn;
|
|
||||||
|
|
||||||
return isRequestAuthorized;
|
export const clearAuthEmailCookie = () =>
|
||||||
},
|
deleteCookie(KEY_AUTH_EMAIL);
|
||||||
},
|
|
||||||
pages: {
|
|
||||||
signIn: '/sign-in',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const runAuthenticatedAdminServerAction = async <T>(
|
export const hasAuthEmailCookie = () =>
|
||||||
callback: () => T,
|
Boolean(getCookie(KEY_AUTH_EMAIL));
|
||||||
): Promise<T> => {
|
|
||||||
const session = await auth();
|
|
||||||
if (session?.user) {
|
|
||||||
return callback();
|
|
||||||
} else {
|
|
||||||
throw new Error('Unauthorized server action request');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const generateAuthSecret = () => fetch(
|
export const generateAuthSecret = () => fetch(
|
||||||
'https://generate-secret.vercel.app/32',
|
'https://generate-secret.vercel.app/32',
|
||||||
|
|||||||
51
src/auth/server.ts
Normal file
51
src/auth/server.ts
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -12,8 +12,11 @@ import {
|
|||||||
FUJIFILM_SIMULATION_FORM_INPUT_OPTIONS,
|
FUJIFILM_SIMULATION_FORM_INPUT_OPTIONS,
|
||||||
labelForFujifilmSimulation,
|
labelForFujifilmSimulation,
|
||||||
} from '@/platforms/fujifilm/simulation';
|
} from '@/platforms/fujifilm/simulation';
|
||||||
import { deparameterize, formatCount } from '@/utility/string';
|
import {
|
||||||
import { formatCountDescriptive } from '@/utility/string';
|
deparameterize,
|
||||||
|
formatCount,
|
||||||
|
formatCountDescriptive,
|
||||||
|
} from '@/utility/string';
|
||||||
import { AnnotatedTag } from '@/photo/form';
|
import { AnnotatedTag } from '@/photo/form';
|
||||||
import PhotoFilmIcon from './PhotoFilmIcon';
|
import PhotoFilmIcon from './PhotoFilmIcon';
|
||||||
|
|
||||||
|
|||||||
@ -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 '.';
|
export default function PhotoGridPage(
|
||||||
import { PATH_GRID_INFERRED } from '@/app/paths';
|
props: ComponentProps<typeof PhotoGridPageClient>,
|
||||||
import PhotoGridSidebar from './PhotoGridSidebar';
|
) {
|
||||||
import PhotoGridContainer from './PhotoGridContainer';
|
const aboutTextSafelyParsedHtml = PAGE_ABOUT
|
||||||
import { useEffect, useRef } from 'react';
|
? safelyParseFormattedHtml(PAGE_ABOUT)
|
||||||
import { useAppState } from '@/state/AppState';
|
: undefined;
|
||||||
import clsx from 'clsx/lite';
|
const aboutTextHasBrParagraphBreaks = PAGE_ABOUT
|
||||||
import { PhotoSetCategories } from '@/category';
|
? htmlHasBrParagraphBreaks(PAGE_ABOUT)
|
||||||
import useElementHeight from '@/utility/useElementHeight';
|
: false;
|
||||||
import MaskedScroll from '@/components/MaskedScroll';
|
|
||||||
|
|
||||||
export default function PhotoGridPage({
|
return <PhotoGridPageClient {...{
|
||||||
photos,
|
...props,
|
||||||
photosCount,
|
aboutTextSafelyParsedHtml,
|
||||||
...categories
|
aboutTextHasBrParagraphBreaks,
|
||||||
}: 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
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
56
src/photo/PhotoGridPageClient.tsx
Normal file
56
src/photo/PhotoGridPageClient.tsx
Normal 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
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -10,11 +10,7 @@ import FavsTag from '../tag/FavsTag';
|
|||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/state/AppState';
|
||||||
import { useMemo, useRef } from 'react';
|
import { useMemo, useRef } from 'react';
|
||||||
import HiddenTag from '@/tag/HiddenTag';
|
import HiddenTag from '@/tag/HiddenTag';
|
||||||
import { CATEGORY_VISIBILITY, PAGE_ABOUT } from '@/app/config';
|
import { CATEGORY_VISIBILITY } from '@/app/config';
|
||||||
import {
|
|
||||||
htmlHasBrParagraphBreaks,
|
|
||||||
safelyParseFormattedHtml,
|
|
||||||
} from '@/utility/html';
|
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
import PhotoRecipe from '@/recipe/PhotoRecipe';
|
import PhotoRecipe from '@/recipe/PhotoRecipe';
|
||||||
import IconCamera from '@/components/icons/IconCamera';
|
import IconCamera from '@/components/icons/IconCamera';
|
||||||
@ -38,11 +34,15 @@ export default function PhotoGridSidebar({
|
|||||||
photosCount,
|
photosCount,
|
||||||
photosDateRange,
|
photosDateRange,
|
||||||
containerHeight,
|
containerHeight,
|
||||||
|
aboutTextSafelyParsedHtml,
|
||||||
|
aboutTextHasBrParagraphBreaks,
|
||||||
...categories
|
...categories
|
||||||
}: PhotoSetCategories & {
|
}: PhotoSetCategories & {
|
||||||
photosCount: number
|
photosCount: number
|
||||||
photosDateRange?: PhotoDateRange
|
photosDateRange?: PhotoDateRange
|
||||||
containerHeight?: number
|
containerHeight?: number
|
||||||
|
aboutTextSafelyParsedHtml?: string
|
||||||
|
aboutTextHasBrParagraphBreaks?: boolean
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
cameras,
|
cameras,
|
||||||
@ -245,16 +245,16 @@ export default function PhotoGridSidebar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{PAGE_ABOUT && <HeaderList
|
{aboutTextSafelyParsedHtml && <HeaderList
|
||||||
items={[<p
|
items={[<p
|
||||||
key="about"
|
key="about"
|
||||||
ref={aboutRef}
|
ref={aboutRef}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'max-w-60 normal-case text-dim',
|
'max-w-60 normal-case text-dim',
|
||||||
htmlHasBrParagraphBreaks(PAGE_ABOUT) && 'pb-2',
|
aboutTextHasBrParagraphBreaks && 'pb-2',
|
||||||
)}
|
)}
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: safelyParseFormattedHtml(PAGE_ABOUT),
|
__html: aboutTextSafelyParsedHtml,
|
||||||
}}
|
}}
|
||||||
/>]}
|
/>]}
|
||||||
/>}
|
/>}
|
||||||
|
|||||||
@ -46,7 +46,7 @@ import {
|
|||||||
} from './server';
|
} from './server';
|
||||||
import { TAG_FAVS, isTagFavs } from '@/tag';
|
import { TAG_FAVS, isTagFavs } from '@/tag';
|
||||||
import { convertPhotoToPhotoDbInsert, Photo } from '.';
|
import { convertPhotoToPhotoDbInsert, Photo } from '.';
|
||||||
import { runAuthenticatedAdminServerAction } from '@/auth';
|
import { runAuthenticatedAdminServerAction } from '@/auth/server';
|
||||||
import { AiImageQuery, getAiImageQuery } from './ai';
|
import { AiImageQuery, getAiImageQuery } from './ai';
|
||||||
import { streamOpenAiImageQuery } from '@/platforms/openai';
|
import { streamOpenAiImageQuery } from '@/platforms/openai';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@ -1,22 +1,13 @@
|
|||||||
import type { ExifData } from 'ts-exif-parser';
|
import { DEFAULT_ASPECT_RATIO, Photo, PhotoDbInsert } from '..';
|
||||||
import { DEFAULT_ASPECT_RATIO, Photo, PhotoDbInsert, PhotoExif } from '..';
|
|
||||||
import {
|
import {
|
||||||
convertTimestampToNaivePostgresString,
|
|
||||||
convertTimestampWithOffsetToPostgresString,
|
|
||||||
generateLocalNaivePostgresString,
|
generateLocalNaivePostgresString,
|
||||||
generateLocalPostgresString,
|
generateLocalPostgresString,
|
||||||
validationMessageNaivePostgresDateString,
|
validationMessageNaivePostgresDateString,
|
||||||
validationMessagePostgresDateString,
|
validationMessagePostgresDateString,
|
||||||
} from '@/utility/date';
|
} from '@/utility/date';
|
||||||
import {
|
|
||||||
convertApertureValueToFNumber,
|
|
||||||
getAspectRatioFromExif,
|
|
||||||
getOffsetFromExif,
|
|
||||||
} from '@/utility/exif';
|
|
||||||
import { roundToNumber } from '@/utility/number';
|
import { roundToNumber } from '@/utility/number';
|
||||||
import { convertStringToArray, parameterize } from '@/utility/string';
|
import { convertStringToArray, parameterize } from '@/utility/string';
|
||||||
import { generateNanoid } from '@/utility/nanoid';
|
import { generateNanoid } from '@/utility/nanoid';
|
||||||
import { GEO_PRIVACY_ENABLED } from '@/app/config';
|
|
||||||
import { TAG_FAVS, getValidationMessageForTags } from '@/tag';
|
import { TAG_FAVS, getValidationMessageForTags } from '@/tag';
|
||||||
import { MAKE_FUJIFILM } from '@/platforms/fujifilm';
|
import { MAKE_FUJIFILM } from '@/platforms/fujifilm';
|
||||||
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
|
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
|
||||||
@ -272,46 +263,6 @@ export const convertPhotoToFormData = (photo: Photo): PhotoFormData => {
|
|||||||
} as 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
|
// PREPARE FORM FOR DB INSERT
|
||||||
|
|
||||||
export const convertFormDataToPhotoDbInsert = (
|
export const convertFormDataToPhotoDbInsert = (
|
||||||
|
|||||||
50
src/photo/form/server.ts
Normal file
50
src/photo/form/server.ts
Normal 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),
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -2,10 +2,7 @@ import {
|
|||||||
getExtensionFromStorageUrl,
|
getExtensionFromStorageUrl,
|
||||||
getIdFromStorageUrl,
|
getIdFromStorageUrl,
|
||||||
} from '@/platforms/storage';
|
} from '@/platforms/storage';
|
||||||
import {
|
import { convertFormDataToPhotoDbInsert } from '@/photo/form';
|
||||||
convertExifToFormData,
|
|
||||||
convertFormDataToPhotoDbInsert,
|
|
||||||
} from '@/photo/form';
|
|
||||||
import {
|
import {
|
||||||
FujifilmSimulation,
|
FujifilmSimulation,
|
||||||
getFujifilmSimulationFromMakerNote,
|
getFujifilmSimulationFromMakerNote,
|
||||||
@ -17,7 +14,7 @@ import {
|
|||||||
GEO_PRIVACY_ENABLED,
|
GEO_PRIVACY_ENABLED,
|
||||||
PRESERVE_ORIGINAL_UPLOADS,
|
PRESERVE_ORIGINAL_UPLOADS,
|
||||||
} from '@/app/config';
|
} from '@/app/config';
|
||||||
import { isExifForFujifilm } from '@/platforms/fujifilm';
|
import { isExifForFujifilm } from '@/platforms/fujifilm/server';
|
||||||
import {
|
import {
|
||||||
FujifilmRecipe,
|
FujifilmRecipe,
|
||||||
getFujifilmRecipeFromMakerNote,
|
getFujifilmRecipeFromMakerNote,
|
||||||
@ -27,6 +24,8 @@ import {
|
|||||||
updateAllMatchingRecipeTitles,
|
updateAllMatchingRecipeTitles,
|
||||||
} from './db/query';
|
} from './db/query';
|
||||||
import { PhotoDbInsert } from '.';
|
import { PhotoDbInsert } from '.';
|
||||||
|
import { convertExifToFormData } from './form/server';
|
||||||
|
|
||||||
const IMAGE_WIDTH_RESIZE = 200;
|
const IMAGE_WIDTH_RESIZE = 200;
|
||||||
const IMAGE_WIDTH_BLUR = 200;
|
const IMAGE_WIDTH_BLUR = 200;
|
||||||
|
|
||||||
@ -124,7 +123,7 @@ export const extractImageDataFromBlobPath = async (
|
|||||||
url,
|
url,
|
||||||
},
|
},
|
||||||
...generateBlurData && { blurData },
|
...generateBlurData && { blurData },
|
||||||
...convertExifToFormData(exifData, film, recipe),
|
...convertExifToFormData (exifData, film, recipe),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
imageResizedBase64,
|
imageResizedBase64,
|
||||||
|
|||||||
@ -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';
|
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) =>
|
export const isMakeFujifilm = (make?: string) =>
|
||||||
make?.toLocaleUpperCase() === MAKE_FUJIFILM;
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { parseFujifilmMakerNote } from '.';
|
import { parseFujifilmMakerNote } from './server';
|
||||||
|
|
||||||
const TAG_ID_DYNAMIC_RANGE = 0x1400;
|
const TAG_ID_DYNAMIC_RANGE = 0x1400;
|
||||||
const TAG_ID_DYNAMIC_RANGE_SETTING = 0x1402;
|
const TAG_ID_DYNAMIC_RANGE_SETTING = 0x1402;
|
||||||
|
|||||||
80
src/platforms/fujifilm/server.ts
Normal file
80
src/platforms/fujifilm/server.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { parseFujifilmMakerNote } from '.';
|
import { parseFujifilmMakerNote } from './server';
|
||||||
|
|
||||||
const TAG_ID_SATURATION = 0x1003;
|
const TAG_ID_SATURATION = 0x1003;
|
||||||
const TAG_ID_FILM_MODE = 0x1401;
|
const TAG_ID_FILM_MODE = 0x1401;
|
||||||
|
|||||||
@ -19,7 +19,7 @@ import {
|
|||||||
storeAuthEmailCookie,
|
storeAuthEmailCookie,
|
||||||
clearAuthEmailCookie,
|
clearAuthEmailCookie,
|
||||||
hasAuthEmailCookie,
|
hasAuthEmailCookie,
|
||||||
} from '@/auth/client';
|
} from '@/auth';
|
||||||
import { useRouter, usePathname } from 'next/navigation';
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
import { isPathAdmin, PATH_ROOT } from '@/app/paths';
|
import { isPathAdmin, PATH_ROOT } from '@/app/paths';
|
||||||
import { INITIAL_UPLOAD_STATE, UploadState } from '@/admin/upload';
|
import { INITIAL_UPLOAD_STATE, UploadState } from '@/admin/upload';
|
||||||
|
|||||||
@ -9,4 +9,4 @@ export const safelyParseFormattedHtml = (text: string) =>
|
|||||||
|
|
||||||
// Matches two or more <br> or <br /> tags in a row
|
// Matches two or more <br> or <br /> tags in a row
|
||||||
export const htmlHasBrParagraphBreaks = (text: string) =>
|
export const htmlHasBrParagraphBreaks = (text: string) =>
|
||||||
text.match(/(<br\s*\/?>){2}/i);
|
/(<br\s*\/?>){2}/i.test(text);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user