mirror of
https://github.com/documenso/documenso.git
synced 2025-11-15 01:01:49 +10:00
feat: add single player mode
This commit is contained in:
56
packages/ui/components/document/document-dialog.tsx
Normal file
56
packages/ui/components/document/document-dialog.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
import { Dialog, DialogOverlay, DialogPortal } from '../../primitives/dialog';
|
||||
import { LazyPDFViewerNoLoader } from '../../primitives/lazy-pdf-viewer';
|
||||
|
||||
export type DocumentDialogProps = {
|
||||
document: string;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
/**
|
||||
* A dialog which renders the provided document.
|
||||
*/
|
||||
export default function DocumentDialog({ document, ...props }: DocumentDialogProps) {
|
||||
const [documentLoaded, setDocumentLoaded] = useState(false);
|
||||
|
||||
const onDocumentLoad = () => {
|
||||
setDocumentLoaded(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogPortal>
|
||||
<DialogOverlay className="bg-black/80" />
|
||||
|
||||
<DialogPrimitive.Content
|
||||
className={cn(
|
||||
'animate-in data-[state=open]:fade-in-90 sm:zoom-in-90 pointer-events-none fixed z-50 h-screen w-screen overflow-y-auto px-2 py-14 opacity-0 transition-opacity lg:py-32',
|
||||
{
|
||||
'opacity-100': documentLoaded,
|
||||
},
|
||||
)}
|
||||
onClick={() => props.onOpenChange?.(false)}
|
||||
>
|
||||
<LazyPDFViewerNoLoader
|
||||
className="mx-auto w-full max-w-3xl xl:max-w-5xl"
|
||||
document={`data:application/pdf;base64,${document}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDocumentLoad={onDocumentLoad}
|
||||
/>
|
||||
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none">
|
||||
<X className="h-6 w-6 text-white" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
78
packages/ui/components/document/document-download-button.tsx
Normal file
78
packages/ui/components/document/document-download-button.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { HTMLAttributes, useState } from 'react';
|
||||
|
||||
import { Download } from 'lucide-react';
|
||||
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { DocumentData } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type DownloadButtonProps = HTMLAttributes<HTMLButtonElement> & {
|
||||
disabled?: boolean;
|
||||
fileName?: string;
|
||||
documentData?: DocumentData;
|
||||
};
|
||||
|
||||
export const DocumentDownloadButton = ({
|
||||
className,
|
||||
fileName,
|
||||
documentData,
|
||||
disabled,
|
||||
...props
|
||||
}: DownloadButtonProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
if (!documentData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bytes = await getFile(documentData);
|
||||
|
||||
const blob = new Blob([bytes], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
|
||||
const link = window.document.createElement('a');
|
||||
|
||||
link.href = window.URL.createObjectURL(blob);
|
||||
link.download = fileName || 'document.pdf';
|
||||
|
||||
link.click();
|
||||
|
||||
window.URL.revokeObjectURL(link.href);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while downloading your document.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={className}
|
||||
disabled={disabled || !documentData}
|
||||
onClick={onDownloadClick}
|
||||
loading={isLoading}
|
||||
{...props}
|
||||
>
|
||||
<Download className="mr-2 h-5 w-5" />
|
||||
Download
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
205
packages/ui/components/signing-card.tsx
Normal file
205
packages/ui/components/signing-card.tsx
Normal file
@ -0,0 +1,205 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import Image, { StaticImageData } from 'next/image';
|
||||
|
||||
import { animate, motion, useMotionTemplate, useMotionValue, useTransform } from 'framer-motion';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
|
||||
export type SigningCardProps = {
|
||||
name: string;
|
||||
signingCelebrationImage?: StaticImageData;
|
||||
};
|
||||
|
||||
/**
|
||||
* 2D signing card.
|
||||
*/
|
||||
export const SigningCard = ({ name, signingCelebrationImage }: SigningCardProps) => {
|
||||
return (
|
||||
<div className="relative w-full max-w-xs md:max-w-sm">
|
||||
<SigningCardContent name={name} />
|
||||
|
||||
{signingCelebrationImage && (
|
||||
<SigningCardImage signingCelebrationImage={signingCelebrationImage} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 3D signing card that follows the mouse movement within a certain range.
|
||||
*/
|
||||
export const SigningCard3D = ({ name, signingCelebrationImage }: SigningCardProps) => {
|
||||
// Should use % based dimensions by calculating the window height/width.
|
||||
const boundary = 400;
|
||||
|
||||
const [trackMouse, setTrackMouse] = useState(false);
|
||||
|
||||
const timeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
const cardX = useMotionValue(0);
|
||||
const cardY = useMotionValue(0);
|
||||
const rotateX = useTransform(cardY, [-600, 600], [8, -8]);
|
||||
const rotateY = useTransform(cardX, [-600, 600], [-8, 8]);
|
||||
|
||||
const diagonalMovement = useTransform<number, number>(
|
||||
[rotateX, rotateY],
|
||||
([newRotateX, newRotateY]) => newRotateX + newRotateY,
|
||||
);
|
||||
|
||||
const sheenPosition = useTransform(diagonalMovement, [-16, 16], [-100, 200]);
|
||||
const sheenOpacity = useTransform(sheenPosition, [-100, 50, 200], [0, 0.1, 0]);
|
||||
const sheenGradient = useMotionTemplate`linear-gradient(
|
||||
30deg,
|
||||
transparent,
|
||||
rgba(200 200 200 / ${trackMouse ? sheenOpacity : 0}) ${sheenPosition}%,
|
||||
transparent)`;
|
||||
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const cardCenterPosition = useCallback(() => {
|
||||
if (!cardRef.current) {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
const { x, y, width, height } = cardRef.current.getBoundingClientRect();
|
||||
|
||||
return { x: x + width / 2, y: y + height / 2 };
|
||||
}, [cardRef]);
|
||||
|
||||
const onMouseMove = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
const { x, y } = cardCenterPosition();
|
||||
|
||||
const offsetX = event.clientX - x;
|
||||
const offsetY = event.clientY - y;
|
||||
|
||||
// Calculate distance between the mouse pointer and center of the card.
|
||||
const distance = Math.sqrt(offsetX * offsetX + offsetY * offsetY);
|
||||
|
||||
// Mouse enters enter boundary.
|
||||
if (distance <= boundary && !trackMouse) {
|
||||
setTrackMouse(true);
|
||||
}
|
||||
|
||||
if (!trackMouse) {
|
||||
return;
|
||||
}
|
||||
|
||||
void animate(cardX, offsetX, { duration: 0.125 });
|
||||
void animate(cardY, offsetY, { duration: 0.125 });
|
||||
|
||||
clearTimeout(timeoutRef.current);
|
||||
|
||||
// Revert the card back to the center position after the mouse stops moving.
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
void animate(cardX, 0, { duration: 2, ease: 'backInOut' });
|
||||
void animate(cardY, 0, { duration: 2, ease: 'backInOut' });
|
||||
|
||||
setTrackMouse(false);
|
||||
}, 1000);
|
||||
},
|
||||
[cardX, cardY, cardCenterPosition, trackMouse],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
};
|
||||
}, [onMouseMove]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full max-w-xs md:max-w-sm" style={{ perspective: 800 }}>
|
||||
<motion.div
|
||||
className="bg-background w-full"
|
||||
ref={cardRef}
|
||||
style={{
|
||||
perspective: '800',
|
||||
backgroundImage: sheenGradient,
|
||||
transformStyle: 'preserve-3d',
|
||||
rotateX,
|
||||
rotateY,
|
||||
}}
|
||||
>
|
||||
<SigningCardContent className="bg-transparent" name={name} />
|
||||
</motion.div>
|
||||
|
||||
{signingCelebrationImage && (
|
||||
<SigningCardImage signingCelebrationImage={signingCelebrationImage} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type SigningCardContentProps = {
|
||||
name: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const SigningCardContent = ({ className, name }: SigningCardContentProps) => {
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'group mx-auto flex aspect-[21/9] w-full items-center justify-center',
|
||||
className,
|
||||
)}
|
||||
degrees={-145}
|
||||
gradient
|
||||
>
|
||||
<CardContent
|
||||
className="font-signature p-6 text-center"
|
||||
style={{
|
||||
container: 'main',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-muted-foreground/60 group-hover:text-primary/80 break-all font-semibold duration-300"
|
||||
style={{
|
||||
fontSize: `max(min(4rem, ${(100 / name.length / 2).toFixed(4)}cqw), 1.875rem)`,
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
type SigningCardImageProps = {
|
||||
signingCelebrationImage: StaticImageData;
|
||||
};
|
||||
|
||||
const SigningCardImage = ({ signingCelebrationImage }: SigningCardImageProps) => {
|
||||
return (
|
||||
<motion.div
|
||||
className="pointer-events-none absolute -inset-32 -z-10 flex items-center justify-center md:-inset-44 xl:-inset-60 2xl:-inset-80"
|
||||
initial={{
|
||||
opacity: 0,
|
||||
scale: 0.8,
|
||||
}}
|
||||
animate={{
|
||||
scale: 1,
|
||||
opacity: 0.5,
|
||||
}}
|
||||
transition={{
|
||||
delay: 0.5,
|
||||
duration: 0.5,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={signingCelebrationImage}
|
||||
alt="background pattern"
|
||||
className="w-full"
|
||||
style={{
|
||||
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
|
||||
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user