mirror of
https://github.com/documenso/documenso.git
synced 2025-11-21 04:01:45 +10:00
fix: wip
This commit is contained in:
@ -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',
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
19
apps/remix/app/routes/api+/locale.tsx
Normal file
19
apps/remix/app/routes/api+/locale.tsx
Normal 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) },
|
||||
});
|
||||
};
|
||||
6
apps/remix/app/storage/lang-cookie.server.ts
Normal file
6
apps/remix/app/storage/lang-cookie.server.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { createCookie } from 'react-router';
|
||||
|
||||
export const langCookie = createCookie('lang', {
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 365 * 2,
|
||||
});
|
||||
Reference in New Issue
Block a user