This commit is contained in:
David Nguyen
2025-02-03 19:52:23 +11:00
parent b2af10173a
commit 8bffa7c3ed
40 changed files with 1012 additions and 979 deletions

View File

@ -18,7 +18,6 @@ import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc';
import { switchI18NLanguage } from '@documenso/lib/server-only/i18n/switch-i18n-language';
import { dynamicActivate } from '@documenso/lib/utils/i18n';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@ -288,9 +287,23 @@ const LanguageCommands = () => {
setIsLoading(true);
try {
await dynamicActivate(i18n, lang);
await switchI18NLanguage(lang);
} catch (err) {
await dynamicActivate(lang);
const formData = new FormData();
formData.append('lang', lang);
const response = await fetch('/api/locale', {
method: 'post',
body: formData,
});
if (!response.ok) {
throw new Error(response.statusText);
}
} catch (e) {
console.error(`Failed to set language: ${e}`);
toast({
title: _(msg`An unknown error occurred`),
variant: 'destructive',

View File

@ -17,15 +17,14 @@ import { cn } from '@documenso/ui/lib/utils';
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export type DocumentUploadDropzoneProps = {
className?: string;
team?: {
id: number;
url: string;
};
};
export const DocumentUploadDropzone = ({ className, team }: DocumentUploadDropzoneProps) => {
export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProps) => {
const team = useOptionalCurrentTeam();
const navigate = useNavigate();
const userTimezone =
@ -70,7 +69,7 @@ export const DocumentUploadDropzone = ({ className, team }: DocumentUploadDropzo
method: 'POST',
body: formData,
})
.then((res) => res.json())
.then(async (res) => res.json())
.catch((e) => {
console.error('Upload failed:', e);
throw new AppError('UPLOAD_FAILED');

View File

@ -2,7 +2,6 @@ import { useMemo, useTransition } from 'react';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { Team } from '@prisma/client';
import { Loader } from 'lucide-react';
import { DateTime } from 'luxon';
import { Link } from 'react-router';
@ -21,6 +20,7 @@ import { TableCell } from '@documenso/ui/primitives/table';
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
import { DocumentStatus } from '~/components/formatter/document-status';
import { useOptionalCurrentTeam } from '~/providers/team';
import { DocumentsTableActionButton } from './documents-table-action-button';
import { DocumentsTableActionDropdown } from './documents-table-action-dropdown';
@ -29,21 +29,14 @@ export type DocumentsTableProps = {
data?: TFindDocumentsResponse;
isLoading?: boolean;
isLoadingError?: boolean;
showSenderColumn?: boolean;
team?: Pick<Team, 'id' | 'url'> & { teamEmail?: string };
};
type DocumentsTableRow = TFindDocumentsResponse['data'][number];
export const DocumentsTable = ({
data,
showSenderColumn,
team,
isLoading,
isLoadingError,
}: DocumentsTableProps) => {
export const DocumentsTable = ({ data, isLoading, isLoadingError }: DocumentsTableProps) => {
const { _, i18n } = useLingui();
const team = useOptionalCurrentTeam();
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
@ -120,7 +113,7 @@ export const DocumentsTable = ({
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
columnVisibility={{
sender: Boolean(showSenderColumn),
sender: team !== undefined,
}}
error={{
enable: isLoadingError || false,

View File

@ -1,24 +1,17 @@
import { StrictMode, startTransition } from 'react';
import { i18n } from '@lingui/core';
import { detect, fromHtmlTag } from '@lingui/detect-locale';
import { I18nProvider } from '@lingui/react';
import { hydrateRoot } from 'react-dom/client';
import { HydratedRouter } from 'react-router/dom';
import { dynamicActivate } from '@documenso/lib/utils/i18n';
async function main() {
// const locale = detect(fromHtmlTag('lang')) || 'en';
const locale = detect(fromHtmlTag('lang')) || 'en';
// await dynamicActivate(locale);
// await new Promise((resolve) => setTimeout(resolve, 100));
// Todo: i18n
const locale = 'en';
// const { messages } = await import(`../../../packages/lib/translations/en/web.po`);
// const { messages } = await import(`../../../packages/lib/translations/${locale}/web.po`);
i18n.loadAndActivate({ locale, messages: {} });
await dynamicActivate(locale);
startTransition(() => {
hydrateRoot(

View File

@ -8,6 +8,11 @@ import { renderToPipeableStream } from 'react-dom/server';
import type { AppLoadContext, EntryContext } from 'react-router';
import { ServerRouter } from 'react-router';
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import { dynamicActivate, extractLocaleData } from '@documenso/lib/utils/i18n';
import { langCookie } from './storage/lang-cookie.server';
export const streamTimeout = 5_000;
export default async function handleRequest(
@ -17,12 +22,13 @@ export default async function handleRequest(
routerContext: EntryContext,
_loadContext: AppLoadContext,
) {
// Todo: i18n
const locale = 'en';
// const { messages } = await import(`../../../packages/lib/translations/en/web.po`);
// const { messages } = await import(`../../../packages/lib/translations/${locale}/web.po`);
let language = await langCookie.parse(request.headers.get('cookie') ?? '');
i18n.loadAndActivate({ locale, messages: {} });
if (!APP_I18N_OPTIONS.supportedLangs.includes(language)) {
language = extractLocaleData({ headers: request.headers }).lang;
}
await dynamicActivate(language);
return new Promise((resolve, reject) => {
let shellRendered = false;

View File

@ -4,18 +4,22 @@ import {
Outlet,
Scripts,
ScrollRestoration,
data,
isRouteErrorResponse,
useLoaderData,
} from 'react-router';
import { ThemeProvider } from 'remix-themes';
import { SessionProvider } from '@documenso/lib/client-only/providers/session';
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import { extractLocaleData } from '@documenso/lib/utils/i18n';
import { TrpcProvider } from '@documenso/trpc/react';
import { Toaster } from '@documenso/ui/primitives/toaster';
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
import type { Route } from './+types/root';
import stylesheet from './app.css?url';
import { langCookie } from './storage/lang-cookie.server';
import { themeSessionResolver } from './storage/theme-session.server';
export const links: Route.LinksFunction = () => [
@ -39,27 +43,42 @@ export const links: Route.LinksFunction = () => [
export async function loader({ request, context }: Route.LoaderArgs) {
const { getTheme } = await themeSessionResolver(request);
return {
let lang = await langCookie.parse(request.headers.get('cookie') ?? '');
if (!APP_I18N_OPTIONS.supportedLangs.includes(lang)) {
lang = extractLocaleData({ headers: request.headers });
}
return data(
{
lang,
theme: getTheme(),
session: context.session,
__ENV__: Object.fromEntries(
Object.entries(process.env).filter(([key]) => key.startsWith('NEXT_')),
Object.entries(process.env).filter(([key]) => key.startsWith('NEXT_')), // Todo: I'm pretty sure this will leak?
),
};
},
{
headers: {
'Set-Cookie': await langCookie.serialize(lang),
},
},
);
}
export function Layout({ children }: { children: React.ReactNode }) {
const { __ENV__, theme } = useLoaderData<typeof loader>() || {};
const { __ENV__, theme, lang } = useLoaderData<typeof loader>() || {};
// const [theme] = useTheme();
return (
<html lang="en" data-theme={theme ?? ''}>
<html translate="no" lang={lang} data-theme={theme ?? ''}>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
<meta name="google" content="notranslate" />
{/* <PreventFlashOnWrongTheme ssrTheme={Boolean(theme)} /> */}
</head>
<body>

View File

@ -1,23 +1,24 @@
import { useEffect, useMemo, useState } from 'react';
import { Trans } from '@lingui/macro';
import { useSearchParams } from 'react-router';
import { Link } from 'react-router';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { parseToIntegerArray } from '@documenso/lib/utils/params';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { trpc } from '@documenso/trpc/react';
import {
type TFindDocumentsInternalResponse,
ZFindDocumentsInternalRequestSchema,
} from '@documenso/trpc/server/document-router/schema';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { DocumentSearch } from '~/components/(dashboard)/document-search/document-search';
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
import { DocumentUploadDropzone } from '~/components/document/document-upload';
import { DocumentStatus } from '~/components/formatter/document-status';
import { UpcomingProfileClaimTeaser } from '~/components/general/upcoming-profile-claim-teaser';
import { DocumentsTable } from '~/components/tables/documents-table';
import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state';
import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter';
@ -27,48 +28,35 @@ export function meta() {
return [{ title: 'Documents' }];
}
// searchParams?: {
// status?: ExtendedDocumentStatus;
// period?: PeriodSelectorValue;
// page?: string;
// perPage?: string;
// senderIds?: string;
// search?: string;
// };
const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
status: true,
period: true,
page: true,
perPage: true,
senderIds: true,
query: true,
});
export default function DocumentsPage() {
const [searchParams] = useSearchParams();
const { user } = useSession();
const team = useOptionalCurrentTeam();
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 20;
const senderIds = parseToIntegerArray(searchParams.senderIds ?? '');
const search = searchParams.search || '';
const currentTeam = team
? { id: team.id, url: team.url, teamEmail: team.teamEmail?.email }
: undefined;
const currentTeamMemberRole = team?.currentTeamMember?.role;
const [stats, setStats] = useState<TFindDocumentsInternalResponse['stats']>({
[ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0,
});
// const results = await findDocuments({
// status,
// orderBy: {
// column: 'createdAt',
// direction: 'desc',
// },
// page,
// perPage,
// period,
// senderIds,
// query: search,
// });
const findDocumentSearchParams = useMemo(
() => ZSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {},
[searchParams],
);
const { data, isLoading, isLoadingError } = trpc.document.findDocuments.useQuery({
page,
perPage,
const { data, isLoading, isLoadingError } = trpc.document.findDocumentsInternal.useQuery({
...findDocumentSearchParams,
});
const getTabHref = (value: typeof status) => {
@ -83,11 +71,15 @@ export default function DocumentsPage() {
return `${formatDocumentsPath(team?.url)}?${params.toString()}`;
};
useEffect(() => {
if (data?.stats) {
setStats(data.stats);
}
}, [data?.stats]);
return (
<>
<UpcomingProfileClaimTeaser />
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<DocumentUploadDropzone team={currentTeam} />
<DocumentUploadDropzone />
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
<div className="flex flex-row items-center">
@ -106,7 +98,7 @@ export default function DocumentsPage() {
</div>
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
<Tabs value={status} className="overflow-x-auto">
<Tabs value={findDocumentSearchParams.status} className="overflow-x-auto">
<TabsList>
{[
ExtendedDocumentStatus.INBOX,
@ -125,7 +117,7 @@ export default function DocumentsPage() {
<DocumentStatus status={value} />
{value !== ExtendedDocumentStatus.ALL && (
<span className="ml-1 inline-block opacity-50">todo</span>
<span className="ml-1 inline-block opacity-50">{stats[value]}</span>
)}
</Link>
</TabsTrigger>
@ -139,7 +131,7 @@ export default function DocumentsPage() {
<PeriodSelector />
</div>
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<DocumentSearch initialValue={search} />
<DocumentSearch initialValue={findDocumentSearchParams.query} />
</div>
</div>
</div>
@ -147,19 +139,14 @@ export default function DocumentsPage() {
<div className="mt-8">
<div>
{data && data.count === 0 ? (
<DocumentsTableEmptyState status={status} />
) : (
<DocumentsTable
data={data}
isLoading={isLoading}
isLoadingError={isLoadingError}
showSenderColumn={team !== undefined}
team={currentTeam}
<DocumentsTableEmptyState
status={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL}
/>
) : (
<DocumentsTable data={data} isLoading={isLoading} isLoadingError={isLoadingError} />
)}
</div>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,19 @@
import type { ActionFunctionArgs } from 'react-router';
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import { langCookie } from '~/storage/lang-cookie.server';
export const action = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const lang = formData.get('lang') || '';
if (!APP_I18N_OPTIONS.supportedLangs.find((l) => l === lang)) {
throw new Response('Unsupported language', { status: 400 });
}
return new Response('OK', {
status: 200,
headers: { 'Set-Cookie': await langCookie.serialize(lang) },
});
};

View File

@ -0,0 +1,6 @@
import { createCookie } from 'react-router';
export const langCookie = createCookie('lang', {
path: '/',
maxAge: 60 * 60 * 24 * 365 * 2,
});

View File

@ -16,6 +16,8 @@
"@hono/node-server": "^1.13.7",
"@hono/trpc-server": "^0.3.4",
"@hono/zod-validator": "^0.4.2",
"@lingui/core": "^4.11.3",
"@lingui/detect-locale": "^4.11.1",
"@lingui/macro": "^4.11.3",
"@lingui/react": "^4.11.3",
"@oslojs/crypto": "^1.0.1",
@ -37,7 +39,7 @@
"ts-pattern": "^5.0.5"
},
"devDependencies": {
"@lingui/vite-plugin": "^5.1.2",
"@lingui/vite-plugin": "^5.2.0",
"@react-router/dev": "^7.1.1",
"@react-router/remix-routes-option-adapter": "^7.1.3",
"@types/node": "^20",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 529 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 693 KiB

File diff suppressed because one or more lines are too long

View File

@ -1,19 +0,0 @@
{
"name": "Documenso",
"short_name": "Documenso",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#A2E771",
"background_color": "#FFFFFF",
"display": "standalone"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 784 B

View File

@ -1,33 +0,0 @@
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1080_12656)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.56772 0.890928C9.5882 -0.296974 11.4118 -0.296978 12.4323 0.890927L13.2272 1.81624C13.3589 1.96964 13.5596 2.0435 13.758 2.01166L14.955 1.81961C16.4917 1.57307 17.8887 2.75864 17.9154 4.33206L17.9363 5.55768C17.9398 5.76086 18.0465 5.94788 18.2188 6.0525L19.2578 6.68358C20.5916 7.49375 20.9083 9.31015 19.9288 10.5329L19.1659 11.4853C19.0394 11.6432 19.0023 11.8559 19.0678 12.048L19.4627 13.2069C19.9696 14.6947 19.0578 16.292 17.5304 16.5919L16.3406 16.8255C16.1434 16.8643 15.9798 17.0031 15.9079 17.1928L15.4738 18.3373C14.9166 19.8066 13.203 20.4374 11.8423 19.6741L10.7825 19.0796C10.6068 18.981 10.3932 18.981 10.2175 19.0796L9.15768 19.6741C7.79704 20.4374 6.08341 19.8066 5.52618 18.3373L5.09212 17.1928C5.02017 17.0031 4.8566 16.8643 4.65937 16.8255L3.46962 16.5919C1.94224 16.292 1.03044 14.6947 1.53734 13.2069L1.93219 12.048C1.99765 11.8559 1.96057 11.6432 1.8341 11.4853L1.07116 10.5329C0.0917119 9.31015 0.408373 7.49375 1.74223 6.68358L2.78123 6.0525C2.95348 5.94788 3.06024 5.76086 3.0637 5.55768L3.08456 4.33206C3.11133 2.75864 4.50829 1.57307 6.04498 1.81961L7.24197 2.01166C7.4404 2.0435 7.64105 1.96964 7.77282 1.81624L8.56772 0.890928Z" fill="url(#paint0_linear_1080_12656)"/>
<g filter="url(#filter0_di_1080_12656)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.3714 14.5609C13.5195 14.6358 13.6925 14.5149 13.6642 14.3563L13.1163 11.2805L15.4388 9.10299C15.5586 8.9907 15.4925 8.79506 15.327 8.77192L12.1176 8.32508L10.681 5.52519C10.6069 5.38093 10.3931 5.38093 10.319 5.52519L8.88116 8.32354L5.673 8.77192C5.50748 8.79506 5.44139 8.9907 5.56116 9.10299L7.8843 11.2803L7.33579 14.3563C7.30752 14.5149 7.48055 14.6358 7.62859 14.5609L10.5014 13.1083L13.3714 14.5609Z" fill="#FFFCEB"/>
</g>
</g>
<defs>
<filter id="filter0_di_1080_12656" x="5.33521" y="5.41699" width="10.6591" height="9.90853" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="0.164785" dy="0.411963"/>
<feGaussianBlur stdDeviation="0.164785"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.414307 0 0 0 0 0.24341 0 0 0 0 0.0856598 0 0 0 0.1 0"/>
<feBlend mode="plus-darker" in2="BackgroundImageFix" result="effect1_dropShadow_1080_12656"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1080_12656" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="0.164785" dy="0.164785"/>
<feGaussianBlur stdDeviation="0.0823927"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.5 0"/>
<feBlend mode="screen" in2="shape" result="effect2_innerShadow_1080_12656"/>
</filter>
<linearGradient id="paint0_linear_1080_12656" x1="12.5596" y1="-9.0568e-08" x2="6.25112" y2="19.9592" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFE76A"/>
<stop offset="1" stop-color="#E8C445"/>
</linearGradient>
<clipPath id="clip0_1080_12656">
<rect width="20" height="20" fill="white" transform="translate(0.5)"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -1,9 +0,0 @@
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.54474 0.890944C9.57689 -0.296979 11.4213 -0.296983 12.4535 0.890943L13.2575 1.81628C13.3908 1.96967 13.5937 2.04354 13.7944 2.0117L15.0051 1.81965C16.5593 1.57309 17.9723 2.75869 17.9994 4.33214L18.0205 5.55778C18.024 5.76096 18.1319 5.94799 18.3061 6.05261L19.357 6.6837C20.7061 7.49389 21.0264 9.31032 20.0358 10.5331L19.2641 11.4855C19.1362 11.6434 19.0987 11.8561 19.1649 12.0482L19.5643 13.2072C20.077 14.695 19.1547 16.2923 17.6099 16.5922L16.4065 16.8258C16.207 16.8646 16.0416 17.0034 15.9688 17.1931L15.5298 18.3376C14.9662 19.8069 13.233 20.4378 11.8568 19.6745L10.7848 19.08C10.6071 18.9814 10.3911 18.9814 10.2134 19.08L9.14145 19.6745C7.76525 20.4378 6.03203 19.8069 5.46842 18.3376L5.0294 17.1931C4.95662 17.0034 4.79119 16.8646 4.5917 16.8258L3.38834 16.5922C1.8435 16.2923 0.921268 14.695 1.43397 13.2072L1.83334 12.0482C1.89954 11.8561 1.86204 11.6434 1.73412 11.4855L0.962455 10.5331C-0.0281913 9.31032 0.292091 7.49389 1.6412 6.6837L2.69209 6.05261C2.8663 5.94799 2.97428 5.76096 2.97778 5.55778L2.99888 4.33214C3.02596 2.75869 4.4389 1.57309 5.99315 1.81965L7.20383 2.0117C7.40454 2.04354 7.60747 1.96967 7.74076 1.81628L8.54474 0.890944ZM13.7062 9.20711C14.0968 8.81658 14.0968 8.18342 13.7062 7.79289C13.3157 7.40237 12.6825 7.40237 12.292 7.79289L9.49912 10.5858L8.70622 9.79289C8.3157 9.40237 7.68253 9.40237 7.29201 9.79289C6.90148 10.1834 6.90148 10.8166 7.29201 11.2071L8.43846 12.3536C9.02425 12.9393 9.97399 12.9393 10.5598 12.3536L13.7062 9.20711Z" fill="url(#paint0_linear_1080_12647)"/>
<defs>
<linearGradient id="paint0_linear_1080_12647" x1="12.5823" y1="-9.05696e-08" x2="6.33214" y2="20.0004" gradientUnits="userSpaceOnUse">
<stop stop-color="#96D766"/>
<stop offset="1" stop-color="#5AAE30"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 708 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 367 B

1371
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +0,0 @@
'use server';
import { cookies } from 'next/headers';
// eslint-disable-next-line @typescript-eslint/require-await
export const switchI18NLanguage = async (lang: string) => {
// Two year expiry.
const maxAge = 60 * 60 * 24 * 365 * 2;
cookies().set('language', lang, { maxAge });
};

View File

@ -1,19 +1,19 @@
import type { ReadonlyRequestCookies } from 'next/dist/server/web/spec-extension/adapters/request-cookies';
import type { I18n, MessageDescriptor } from '@lingui/core';
import { i18n } from '@lingui/core';
import type { I18nLocaleData, SupportedLanguageCodes } from '../constants/i18n';
import { APP_I18N_OPTIONS } from '../constants/i18n';
import { env } from './env';
export async function dynamicActivate(i18nInstance: I18n, locale: string) {
export async function dynamicActivate(locale: string) {
const extension = env('NODE_ENV') === 'development' ? 'po' : 'js';
// const { messages } = await import(`../translations/${locale}/web.${extension}`);
// todo
const messages = {};
// Todo: Use extension (currently breaks).
i18nInstance.loadAndActivate({ locale, messages });
// const { messages } = await import(`../translations/${locale}/web.${extension}`);
const { messages } = await import(`../translations/${locale}/web.po`);
i18n.loadAndActivate({ locale, messages });
}
const parseLanguageFromLocale = (locale: string): SupportedLanguageCodes | null => {
@ -30,25 +30,6 @@ const parseLanguageFromLocale = (locale: string): SupportedLanguageCodes | null
return foundSupportedLanguage;
};
/**
* Extract the language if supported from the cookies header.
*
* Returns `null` if not supported or not found.
*/
export const extractLocaleDataFromCookies = (
cookies: ReadonlyRequestCookies,
): SupportedLanguageCodes | null => {
const preferredLocale = cookies.get('language')?.value || '';
const language = parseLanguageFromLocale(preferredLocale || '');
if (!language) {
return null;
}
return language;
};
/**
* Extracts the language from the `accept-language` header.
*/
@ -67,30 +48,24 @@ export const extractLocaleDataFromHeaders = (
type ExtractLocaleDataOptions = {
headers: Headers;
cookies: ReadonlyRequestCookies;
};
/**
* Extract the supported language from the cookies, then header if not found.
* Extract the supported language from the header.
*
* Will return the default fallback language if not found.
*/
export const extractLocaleData = ({
headers,
cookies,
}: ExtractLocaleDataOptions): I18nLocaleData => {
let lang: SupportedLanguageCodes | null = extractLocaleDataFromCookies(cookies);
export const extractLocaleData = ({ headers }: ExtractLocaleDataOptions): I18nLocaleData => {
const headerLocales = (headers.get('accept-language') ?? '').split(',');
const langHeader = extractLocaleDataFromHeaders(headers);
if (!lang && langHeader?.lang) {
lang = langHeader.lang;
}
const unknownLanguages = headerLocales
.map((locale) => parseLanguageFromLocale(locale))
.filter((value): value is SupportedLanguageCodes => value !== null);
// Filter out locales that are not valid.
const locales = (langHeader?.locales ?? []).filter((locale) => {
const languages = (unknownLanguages ?? []).filter((language) => {
try {
new Intl.Locale(locale);
new Intl.Locale(language);
return true;
} catch {
return false;
@ -98,8 +73,8 @@ export const extractLocaleData = ({
});
return {
lang: lang || APP_I18N_OPTIONS.sourceLang,
locales,
lang: languages[0] || APP_I18N_OPTIONS.sourceLang,
locales: headerLocales,
};
};

View File

@ -18,11 +18,14 @@ import { findDocuments } from '@documenso/lib/server-only/document/find-document
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
import type { GetStatsInput } from '@documenso/lib/server-only/document/get-stats';
import { getStats } from '@documenso/lib/server-only/document/get-stats';
import { moveDocumentToTeam } from '@documenso/lib/server-only/document/move-document-to-team';
import { resendDocument } from '@documenso/lib/server-only/document/resend-document';
import { searchDocumentsWithKeyword } from '@documenso/lib/server-only/document/search-documents-with-keyword';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
@ -39,6 +42,8 @@ import {
ZDuplicateDocumentRequestSchema,
ZDuplicateDocumentResponseSchema,
ZFindDocumentAuditLogsQuerySchema,
ZFindDocumentsInternalRequestSchema,
ZFindDocumentsInternalResponseSchema,
ZFindDocumentsRequestSchema,
ZFindDocumentsResponseSchema,
ZGenericSuccessResponse,
@ -124,6 +129,82 @@ export const documentRouter = router({
return documents;
}),
/**
* Internal endpoint for /documents page to additionally return getStats.
*
* @private
*/
findDocumentsInternal: authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/document',
summary: 'Find documents',
description: 'Find documents based on a search criteria',
tags: ['Document'],
},
})
.input(ZFindDocumentsInternalRequestSchema)
.output(ZFindDocumentsInternalResponseSchema)
.query(async ({ input, ctx }) => {
const { user, teamId } = ctx;
const {
query,
templateId,
page,
perPage,
orderByDirection,
orderByColumn,
source,
status,
period,
senderIds,
} = input;
const getStatOptions: GetStatsInput = {
user,
period,
search: query,
};
if (teamId) {
const team = await getTeamById({ userId: user.id, teamId });
getStatOptions.team = {
teamId: team.id,
teamEmail: team.teamEmail?.email,
senderIds,
currentTeamMemberRole: team.currentTeamMember?.role,
currentUserEmail: user.email,
userId: user.id,
};
}
const [stats, documents] = await Promise.all([
getStats(getStatOptions),
findDocuments({
userId: user.id,
teamId,
templateId,
query,
source,
status,
page,
perPage,
period,
orderBy: orderByColumn
? { column: orderByColumn, direction: orderByDirection }
: undefined,
}),
]);
return {
...documents,
stats,
};
}),
/**
* @public
*

View File

@ -31,6 +31,7 @@ import {
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { ZCreateRecipientSchema } from '../recipient-router/schema';
@ -131,6 +132,25 @@ export const ZFindDocumentsResponseSchema = ZFindResultResponse.extend({
export type TFindDocumentsResponse = z.infer<typeof ZFindDocumentsResponseSchema>;
export const ZFindDocumentsInternalRequestSchema = ZFindDocumentsRequestSchema.extend({
period: z.enum(['7d', '14d', '30d']).optional(),
senderIds: z.array(z.number()).optional(),
status: z.nativeEnum(ExtendedDocumentStatus).optional(),
});
export const ZFindDocumentsInternalResponseSchema = ZFindResultResponse.extend({
data: ZDocumentManySchema.array(),
stats: z.object({
[ExtendedDocumentStatus.DRAFT]: z.number(),
[ExtendedDocumentStatus.PENDING]: z.number(),
[ExtendedDocumentStatus.COMPLETED]: z.number(),
[ExtendedDocumentStatus.INBOX]: z.number(),
[ExtendedDocumentStatus.ALL]: z.number(),
}),
});
export type TFindDocumentsInternalResponse = z.infer<typeof ZFindDocumentsInternalResponseSchema>;
export const ZFindDocumentAuditLogsQuerySchema = ZFindSearchParamsSchema.extend({
documentId: z.number().min(1),
cursor: z.string().optional(),

View File

@ -3,7 +3,6 @@ import { useLingui } from '@lingui/react';
import { CheckIcon } from 'lucide-react';
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import { switchI18NLanguage } from '@documenso/lib/server-only/i18n/switch-i18n-language';
import { dynamicActivate } from '@documenso/lib/utils/i18n';
import { cn } from '@documenso/ui/lib/utils';
import {
@ -25,8 +24,16 @@ export const LanguageSwitcherDialog = ({ open, setOpen }: LanguageSwitcherDialog
const setLanguage = async (lang: string) => {
setOpen(false);
await dynamicActivate(i18n, lang);
await switchI18NLanguage(lang);
await dynamicActivate(lang);
const formData = new FormData();
formData.append('lang', lang);
await fetch('/api/locale', {
method: 'post',
body: formData,
});
};
return (