├── .eslintrc.json ├── .github └── workflows │ └── main.yml ├── .gitignore ├── README.md ├── app ├── (auth) │ ├── DynamicImage.tsx │ ├── actions.ts │ ├── forget-password │ │ └── page.tsx │ ├── layout.tsx │ ├── otp │ │ └── [id] │ │ │ └── page.tsx │ ├── reset-password │ │ └── [id] │ │ │ └── page.tsx │ ├── signin │ │ ├── components │ │ │ ├── GoogleAuthButton.tsx │ │ │ └── SignInForm.tsx │ │ └── page.tsx │ ├── signup │ │ ├── components │ │ │ ├── GoogleAuthButton.tsx │ │ │ └── SignUpForm.tsx │ │ └── page.tsx │ └── zodSchema.ts ├── NextAuthProvider.tsx ├── ReactQueryProvider.tsx ├── api │ └── auth │ │ └── [...nextauth] │ │ └── route.ts ├── components │ ├── AboutSection.tsx │ ├── ContactSection.tsx │ ├── FeaturesSection.tsx │ ├── Footer.tsx │ └── HeroSection.tsx ├── document │ ├── actions.ts │ ├── components │ │ ├── Card │ │ │ ├── Card.tsx │ │ │ ├── actions.ts │ │ │ └── components │ │ │ │ ├── Input.tsx │ │ │ │ └── Options.tsx │ │ ├── Header │ │ │ ├── Header.tsx │ │ │ ├── actions.ts │ │ │ └── components │ │ │ │ ├── HeaderButtons.tsx │ │ │ │ ├── ProfileBtn.tsx │ │ │ │ └── SearchBar.tsx │ │ └── Onboarding.tsx │ ├── loading.tsx │ └── page.tsx ├── favicon.ico ├── globals.css ├── layout.tsx ├── page.tsx └── writer │ └── [id] │ ├── actions.ts │ ├── components │ ├── BubbleMenuComp │ │ ├── AskAI.tsx │ │ ├── GeneratedText.tsx │ │ ├── generateTextConfig.ts │ │ └── index.tsx │ ├── EditorLoading.tsx │ ├── Header │ │ └── Header.tsx │ ├── OptionsResp.tsx │ ├── Tabs.tsx │ └── options │ │ ├── GoBack.tsx │ │ ├── format │ │ ├── BulletListBtns.tsx │ │ ├── ColorHighlight.tsx │ │ ├── Font.tsx │ │ ├── FormattingBtns.tsx │ │ ├── Heading.tsx │ │ ├── ParagraphBtns.tsx │ │ ├── functions.ts │ │ ├── index.tsx │ │ └── textEditorOptions.ts │ │ ├── index.ts │ │ └── insert │ │ └── index.tsx │ ├── editor │ ├── editorConfig.ts │ └── index.ts │ └── page.tsx ├── components.json ├── components ├── AvatarList.tsx ├── GridBg.tsx ├── HeaderBtn.tsx ├── HeroImage.tsx ├── LoaderButton.tsx ├── LoopWords.tsx ├── Navbar.tsx ├── ScrollCard.tsx ├── aiAnimation │ ├── components │ │ ├── BubbleMenu.tsx │ │ ├── ColorHighlight.tsx │ │ └── FormattingBtns.tsx │ └── index.tsx └── ui │ ├── avatar.tsx │ ├── badge.tsx │ ├── button.tsx │ ├── card.tsx │ ├── dialog.tsx │ ├── drawer.tsx │ ├── dropdown.tsx │ ├── input.tsx │ ├── label.tsx │ ├── popover.tsx │ ├── scroll-area.tsx │ ├── select.tsx │ ├── sonner.tsx │ ├── tabs.tsx │ ├── textarea.tsx │ └── tooltip.tsx ├── docker-compose.yaml ├── eslint.js ├── helpers ├── generateJWT.ts ├── getInitials.ts ├── getRandomColor.ts └── prettifyDates.ts ├── lib ├── auth.ts ├── customHooks │ ├── ReturnType.ts │ ├── action.ts │ ├── getServerSession.ts │ ├── useClientSession.tsx │ ├── useDebounce.tsx │ └── useMotionTimeline.tsx ├── guestServices.ts ├── mail │ ├── EmailVerificationMailTemplate.tsx │ ├── resetPasswordMailTemplate.tsx │ └── sendMail.ts └── utils.ts ├── middleware.ts ├── next-env.d.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── prisma ├── migrations │ ├── 20240709131843_init │ │ └── migration.sql │ ├── 20240715060410_15_07_2024 │ │ └── migration.sql │ ├── 20240715112437_ │ │ └── migration.sql │ ├── 20240715114926_schema_change │ │ └── migration.sql │ ├── 20240715134816_ │ │ └── migration.sql │ ├── 20240716082106_cascade_delete │ │ └── migration.sql │ ├── 20240717054532_thumbnail │ │ └── migration.sql │ ├── 20240724135206_init2 │ │ └── migration.sql │ ├── 20240804151601_new_changes │ │ └── migration.sql │ ├── 20240805092134_password_null │ │ └── migration.sql │ ├── 20240805092953_picture_null │ │ └── migration.sql │ ├── 20241020044532_otp │ │ └── migration.sql │ └── migration_lock.toml ├── prismaClient.ts └── schema.prisma ├── public ├── 110045644.png ├── Forgot password.webp ├── Hero image mobile view.webp ├── Hero video thumbnail.png ├── Hero_section_image.png ├── Reset password.webp ├── Signup.webp ├── Toolbar.png ├── Verify otp.webp ├── ai-feature.png ├── collab-feature.png ├── createdoc.png ├── favicon.ico ├── github.png ├── google_icon.svg ├── grid_bg.svg ├── logo.png ├── logo.svg ├── mask.svg ├── output-onlinepngtools.svg ├── profilepic_placeholder.png └── signin.webp ├── tailwind.config.ts ├── tsconfig.json └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI/CD Pipeline 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - "**" 7 | paths-ignore: 8 | - "README.md" 9 | 10 | permissions: 11 | id-token: write 12 | contents: read 13 | 14 | jobs: 15 | Continuous_Integration: 16 | name: Check for Linting and Formatting 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout Code 20 | uses: actions/checkout@v3 21 | 22 | - name: Install dependancies 23 | run: npm install 24 | 25 | - name: Lint code 26 | run: npm run lint:check 27 | 28 | - name: Check Formatting 29 | run: npm run format:check 30 | 31 | - name: Run unit tests 32 | run: echo "Running unit tests" 33 | 34 | # build-and-push-ecr-image: 35 | # name: Build and Push to ECR 36 | # needs: integration 37 | # runs-on: ubuntu-latest 38 | # steps: 39 | # - name: Checkout Code 40 | # uses: actions/checkout@v2 41 | 42 | # - name: Configure AWS credentials 43 | # uses: aws-actions/configure-aws-credentials@v1 44 | # with: 45 | # aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 46 | # aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 47 | # aws-region: ${{ secrets.AWS_DEFAULT_REGION }} 48 | 49 | # - name: Login to Amazon ECR 50 | # id: login-ecr 51 | # uses: aws-actions/amazon-ecr-login@v1 52 | 53 | # - name: Build and tag Docker images 54 | # run: | 55 | # # Build Docker containers and tag them. 56 | # docker compose -f D:\Projects\Google docs clone\docker-compose.yaml build 57 | 58 | # - name: Push Docker images to Amazon ECR 59 | # run: | 60 | # # Push the Docker images to Amazon ECR. 61 | # docker compose -f D:\Projects\Google docs clone\docker-compose.yaml push 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | .env 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /app/(auth)/DynamicImage.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useCallback, useEffect, useState } from "react"; 4 | import { usePathname } from "next/navigation"; 5 | import Image from "next/image"; 6 | 7 | import SignIn from "@/public/signin.webp"; 8 | import SignUp from "@/public/Signup.webp"; 9 | import ResetPassword from "@/public/Reset password.webp"; 10 | import ForgotPassword from "@/public/Forgot password.webp"; 11 | import VerifyOtp from "@/public/Verify otp.webp"; 12 | 13 | const images = [ 14 | { path: "signin", image: SignIn }, 15 | { path: "signup", image: SignUp }, 16 | { path: "reset-password", image: ResetPassword }, 17 | { path: "forget-password", image: ForgotPassword }, 18 | { path: "otp", image: VerifyOtp }, 19 | ]; 20 | export default function DynamicImage() { 21 | const url = usePathname(); 22 | 23 | const getImageFromRoute = useCallback(() => { 24 | return images.find((e) => e.path === url.split("/")[1])?.image || ""; 25 | }, [url]); 26 | 27 | const [currentImage, setCurrentImage] = useState(getImageFromRoute()); 28 | 29 | useEffect(() => { 30 | setCurrentImage(getImageFromRoute()); 31 | }, [getImageFromRoute, url]); 32 | 33 | return ( 34 | Image 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /app/(auth)/forget-password/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { useRouter } from "next/navigation"; 5 | import { toast } from "sonner"; 6 | import { KeyRound } from "lucide-react"; 7 | 8 | import LoaderButton from "@/components/LoaderButton"; 9 | import { Input } from "@/components/ui/input"; 10 | import { Label } from "@/components/ui/label"; 11 | import { sendResetPasswordMail } from "../actions"; 12 | 13 | export default function ForgetPassoword() { 14 | const router = useRouter(); 15 | 16 | const [inputEmail, setInputEmail] = useState(""); 17 | const [isSubmitting, setIsSubmitting] = useState(false); 18 | 19 | const sendMail = async () => { 20 | setIsSubmitting(true); 21 | try { 22 | const response = await sendResetPasswordMail(inputEmail); 23 | if (response.success) { 24 | toast.success(response.data); 25 | router.push(`/signin`); 26 | setIsSubmitting(false); 27 | } else { 28 | console.log(response.error); 29 | toast.error(response.error); 30 | setIsSubmitting(false); 31 | } 32 | } catch (e) { 33 | console.log(e); 34 | setIsSubmitting(false); 35 | } 36 | }; 37 | return ( 38 | <> 39 |
40 | 41 |

42 | Forgot your  43 | Password ? 44 |

45 |

46 | Enter the email address , and we'll send you a link to reset your 47 | password. 48 |

49 |
50 |
51 | 52 | setInputEmail(e.target.value)} 56 | /> 57 | 62 | Send email 63 | 64 |
65 | 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | import DynamicImage from "./DynamicImage"; 2 | 3 | export default function AuthLayout({ 4 | children, 5 | }: { 6 | children: React.ReactNode; 7 | }) { 8 | return ( 9 |
10 |
11 |
{children}
12 |
13 |
14 | 15 |
16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /app/(auth)/otp/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | import { useParams, useRouter } from "next/navigation"; 5 | import { toast } from "sonner"; 6 | import { MailCheck } from "lucide-react"; 7 | 8 | import LoaderButton from "@/components/LoaderButton"; 9 | import { Input } from "@/components/ui/input"; 10 | import { resendVerifyCode, verifyEmail } from "../../actions"; 11 | 12 | export default function OTP() { 13 | const params = useParams(); 14 | const router = useRouter(); 15 | 16 | const userEmail = params.id.toString().replace("%40", "@"); 17 | 18 | const [inputValue, setInputValue] = useState(""); 19 | const [isSubmitting, setIsSubmitting] = useState(false); 20 | const [isResending, setIsResending] = useState(false); 21 | const [timer, setTimer] = useState(5); 22 | 23 | useEffect(() => { 24 | const interval = setInterval(() => { 25 | setTimer((prev) => { 26 | if (prev <= 1) { 27 | // clearInterval(interval); 28 | return 0; 29 | } 30 | return prev - 1; 31 | }); 32 | }, 1000); 33 | 34 | return () => { 35 | clearInterval(interval); 36 | }; 37 | }, []); 38 | 39 | const verify = async () => { 40 | setIsSubmitting(true); 41 | try { 42 | const response = await verifyEmail(inputValue, userEmail); 43 | if (response.success) { 44 | toast.success(response.data); 45 | router.push(`/document`); 46 | setIsSubmitting(false); 47 | } else { 48 | console.log(response.error); 49 | toast.error(response.error); 50 | setIsSubmitting(false); 51 | } 52 | } catch (e) { 53 | console.log(e); 54 | setIsSubmitting(false); 55 | } 56 | }; 57 | 58 | const resendCode = async () => { 59 | setIsResending(true); 60 | try { 61 | const response = await resendVerifyCode(userEmail); 62 | if (response.success) { 63 | toast.success(response.data); 64 | setTimer(121); 65 | setIsResending(false); 66 | } else { 67 | console.log(response.error); 68 | toast.error(response.error); 69 | setIsResending(false); 70 | } 71 | } catch (e) { 72 | console.log(e); 73 | setIsResending(false); 74 | } 75 | }; 76 | return ( 77 | <> 78 |
79 | 80 |

81 | Please check your  82 | mail 83 |

84 |

85 | We've sent a code to {userEmail} 86 |

87 |
88 |
89 | setInputValue(e.target.value)} 93 | /> 94 | 99 | Verify 100 | 101 | {timer === 0 ? ( 102 | 127 | ) : ( 128 |

129 | Resend code in: {Math.floor(timer / 60)}: 130 | {timer - Math.floor(timer / 60) * 60 < 10 131 | ? "0" + String(timer - Math.floor(timer / 60) * 60) 132 | : timer - Math.floor(timer / 60) * 60} 133 |

134 | )} 135 |
136 | 137 | ); 138 | } 139 | -------------------------------------------------------------------------------- /app/(auth)/reset-password/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { useParams, useRouter } from "next/navigation"; 5 | import { toast } from "sonner"; 6 | import { KeyRound } from "lucide-react"; 7 | 8 | import LoaderButton from "@/components/LoaderButton"; 9 | import { Input } from "@/components/ui/input"; 10 | import { Label } from "@/components/ui/label"; 11 | import { resetPassword } from "../../actions"; 12 | import { passwordValidation } from "../../zodSchema"; 13 | 14 | export default function ResetPassword() { 15 | const params = useParams(); 16 | const router = useRouter(); 17 | 18 | const [newPassword, setNewPassword] = useState(""); 19 | const [confirmNewPassword, setConfirmNewPassword] = useState(""); 20 | const [isSubmitting, setIsSubmitting] = useState(false); 21 | const [isRegexError, setIsRegexError] = useState(false); 22 | 23 | const changePassword = async () => { 24 | setIsSubmitting(true); 25 | try { 26 | if (passwordValidation.test(newPassword)) { 27 | if (newPassword !== confirmNewPassword) { 28 | setIsSubmitting(false); 29 | return toast.error("New password and confirm password do not match."); 30 | } 31 | 32 | const response = await resetPassword(params.id, newPassword); 33 | if (response.success) { 34 | toast.success(response.data); 35 | router.push(`/signin`); 36 | setIsSubmitting(false); 37 | } else { 38 | console.log(response.error); 39 | toast.error(response.error); 40 | setIsSubmitting(false); 41 | } 42 | } else { 43 | setIsRegexError(true); 44 | setIsSubmitting(false); 45 | } 46 | } catch (e) { 47 | console.log(e); 48 | setIsSubmitting(false); 49 | } 50 | }; 51 | return ( 52 | <> 53 |
54 | 55 |

56 | Change your  57 | Password 58 |

59 |
60 |
{ 63 | e.preventDefault(); 64 | changePassword(); 65 | }} 66 | > 67 | 68 | setNewPassword(e.target.value)} 72 | /> 73 | {isRegexError ? ( 74 |

75 | Password must include at least one uppercase, one lowercase, one 76 | digit, one special character, and be at least 8 characters long. 77 |

78 | ) : ( 79 | <> 80 | )} 81 | 82 | 83 | setConfirmNewPassword(e.target.value)} 87 | /> 88 | 89 | 94 | Change my password 95 | 96 |
97 | 98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /app/(auth)/signin/components/GoogleAuthButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | import { signIn } from "next-auth/react"; 5 | 6 | import Google from "@/public/google_icon.svg"; 7 | import { Button } from "@/components/ui/button"; 8 | 9 | export default function GoogleAuthButton() { 10 | return ( 11 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /app/(auth)/signin/components/SignInForm.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { useRouter } from "next/navigation"; 5 | import { useForm } from "react-hook-form"; 6 | import { toast } from "sonner"; 7 | import { Eye, EyeOff } from "lucide-react"; 8 | import { z } from "zod"; 9 | 10 | import { Input } from "@/components/ui/input"; 11 | import { Label } from "@/components/ui/label"; 12 | import LoaderButton from "@/components/LoaderButton"; 13 | 14 | import { SigninAction } from "../../actions"; 15 | import { signinSchema } from "../../zodSchema"; 16 | 17 | export default function CredentialsForm() { 18 | const router = useRouter(); 19 | 20 | const { register, handleSubmit } = useForm>(); 21 | 22 | const [isSubmitting, setIsSubmitting] = useState(false); 23 | const [isPasswordVisible, setIsPasswordVisible] = useState(false); 24 | 25 | const submitForm = async (data: z.infer) => { 26 | setIsSubmitting(true); 27 | const parsedData = signinSchema.parse({ 28 | email: data.email, 29 | password: data.password, 30 | }); 31 | const response = await SigninAction(parsedData); 32 | if (response.success) { 33 | toast.success("login completed"); 34 | router.push("/document"); 35 | setIsSubmitting(false); 36 | } else { 37 | toast.error(response.error); 38 | setIsSubmitting(false); 39 | } 40 | }; 41 | 42 | return ( 43 |
44 |
45 | 46 | 51 |
52 |
53 |
54 | 55 | 61 |
62 |
63 | 68 | {!isPasswordVisible ? ( 69 | setIsPasswordVisible(true)} 72 | className="absolute transform -translate-y-[1.7rem] translate-x-80 cursor-pointer text-neutral-500 hover:text-black" 73 | /> 74 | ) : ( 75 | setIsPasswordVisible(false)} 78 | className="absolute transform -translate-y-[1.7rem] translate-x-80 cursor-pointer text-neutral-500 hover:text-black" 79 | /> 80 | )} 81 |
82 |
83 | 88 | Sign in 89 | 90 |
91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /app/(auth)/signin/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | import SignInForm from "./components/SignInForm"; 4 | import GoogleAuthButton from "./components/GoogleAuthButton"; 5 | 6 | export default function Login() { 7 | return ( 8 | <> 9 |
10 |

11 | Sign  12 | in 13 |

14 |

15 | Enter your email below to login to your account 16 |

17 |
18 |
19 | 20 |

OR

21 | 22 |

23 | Don't have an account?{" "} 24 | 25 | Sign up 26 | 27 |

28 |
29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /app/(auth)/signup/components/GoogleAuthButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | import { signIn } from "next-auth/react"; 5 | 6 | import Google from "@/public/google_icon.svg"; 7 | import { Button } from "@/components/ui/button"; 8 | 9 | export default function GoogleAuthButton() { 10 | return ( 11 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /app/(auth)/signup/components/SignUpForm.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { useRouter } from "next/navigation"; 5 | import { useForm } from "react-hook-form"; 6 | import { toast } from "sonner"; 7 | import { z, ZodError } from "zod"; 8 | import { Eye, EyeOff } from "lucide-react"; 9 | 10 | import { Input } from "@/components/ui/input"; 11 | import { Label } from "@/components/ui/label"; 12 | import LoaderButton from "@/components/LoaderButton"; 13 | 14 | import { SignupAction } from "../../actions"; 15 | import { signupSchema } from "../../zodSchema"; 16 | 17 | export default function CredentialsForm() { 18 | const router = useRouter(); 19 | 20 | const { register, handleSubmit } = useForm>(); 21 | 22 | const [isSubmitting, setIsSubmitting] = useState(false); 23 | const [isPasswordVisible, setIsPasswordVisible] = useState(false); 24 | const [isRegexError, setIsRegexError] = useState(false); 25 | 26 | const submitForm = async (data: z.infer) => { 27 | setIsSubmitting(true); 28 | try { 29 | const parsedData = signupSchema.safeParse({ 30 | name: data.name, 31 | username: data.username, 32 | email: data.email, 33 | password: data.password, 34 | }); 35 | if (!parsedData.success) { 36 | if (parsedData.error.issues[0].code === "invalid_string") 37 | setIsRegexError(true); 38 | setIsSubmitting(false); 39 | } 40 | 41 | if (!parsedData.data) return; 42 | 43 | const response = await SignupAction(parsedData.data); 44 | if (response.success) { 45 | toast.success("User registerd successfully. Please verify your email."); 46 | router.push(`/otp/${data.email}`); 47 | setIsSubmitting(false); 48 | } else { 49 | console.log(response.error); 50 | console.log("here"); 51 | toast.error(response.error); 52 | setIsSubmitting(false); 53 | } 54 | } catch (e: ZodError | any) { 55 | console.log(e); 56 | setIsSubmitting(false); 57 | } 58 | }; 59 | return ( 60 |
61 |
62 | 63 | 68 |
69 |
70 | 71 | 76 |
77 |
78 | 79 | 84 |
85 |
86 | 87 |
88 | 93 | {!isPasswordVisible ? ( 94 | setIsPasswordVisible(true)} 97 | className="absolute transform -translate-y-[1.7rem] translate-x-80 cursor-pointer text-neutral-500 hover:text-black" 98 | /> 99 | ) : ( 100 | setIsPasswordVisible(false)} 103 | className="absolute transform -translate-y-[1.7rem] translate-x-80 cursor-pointer text-neutral-500 hover:text-black" 104 | /> 105 | )} 106 | {isRegexError ? ( 107 |

108 | Password must include at least one uppercase, one lowercase, one 109 | digit, one special character, and be at least 8 characters long. 110 |

111 | ) : ( 112 | <> 113 | )} 114 |
115 |
116 | 120 | Sign up 121 | 122 |
123 | ); 124 | } 125 | -------------------------------------------------------------------------------- /app/(auth)/signup/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | import SignUpForm from "./components/SignUpForm"; 4 | import GoogleAuthButton from "./components/GoogleAuthButton"; 5 | 6 | export default function Signup() { 7 | return ( 8 | <> 9 |
10 |

11 | Sign  12 | up 13 |

14 |

15 | Enter your credentials below to login to your account 16 |

17 |
18 |
19 | 20 |

OR

21 | 22 |

23 | Already have an account?{" "} 24 | 25 | Sign in 26 | 27 |

28 |
29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /app/(auth)/zodSchema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | // Minimum 8 characters, at least one uppercase letter, one lowercase letter, one number and one special character 4 | export const passwordValidation = new RegExp( 5 | /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/, 6 | ); 7 | 8 | export const signupSchema = z.object({ 9 | name: z.string().min(1).max(50), 10 | username: z.string().min(1).max(20), 11 | email: z.string().email(), 12 | password: z.string().min(1).regex(passwordValidation), 13 | }); 14 | 15 | export const signinSchema = z.object({ 16 | email: z.string().email(), 17 | password: z.string(), 18 | }); 19 | -------------------------------------------------------------------------------- /app/NextAuthProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ReactNode } from "react"; 4 | import { SessionProvider } from "next-auth/react"; 5 | 6 | export const NextAuthProvider = ({ children }: { children: ReactNode }) => { 7 | return {children}; 8 | }; 9 | -------------------------------------------------------------------------------- /app/ReactQueryProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 5 | 6 | const queryClient = new QueryClient(); 7 | 8 | export default function ReactQueryProvider({ 9 | children, 10 | }: { 11 | children: React.ReactNode; 12 | }) { 13 | return ( 14 | {children} 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import { authOptions } from "@/lib/auth"; 2 | import NextAuth from "next-auth"; 3 | 4 | const handler = NextAuth(authOptions); 5 | 6 | export { handler as GET, handler as POST }; 7 | -------------------------------------------------------------------------------- /app/components/AboutSection.tsx: -------------------------------------------------------------------------------- 1 | export default function AboutSection() { 2 | return ( 3 |
7 |
8 |
9 |

10 | About  11 | DocX 12 |

13 |

14 | DocX is an open-source Google Docs alternative, created by a team of 15 | passionate developers who believe in the power of collaborative 16 | editing. Our mission is to provide a user-friendly, feature-rich 17 | platform that empowers teams to work together seamlessly. 18 |

19 |
20 |
21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /app/components/ContactSection.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { Input } from "@/components/ui/input"; 3 | 4 | export default function ContactSection() { 5 | return ( 6 |
10 |
11 |
12 |

13 | Get in  14 | Touch 15 |

16 |

17 | Have questions or feedback? We'd love to hear from you. Fill 18 | out the form below, and one of our team members will get back to 19 | you. 20 |

21 |
22 |
23 |
24 | 29 | 32 |
33 |
34 |
35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /app/components/FeaturesSection.tsx: -------------------------------------------------------------------------------- 1 | import { Cloud, Users, Wand } from "lucide-react"; 2 | import * as motion from "framer-motion/client"; 3 | import AiAnimation from "@/components/aiAnimation"; 4 | import AiFeature from "@/public/ai-feature.png" 5 | import CollabFeature from "@/public/collab-feature.png" 6 | import Image, { StaticImageData } from "next/image"; 7 | 8 | export default function FeaturesSection() { 9 | const features = [ 10 | { 11 | title: "AI powered", 12 | description: 13 | "Leverage advanced AI capabilities, offering intelligent assistance and seamless automation without additional costs", 14 | animation: , 15 | image: AiFeature 16 | }, 17 | { 18 | title: "Real-Time Collaboration ", 19 | description: 20 | "Multiple users can edit the same document simultaneously, with changes syncing in real-time.", 21 | animation: , 22 | image: CollabFeature 23 | }, 24 | // { 25 | // title: "Highly scalable", 26 | // description: 27 | // "Adapts effortlessly to growth, maintaining performance as needs expand.", 28 | // animation: , 29 | // image: AiFeature 30 | // }, 31 | ]; 32 | 33 | return ( 34 |
38 |
39 |
40 | 48 | Key  49 | Features 50 | 51 |
52 |
53 | {features.map((feature, index) => ( 54 | 55 | ))} 56 |
57 |
58 |
59 | ); 60 | } 61 | 62 | function Feature({ 63 | title, 64 | description, 65 | animation, 66 | image, 67 | index, 68 | }: { 69 | title: string; 70 | description: string; 71 | animation: React.ReactNode; 72 | image: StaticImageData, 73 | index: number; 74 | }) { 75 | const featureSize = "h-60 w-96"; 76 | 77 | return ( 78 |
81 | 92 | {/* {animation} */} 93 | feat 94 | 95 | {/*
*/} 102 | 103 | {/*
*/} 110 |
111 | 112 | 123 |

{title}

124 |

{description}

125 |
126 |
127 | ); 128 | } 129 | 130 | const FeaturesVariant = { 131 | hidden: { 132 | y: 20, 133 | opacity: 0, 134 | }, 135 | visible: { 136 | y: 0, 137 | opacity: 1, 138 | }, 139 | }; 140 | 141 | const FeatureVariant = { 142 | hiddenLeft: { 143 | x: "-100%", 144 | opacity: 0, 145 | }, 146 | hiddenRight: { 147 | x: "100%", 148 | opacity: 0, 149 | }, 150 | visible: { 151 | x: "0%", 152 | opacity: 1, 153 | }, 154 | }; 155 | -------------------------------------------------------------------------------- /app/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { Montserrat_Alternates as Montserrat } from "next/font/google"; 3 | import { Github, Star } from "lucide-react"; 4 | 5 | import logo from "@/public/logo.svg"; 6 | 7 | const roboto = Montserrat({ 8 | weight: "500", 9 | style: "normal", 10 | subsets: ["cyrillic"], 11 | }); 12 | export default function Footer() { 13 | return ( 14 | 102 | ); 103 | } 104 | -------------------------------------------------------------------------------- /app/components/HeroSection.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import * as motion from "framer-motion/client"; 3 | 4 | import GridBg from "@/components/GridBg"; 5 | import Navbar from "@/components/Navbar"; 6 | import LoopWords from "@/components/LoopWords"; 7 | import HeroImage from "@/components/HeroImage"; 8 | 9 | export default function HeroSection() { 10 | return ( 11 | <> 12 | 13 | 14 |
15 | 23 |
24 |
25 |
26 | 36 | 37 |

Unlock the 

38 |
39 | 40 | 41 |
42 |
43 |

of AI-Driven Editing.

44 |
45 | 56 | DocX is an open-source AI powered alternative to Google Docs 57 | that empowers teams to create, edit, and collaborate seamlessly. 58 | 59 |
60 | 71 | 76 | Try DocX 77 | 78 | 83 | Learn More 84 | 85 | 86 |
87 |
88 |
89 | 90 | 91 | 92 | ); 93 | } 94 | 95 | const HeroVariant = { 96 | hidden: { 97 | y: 10, 98 | opacity: 0, 99 | }, 100 | visible: { 101 | y: 0, 102 | opacity: 1, 103 | }, 104 | }; 105 | -------------------------------------------------------------------------------- /app/document/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import getServerSession from "@/lib/customHooks/getServerSession"; 4 | import prisma from "@/prisma/prismaClient"; 5 | 6 | export const GetAllDocs = async (userId: string) => { 7 | try { 8 | const session = await getServerSession(); 9 | if (!session.id) 10 | return { 11 | success: false, 12 | error: "User is not logged in", 13 | }; 14 | 15 | const documents = await prisma.document.findMany({ 16 | where: { 17 | users: { 18 | some: { 19 | user: { 20 | id: userId, 21 | }, 22 | }, 23 | }, 24 | }, 25 | select: { 26 | id: true, 27 | thumbnail: true, 28 | name: true, 29 | updatedAt: true, 30 | createdBy: true, 31 | users: { 32 | select: { 33 | user: { 34 | select: { 35 | name: true, 36 | picture: true, 37 | }, 38 | }, 39 | }, 40 | }, 41 | }, 42 | orderBy: { updatedAt: "desc" }, 43 | }); 44 | 45 | if (!documents) 46 | return { 47 | success: false, 48 | error: "There is some problem fetching documents.", 49 | }; 50 | 51 | return { success: true, data: documents }; 52 | } catch (e) { 53 | console.log(e); 54 | return { success: false, error: "Internal server error" }; 55 | } 56 | }; 57 | -------------------------------------------------------------------------------- /app/document/components/Card/Card.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRef, useState } from "react"; 4 | import { useRouter } from "next/navigation"; 5 | 6 | import { Card, CardContent, CardFooter } from "@/components/ui/card"; 7 | import { Input } from "@/components/ui/input"; 8 | import type { User } from "@prisma/client"; 9 | 10 | import { RenameDocument } from "./actions"; 11 | import AvatarList from "@/components/AvatarList"; 12 | import CardOptions from "./components/Options"; 13 | import prettifyDate from "@/helpers/prettifyDates"; 14 | import useClientSession from "@/lib/customHooks/useClientSession"; 15 | import useDebounce from "@/lib/customHooks/useDebounce"; 16 | import { updateGuestDocument } from "@/lib/guestServices"; 17 | 18 | type DocCardPropType = { 19 | docId: string; 20 | thumbnail: string | null; 21 | title: string; 22 | updatedAt: Date; 23 | users: { 24 | user: Pick; 25 | }[]; 26 | }; 27 | export default function DocCard({ 28 | docId, 29 | thumbnail, 30 | title, 31 | updatedAt, 32 | users, 33 | }: DocCardPropType) { 34 | const router = useRouter(); 35 | 36 | const debounce = useDebounce(async () => { 37 | if (!inputRef.current) return; 38 | if (session?.id) { 39 | await RenameDocument(docId, inputRef.current.value); 40 | } else { 41 | updateGuestDocument(docId, 'name', inputRef.current.value); 42 | } 43 | }, 1000); 44 | 45 | const session = useClientSession(); 46 | localStorage.setItem("name", session.name as string); 47 | 48 | const inputRef = useRef(null); 49 | 50 | const [name, setName] = useState(title); 51 | 52 | return ( 53 | 54 | router.push(`/writer/${docId}`)} 58 | > 59 | 60 |
61 | { 66 | setName(e.target.value); 67 | debounce(e.target.value); 68 | }} 69 | /> 70 |
71 |
72 |
73 | 74 |

75 | {prettifyDate(String(updatedAt), { 76 | year: "numeric", 77 | month: "short", 78 | day: "2-digit", 79 | })} 80 |

81 |
82 | 83 |
84 |
85 |
86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /app/document/components/Card/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { revalidatePath } from "next/cache"; 4 | 5 | import getServerSession from "@/lib/customHooks/getServerSession"; 6 | import prisma from "@/prisma/prismaClient"; 7 | 8 | export const DeleteDocument = async (docId: any) => { 9 | try { 10 | const session = await getServerSession(); 11 | if (!session.id) 12 | return { 13 | success: false, 14 | error: "User is not logged in", 15 | }; 16 | 17 | const doc = await prisma.document.findFirst({ 18 | where: { 19 | id: docId, 20 | userId: session.id, 21 | }, 22 | }); 23 | if (!doc) { 24 | return { 25 | success: false, 26 | error: "Document does not exist", 27 | }; 28 | } 29 | 30 | await prisma.document.delete({ 31 | where: { 32 | id: docId, 33 | userId: session.id, 34 | }, 35 | }); 36 | revalidatePath("/"); 37 | 38 | return { success: true, data: "Document successfully deleted" }; 39 | } catch (e) { 40 | console.log(e); 41 | return { success: false, error: "Internal server error" }; 42 | } 43 | }; 44 | 45 | export const RenameDocument = async (docId: any, newName: string) => { 46 | try { 47 | const session = await getServerSession(); 48 | if (!session.id) 49 | return { 50 | success: false, 51 | error: "User is not logged in", 52 | }; 53 | 54 | const doc = await prisma.document.findFirst({ 55 | where: { 56 | id: docId, 57 | userId: session.id, 58 | }, 59 | }); 60 | if (!doc) { 61 | return { 62 | success: false, 63 | error: "Document does not exist", 64 | }; 65 | } 66 | 67 | await prisma.document.update({ 68 | where: { 69 | id: docId, 70 | userId: session.id, 71 | }, 72 | data: { name: newName }, 73 | }); 74 | revalidatePath("/"); 75 | 76 | return { success: true, data: "Document successfully renamed" }; 77 | } catch (e) { 78 | console.log(e); 79 | return { success: false, error: "Internal server error" }; 80 | } 81 | }; 82 | -------------------------------------------------------------------------------- /app/document/components/Card/components/Input.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Input } from "@/components/ui/input"; 4 | import { CircleCheck } from "lucide-react"; 5 | 6 | export default function CardInput({ title, value, ...props }: any) { 7 | console.log(value); 8 | return ( 9 |
10 | 11 | 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /app/document/components/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import Image from "next/image"; 4 | import { Montserrat_Alternates as Montserrat } from "next/font/google"; 5 | import * as motion from "framer-motion/client"; 6 | 7 | import { SessionReturnType } from "@/lib/customHooks/ReturnType"; 8 | import logo from "@/public/logo.svg"; 9 | 10 | import SearchBar from "./components/SearchBar"; 11 | import HeaderButtons from "./components/HeaderButtons"; 12 | import ProfileBtn from "./components/ProfileBtn"; 13 | import useClientSession from "@/lib/customHooks/useClientSession"; 14 | 15 | const roboto = Montserrat({ 16 | weight: "500", 17 | style: "normal", 18 | subsets: ["cyrillic"], 19 | }); 20 | 21 | type HeaderPropType = Pick; 22 | export default function Header({ image, name }: HeaderPropType) { 23 | const session = useClientSession(); 24 | 25 | return ( 26 | <> 27 | 39 |
40 | logo 41 |
42 | 60 |
61 |
62 | {session?.id && } 63 |
64 | 65 | 66 |
67 |
68 | 69 |
70 |
71 | logo 72 |
73 | 74 | 75 | 76 |
77 | 78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /app/document/components/Header/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { cookies } from "next/headers"; 4 | 5 | import prisma from "@/prisma/prismaClient"; 6 | import getServerSession from "@/lib/customHooks/getServerSession"; 7 | 8 | export const SearchDocAction = async (value: string) => { 9 | const session = await getServerSession(); 10 | if (!session.id) 11 | return { 12 | success: false, 13 | error: "User is not logged in", 14 | }; 15 | 16 | try { 17 | const searchResult = await prisma.document.findMany({ 18 | where: { 19 | name: { 20 | contains: value, 21 | mode: "insensitive", 22 | }, 23 | users: { 24 | some: { 25 | user: { 26 | id: session?.id, 27 | }, 28 | }, 29 | }, 30 | }, 31 | select: { 32 | id: true, 33 | updatedAt: true, 34 | createdBy: { 35 | select: { name: true }, 36 | }, 37 | name: true, 38 | // users: true 39 | }, 40 | }); 41 | 42 | if (searchResult.length > 0) return { success: true, data: searchResult }; 43 | return { success: false, error: "Couldn't find document" }; 44 | } catch (e) { 45 | console.error(e); 46 | return { success: false, error: "Couldn't find document" }; 47 | } 48 | }; 49 | 50 | export const CreateNewDocument = async () => { 51 | try { 52 | const session = await getServerSession(); 53 | if (session.id) 54 | return { 55 | success: false, 56 | error: "User is not logged in", 57 | }; 58 | 59 | const doc = await prisma.document.create({ 60 | data: { 61 | data: "", 62 | userId: session.id, 63 | users: { 64 | create: { 65 | user: { 66 | connect: { 67 | id: session.id, 68 | }, 69 | }, 70 | }, 71 | }, 72 | }, 73 | }); 74 | 75 | return { success: true, data: doc }; 76 | } catch (e) { 77 | console.error(e); 78 | return { success: false, error: "Internal server error" }; 79 | } 80 | }; 81 | 82 | export const LogoutAction = async () => { 83 | try { 84 | cookies().delete("token"); 85 | cookies().delete("next-auth.session-token"); 86 | 87 | return { success: true, data: null }; 88 | } catch (e) { 89 | console.error(e); 90 | return { success: false, error: "Internal server error" }; 91 | } 92 | }; 93 | -------------------------------------------------------------------------------- /app/document/components/Header/components/HeaderButtons.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { useRouter } from "next/navigation"; 5 | import { toast } from "sonner"; 6 | import { CloudUpload, PlusIcon } from "lucide-react"; 7 | 8 | import { CreateNewDocument } from "../actions"; 9 | import { createGuestDocument } from "@/lib/guestServices"; 10 | import { Button } from "@/components/ui/button"; 11 | import LoaderButton from "@/components/LoaderButton"; 12 | import useClientSession from "@/lib/customHooks/useClientSession"; 13 | 14 | export default function HeaderButtons() { 15 | const router = useRouter(); 16 | 17 | const session = useClientSession(); 18 | 19 | const [isLoading, setIsLoading] = useState(false); 20 | 21 | const createDocument = async () => { 22 | if (session?.id) { 23 | setIsLoading(true); 24 | 25 | const response = await CreateNewDocument(); 26 | if (response.success) { 27 | setIsLoading(false); 28 | toast.success("Successfully created new document"); 29 | router.push(`/writer/${response.data?.id}`); 30 | } else { 31 | setIsLoading(false); 32 | toast.error(response.error); 33 | } 34 | } else { 35 | const document = createGuestDocument(); 36 | toast.success("Successfully created new document"); 37 | router.push(`/writer/${document.id}`); 38 | } 39 | }; 40 | 41 | return ( 42 |
43 | {process.env.NODE_ENV === "development" ? ( 44 | 51 | ) : ( 52 | <> 53 | )} 54 | } 59 | > 60 |

Create New

61 |
62 |
63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /app/document/components/Header/components/ProfileBtn.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRouter } from "next/navigation"; 4 | import { toast } from "sonner"; 5 | import { LogIn, LogOut } from "lucide-react"; 6 | import { PopoverContent, PopoverTrigger } from "@radix-ui/react-popover"; 7 | import { motion } from "framer-motion"; 8 | 9 | import { Avatar, AvatarImage } from "@/components/ui/avatar"; 10 | import { Popover } from "@/components/ui/popover"; 11 | import { Button } from "@/components/ui/button"; 12 | import { SessionReturnType } from "@/lib/customHooks/ReturnType"; 13 | import getInitials from "@/helpers/getInitials"; 14 | 15 | import { LogoutAction } from "../actions"; 16 | import useClientSession from "@/lib/customHooks/useClientSession"; 17 | 18 | type ProfileBtnPropType = Pick; 19 | export default function ProfileBtn({ name, image }: ProfileBtnPropType) { 20 | const router = useRouter(); 21 | 22 | const session = useClientSession(); 23 | 24 | const logout = async () => { 25 | const response = await LogoutAction(); 26 | if (response.success) { 27 | toast.success("Successfully logged out"); 28 | router.push("/api/auth/signin"); 29 | } else { 30 | toast.error(response.error); 31 | } 32 | }; 33 | return ( 34 | !session?.id 35 | ? 36 | 44 | 45 | : 46 | 47 |
48 |

{getInitials(name ?? "X")}

49 |
50 | {image ? ( 51 | 52 | 53 | 54 | ) : ( 55 | <> 56 | )} 57 |
58 | 59 | 69 | 77 | 78 | 79 |
80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /app/document/components/Header/components/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useRef, useState } from "react"; 4 | import { useRouter } from "next/navigation"; 5 | import { Search, X } from "lucide-react"; 6 | import Image from "next/image"; 7 | 8 | import { Input } from "@/components/ui/input"; 9 | import prettifyDate from "@/helpers/prettifyDates"; 10 | import doc from "@/public/output-onlinepngtools.svg"; 11 | import useDebounce from "@/lib/customHooks/useDebounce"; 12 | 13 | import { SearchDocAction } from "../actions"; 14 | import useClientSession from "@/lib/customHooks/useClientSession"; 15 | 16 | type SearchResultType = { 17 | id: string; 18 | name: string; 19 | updatedAt: Date; 20 | createdBy: { 21 | name: string; 22 | }; 23 | }; 24 | type SearchResponse = { 25 | success: boolean; 26 | data?: SearchResultType[]; 27 | error?: string; 28 | }; 29 | export default function SearchBar() { 30 | const router = useRouter(); 31 | 32 | const debounce = useDebounce(async (value: string) => { 33 | if (!searchValue) return; 34 | setIsSearching(true); 35 | setSearchResponse(await SearchDocAction(value)); 36 | setIsSearching(false); 37 | }, 500); 38 | 39 | const searchedResponseRef = useRef(null); 40 | 41 | const [searchResponse, setSearchResponse] = useState< 42 | SearchResponse | undefined 43 | >(undefined); 44 | const [searchValue, setSearchValue] = useState(""); 45 | const [isFocused, setIsFocused] = useState(false); 46 | const [isSearching, setIsSearching] = useState(false); 47 | 48 | const handleDocumentClick = (e: any) => { 49 | if ( 50 | searchedResponseRef.current && 51 | !searchedResponseRef.current.contains(e.target) 52 | ) { 53 | setIsFocused(false); 54 | } 55 | }; 56 | useEffect(() => { 57 | document.addEventListener("mousedown", handleDocumentClick); 58 | return () => { 59 | document.removeEventListener("mousedown", handleDocumentClick); 60 | }; 61 | }, []); 62 | 63 | return ( 64 |
67 |
68 | setIsFocused(true)} 74 | value={searchValue} 75 | onChange={(e) => { 76 | setSearchValue(e.target.value); 77 | if (!e.target.value) return setSearchResponse(undefined); 78 | // debouncedSearch(e.target.value); 79 | debounce(e.target.value); 80 | }} 81 | placeholder="Search documents..." 82 | /> 83 | 87 | setSearchValue("")} 90 | className={`${!searchValue ? "hidden" : "" 91 | } absolute text-slate-500 right-0 top-1/2 transform -translate-y-1/2 mr-2 cursor-pointer`} 92 | /> 93 |
94 | 95 |
100 | {isSearching ? ( 101 |
102 | 114 | 115 | 116 | Searching 117 |
118 | ) : !searchResponse?.success ? ( 119 |
120 | {searchResponse?.error} 121 |
122 | ) : ( 123 | searchResponse.data?.map((data: SearchResultType) => { 124 | return ( 125 |
router.push(`writer/${data.id}`)} 128 | className="z-50 flex cursor-pointer justify-between p-2 border-l-2 border-white hover:bg-neutral-100 hover:border-blue-500 " 129 | > 130 |
131 | doc 132 |
133 |

{data.name}

134 |

135 | {data.createdBy.name} 136 |

137 |
138 |
139 |

140 | {prettifyDate(String(data.updatedAt), { 141 | month: "short", 142 | day: "2-digit", 143 | })} 144 |

145 |
146 | ); 147 | }) 148 | )} 149 |
150 |
151 | ); 152 | } 153 | -------------------------------------------------------------------------------- /app/document/components/Onboarding.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useRouter } from "next/navigation"; 4 | import { toast } from "sonner"; 5 | import Image from "next/image"; 6 | 7 | import { CreateNewDocument } from "./Header/actions"; 8 | import { createGuestDocument } from "@/lib/guestServices"; 9 | import EmptyBox from "@/public/createdoc.png" 10 | import useClientSession from "@/lib/customHooks/useClientSession"; 11 | 12 | export default function Onboarding() { 13 | const router = useRouter(); 14 | 15 | const session = useClientSession(); 16 | 17 | const createDocument = async () => { 18 | if (session?.id) { 19 | const response = await CreateNewDocument(); 20 | if (response.success) { 21 | toast.success("Successfully created new document"); 22 | router.push(`/writer/${response.data?.id}`); 23 | } else { 24 | toast.error(response.error); 25 | } 26 | } else { 27 | const document = createGuestDocument(); 28 | toast.success("Successfully created new document"); 29 | router.push(`/writer/${document.id}`); 30 | } 31 | }; 32 | return ( 33 |
39 | empty 45 |
46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /app/document/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardFooter } from "@/components/ui/card"; 2 | import Header from "./components/Header/Header"; 3 | 4 | export default function Loading() { 5 | return ( 6 | <> 7 |
8 |
9 | {[1, 2, 3, 4].map((i) => { 10 | return ( 11 | 12 |
13 | 14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | ); 27 | })} 28 |
29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /app/document/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useEffect, useState } from "react"; 4 | 5 | import { GetAllDocs } from "./actions"; 6 | import DocCard from "./components/Card/Card"; 7 | import Header from "./components/Header/Header"; 8 | import Onboarding from "./components/Onboarding"; 9 | import useClientSession from "@/lib/customHooks/useClientSession"; 10 | import { getAllGuestDocuments } from "@/lib/guestServices"; 11 | 12 | export default function Home() { 13 | const session = useClientSession(); 14 | 15 | const [data, setData] = useState(null); 16 | 17 | useEffect(() => { 18 | (async () => { 19 | if (session?.id) { 20 | const response = await GetAllDocs(session.id); 21 | if (response.success) { 22 | setData(response.data!); 23 | } else { 24 | setData([]); 25 | } 26 | } else { 27 | setData(getAllGuestDocuments()); 28 | } 29 | })() 30 | }, [session.id]) 31 | 32 | return ( 33 |
34 |
35 | {data && data.length > 0 ? ( 36 |
39 | {data.map((doc, index) => { 40 | return ( 41 | 49 | ); 50 | })} 51 |
52 | ) : ( 53 | 54 | )} 55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/git-init-priyanshu/Docx/74239b9d003fb588ce9402ed740566aaef0c15e3/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 222.2 84% 4.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --primary: 222.2 47.4% 11.2%; 17 | --primary-foreground: 210 40% 98%; 18 | 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | 22 | --muted: 210 40% 96.1%; 23 | --muted-foreground: 215.4 16.3% 46.9%; 24 | 25 | --accent: 210 40% 96.1%; 26 | --accent-foreground: 222.2 47.4% 11.2%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 210 40% 98%; 30 | 31 | --border: 214.3 31.8% 91.4%; 32 | --input: 214.3 31.8% 91.4%; 33 | --ring: 222.2 84% 4.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 222.2 84% 4.9%; 40 | --foreground: 210 40% 98%; 41 | 42 | --card: 222.2 84% 4.9%; 43 | --card-foreground: 210 40% 98%; 44 | 45 | --popover: 222.2 84% 4.9%; 46 | --popover-foreground: 210 40% 98%; 47 | 48 | --primary: 210 40% 98%; 49 | --primary-foreground: 222.2 47.4% 11.2%; 50 | 51 | --secondary: 217.2 32.6% 17.5%; 52 | --secondary-foreground: 210 40% 98%; 53 | 54 | --muted: 217.2 32.6% 17.5%; 55 | --muted-foreground: 215 20.2% 65.1%; 56 | 57 | --accent: 217.2 32.6% 17.5%; 58 | --accent-foreground: 210 40% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 210 40% 98%; 62 | 63 | --border: 217.2 32.6% 17.5%; 64 | --input: 217.2 32.6% 17.5%; 65 | --ring: 212.7 26.8% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } 77 | 78 | .divider { 79 | display: flex; 80 | align-items: center; 81 | text-align: center; 82 | width: 100%; 83 | } 84 | 85 | .divider::before, 86 | .divider::after { 87 | content: ""; 88 | flex: 1; 89 | border-bottom: 0.5px solid #808080; 90 | } 91 | 92 | .divider:not(:empty)::before { 93 | margin-right: 0.25em; 94 | } 95 | 96 | .divider:not(:empty)::after { 97 | margin-left: 0.25em; 98 | } 99 | 100 | /* Give a remote user a caret */ 101 | .collaboration-cursor__caret { 102 | border-left: 1px solid #0d0d0d; 103 | border-right: 1px solid #0d0d0d; 104 | margin-left: -1px; 105 | margin-right: -1px; 106 | pointer-events: none; 107 | position: relative; 108 | word-break: normal; 109 | } 110 | 111 | /* Render the username above the caret */ 112 | .collaboration-cursor__label { 113 | border-radius: 3px 3px 3px 0; 114 | color: #0d0d0d; 115 | font-size: 12px; 116 | font-style: normal; 117 | font-weight: 600; 118 | left: -1px; 119 | line-height: normal; 120 | padding: 0.1rem 0.3rem; 121 | position: absolute; 122 | top: -1.4em; 123 | user-select: none; 124 | white-space: nowrap; 125 | } 126 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Analytics } from "@vercel/analytics/react"; 2 | import type { Metadata } from "next"; 3 | import { Inter } from "next/font/google"; 4 | import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | import { Toaster } from "@/components/ui/sonner"; 8 | 9 | import { NextAuthProvider } from "./NextAuthProvider"; 10 | import ReactQueryProvider from "./ReactQueryProvider"; 11 | import "./globals.css"; 12 | 13 | const inter = Inter({ subsets: ["latin"] }); 14 | 15 | export const metadata: Metadata = { 16 | title: "DocX", 17 | description: 18 | "An open-source alternative to Google Docs, that lets you write and customize your docs collaboratively with others", 19 | }; 20 | 21 | export default function RootLayout({ 22 | children, 23 | }: Readonly<{ 24 | children: React.ReactNode; 25 | }>) { 26 | return ( 27 | 28 | 29 | 30 | 36 | 37 | 38 | {children} 39 | 40 | 41 | 42 | 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import FeaturesSection from "./components/FeaturesSection"; 2 | import HeroSection from "./components/HeroSection"; 3 | import AboutSection from "./components/AboutSection"; 4 | import ContactSection from "./components/ContactSection"; 5 | import Footer from "./components/Footer"; 6 | 7 | export default function Component() { 8 | return ( 9 |
10 | 11 | 12 | {/* */} 13 | {/* */} 14 |
15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /app/writer/[id]/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { revalidatePath } from "next/cache"; 4 | import { GoogleGenerativeAI } from "@google/generative-ai"; 5 | 6 | import prisma from "@/prisma/prismaClient"; 7 | import getServerSession from "@/lib/customHooks/getServerSession"; 8 | import { 9 | generateTextOptions, 10 | prompts, 11 | } from "./components/BubbleMenuComp/generateTextConfig"; 12 | 13 | export const GetDocDetails = async (id: any) => { 14 | try { 15 | const session = await getServerSession(); 16 | if (!session.id) 17 | return { 18 | success: false, 19 | error: "User is not logged in", 20 | }; 21 | 22 | const doc = await prisma.document.update({ 23 | where: { 24 | id, 25 | }, 26 | data: { 27 | users: { 28 | upsert: { 29 | where: { 30 | userId_documentId: { 31 | documentId: id, 32 | userId: session.id, 33 | }, 34 | }, 35 | update: {}, 36 | create: { 37 | user: { 38 | connect: { 39 | id: session.id, 40 | }, 41 | }, 42 | }, 43 | }, 44 | }, 45 | }, 46 | }); 47 | if (!doc) 48 | return { 49 | success: false, 50 | error: "Document does not exist", 51 | }; 52 | 53 | return { success: true, data: doc }; 54 | } catch (e) { 55 | console.log(e); 56 | return { success: false, error: "Internal server error" }; 57 | } 58 | }; 59 | 60 | export const UpdateDocData = async (id: any, data: string) => { 61 | const session = await getServerSession(); 62 | if (!session.id) 63 | return { 64 | success: false, 65 | error: "User is not logged in", 66 | }; 67 | 68 | try { 69 | const doc = await prisma.document.findFirst({ 70 | where: { 71 | id, 72 | users: { 73 | some: { userId: session.id }, 74 | }, 75 | }, 76 | }); 77 | if (!doc) 78 | return { 79 | success: false, 80 | error: "Document does not exist", 81 | }; 82 | 83 | await prisma.document.update({ 84 | where: { 85 | id, 86 | users: { 87 | some: { userId: session.id }, 88 | }, 89 | }, 90 | data: { 91 | data: data, 92 | updatedAt: new Date(), 93 | }, 94 | }); 95 | 96 | return { success: true, data: "Saved" }; 97 | } catch (e) { 98 | console.log(e); 99 | return { success: false, error: "Internal server error" }; 100 | } 101 | }; 102 | 103 | export const UpdateThumbnail = async (id: any, thumbnail: string) => { 104 | try { 105 | const session = await getServerSession(); 106 | if (!session.id) 107 | return { 108 | success: false, 109 | error: "User is not logged in", 110 | }; 111 | 112 | const response = await fetch( 113 | `${process.env.BACKEND_SERVER_URL}/push-to-quque`, 114 | { 115 | method: "POST", 116 | headers: { 117 | "Content-Type": "application/json", 118 | }, 119 | body: JSON.stringify({ 120 | docId: id, 121 | thumbnail, 122 | userId: session.id, 123 | }), 124 | }, 125 | ); 126 | revalidatePath("/"); 127 | 128 | return await response.json(); 129 | } catch (e) { 130 | console.log(e); 131 | return { success: false, error: "Internal server error" }; 132 | } 133 | }; 134 | 135 | export const generateText = async ( 136 | option: generateTextOptions, 137 | text: string, 138 | language?: string, 139 | ) => { 140 | try { 141 | const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY || ""); 142 | const model = genAI.getGenerativeModel({ model: "gemini-1.5-flash" }); 143 | 144 | let prompt: string; 145 | if (option === generateTextOptions.TRANSLATE) { 146 | if (!language) return { success: false, error: "Undefined prompt" }; 147 | prompt = `Here is the text: ${text} and language: ${language}. ${prompts.find((e) => e.option === option)?.prompt}`; 148 | } else { 149 | prompt = `Here is the text: "${text}". ${prompts.find((e) => e.option === option)?.prompt}`; 150 | } 151 | if (!prompt) return { success: false, error: "Undefined prompt" }; 152 | 153 | const note = "Note: Provide only the required text."; 154 | const result = await model.generateContent(prompt + note); 155 | 156 | return { success: true, data: result.response.text() }; 157 | } catch (e) { 158 | console.log(e); 159 | return { success: false, error: "Internal server error" }; 160 | } 161 | }; 162 | -------------------------------------------------------------------------------- /app/writer/[id]/components/BubbleMenuComp/generateTextConfig.ts: -------------------------------------------------------------------------------- 1 | export enum generateTextOptions { 2 | IMPROVE_WRITING = "improve_writing", 3 | FIX_SPELLINGS_AND_GRAMMAR = "fix_spellings_&_grammar", 4 | TRANSLATE = "translate", 5 | MAKE_LONGER = "make_longer", 6 | MAKE_SHORTER = "make_shorter", 7 | SIMPLIFY_LANGUAGE = "simplify_language", 8 | TRY_AGAIN = "try_again", 9 | } 10 | 11 | export const prompts = [ 12 | { 13 | option: generateTextOptions.IMPROVE_WRITING, 14 | prompt: "Enhance the structure, clarity, and tone of the text.", 15 | }, 16 | { 17 | option: generateTextOptions.FIX_SPELLINGS_AND_GRAMMAR, 18 | prompt: "Correct any spelling and grammatical mistakes in the text.", 19 | }, 20 | { 21 | option: generateTextOptions.TRANSLATE, 22 | prompt: "Translate the given text to the above mentioned language.", 23 | }, 24 | { 25 | option: generateTextOptions.MAKE_LONGER, 26 | prompt: "Expand the content to provide more details and depth.", 27 | }, 28 | { 29 | option: generateTextOptions.MAKE_SHORTER, 30 | prompt: "Condense the text while keeping the key points intact.", 31 | }, 32 | { 33 | option: generateTextOptions.SIMPLIFY_LANGUAGE, 34 | prompt: "Rewrite the text in simpler language to enhance readability.", 35 | }, 36 | { 37 | option: generateTextOptions.TRY_AGAIN, 38 | prompt: "Rewrite the text with a new variation.", 39 | }, 40 | ]; 41 | -------------------------------------------------------------------------------- /app/writer/[id]/components/BubbleMenuComp/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import type { Editor } from "@tiptap/react"; 3 | 4 | import AskAI from "./AskAI"; 5 | import GeneratedText from "./GeneratedText"; 6 | import ColorHighlight from "../options/format/ColorHighlight"; 7 | import FormattingBtns from "../options/format/FormattingBtns"; 8 | 9 | type BubbleMenuPropType = { 10 | editor: Editor | null; 11 | isHighlighted: boolean; 12 | bubblePosition: { x: number; y: number }; 13 | generativeTextBubblePosition: { x: number; y: number; width: number }; 14 | }; 15 | export default function BubbleMenuComp({ 16 | editor, 17 | isHighlighted, 18 | bubblePosition, 19 | generativeTextBubblePosition, 20 | }: BubbleMenuPropType) { 21 | const [isAiActive, setIsAiActive] = useState(false); 22 | const [isGeneratingText, setIsGeneratingText] = useState(false); 23 | const [generativeTextResult, setGenerativeTextResult] = useState(""); 24 | 25 | if (!editor) return; 26 | return ( 27 | <> 28 |
32 |
33 | 40 | 41 | 42 |
43 |
44 | 55 | 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /app/writer/[id]/components/EditorLoading.tsx: -------------------------------------------------------------------------------- 1 | export default function Loading() { 2 | return ( 3 |
4 |
5 |
6 | 7 |
8 |
9 |
10 |
11 | 12 |
13 |
14 |
15 |
16 | 17 |
18 |
19 | 20 |
21 |
22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /app/writer/[id]/components/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRouter } from "next/navigation"; 4 | import { ALargeSmall, Redo, Undo, X } from "lucide-react"; 5 | import { Editor } from "@tiptap/react"; 6 | 7 | import { 8 | Drawer, 9 | DrawerClose, 10 | DrawerContent, 11 | DrawerHeader, 12 | DrawerTitle, 13 | DrawerTrigger, 14 | } from "@/components/ui/drawer"; 15 | import { Button } from "@/components/ui/button"; 16 | import logo from "@/public/logo.svg"; 17 | 18 | import Heading from "../options/format/Heading"; 19 | import Font from "../options/format/Font"; 20 | import FormattingBtns from "../options/format/FormattingBtns"; 21 | import ColorHighlight from "../options/format/ColorHighlight"; 22 | import ParagraphBtns from "../options/format/ParagraphBtns"; 23 | import Image from "next/image"; 24 | 25 | type HeaderPropType = { 26 | editor: Editor | null; 27 | name: string; 28 | isSaving: boolean; 29 | }; 30 | 31 | export default function Header({ editor, name, isSaving }: HeaderPropType) { 32 | const router = useRouter(); 33 | 34 | return ( 35 |
36 |
37 | 41 |
42 | 43 |
44 |
45 | {name ? ( 46 |

47 | {name} 48 |

49 | ) : ( 50 |
51 | )} 52 | 53 | 61 | 69 | 70 | 71 | 72 | 78 | 79 | 80 | 81 | Format 82 | 83 | 86 | 87 | 88 |
89 | 90 | 91 | 92 | 93 | 94 |
95 |
96 |
97 | 98 | {isSaving ? ( 99 | <> 100 | 112 | 113 | 114 |

Saving...

115 | 116 | ) : ( 117 | <> 118 | )} 119 |
120 | 121 | {/* */} 131 |
132 |
133 | ); 134 | } 135 | -------------------------------------------------------------------------------- /app/writer/[id]/components/Tabs.tsx: -------------------------------------------------------------------------------- 1 | import { ALargeSmall, FileInput, LifeBuoy, SquareUser } from "lucide-react"; 2 | import { 3 | Tooltip, 4 | TooltipContent, 5 | TooltipProvider, 6 | TooltipTrigger, 7 | } from "@/components/ui/tooltip"; 8 | import { Button } from "@/components/ui/button"; 9 | 10 | type TabsPropType = { 11 | option: number; 12 | setOption: React.Dispatch>; 13 | }; 14 | export default function Tabs({ option, setOption }: TabsPropType) { 15 | return ( 16 | 92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /app/writer/[id]/components/options/GoBack.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { MoveLeft } from "lucide-react"; 3 | import { useRouter } from "next/navigation"; 4 | 5 | export default function GoBack() { 6 | const router = useRouter(); 7 | return ( 8 | router.push("/document")} 13 | /> 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /app/writer/[id]/components/options/format/BulletListBtns.tsx: -------------------------------------------------------------------------------- 1 | import { List } from "lucide-react"; 2 | 3 | import { BulletBtns } from "./textEditorOptions"; 4 | 5 | export default function BulletListBtns({ editor }: any) { 6 | return ( 7 |
8 | {BulletBtns.map(({ Icon, key }, i) => { 9 | return ( 10 | 14 | Icon === List 15 | ? editor?.chain()?.focus().toggleBulletList().run() 16 | : editor?.chain().focus().toggleOrderedList().run() 17 | } 18 | className={`${ 19 | Icon === List 20 | ? editor?.isActive("bulletList") 21 | ? "bg-blue-500 text-white hover:bg-blue-500" 22 | : "hover:bg-slate-100 bg-white" 23 | : editor?.isActive("orderedList") 24 | ? "bg-blue-500 text-white hover:bg-blue-500" 25 | : "hover:bg-slate-100 bg-white" 26 | } p-2 rounded ${ 27 | i === BulletBtns.length - 1 ? "border-none" : "border-r" 28 | }`} 29 | /> 30 | ); 31 | })} 32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /app/writer/[id]/components/options/format/ColorHighlight.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useRef, useState } from "react"; 4 | import { Editor } from "@tiptap/react"; 5 | import { Baseline, ChevronDown, Highlighter } from "lucide-react"; 6 | import { HexColorPicker } from "react-colorful"; 7 | 8 | import { Input } from "@/components/ui/input"; 9 | import { 10 | DropdownMenu, 11 | DropdownMenuContent, 12 | DropdownMenuTrigger, 13 | } from "@/components/ui/dropdown"; 14 | 15 | type ColorHighlightPropType = { 16 | editor: Editor | null; 17 | isBubbleMenuBtn: boolean; 18 | }; 19 | export default function ColorHighlight({ 20 | editor, 21 | isBubbleMenuBtn, 22 | }: ColorHighlightPropType) { 23 | const colorPopoverRef = useRef(null); 24 | const bgPopoverRef = useRef(null); 25 | 26 | const [isColorPopoverOpen, setIsColorPopoverOpen] = useState(false); 27 | const [isBgPopoverOpen, setIsBgPopoverOpen] = useState(false); 28 | const [fontColor, setFontColor] = useState("#000000"); 29 | const [highlightColor, setHighlightColor] = useState("#fdfb7a"); 30 | 31 | const onFontColorChange = (hex: string) => { 32 | if (!editor) return; 33 | setFontColor(hex); 34 | editor.chain().focus().setColor(hex).run(); 35 | }; 36 | const onHighlightColorChange = (hex: string) => { 37 | if (!editor) return; 38 | setHighlightColor(hex); 39 | editor.chain().focus().toggleHighlight({ color: hex }).run(); 40 | }; 41 | 42 | const handleDocumentClick = (e: any) => { 43 | if ( 44 | (colorPopoverRef.current && 45 | !colorPopoverRef.current.contains(e.target)) || 46 | (bgPopoverRef.current && !bgPopoverRef.current.contains(e.target)) 47 | ) { 48 | setIsColorPopoverOpen(false); 49 | setIsBgPopoverOpen(false); 50 | } 51 | }; 52 | useEffect(() => { 53 | document.addEventListener("mousedown", handleDocumentClick); 54 | return () => { 55 | document.removeEventListener("mousedown", handleDocumentClick); 56 | }; 57 | }, []); 58 | 59 | return ( 60 |
63 | editor?.chain().focus().setColor(fontColor).run()} 67 | className={`hover:bg-slate-100 p-2 rounded ${isBubbleMenuBtn ? "" : "border-r"}`} 68 | /> 69 | 70 | setIsColorPopoverOpen(!isColorPopoverOpen)} 73 | > 74 | 75 | 76 | 81 |
82 | setFontColor(e.target.value)} 85 | className="mb-4" 86 | /> 87 | 92 |
93 |
94 |
95 | 99 | editor 100 | ?.chain() 101 | .focus() 102 | .toggleHighlight({ color: highlightColor }) 103 | .run() 104 | } 105 | className={`hover:bg-slate-100 p-2 rounded ${isBubbleMenuBtn ? "" : "border-r"}`} 106 | /> 107 | 108 | setIsBgPopoverOpen(!isBgPopoverOpen)} 111 | > 112 | 113 | 114 | 119 |
120 | setHighlightColor(e.target.value)} 123 | className="mb-4" 124 | /> 125 | 130 |
131 |
132 |
133 |
134 | ); 135 | } 136 | -------------------------------------------------------------------------------- /app/writer/[id]/components/options/format/Font.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Select, 3 | SelectContent, 4 | SelectItem, 5 | SelectTrigger, 6 | SelectValue, 7 | } from "@/components/ui/select"; 8 | 9 | import { setDefaultFontFamily, onFontFamilyChange } from "./functions"; 10 | import { fontFamily } from "./textEditorOptions"; 11 | 12 | export default function Font({ editor }: any) { 13 | return ( 14 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /app/writer/[id]/components/options/format/FormattingBtns.tsx: -------------------------------------------------------------------------------- 1 | import { formattingBtns } from "./textEditorOptions"; 2 | 3 | type FormattingBtnsPropType = { 4 | editor: any; 5 | isBubbleMenuBtn: boolean; 6 | }; 7 | export default function FormattingBtns({ 8 | editor, 9 | isBubbleMenuBtn, 10 | }: FormattingBtnsPropType) { 11 | return ( 12 |
15 | {formattingBtns.map(({ func, name, Icon }, i) => { 16 | return ( 17 | 32 | ); 33 | })} 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /app/writer/[id]/components/options/format/Heading.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Select, 3 | SelectContent, 4 | SelectItem, 5 | SelectTrigger, 6 | SelectValue, 7 | } from "@/components/ui/select"; 8 | 9 | import { setDefaultStyleValue, onFontStyleChange } from "./functions"; 10 | 11 | export default function Heading({ editor }: any) { 12 | return ( 13 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /app/writer/[id]/components/options/format/ParagraphBtns.tsx: -------------------------------------------------------------------------------- 1 | import { paragraphBtns } from "./textEditorOptions"; 2 | 3 | export default function ParagraphBtns({ editor }: any) { 4 | return ( 5 |
6 | {paragraphBtns.map(({ align, Icon }, i) => { 7 | return ( 8 | editor?.chain().focus().setTextAlign(align).run()} 12 | className={`${ 13 | editor?.isActive({ textAlign: align }) 14 | ? "bg-blue-500 text-white hover:bg-blue-500" 15 | : "hover:bg-slate-100 bg-white" 16 | } p-2 rounded ${ 17 | i === paragraphBtns.length - 1 ? "border-none" : "border-r" 18 | }`} 19 | /> 20 | ); 21 | })} 22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /app/writer/[id]/components/options/format/functions.ts: -------------------------------------------------------------------------------- 1 | import { fontFamily } from "./textEditorOptions"; 2 | 3 | export const setDefaultStyleValue = (editor: any) => { 4 | let level: string = "normal"; 5 | for (let i = 1; i <= 6; i++) { 6 | if (editor?.isActive("heading", { level: i })) 7 | return (level = `heading ${String(i)}`); 8 | } 9 | return level; 10 | }; 11 | 12 | export const onFontStyleChange = (editor: any, val: string) => { 13 | const matcher = val.split(" "); 14 | if (matcher[0] === "normal") return editor?.commands.setParagraph(); 15 | // @ts-ignore 16 | return editor?.commands.setHeading({ level: Number(matcher[1]) }); 17 | }; 18 | 19 | export const setDefaultFontFamily = (editor: any) => { 20 | const matcher = fontFamily.find((item) => { 21 | return editor?.isActive("textStyle", { fontFamily: item.font }); 22 | }); 23 | return matcher?.font || "Inter"; 24 | }; 25 | 26 | export const onFontFamilyChange = (editor: any, font: string) => { 27 | return editor?.chain().focus().setFontFamily(font).run(); 28 | }; 29 | -------------------------------------------------------------------------------- /app/writer/[id]/components/options/format/index.tsx: -------------------------------------------------------------------------------- 1 | import type { Editor } from "@tiptap/react"; 2 | 3 | import Heading from "./Heading"; 4 | import Font from "./Font"; 5 | import FormattingBtns from "./FormattingBtns"; 6 | import ColorHighlight from "./ColorHighlight"; 7 | import ParagraphBtns from "./ParagraphBtns"; 8 | import BulletListBtns from "./BulletListBtns"; 9 | import GoBack from "../GoBack"; 10 | 11 | export default function FormatOptions({ editor }: { editor: Editor | null }) { 12 | return ( 13 |
17 | 18 |
19 |
20 | Style 21 |
22 | 23 |
24 |
25 |
26 | Font 27 |
28 | 29 |
30 | 31 | 32 |
33 |
34 |
35 |
36 | Paragraph 37 |
38 | 39 | 40 |
41 |
42 | 43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /app/writer/[id]/components/options/format/textEditorOptions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AlignCenter, 3 | AlignJustify, 4 | AlignLeft, 5 | AlignRight, 6 | Bold, 7 | Italic, 8 | List, 9 | ListOrdered, 10 | Strikethrough, 11 | Underline, 12 | } from "lucide-react"; 13 | 14 | export const formattingBtns = [ 15 | { Icon: Bold, name: "bold", func: "toggleBold" }, 16 | { Icon: Italic, name: "italic", func: "toggleItalic" }, 17 | { Icon: Underline, name: "underline", func: "toggleUnderline" }, 18 | { Icon: Strikethrough, name: "strike", func: "toggleStrike" }, 19 | ]; 20 | export const paragraphBtns = [ 21 | { Icon: AlignLeft, align: "left" }, 22 | { Icon: AlignCenter, align: "center" }, 23 | { Icon: AlignRight, align: "right" }, 24 | { Icon: AlignJustify, align: "justify" }, 25 | ]; 26 | export const BulletBtns = [ 27 | { Icon: List, key: "list" }, 28 | { Icon: ListOrdered, key: "ordered_list" }, 29 | ]; 30 | export const fontFamily = [ 31 | { title: "Inter", font: "Inter" }, 32 | { title: "Comic Sans", font: "Comic Sans MS, Comic Sans" }, 33 | { title: "Serif", font: "serif" }, 34 | { title: "Monospace", font: "monospace" }, 35 | { title: "Cursive", font: "cursive" }, 36 | ]; 37 | -------------------------------------------------------------------------------- /app/writer/[id]/components/options/index.ts: -------------------------------------------------------------------------------- 1 | import FormatOptions from "./format"; 2 | import InsertOptions from "./insert"; 3 | 4 | export { FormatOptions, InsertOptions }; 5 | -------------------------------------------------------------------------------- /app/writer/[id]/components/options/insert/index.tsx: -------------------------------------------------------------------------------- 1 | import { Editor } from "@tiptap/react"; 2 | import GoBack from "../GoBack"; 3 | 4 | type InsterOptionsPropTypes = { 5 | editor: Editor | null; 6 | }; 7 | export default function InsertOptions({ editor }: InsterOptionsPropTypes) { 8 | return ( 9 |
13 | 14 |
15 |
16 | Style 17 |
{/* */}
18 |
19 |
20 | Font 21 |
22 | {/* */} 23 |
24 | {/* */} 25 | {/* */} 26 |
27 |
28 |
29 |
30 | Paragraph 31 |
32 | {/* */} 33 | {/* */} 34 |
35 |
36 | 37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /app/writer/[id]/editor/editorConfig.ts: -------------------------------------------------------------------------------- 1 | import { Color } from "@tiptap/extension-color"; 2 | import { WebsocketProvider } from "y-websocket"; 3 | import StarterKit from "@tiptap/starter-kit"; 4 | import Highlight from "@tiptap/extension-highlight"; 5 | import Underline from "@tiptap/extension-underline"; 6 | import TextAlign from "@tiptap/extension-text-align"; 7 | import FontFamily from "@tiptap/extension-font-family"; 8 | import TextStyle from "@tiptap/extension-text-style"; 9 | import * as Y from "yjs"; 10 | 11 | import { cn } from "@/lib/utils"; 12 | 13 | export const ydoc = new Y.Doc(); 14 | 15 | const room = `room.${new Date() 16 | .getFullYear() 17 | .toString() 18 | .slice(-2)}${new Date().getMonth() + 1}${new Date().getDate()}`; 19 | 20 | export const provider = new WebsocketProvider( 21 | process.env.NEXT_PUBLIC_WEBSOCKET_URL as string, 22 | room, 23 | ydoc, 24 | ); 25 | 26 | export const extensions = [ 27 | StarterKit.configure({ 28 | history: false, 29 | heading: { 30 | levels: [1, 2, 3, 4, 5, 6], 31 | }, 32 | }), 33 | Color.configure({ types: [TextStyle.name] }), 34 | Highlight.configure({ multicolor: true }), 35 | Underline, 36 | TextStyle, 37 | FontFamily, 38 | TextAlign.configure({ 39 | types: ["heading", "paragraph"], 40 | }), 41 | ]; 42 | 43 | export const props = { 44 | attributes: { 45 | class: cn( 46 | "prose [&_ol]:list-decimal [&_ul]:list-disc w-[816.3px] max-w-[816.3px] h-[1056.36px] mx-auto bg-white rounded-md border p-24 my-2 shadow-none focus-visible:outline-none", 47 | ), 48 | }, 49 | }; 50 | -------------------------------------------------------------------------------- /app/writer/[id]/editor/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState, useCallback } from "react"; 4 | import { useParams } from "next/navigation"; 5 | import { useEditor } from "@tiptap/react"; 6 | import { toast } from "sonner"; 7 | import type { Document } from "@prisma/client"; 8 | import html2canvas from "html2canvas"; 9 | import Collaboration from "@tiptap/extension-collaboration"; 10 | import CollaborationCursor from "@tiptap/extension-collaboration-cursor"; 11 | 12 | import { getRandomColor } from "@/helpers/getRandomColor"; 13 | import { getGuestDocumentDetails, updateGuestDocument } from "@/lib/guestServices"; 14 | import useClientSession from "@/lib/customHooks/useClientSession"; 15 | import useDebounce from "@/lib/customHooks/useDebounce"; 16 | 17 | import { ydoc, provider, extensions, props } from "./editorConfig"; 18 | import { GetDocDetails, UpdateDocData, UpdateThumbnail } from "../actions"; 19 | 20 | type EditorPropType = { 21 | setIsSaving: React.Dispatch>; 22 | }; 23 | export const Editor = ({ setIsSaving }: EditorPropType) => { 24 | const params = useParams(); 25 | 26 | const session = useClientSession(); 27 | 28 | const [name, setName] = useState(""); 29 | const [docData, setDocData] = useState(undefined); 30 | const [status, setStatus] = useState("connecting"); 31 | // console.log(status); 32 | 33 | useEffect(() => { 34 | setName(localStorage.getItem("name") || ""); 35 | }, []); 36 | 37 | useEffect(() => { 38 | // Update status changes 39 | const statusHandler = (event: any) => { 40 | setStatus(event.status); 41 | }; 42 | 43 | provider.on("status", statusHandler); 44 | 45 | return () => { 46 | provider.off("status", statusHandler); 47 | }; 48 | }, []); 49 | 50 | // Doc data fetching 51 | useEffect(() => { 52 | if (session?.id) { 53 | (async () => { 54 | const response = await GetDocDetails(params.id); 55 | if (response.success) { 56 | setDocData(response.data); 57 | } else { 58 | toast.error(response.error); 59 | } 60 | })(); 61 | } else { 62 | const data = getGuestDocumentDetails(params.id as string) 63 | setDocData(data); 64 | } 65 | }, [session?.id, params.id]); 66 | 67 | const createDocThumbnail = useCallback(async () => { 68 | try { 69 | const page = document.getElementsByClassName("tiptap")[0]; 70 | if (!page) return setIsSaving(false); 71 | // @ts-ignore 72 | const canvas = await html2canvas(page, { scale: 1 }); 73 | 74 | const thumbnail = canvas 75 | .toDataURL(`${docData?.id}thumbnail/png`) 76 | .replace(/^data:image\/\w+;base64,/, ""); 77 | 78 | if (session?.id) { 79 | await UpdateThumbnail(params.id, thumbnail); 80 | } else { 81 | updateGuestDocument(params.id as string, "thumbnail", thumbnail); 82 | } 83 | setIsSaving(false); 84 | } catch (e) { 85 | console.log(e); 86 | toast.error("Something went wrong"); 87 | } 88 | }, [docData?.id, params.id]); 89 | 90 | const debounce = useDebounce(async (editor: any) => { 91 | setIsSaving(true); 92 | 93 | if (session?.id) { 94 | const response = await UpdateDocData( 95 | params.id as string, 96 | JSON.stringify(editor.getJSON()), 97 | ); 98 | if (response.success) { 99 | setIsSaving(false); 100 | return createDocThumbnail(); 101 | } 102 | setIsSaving(false); 103 | toast.error(response.error); 104 | } else { 105 | updateGuestDocument( 106 | params.id as string, 107 | "data", 108 | JSON.stringify(editor.getJSON()), 109 | ) 110 | createDocThumbnail(); 111 | setIsSaving(false); 112 | } 113 | }, 1000); 114 | 115 | // Editor instance 116 | const editor = useEditor({ 117 | onCreate: ({ editor: currentEditor }) => { 118 | provider.on("sync", () => { 119 | if (currentEditor.isEmpty) { 120 | currentEditor.commands.setContent(""); 121 | } 122 | }); 123 | }, 124 | extensions: [ 125 | ...extensions, 126 | Collaboration.configure({ 127 | document: ydoc, 128 | }), 129 | CollaborationCursor.configure({ 130 | provider, 131 | user: { 132 | name, 133 | color: getRandomColor(), 134 | }, 135 | }), 136 | ], 137 | editorProps: props, 138 | content: "", 139 | onUpdate({ editor }) { 140 | debounce(editor); 141 | }, 142 | }); 143 | 144 | // Save current user to localStorage and emit to editor 145 | useEffect(() => { 146 | if (editor) { 147 | editor.chain().focus().updateUser({ name }).run(); 148 | } 149 | }, [editor, name]); 150 | 151 | // Set content of the doc 152 | useEffect(() => { 153 | if (editor && docData) { 154 | editor.commands.setContent( 155 | docData?.data ? JSON.parse(docData?.data) : "", 156 | ); 157 | } 158 | }, [editor, docData, docData?.data]); 159 | 160 | return { editor, docData }; 161 | }; 162 | -------------------------------------------------------------------------------- /app/writer/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | import { EditorContent } from "@tiptap/react"; 5 | 6 | import { ScrollArea } from "@/components/ui/scroll-area"; 7 | 8 | import { Editor } from "./editor"; 9 | import { FormatOptions, InsertOptions } from "./components/options"; 10 | import Header from "./components/Header/Header"; 11 | import Tabs from "./components/Tabs"; 12 | import Loading from "./components/EditorLoading"; 13 | import BubbleMenuComp from "./components/BubbleMenuComp"; 14 | 15 | export default function Dashboard() { 16 | const [option, setOption] = useState(0); 17 | const [isSaving, setIsSaving] = useState(false); 18 | const [isHighlighted, setIsHighlighted] = useState(false); 19 | const [position, setPosition] = useState({ x: 0, y: 0 }); 20 | const [position2, setPosition2] = useState({ x: 0, y: 0, width: 0 }); 21 | 22 | const { editor, docData } = Editor({ setIsSaving }); 23 | 24 | const Options = [ 25 | , 26 | , 27 | ]; 28 | 29 | // For bubble menu 30 | useEffect(() => { 31 | const handleSelectionChange = () => { 32 | const selection = window.getSelection(); 33 | if (!selection) return; 34 | 35 | setIsHighlighted(selection && selection.toString().length > 0); 36 | 37 | const editorDimensions = document 38 | .getElementsByClassName("tiptap")[0] 39 | .getBoundingClientRect(); 40 | const selectionDimensions = selection 41 | .getRangeAt(0) 42 | .getBoundingClientRect(); 43 | setPosition({ 44 | x: 45 | selectionDimensions.left - 46 | editorDimensions.left + 47 | selectionDimensions.width / 2, 48 | y: selectionDimensions.top - editorDimensions.top - 60, 49 | }); 50 | setPosition2({ 51 | x: 52 | selectionDimensions.left - 53 | editorDimensions.left + 54 | selectionDimensions.width / 2, 55 | y: 56 | selectionDimensions.top - 57 | editorDimensions.top + 58 | selectionDimensions.height + 59 | 20, 60 | width: selectionDimensions.width, 61 | }); 62 | }; 63 | 64 | document.addEventListener("selectionchange", handleSelectionChange); 65 | 66 | return () => { 67 | document.removeEventListener("selectionchange", handleSelectionChange); 68 | }; 69 | }, []); 70 | 71 | return ( 72 |
73 |
74 |
75 | 76 |
77 | {Options[option]} 78 | 79 | {!docData ? ( 80 | 81 | ) : ( 82 | <> 83 | 89 | 90 | 91 | )} 92 | 93 |
94 |
95 |
96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /components/AvatarList.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Avatar, AvatarImage } from "@/components/ui/avatar"; 4 | import type { User } from "@prisma/client"; 5 | import getInitials from "@/helpers/getInitials"; 6 | 7 | type AvatarListPropType = { 8 | users: { 9 | user: Pick; 10 | }[]; 11 | }; 12 | 13 | export default function AvatarList({ users }: AvatarListPropType) { 14 | return ( 15 |
16 | {users.map((e, index) => { 17 | return ( 18 |
19 |
20 |

{getInitials(e.user.name ?? "X")}

21 |
22 | {e.user.picture ? ( 23 | 24 | 25 | 26 | ) : ( 27 | <> 28 | )} 29 |
30 | ); 31 | })} 32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /components/GridBg.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | import { motion } from "framer-motion"; 5 | 6 | type GridBgPropType = { 7 | customClass?: string, 8 | isCursorMaskEnabled: boolean, 9 | cursorMaskSize: number; 10 | bgMaskSize: number; 11 | bgMaskPos: [number, number] 12 | blurValue: number; 13 | opacity: number; 14 | } 15 | export default function GridBg({ 16 | customClass = "", 17 | isCursorMaskEnabled, 18 | cursorMaskSize, 19 | bgMaskSize, 20 | bgMaskPos, 21 | blurValue, 22 | opacity 23 | }: GridBgPropType) { 24 | const commonClass = "absolute w-full h-full"; 25 | const commonStyle = { 26 | background: "url('/grid_bg.svg')", 27 | backgroundSize: "200px", 28 | WebkitMaskImage: "url('/mask.svg')", 29 | WebkitMaskRepeat: "no-repeat", 30 | filter: `blur(${blurValue}px)`, 31 | }; 32 | 33 | const [cursorPosX, setCursorPosX] = useState(0); 34 | const [cursorPosY, setCursorPosY] = useState(0); 35 | 36 | const handleMouseMove = (e: MouseEvent) => { 37 | setCursorPosX(e.clientX); 38 | setCursorPosY(e.pageY); 39 | }; 40 | useEffect(() => { 41 | window.addEventListener("mousemove", handleMouseMove); 42 | 43 | return () => { 44 | window.removeEventListener("mousemove", handleMouseMove); 45 | }; 46 | }, []); 47 | 48 | return ( 49 | <> 50 | {/* Cursor following mask */} 51 | {isCursorMaskEnabled && 52 | 66 | } 67 | {/* Fixed mask */} 68 | 81 | 82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /components/HeaderBtn.tsx: -------------------------------------------------------------------------------- 1 | import { Button, ButtonProps } from "./ui/button"; 2 | 3 | type HeaderBtnPropType = { 4 | icon?: React.ReactNode; 5 | size?: number; 6 | } & ButtonProps; 7 | 8 | export default function HeaderBtn({ 9 | icon, 10 | size, 11 | children, 12 | ...props 13 | }: HeaderBtnPropType) { 14 | return ; 15 | } 16 | -------------------------------------------------------------------------------- /components/LoaderButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button, ButtonProps } from "./ui/button"; 2 | 3 | type LoaderButtonType = { 4 | onClickFunc?: (() => void) | (() => Promise); 5 | isLoading: boolean; 6 | className: string; 7 | children: React.ReactNode; 8 | icon?: React.ReactNode; 9 | } & ButtonProps; 10 | 11 | export default function LoaderButton({ 12 | onClickFunc, 13 | isLoading, 14 | className, 15 | children, 16 | icon, 17 | ...props 18 | }: LoaderButtonType) { 19 | return ( 20 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /components/LoopWords.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { motion, AnimatePresence } from "framer-motion"; 4 | import { useEffect, useState } from "react"; 5 | 6 | type LoopWordsPropType = { 7 | className?: string; 8 | words: string[]; 9 | }; 10 | 11 | export default function LoopWords({ className, words }: LoopWordsPropType) { 12 | const [currentWord, setCurrentWord] = useState(0); 13 | 14 | useEffect(() => { 15 | const interval = setInterval(() => { 16 | setCurrentWord((curr) => (curr + 1) % words.length); 17 | }, 2000); 18 | 19 | return () => clearInterval(interval); 20 | }, [words]); 21 | 22 | // const letterVariants = { 23 | // hidden: { opacity: 0, y: 20 }, 24 | // visible: { opacity: 1, y: 0 }, 25 | // exit: { opacity: 0, y: -20 } 26 | // }; 27 | 28 | return ( 29 | 30 | 47 | {words[currentWord].split("").map((char, i) => { 48 | return ( 49 | 63 | {char} 64 | 65 | ); 66 | })} 67 | 68 | 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import Image from "next/image"; 3 | import * as motion from "framer-motion/client"; 4 | import { Montserrat_Alternates as Montserrat } from "next/font/google"; 5 | 6 | import logo from "@/public/logo.svg"; 7 | import Github from "@/public/github.png"; 8 | import { GithubIcon } from "lucide-react"; 9 | 10 | const roboto = Montserrat({ 11 | weight: "500", 12 | style: "normal", 13 | subsets: ["cyrillic"], 14 | }); 15 | export default function Navbar() { 16 | return ( 17 | <> 18 | 28 | 86 | 87 | ); 88 | } 89 | 90 | const NavVariant = { 91 | hidden: { 92 | y: 10, 93 | opacity: 0, 94 | }, 95 | visible: { 96 | y: 0, 97 | opacity: 1, 98 | }, 99 | }; 100 | -------------------------------------------------------------------------------- /components/ScrollCard.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useRef } from "react"; 3 | import { useScroll, useTransform, motion, MotionValue } from "framer-motion"; 4 | 5 | export const ScrollCard = ({ children }: { children: React.ReactNode }) => { 6 | const containerRef = useRef(null); 7 | const { scrollYProgress } = useScroll({ 8 | target: containerRef, 9 | }); 10 | const [isMobile, setIsMobile] = React.useState(false); 11 | 12 | React.useEffect(() => { 13 | const checkMobile = () => { 14 | setIsMobile(window.innerWidth <= 768); 15 | }; 16 | checkMobile(); 17 | window.addEventListener("resize", checkMobile); 18 | return () => { 19 | window.removeEventListener("resize", checkMobile); 20 | }; 21 | }, []); 22 | 23 | const scaleDimensions = () => { 24 | return isMobile ? [0.7, 0.9] : [1.05, 1]; 25 | }; 26 | 27 | const rotate = useTransform(scrollYProgress, [0, 1], [20, 0]); 28 | const scale = useTransform(scrollYProgress, [0, 1], scaleDimensions()); 29 | const translate = useTransform(scrollYProgress, [0, 1], [0, -100]); 30 | 31 | return ( 32 |
36 |
42 | 43 | {children} 44 | 45 |
46 |
47 | ); 48 | }; 49 | 50 | export const Header = ({ translate, titleComponent }: any) => { 51 | return ( 52 | 58 | {titleComponent} 59 | 60 | ); 61 | }; 62 | 63 | export const Card = ({ 64 | rotate, 65 | scale, 66 | children, 67 | }: { 68 | rotate: MotionValue; 69 | scale: MotionValue; 70 | translate: MotionValue; 71 | children: React.ReactNode; 72 | }) => { 73 | return ( 74 | 83 |
84 | {children} 85 |
86 |
87 | ); 88 | }; 89 | -------------------------------------------------------------------------------- /components/aiAnimation/components/ColorHighlight.tsx: -------------------------------------------------------------------------------- 1 | import { Baseline, ChevronDown, Highlighter } from "lucide-react"; 2 | 3 | type ColorHighlightPropType = { 4 | isBubbleMenuBtn: boolean; 5 | }; 6 | export default function ColorHighlight({ isBubbleMenuBtn }: ColorHighlightPropType) { 7 | return ( 8 |
11 | 15 | 20 | 25 | 30 |
31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /components/aiAnimation/components/FormattingBtns.tsx: -------------------------------------------------------------------------------- 1 | import { Bold, Italic, Strikethrough, Underline } from "lucide-react"; 2 | 3 | const formattingBtns = [ 4 | { Icon: Bold, name: "bold", func: "toggleBold" }, 5 | { Icon: Italic, name: "italic", func: "toggleItalic" }, 6 | { Icon: Underline, name: "underline", func: "toggleUnderline" }, 7 | { Icon: Strikethrough, name: "strike", func: "toggleStrike" }, 8 | ]; 9 | type FormattingBtnsPropType = { 10 | isBubbleMenuBtn: boolean; 11 | }; 12 | export default function FormattingBtns({ 13 | isBubbleMenuBtn, 14 | }: FormattingBtnsPropType) { 15 | return ( 16 |
19 | {formattingBtns.map(({ name, Icon }, i) => { 20 | return ( 21 | 32 | ); 33 | })} 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )); 21 | Avatar.displayName = AvatarPrimitive.Root.displayName; 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )); 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName; 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )); 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; 49 | 50 | export { Avatar, AvatarImage, AvatarFallback }; 51 | -------------------------------------------------------------------------------- /components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | }, 24 | ); 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ); 34 | } 35 | 36 | export { Badge, badgeVariants }; 37 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | }, 34 | ); 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean; 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button"; 45 | return ( 46 | 51 | ); 52 | }, 53 | ); 54 | Button.displayName = "Button"; 55 | 56 | export { Button, buttonVariants }; 57 | -------------------------------------------------------------------------------- /components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )); 18 | Card.displayName = "Card"; 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )); 30 | CardHeader.displayName = "CardHeader"; 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

44 | )); 45 | CardTitle.displayName = "CardTitle"; 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |

56 | )); 57 | CardDescription.displayName = "CardDescription"; 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes 62 | >(({ className, ...props }, ref) => ( 63 |

64 | )); 65 | CardContent.displayName = "CardContent"; 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
76 | )); 77 | CardFooter.displayName = "CardFooter"; 78 | 79 | export { 80 | Card, 81 | CardHeader, 82 | CardFooter, 83 | CardTitle, 84 | CardDescription, 85 | CardContent, 86 | }; 87 | -------------------------------------------------------------------------------- /components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as DialogPrimitive from "@radix-ui/react-dialog"; 5 | import { X } from "lucide-react"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | const Dialog = DialogPrimitive.Root; 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger; 12 | 13 | const DialogPortal = DialogPrimitive.Portal; 14 | 15 | const DialogClose = DialogPrimitive.Close; 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )); 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )); 54 | DialogContent.displayName = DialogPrimitive.Content.displayName; 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes) => ( 60 |
67 | ); 68 | DialogHeader.displayName = "DialogHeader"; 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes) => ( 74 |
81 | ); 82 | DialogFooter.displayName = "DialogFooter"; 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 96 | )); 97 | DialogTitle.displayName = DialogPrimitive.Title.displayName; 98 | 99 | const DialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )); 109 | DialogDescription.displayName = DialogPrimitive.Description.displayName; 110 | 111 | export { 112 | Dialog, 113 | DialogPortal, 114 | DialogOverlay, 115 | DialogClose, 116 | DialogTrigger, 117 | DialogContent, 118 | DialogHeader, 119 | DialogFooter, 120 | DialogTitle, 121 | DialogDescription, 122 | }; 123 | -------------------------------------------------------------------------------- /components/ui/drawer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { Drawer as DrawerPrimitive } from "vaul"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Drawer = ({ 9 | shouldScaleBackground = true, 10 | ...props 11 | }: React.ComponentProps) => ( 12 | 16 | ); 17 | Drawer.displayName = "Drawer"; 18 | 19 | const DrawerTrigger = DrawerPrimitive.Trigger; 20 | 21 | const DrawerPortal = DrawerPrimitive.Portal; 22 | 23 | const DrawerClose = DrawerPrimitive.Close; 24 | 25 | const DrawerOverlay = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef 28 | >(({ className, ...props }, ref) => ( 29 | 34 | )); 35 | DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName; 36 | 37 | const DrawerContent = React.forwardRef< 38 | React.ElementRef, 39 | React.ComponentPropsWithoutRef 40 | >(({ className, children, ...props }, ref) => ( 41 | 42 | 43 | 51 |
52 | {children} 53 | 54 | 55 | )); 56 | DrawerContent.displayName = "DrawerContent"; 57 | 58 | const DrawerHeader = ({ 59 | className, 60 | ...props 61 | }: React.HTMLAttributes) => ( 62 |
66 | ); 67 | DrawerHeader.displayName = "DrawerHeader"; 68 | 69 | const DrawerFooter = ({ 70 | className, 71 | ...props 72 | }: React.HTMLAttributes) => ( 73 |
77 | ); 78 | DrawerFooter.displayName = "DrawerFooter"; 79 | 80 | const DrawerTitle = React.forwardRef< 81 | React.ElementRef, 82 | React.ComponentPropsWithoutRef 83 | >(({ className, ...props }, ref) => ( 84 | 92 | )); 93 | DrawerTitle.displayName = DrawerPrimitive.Title.displayName; 94 | 95 | const DrawerDescription = React.forwardRef< 96 | React.ElementRef, 97 | React.ComponentPropsWithoutRef 98 | >(({ className, ...props }, ref) => ( 99 | 104 | )); 105 | DrawerDescription.displayName = DrawerPrimitive.Description.displayName; 106 | 107 | export { 108 | Drawer, 109 | DrawerPortal, 110 | DrawerOverlay, 111 | DrawerTrigger, 112 | DrawerClose, 113 | DrawerContent, 114 | DrawerHeader, 115 | DrawerFooter, 116 | DrawerTitle, 117 | DrawerDescription, 118 | }; 119 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ); 21 | }, 22 | ); 23 | Input.displayName = "Input"; 24 | 25 | export { Input }; 26 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as LabelPrimitive from "@radix-ui/react-label"; 5 | import { cva, type VariantProps } from "class-variance-authority"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", 11 | ); 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )); 24 | Label.displayName = LabelPrimitive.Root.displayName; 25 | 26 | export { Label }; 27 | -------------------------------------------------------------------------------- /components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as PopoverPrimitive from "@radix-ui/react-popover"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Popover = PopoverPrimitive.Root; 9 | 10 | const PopoverTrigger = PopoverPrimitive.Trigger; 11 | 12 | const PopoverContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 16 | 17 | 27 | 28 | )); 29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName; 30 | 31 | export { Popover, PopoverTrigger, PopoverContent }; 32 | -------------------------------------------------------------------------------- /components/ui/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const ScrollArea = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, children, ...props }, ref) => ( 12 | 17 | 18 | {children} 19 | 20 | 21 | 22 | 23 | )); 24 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; 25 | 26 | const ScrollBar = React.forwardRef< 27 | React.ElementRef, 28 | React.ComponentPropsWithoutRef 29 | >(({ className, orientation = "vertical", ...props }, ref) => ( 30 | 43 | 44 | 45 | )); 46 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; 47 | 48 | export { ScrollArea, ScrollBar }; 49 | -------------------------------------------------------------------------------- /components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useTheme } from "next-themes"; 4 | import { Toaster as Sonner } from "sonner"; 5 | 6 | type ToasterProps = React.ComponentProps; 7 | 8 | const Toaster = ({ ...props }: ToasterProps) => { 9 | const { theme = "system" } = useTheme(); 10 | 11 | return ( 12 | 28 | ); 29 | }; 30 | 31 | export { Toaster }; 32 | -------------------------------------------------------------------------------- /components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as TabsPrimitive from "@radix-ui/react-tabs"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Tabs = TabsPrimitive.Root; 9 | 10 | const TabsList = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, ...props }, ref) => ( 14 | 22 | )); 23 | TabsList.displayName = TabsPrimitive.List.displayName; 24 | 25 | const TabsTrigger = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef 28 | >(({ className, ...props }, ref) => ( 29 | 37 | )); 38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; 39 | 40 | const TabsContent = React.forwardRef< 41 | React.ElementRef, 42 | React.ComponentPropsWithoutRef 43 | >(({ className, ...props }, ref) => ( 44 | 52 | )); 53 | TabsContent.displayName = TabsPrimitive.Content.displayName; 54 | 55 | export { Tabs, TabsList, TabsTrigger, TabsContent }; 56 | -------------------------------------------------------------------------------- /components/ui/textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | export interface TextareaProps 6 | extends React.TextareaHTMLAttributes {} 7 | 8 | const Textarea = React.forwardRef( 9 | ({ className, ...props }, ref) => { 10 | return ( 11 |