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 {
theme: getTheme(),
session: context.session,
__ENV__: Object.fromEntries(
Object.entries(process.env).filter(([key]) => key.startsWith('NEXT_')),
),
};
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_')), // 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,83 +71,82 @@ 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} />
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<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">
{team && (
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
<AvatarFallback className="text-xs text-gray-400">
{team.name.slice(0, 1)}
</AvatarFallback>
</Avatar>
)}
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
<div className="flex flex-row items-center">
{team && (
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
<AvatarFallback className="text-xs text-gray-400">
{team.name.slice(0, 1)}
</AvatarFallback>
</Avatar>
)}
<h1 className="text-4xl font-semibold">
<Trans>Documents</Trans>
</h1>
</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">
<TabsList>
{[
ExtendedDocumentStatus.INBOX,
ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.ALL,
].map((value) => (
<TabsTrigger
key={value}
className="hover:text-foreground min-w-[60px]"
value={value}
asChild
>
<Link to={getTabHref(value)} preventScrollReset>
<DocumentStatus status={value} />
{value !== ExtendedDocumentStatus.ALL && (
<span className="ml-1 inline-block opacity-50">todo</span>
)}
</Link>
</TabsTrigger>
))}
</TabsList>
</Tabs>
{team && <DocumentsTableSenderFilter teamId={team.id} />}
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<PeriodSelector />
</div>
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<DocumentSearch initialValue={search} />
</div>
</div>
<h1 className="text-4xl font-semibold">
<Trans>Documents</Trans>
</h1>
</div>
<div className="mt-8">
<div>
{data && data.count === 0 ? (
<DocumentsTableEmptyState status={status} />
) : (
<DocumentsTable
data={data}
isLoading={isLoading}
isLoadingError={isLoadingError}
showSenderColumn={team !== undefined}
team={currentTeam}
/>
)}
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
<Tabs value={findDocumentSearchParams.status} className="overflow-x-auto">
<TabsList>
{[
ExtendedDocumentStatus.INBOX,
ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.ALL,
].map((value) => (
<TabsTrigger
key={value}
className="hover:text-foreground min-w-[60px]"
value={value}
asChild
>
<Link to={getTabHref(value)} preventScrollReset>
<DocumentStatus status={value} />
{value !== ExtendedDocumentStatus.ALL && (
<span className="ml-1 inline-block opacity-50">{stats[value]}</span>
)}
</Link>
</TabsTrigger>
))}
</TabsList>
</Tabs>
{team && <DocumentsTableSenderFilter teamId={team.id} />}
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<PeriodSelector />
</div>
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<DocumentSearch initialValue={findDocumentSearchParams.query} />
</div>
</div>
</div>
</>
<div className="mt-8">
<div>
{data && data.count === 0 ? (
<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,
});