feat: document file conversion

This commit is contained in:
Ephraim Atta-Duncan
2025-12-15 11:58:42 +00:00
parent 43486d8448
commit 7a499270be
26 changed files with 252 additions and 63 deletions
@@ -127,6 +127,19 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
const errorMessage = match(error.code)
.with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs`)
.with(
'UNSUPPORTED_FILE_TYPE',
() => t`This file type is not supported. Please upload a PDF, DOCX, JPEG, or PNG file.`,
)
.with(
'CONVERSION_SERVICE_UNAVAILABLE',
() => t`File conversion is temporarily unavailable. Please upload a PDF file instead.`,
)
.with(
'CONVERSION_FAILED',
() =>
t`Failed to convert the file to PDF. Please try again or upload a PDF file instead.`,
)
.with(
AppErrorCode.LIMIT_EXCEEDED,
() => t`You have reached your document limit for this month. Please upgrade your plan.`,
+1 -1
View File
@@ -46,7 +46,7 @@ export const filesRoute = new Hono<HonoEnv>()
return c.json({ error: 'File too large' }, 400);
}
const result = await putNormalizedPdfFileServerSide(file);
const result = await putNormalizedPdfFileServerSide({ file });
return c.json(result);
} catch (error) {
+9
View File
@@ -40,6 +40,15 @@ services:
entrypoint: sh
command: -c 'mkdir -p /data/documenso && minio server /data --console-address ":9001" --address ":9002"'
gotenberg:
image: gotenberg/gotenberg:8
container_name: gotenberg
ports:
- 3001:3000
command:
- 'gotenberg'
- '--api-timeout=30s'
volumes:
minio:
documenso_database:
@@ -384,11 +384,9 @@ test('[TEAMS]: can create a template inside a template folder', async ({ page })
await page.getByText('Upload Template Document').click();
await page.locator('input[type="file"]').nth(0).waitFor({ state: 'attached' });
await page
.locator('input[type="file"]')
.nth(0)
.first()
.setInputFiles(path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'));
await page.waitForTimeout(3000);
+13
View File
@@ -0,0 +1,13 @@
import { env } from '@documenso/lib/utils/env';
export const ALLOWED_UPLOAD_MIME_TYPES: Record<string, string[]> = {
'application/pdf': ['.pdf'],
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
'image/jpeg': ['.jpg', '.jpeg'],
'image/png': ['.png'],
};
export const isAllowedMimeType = (mimeType: string): boolean =>
mimeType in ALLOWED_UPLOAD_MIME_TYPES;
export const getGotenbergUrl = (): string | undefined => env('NEXT_PRIVATE_GOTENBERG_URL');
@@ -5,14 +5,20 @@ import { prisma } from '@documenso/prisma';
export type CreateDocumentDataOptions = {
type: DocumentDataType;
data: string;
originalMimeType?: string;
};
export const createDocumentData = async ({ type, data }: CreateDocumentDataOptions) => {
export const createDocumentData = async ({
type,
data,
originalMimeType,
}: CreateDocumentDataOptions) => {
return await prisma.documentData.create({
data: {
type,
data,
initialData: data,
originalMimeType,
},
});
};
@@ -44,6 +44,7 @@ export const getDocumentByAccessToken = async ({ token }: GetDocumentByAccessTok
type: true,
data: true,
initialData: true,
originalMimeType: true,
},
},
},
@@ -82,6 +82,7 @@ export const sendDocument = async ({
id: true,
data: true,
initialData: true,
originalMimeType: true,
},
},
},
@@ -42,6 +42,7 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop
data: true,
initialData: true,
type: true,
originalMimeType: true,
},
},
},
@@ -0,0 +1,48 @@
import { isAllowedMimeType } from '../../constants/upload';
import { AppError } from '../../errors/app-error';
import { convertFileToPdfViaGotenberg } from '../gotenberg/gotenberg-client';
type FileInput = {
name: string;
type: string;
arrayBuffer: () => Promise<ArrayBuffer>;
};
export type ConvertToPdfResult = {
pdfBuffer: Buffer;
originalMimeType: string;
};
export const convertToPdfIfNeeded = async (file: FileInput): Promise<ConvertToPdfResult> => {
const originalMimeType = file.type;
if (!isAllowedMimeType(originalMimeType)) {
throw new AppError('UNSUPPORTED_FILE_TYPE', {
message: `File type '${originalMimeType}' is not supported`,
userMessage: 'This file type is not supported. Please upload a PDF, DOCX, JPEG, or PNG file.',
statusCode: 400,
});
}
if (originalMimeType === 'application/pdf') {
const arrayBuffer = await file.arrayBuffer();
return {
pdfBuffer: Buffer.from(arrayBuffer),
originalMimeType,
};
}
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const pdfBuffer = await convertFileToPdfViaGotenberg({
file: buffer,
filename: file.name,
mimeType: originalMimeType,
});
return {
pdfBuffer,
originalMimeType,
};
};
@@ -0,0 +1,83 @@
import { getGotenbergUrl } from '../../constants/upload';
import { AppError } from '../../errors/app-error';
export type ConvertFileToPdfOptions = {
file: Buffer;
filename: string;
mimeType: string;
};
const CONVERSION_TIMEOUT_MS = 30_000;
export const convertFileToPdfViaGotenberg = async ({
file,
filename,
mimeType,
}: ConvertFileToPdfOptions): Promise<Buffer> => {
const gotenbergUrl = getGotenbergUrl();
if (!gotenbergUrl) {
throw new AppError('CONVERSION_SERVICE_UNAVAILABLE', {
message: 'Gotenberg URL is not configured',
userMessage: 'File conversion service is not available. Please upload a PDF file instead.',
statusCode: 503,
});
}
const formData = new FormData();
const blob = new Blob([file], { type: mimeType });
formData.append('files', blob, filename);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), CONVERSION_TIMEOUT_MS);
try {
const response = await fetch(`${gotenbergUrl}/forms/libreoffice/convert`, {
method: 'POST',
body: formData,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
console.error(`Gotenberg conversion failed: ${response.status} - ${errorText}`);
throw new AppError('CONVERSION_FAILED', {
message: `Gotenberg returned status ${response.status}: ${errorText}`,
userMessage:
'Failed to convert the file to PDF. Please try again or upload a PDF file instead.',
statusCode: 400,
});
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof AppError) {
throw error;
}
if (error instanceof Error && error.name === 'AbortError') {
throw new AppError('CONVERSION_SERVICE_UNAVAILABLE', {
message: 'Gotenberg request timed out',
userMessage:
'File conversion timed out. Please try again with a smaller file or upload a PDF instead.',
statusCode: 503,
});
}
console.error('Gotenberg conversion error:', error);
throw new AppError('CONVERSION_SERVICE_UNAVAILABLE', {
message: `Failed to reach Gotenberg: ${error instanceof Error ? error.message : 'Unknown error'}`,
userMessage:
'File conversion service is temporarily unavailable. Please upload a PDF file instead.',
statusCode: 503,
});
}
};
+1
View File
@@ -51,6 +51,7 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
id: true,
data: true,
initialData: true,
originalMimeType: true,
}).extend({
envelopeItemId: z.string(),
}),
+1
View File
@@ -45,6 +45,7 @@ export const ZTemplateSchema = TemplateSchema.pick({
id: true,
data: true,
initialData: true,
originalMimeType: true,
}).extend({
envelopeItemId: z.string(),
}),
@@ -44,10 +44,18 @@ export const putPdfFileServerSide = async (file: File) => {
return await createDocumentData({ type, data });
};
type PutNormalizedPdfOptions = {
file: File;
originalMimeType?: string;
};
/**
* Uploads a pdf file and normalizes it.
*/
export const putNormalizedPdfFileServerSide = async (file: File) => {
export const putNormalizedPdfFileServerSide = async ({
file,
originalMimeType,
}: PutNormalizedPdfOptions) => {
const buffer = Buffer.from(await file.arrayBuffer());
const normalized = await normalizePdf(buffer);
@@ -63,6 +71,7 @@ export const putNormalizedPdfFileServerSide = async (file: File) => {
return await createDocumentData({
type: documentData.type,
data: documentData.data,
originalMimeType,
});
};
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "DocumentData" ADD COLUMN "originalMimeType" TEXT;
+6 -5
View File
@@ -486,11 +486,12 @@ enum DocumentSigningOrder {
}
model DocumentData {
id String @id @default(cuid())
type DocumentDataType
data String
initialData String
envelopeItem EnvelopeItem?
id String @id @default(cuid())
type DocumentDataType
data String
initialData String
originalMimeType String?
envelopeItem EnvelopeItem?
}
enum DocumentDistributionMethod {
@@ -47,9 +47,11 @@ export const createDocumentRoute = authenticatedProcedure
}
const { id: documentDataId } = await putNormalizedPdfFileServerSide({
name: file.name,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdf),
file: {
name: file.name,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdf),
},
});
ctx.logger.info({
@@ -18,6 +18,7 @@ export const ZGetMultiSignDocumentResponseSchema = ZDocumentLiteSchema.extend({
id: true,
data: true,
initialData: true,
originalMimeType: true,
}),
documentMeta: DocumentMetaSchema.pick({
signingOrder: true,
@@ -87,7 +87,7 @@ export const createEnvelopeItemsRoute = authenticatedProcedure
// For each file, stream to s3 and create the document data.
const envelopeItems = await Promise.all(
files.map(async (file) => {
const { id: documentDataId } = await putNormalizedPdfFileServerSide(file);
const { id: documentDataId } = await putNormalizedPdfFileServerSide({ file });
return {
title: file.name,
@@ -1,6 +1,7 @@
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
import { convertToPdfIfNeeded } from '@documenso/lib/server-only/file-conversion/convert-to-pdf';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { insertFormValuesInPdf } from '../../../lib/server-only/pdf/insert-form-values-in-pdf';
@@ -59,17 +60,13 @@ export const createEnvelopeRoute = authenticatedProcedure
});
}
if (files.some((file) => !file.type.startsWith('application/pdf'))) {
throw new AppError('INVALID_DOCUMENT_FILE', {
message: 'You cannot upload non-PDF files',
statusCode: 400,
});
}
// For each file, stream to s3 and create the document data.
// For each file, convert to PDF if needed, then store.
const envelopeItems = await Promise.all(
files.map(async (file) => {
let pdf = Buffer.from(await file.arrayBuffer());
// Convert to PDF if needed (DOCX, images, etc)
const { pdfBuffer, originalMimeType } = await convertToPdfIfNeeded(file);
let pdf = pdfBuffer;
if (formValues) {
// eslint-disable-next-line require-atomic-updates
@@ -80,9 +77,12 @@ export const createEnvelopeRoute = authenticatedProcedure
}
const { id: documentDataId } = await putNormalizedPdfFileServerSide({
name: file.name,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdf),
file: {
name: file.name,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdf),
},
originalMimeType,
});
return {
@@ -19,6 +19,7 @@ export const ZGetEnvelopeItemsResponseSchema = z.object({
id: true,
data: true,
initialData: true,
originalMimeType: true,
}),
})
.array(),
@@ -79,7 +79,7 @@ export const useEnvelopeRoute = authenticatedProcedure
// Process uploaded files and create document data for them
const uploadedFiles = await Promise.all(
filesToUpload.map(async (file) => {
const { id: documentDataId } = await putNormalizedPdfFileServerSide(file);
const { id: documentDataId } = await putNormalizedPdfFileServerSide({ file });
return {
name: file.name,
@@ -191,7 +191,7 @@ export const templateRouter = router({
attachments,
} = payload;
const { id: templateDocumentDataId } = await putNormalizedPdfFileServerSide(file);
const { id: templateDocumentDataId } = await putNormalizedPdfFileServerSide({ file });
ctx.logger.info({
input: {
@@ -17,7 +17,7 @@ const EnvelopePdfViewer = lazy(async () => import('./pdf-viewer-konva'));
export const PDFViewerKonvaLazy = (props: PDFViewerProps) => {
return (
<Suspense fallback={<div>Loading client component...</div>}>
<Suspense fallback={<div>Loading...</div>}>
<EnvelopePdfViewer {...props} />
</Suspense>
);
+28 -29
View File
@@ -10,6 +10,7 @@ import { Link } from 'react-router';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { ALLOWED_UPLOAD_MIME_TYPES } from '@documenso/lib/constants/upload';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import {
@@ -55,9 +56,7 @@ export const DocumentDropzone = ({
const organisation = useCurrentOrganisation();
const { getRootProps, getInputProps } = useDropzone({
accept: {
'application/pdf': ['.pdf'],
},
accept: ALLOWED_UPLOAD_MIME_TYPES,
multiple: allowMultiple,
disabled,
onDrop: (acceptedFiles) => {
@@ -87,7 +86,7 @@ export const DocumentDropzone = ({
<Card
role="button"
className={cn(
'focus-visible:ring-ring ring-offset-background group flex flex-1 cursor-pointer flex-col items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
'group flex flex-1 cursor-pointer flex-col items-center justify-center ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className,
)}
gradient={!disabled}
@@ -96,77 +95,77 @@ export const DocumentDropzone = ({
{...getRootProps()}
{...props}
>
<CardContent className="text-muted-foreground/40 flex flex-col items-center justify-center p-6">
<CardContent className="flex flex-col items-center justify-center p-6 text-muted-foreground/40">
{disabled ? (
// Disabled State
<div className="flex">
<motion.div
className="group-hover:bg-destructive/2 border-muted-foreground/20 group-hover:border-destructive/10 dark:bg-muted/80 a z-10 flex aspect-[3/4] w-24 origin-top-right -rotate-[22deg] flex-col gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm"
className="group-hover:bg-destructive/2 a z-10 flex aspect-[3/4] w-24 origin-top-right -rotate-[22deg] flex-col gap-y-1 rounded-lg border border-muted-foreground/20 bg-white/80 px-2 py-4 backdrop-blur-sm group-hover:border-destructive/10 dark:bg-muted/80"
variants={DocumentDropzoneDisabledCardLeftVariants}
>
<div className="bg-muted-foreground/10 group-hover:bg-destructive/10 h-2 w-full rounded-[2px]" />
<div className="bg-muted-foreground/10 group-hover:bg-destructive/10 h-2 w-5/6 rounded-[2px]" />
<div className="bg-muted-foreground/10 group-hover:bg-destructive/10 h-2 w-full rounded-[2px]" />
<div className="h-2 w-full rounded-[2px] bg-muted-foreground/10 group-hover:bg-destructive/10" />
<div className="h-2 w-5/6 rounded-[2px] bg-muted-foreground/10 group-hover:bg-destructive/10" />
<div className="h-2 w-full rounded-[2px] bg-muted-foreground/10 group-hover:bg-destructive/10" />
</motion.div>
<motion.div
className="group-hover:bg-destructive/5 border-muted-foreground/20 group-hover:border-destructive/50 dark:bg-muted/80 z-20 flex aspect-[3/4] w-24 flex-col items-center justify-center gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm"
className="z-20 flex aspect-[3/4] w-24 flex-col items-center justify-center gap-y-1 rounded-lg border border-muted-foreground/20 bg-white/80 px-2 py-4 backdrop-blur-sm group-hover:border-destructive/50 group-hover:bg-destructive/5 dark:bg-muted/80"
variants={DocumentDropzoneDisabledCardCenterVariants}
>
<AlertTriangle
strokeWidth="2px"
className="text-muted-foreground/20 group-hover:text-destructive h-12 w-12"
className="h-12 w-12 text-muted-foreground/20 group-hover:text-destructive"
/>
</motion.div>
<motion.div
className="group-hover:bg-destructive/2 border-muted-foreground/20 group-hover:border-destructive/10 dark:bg-muted/80 z-10 flex aspect-[3/4] w-24 origin-top-left rotate-[22deg] flex-col gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm"
className="group-hover:bg-destructive/2 z-10 flex aspect-[3/4] w-24 origin-top-left rotate-[22deg] flex-col gap-y-1 rounded-lg border border-muted-foreground/20 bg-white/80 px-2 py-4 backdrop-blur-sm group-hover:border-destructive/10 dark:bg-muted/80"
variants={DocumentDropzoneDisabledCardRightVariants}
>
<div className="bg-muted-foreground/10 group-hover:bg-destructive/10 h-2 w-full rounded-[2px]" />
<div className="bg-muted-foreground/10 group-hover:bg-destructive/10 h-2 w-5/6 rounded-[2px]" />
<div className="bg-muted-foreground/10 group-hover:bg-destructive/10 h-2 w-full rounded-[2px]" />
<div className="h-2 w-full rounded-[2px] bg-muted-foreground/10 group-hover:bg-destructive/10" />
<div className="h-2 w-5/6 rounded-[2px] bg-muted-foreground/10 group-hover:bg-destructive/10" />
<div className="h-2 w-full rounded-[2px] bg-muted-foreground/10 group-hover:bg-destructive/10" />
</motion.div>
</div>
) : (
// Non Disabled State
<div className="flex">
<motion.div
className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 a z-10 flex aspect-[3/4] w-24 origin-top-right -rotate-[22deg] flex-col gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm"
className="a z-10 flex aspect-[3/4] w-24 origin-top-right -rotate-[22deg] flex-col gap-y-1 rounded-lg border border-muted-foreground/20 bg-white/80 px-2 py-4 backdrop-blur-sm group-hover:border-documenso/80 dark:bg-muted/80"
variants={DocumentDropzoneCardLeftVariants}
>
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-5/6 rounded-[2px]" />
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
<div className="h-2 w-full rounded-[2px] bg-muted-foreground/20 group-hover:bg-documenso" />
<div className="h-2 w-5/6 rounded-[2px] bg-muted-foreground/20 group-hover:bg-documenso" />
<div className="h-2 w-full rounded-[2px] bg-muted-foreground/20 group-hover:bg-documenso" />
</motion.div>
<motion.div
className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 z-20 flex aspect-[3/4] w-24 flex-col items-center justify-center gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm"
className="z-20 flex aspect-[3/4] w-24 flex-col items-center justify-center gap-y-1 rounded-lg border border-muted-foreground/20 bg-white/80 px-2 py-4 backdrop-blur-sm group-hover:border-documenso/80 dark:bg-muted/80"
variants={DocumentDropzoneCardCenterVariants}
>
<Plus
strokeWidth="2px"
className="text-muted-foreground/20 group-hover:text-documenso h-12 w-12"
className="h-12 w-12 text-muted-foreground/20 group-hover:text-documenso"
/>
</motion.div>
<motion.div
className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 z-10 flex aspect-[3/4] w-24 origin-top-left rotate-[22deg] flex-col gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm"
className="z-10 flex aspect-[3/4] w-24 origin-top-left rotate-[22deg] flex-col gap-y-1 rounded-lg border border-muted-foreground/20 bg-white/80 px-2 py-4 backdrop-blur-sm group-hover:border-documenso/80 dark:bg-muted/80"
variants={DocumentDropzoneCardRightVariants}
>
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-5/6 rounded-[2px]" />
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
<div className="h-2 w-full rounded-[2px] bg-muted-foreground/20 group-hover:bg-documenso" />
<div className="h-2 w-5/6 rounded-[2px] bg-muted-foreground/20 group-hover:bg-documenso" />
<div className="h-2 w-full rounded-[2px] bg-muted-foreground/20 group-hover:bg-documenso" />
</motion.div>
</div>
)}
<input {...getInputProps()} />
<p className="text-foreground mt-6 font-medium">{_(heading[type])}</p>
<p className="mt-6 font-medium text-foreground">{_(heading[type])}</p>
<p className="text-muted-foreground/80 mt-1 text-center text-sm">
{_(disabled ? disabledMessage : msg`Drag & drop your PDF here.`)}
<p className="mt-1 text-center text-sm text-muted-foreground/80">
{_(disabled ? disabledMessage : msg`Drag & drop PDF, DOCX, or images here.`)}
</p>
{disabled && IS_BILLING_ENABLED() && (
<Button className="hover:bg-warning/80 bg-warning mt-4 w-32" asChild>
<Button className="mt-4 w-32 bg-warning hover:bg-warning/80" asChild>
<Link to={`/o/${organisation.url}/settings/billing`}>
<Trans>Upgrade</Trans>
</Link>
@@ -11,6 +11,7 @@ import { Link } from 'react-router';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { ALLOWED_UPLOAD_MIME_TYPES } from '@documenso/lib/constants/upload';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
@@ -52,9 +53,7 @@ export const DocumentUploadButton = ({
const isPersonalLayoutMode = isPersonalLayout(organisations);
const { getRootProps, getInputProps } = useDropzone({
accept: {
'application/pdf': ['.pdf'],
},
accept: ALLOWED_UPLOAD_MIME_TYPES,
multiple: internalVersion === '2',
disabled,
maxFiles,
@@ -79,7 +78,7 @@ export const DocumentUploadButton = ({
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button className="hover:bg-warning/80 bg-warning" asChild>
<Button className="bg-warning hover:bg-warning/80" asChild>
<Link
to={
isPersonalLayoutMode