mirror of
https://github.com/documenso/documenso.git
synced 2025-11-22 20:51:33 +10:00
feat: add folders (#1711)
This commit is contained in:
@ -0,0 +1,192 @@
|
||||
import { type ReactNode, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { Link, useNavigate, useParams } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
export interface DocumentDropZoneWrapperProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZoneWrapperProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { user } = useSession();
|
||||
const { folderId } = useParams();
|
||||
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const userTimezone =
|
||||
TIME_ZONES.find((timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone) ??
|
||||
DEFAULT_DOCUMENT_TIME_ZONE;
|
||||
|
||||
const { quota, remaining, refreshLimits } = useLimits();
|
||||
|
||||
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
|
||||
|
||||
const isUploadDisabled = remaining.documents === 0 || !user.emailVerified;
|
||||
|
||||
const onFileDrop = async (file: File) => {
|
||||
if (isUploadDisabled && IS_BILLING_ENABLED()) {
|
||||
await navigate('/settings/billing');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const response = await putPdfFile(file);
|
||||
|
||||
const { id } = await createDocument({
|
||||
title: file.name,
|
||||
documentDataId: response.id,
|
||||
timezone: userTimezone,
|
||||
folderId: folderId ?? undefined,
|
||||
});
|
||||
|
||||
void refreshLimits();
|
||||
|
||||
toast({
|
||||
title: _(msg`Document uploaded`),
|
||||
description: _(msg`Your document has been uploaded successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
analytics.capture('App: Document Uploaded', {
|
||||
userId: user.id,
|
||||
documentId: id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await navigate(
|
||||
folderId
|
||||
? `${formatDocumentsPath(team?.url)}/f/${folderId}/${id}/edit`
|
||||
: `${formatDocumentsPath(team?.url)}/${id}/edit`,
|
||||
);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
const errorMessage = match(error.code)
|
||||
.with('INVALID_DOCUMENT_FILE', () => msg`You cannot upload encrypted PDFs`)
|
||||
.with(
|
||||
AppErrorCode.LIMIT_EXCEEDED,
|
||||
() => msg`You have reached your document limit for this month. Please upgrade your plan.`,
|
||||
)
|
||||
.otherwise(() => msg`An error occurred while uploading your document.`);
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(errorMessage),
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onFileDropRejected = () => {
|
||||
toast({
|
||||
title: _(msg`Your document failed to upload.`),
|
||||
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
accept: {
|
||||
'application/pdf': ['.pdf'],
|
||||
},
|
||||
//disabled: isUploadDisabled,
|
||||
multiple: false,
|
||||
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
|
||||
onDrop: ([acceptedFile]) => {
|
||||
if (acceptedFile) {
|
||||
void onFileDrop(acceptedFile);
|
||||
}
|
||||
},
|
||||
onDropRejected: () => {
|
||||
void onFileDropRejected();
|
||||
},
|
||||
noClick: true,
|
||||
noDragEventsBubbling: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div {...getRootProps()} className={cn('relative min-h-screen', className)}>
|
||||
<input {...getInputProps()} />
|
||||
{children}
|
||||
|
||||
{isDragActive && (
|
||||
<div className="bg-muted/60 fixed left-0 top-0 z-[9999] h-full w-full backdrop-blur-[4px]">
|
||||
<div className="pointer-events-none flex h-full w-full flex-col items-center justify-center">
|
||||
<h2 className="text-foreground text-2xl font-semibold">
|
||||
<Trans>Upload Document</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground text-md mt-4">
|
||||
<Trans>Drag and drop your PDF file here</Trans>
|
||||
</p>
|
||||
|
||||
{isUploadDisabled && IS_BILLING_ENABLED() && (
|
||||
<Link
|
||||
to="/settings/billing"
|
||||
className="mt-4 text-sm text-amber-500 hover:underline dark:text-amber-400"
|
||||
>
|
||||
<Trans>Upgrade your plan to upload more documents</Trans>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{!isUploadDisabled &&
|
||||
team?.id === undefined &&
|
||||
remaining.documents > 0 &&
|
||||
Number.isFinite(remaining.documents) && (
|
||||
<p className="text-muted-foreground/80 mt-4 text-sm">
|
||||
<Trans>
|
||||
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="bg-muted/30 absolute inset-0 z-50 backdrop-blur-[2px]">
|
||||
<div className="pointer-events-none flex h-1/2 w-full flex-col items-center justify-center">
|
||||
<Loader className="text-primary h-12 w-12 animate-spin" />
|
||||
<p className="text-foreground mt-8 font-medium">
|
||||
<Trans>Uploading document...</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -38,6 +38,9 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
|
||||
const role = recipient?.role;
|
||||
|
||||
const documentsPath = formatDocumentsPath(document.team?.url);
|
||||
const formatPath = document.folderId
|
||||
? `${documentsPath}/f/${document.folderId}/${document.id}/edit`
|
||||
: `${documentsPath}/${document.id}/edit`;
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
@ -101,7 +104,7 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
|
||||
))
|
||||
.with({ isComplete: false }, () => (
|
||||
<Button className="w-full" asChild>
|
||||
<Link to={`${documentsPath}/${document.id}/edit`}>
|
||||
<Link to={formatPath}>
|
||||
<Trans>Edit</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@ -4,7 +4,7 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
@ -17,7 +17,13 @@ import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
@ -30,6 +36,7 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { user } = useSession();
|
||||
const { folderId } = useParams();
|
||||
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
@ -69,6 +76,7 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
|
||||
title: file.name,
|
||||
documentDataId: response.id,
|
||||
timezone: userTimezone,
|
||||
folderId: folderId ?? undefined,
|
||||
});
|
||||
|
||||
void refreshLimits();
|
||||
@ -85,7 +93,11 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await navigate(`${formatDocumentsPath(team?.url)}/${id}/edit`);
|
||||
await navigate(
|
||||
folderId
|
||||
? `${formatDocumentsPath(team?.url)}/f/${folderId}/${id}/edit`
|
||||
: `${formatDocumentsPath(team?.url)}/${id}/edit`,
|
||||
);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
@ -121,25 +133,31 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<DocumentDropzone
|
||||
className="h-[min(400px,50vh)]"
|
||||
disabled={remaining.documents === 0 || !user.emailVerified}
|
||||
disabledMessage={disabledMessage}
|
||||
onDrop={onFileDrop}
|
||||
onDropRejected={onFileDropRejected}
|
||||
/>
|
||||
|
||||
<div className="absolute -bottom-6 right-0">
|
||||
{team?.id === undefined &&
|
||||
remaining.documents > 0 &&
|
||||
Number.isFinite(remaining.documents) && (
|
||||
<p className="text-muted-foreground/60 text-xs">
|
||||
<Trans>
|
||||
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<DocumentDropzone
|
||||
disabled={remaining.documents === 0 || !user.emailVerified}
|
||||
disabledMessage={disabledMessage}
|
||||
onDrop={onFileDrop}
|
||||
onDropRejected={onFileDropRejected}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{team?.id === undefined &&
|
||||
remaining.documents > 0 &&
|
||||
Number.isFinite(remaining.documents) && (
|
||||
<TooltipContent>
|
||||
<p className="text-sm">
|
||||
<Trans>
|
||||
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||
</Trans>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{isLoading && (
|
||||
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
|
||||
|
||||
88
apps/remix/app/components/general/folder/folder-card.tsx
Normal file
88
apps/remix/app/components/general/folder/folder-card.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import { FolderIcon, PinIcon } from 'lucide-react';
|
||||
|
||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||
import { formatFolderCount } from '@documenso/lib/utils/format-folder-count';
|
||||
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
|
||||
export type FolderCardProps = {
|
||||
folder: TFolderWithSubfolders;
|
||||
onNavigate: (folderId: string) => void;
|
||||
onMove: (folder: TFolderWithSubfolders) => void;
|
||||
onPin: (folderId: string) => void;
|
||||
onUnpin: (folderId: string) => void;
|
||||
onSettings: (folder: TFolderWithSubfolders) => void;
|
||||
onDelete: (folder: TFolderWithSubfolders) => void;
|
||||
};
|
||||
|
||||
export const FolderCard = ({
|
||||
folder,
|
||||
onNavigate,
|
||||
onMove,
|
||||
onPin,
|
||||
onUnpin,
|
||||
onSettings,
|
||||
onDelete,
|
||||
}: FolderCardProps) => {
|
||||
return (
|
||||
<div
|
||||
key={folder.id}
|
||||
className="border-border hover:border-muted-foreground/40 group relative flex flex-col rounded-lg border p-4 transition-all hover:shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<button
|
||||
className="flex items-center space-x-2 text-left"
|
||||
onClick={() => onNavigate(folder.id)}
|
||||
>
|
||||
<FolderIcon className="text-documenso h-6 w-6" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium">{folder.name}</h3>
|
||||
{folder.pinned && <PinIcon className="text-documenso h-3 w-3" />}
|
||||
</div>
|
||||
<div className="mt-1 flex space-x-2 text-xs text-gray-500">
|
||||
<span>
|
||||
{formatFolderCount(
|
||||
folder.type === FolderType.TEMPLATE
|
||||
? folder._count.templates
|
||||
: folder._count.documents,
|
||||
folder.type === FolderType.TEMPLATE ? 'template' : 'document',
|
||||
folder.type === FolderType.TEMPLATE ? 'templates' : 'documents',
|
||||
)}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{formatFolderCount(folder._count.subfolders, 'folder', 'folders')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="opacity-0 group-hover:opacity-100">
|
||||
•••
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onMove(folder)}>Move</DropdownMenuItem>
|
||||
{folder.pinned ? (
|
||||
<DropdownMenuItem onClick={() => onUnpin(folder.id)}>Unpin</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={() => onPin(folder.id)}>Pin</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => onSettings(folder)}>Settings</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-red-500" onClick={() => onDelete(folder)}>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -208,7 +208,11 @@ export const TemplateEditForm = ({
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
await navigate(templateRootPath);
|
||||
const templatePath = template.folderId
|
||||
? `${templateRootPath}/f/${template.folderId}`
|
||||
: templateRootPath;
|
||||
|
||||
await navigate(templatePath);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user