{"id":19977091,"url":"https://github.com/adrianhajdin/collaborative-editor","last_synced_at":"2025-05-15T17:08:28.057Z","repository":{"id":260069735,"uuid":"830647796","full_name":"adrianhajdin/collaborative-editor","owner":"adrianhajdin","description":"Learn how to build any collaborative application by building LiveDocs, an improved Google Docs that manages millions of collaborators in real-time.","archived":false,"fork":false,"pushed_at":"2024-10-29T09:23:44.000Z","size":863,"stargazers_count":505,"open_issues_count":12,"forks_count":139,"subscribers_count":2,"default_branch":"main","last_synced_at":"2025-05-11T05:59:36.913Z","etag":null,"topics":["clerk","liveblocks","nextjs","nextjs14","sentry"],"latest_commit_sha":null,"homepage":"https://jsm-live-docs.vercel.app","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/adrianhajdin.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2024-07-18T17:25:31.000Z","updated_at":"2025-05-10T11:05:11.000Z","dependencies_parsed_at":"2024-10-29T12:04:19.515Z","dependency_job_id":null,"html_url":"https://github.com/adrianhajdin/collaborative-editor","commit_stats":null,"previous_names":["adrianhajdin/collaborative-editor"],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/adrianhajdin%2Fcollaborative-editor","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/adrianhajdin%2Fcollaborative-editor/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/adrianhajdin%2Fcollaborative-editor/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/adrianhajdin%2Fcollaborative-editor/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/adrianhajdin","download_url":"https://codeload.github.com/adrianhajdin/collaborative-editor/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":254384988,"owners_count":22062422,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["clerk","liveblocks","nextjs","nextjs14","sentry"],"created_at":"2024-11-13T03:26:46.953Z","updated_at":"2025-05-15T17:08:23.044Z","avatar_url":"https://github.com/adrianhajdin.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cdiv align=\"center\"\u003e\n  \u003cbr /\u003e\n    \u003ca href=\"https://youtu.be/y5vE8y_f_OM\" target=\"_blank\"\u003e\n      \u003cimg src=\"https://github.com/user-attachments/assets/eaaeb1f0-22da-46be-9e29-9bef70e0039d\" alt=\"Project Banner\"\u003e\n    \u003c/a\u003e\n  \u003cbr /\u003e\n\n  \u003cdiv\u003e\n    \u003cimg src=\"https://img.shields.io/badge/-Next_JS-black?style=for-the-badge\u0026logoColor=white\u0026logo=nextdotjs\u0026color=61DAFB\" alt=\"next.js\" /\u003e\n    \u003cimg src=\"https://img.shields.io/badge/-TypeScript-black?style=for-the-badge\u0026logoColor=white\u0026logo=typescript\u0026color=3178C6\" alt=\"typescript\" /\u003e\n    \u003cimg src=\"https://img.shields.io/badge/-Tailwind_CSS-black?style=for-the-badge\u0026logoColor=white\u0026logo=tailwindcss\u0026color=06B6D4\" alt=\"tailwindcss\" /\u003e\n  \u003c/div\u003e\n\n  \u003ch3 align=\"center\"\u003eA Collaborative LiveDocs\u003c/h3\u003e\n\n   \u003cdiv align=\"center\"\u003e\n     Build this project step by step with our detailed tutorial on \u003ca href=\"https://www.youtube.com/@javascriptmastery/videos\" target=\"_blank\"\u003e\u003cb\u003eJavaScript Mastery\u003c/b\u003e\u003c/a\u003e YouTube. Join the JSM family!\n    \u003c/div\u003e\n\u003c/div\u003e\n\n## 📋 \u003ca name=\"table\"\u003eTable of Contents\u003c/a\u003e\n\n1. 🤖 [Introduction](#introduction)\n2. ⚙️ [Tech Stack](#tech-stack)\n3. 🔋 [Features](#features)\n4. 🤸 [Quick Start](#quick-start)\n5. 🕸️ [Snippets (Code to Copy)](#snippets)\n6. 🔗 [Links](#links)\n7. 🚀 [More](#more)\n\n## 🚨 Tutorial\n\nThis repository contains the code corresponding to an in-depth tutorial available on our YouTube channel, \u003ca href=\"https://www.youtube.com/@javascriptmastery/videos\" target=\"_blank\"\u003e\u003cb\u003eJavaScript Mastery\u003c/b\u003e\u003c/a\u003e. \n\nIf you prefer visual learning, this is the perfect resource for you. Follow our tutorial to learn how to build projects like these step-by-step in a beginner-friendly manner!\n\n\u003ca href=\"https://youtu.be/y5vE8y_f_OM\" target=\"_blank\"\u003e\u003cimg src=\"https://github.com/sujatagunale/EasyRead/assets/151519281/1736fca5-a031-4854-8c09-bc110e3bc16d\" /\u003e\u003c/a\u003e\n\n## \u003ca name=\"introduction\"\u003e🤖 Introduction\u003c/a\u003e\n\nBuilt with Next.js to handle the user interface, Liveblocks for real-time features and styled with TailwindCSS, LiveDocs is a clone of Goole Docs. The primary goal is to demonstrate the developer's skills in realtime enviroment that creates a lasting impact.\n\nIf you're getting started and need assistance or face any bugs, join our active Discord community with over **34k+** members. It's a place where people help each other out.\n\n\u003ca href=\"https://discord.com/invite/n6EdbFJ\" target=\"_blank\"\u003e\u003cimg src=\"https://github.com/sujatagunale/EasyRead/assets/151519281/618f4872-1e10-42da-8213-1d69e486d02e\" /\u003e\u003c/a\u003e\n\n## \u003ca name=\"tech-stack\"\u003e⚙️ Tech Stack\u003c/a\u003e\n\n- Next.js\n- TypeScript\n- Liveblocks\n- Lexical Editor\n- ShadCN\n- Tailwind CSS\n\n## \u003ca name=\"features\"\u003e🔋 Features\u003c/a\u003e\n\n👉 **Authentication**: User authentication using GitHub through NextAuth, ensuring secure sign-in/out and session management.\n\n👉 **Collaborative Text Editor**: Multiple users can edit the same document simultaneously with real-time updates.\n\n👉 **Documents Management**\n   - **Create Documents**: Users can create new documents, which are automatically saved and listed.\n   - **Delete Documents**: Users can delete documents they own.\n   - **Share Documents**: Users can share documents via email or link with view/edit permissions.\n   - **List Documents**: Display all documents owned or shared with the user, with search and sorting functionalities.\n\n👉 **Comments**: Users can add inline and general comments, with threading for discussions.\n\n👉 **Active Collaborators on Text Editor**: Show active collaborators with real-time presence indicators.\n\n👉 **Notifications**: Notify users of document shares, new comments, and collaborator activities.\n\n👉 **Responsive**: The application is responsive across all devices.\n\nand many more, including code architecture and reusability \n\n## \u003ca name=\"quick-start\"\u003e🤸 Quick Start\u003c/a\u003e\n\nFollow these steps to set up the project locally on your machine.\n\n**Prerequisites**\n\nMake sure you have the following installed on your machine:\n\n- [Git](https://git-scm.com/)\n- [Node.js](https://nodejs.org/en)\n- [npm](https://www.npmjs.com/) (Node Package Manager)\n\n**Cloning the Repository**\n\n```bash\ngit clone https://github.com/adrianhajdin/collaborative-editor.git\ncd collaborative-editor\n```\n\n**Installation**\n\nInstall the project dependencies using npm:\n\n```bash\nnpm install\n```\n\n**Set Up Environment Variables**\n\nCreate a new file named `.env` in the root of your project and add the following content:\n\n```env\n#Clerk\nNEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=\nCLERK_SECRET_KEY=\nNEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in\nNEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up\n\n#Liveblocks\nNEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY=\nLIVEBLOCKS_SECRET_KEY=\n```\n\nReplace the placeholder values with your actual Clerk \u0026 LiveBlocks credentials. You can obtain these credentials by signing up on the [Clerk](https://clerk.com/) and [Liveblocks](liveblocks.io/) website.\n\n**Running the Project**\n\n```bash\nnpm run dev\n```\n\nOpen [http://localhost:3000](http://localhost:3000) in your browser to view the project.\n\n## \u003ca name=\"snippets\"\u003e🕸️ Snippets\u003c/a\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003ccode\u003eglobals.css\u003c/code\u003e\u003c/summary\u003e\n\n```css\n@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n/* @import \"@liveblocks/react-ui/styles.css\"; */\n/* @import \"@liveblocks/react-lexical/styles.css\"; */\n\n/* @import \"../styles/dark-theme.css\"; */\n\n/* ========================================== TAILWIND STYLES */\n@layer base {\n  :root {\n    background: #09111f;\n    color: #fff;\n    margin: 0;\n  }\n\n  .custom-scrollbar::-webkit-scrollbar {\n    width: 4px;\n    height: 4px;\n    border-radius: 50px;\n  }\n\n  .custom-scrollbar::-webkit-scrollbar-track {\n    background: #09090a;\n  }\n\n  .custom-scrollbar::-webkit-scrollbar-thumb {\n    background: #2e3d5b;\n    border-radius: 50px;\n  }\n\n  .custom-scrollbar::-webkit-scrollbar-thumb:hover {\n    background: #7878a3;\n  }\n}\n\n@layer utilities {\n  .text-28-semibold {\n    @apply text-[28px] font-semibold;\n  }\n  .text-10-regular {\n    @apply text-[10px] font-normal;\n  }\n\n  .gradient-blue {\n    @apply bg-gradient-to-t from-blue-500 to-blue-400;\n  }\n  .gradient-red {\n    @apply bg-gradient-to-t from-red-500 to-red-400;\n  }\n\n  .shad-dialog {\n    @apply w-full max-w-[400px] rounded-xl border-none bg-doc bg-cover px-5 py-7 shadow-xl sm:min-w-[500px] !important;\n  }\n\n  .shad-dialog button {\n    @apply focus:ring-0 focus:ring-offset-0 focus-visible:border-none focus-visible:outline-none focus-visible:ring-transparent focus-visible:ring-offset-0 !important;\n  }\n\n  .shad-select {\n    @apply w-fit border-none bg-transparent text-blue-100 !important;\n  }\n\n  .shad-select svg {\n    @apply ml-1 mt-1;\n  }\n\n  .shad-select-item {\n    @apply cursor-pointer bg-dark-200 text-blue-100 focus:bg-dark-300 hover:bg-dark-300 focus:text-blue-100 !important;\n  }\n\n  .shad-popover {\n    @apply w-[460px] border-none bg-dark-200 shadow-lg !important;\n  }\n\n  .floating-toolbar {\n    @apply flex w-full min-w-max items-center justify-center gap-2 rounded-lg bg-dark-350 p-1.5 shadow-xl;\n  }\n\n  .floating-toolbar-btn {\n    @apply relative inline-flex size-8 items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50;\n  }\n\n  .toolbar-wrapper {\n    @apply z-50 custom-scrollbar w-screen overflow-auto border-y border-dark-300 bg-dark-100 pl-3 pr-4 shadow-sm;\n  }\n\n  .editor-wrapper {\n    @apply custom-scrollbar h-[calc(100vh-140px)] gap-5 overflow-auto px-5 pt-5 lg:flex-row lg:items-start lg:justify-center  xl:gap-10 xl:pt-10;\n  }\n\n  .header {\n    @apply min-h-[92px] min-w-full flex-nowrap bg-dark-100 flex w-full items-center justify-between gap-2 px-4;\n  }\n\n  .document-list-container {\n    @apply flex flex-col items-center mb-10 w-full gap-10 px-5;\n  }\n\n  .document-list-title {\n    @apply max-w-[730px] items-end flex w-full justify-between;\n  }\n\n  .document-list-item {\n    @apply flex items-center justify-between gap-4 rounded-lg bg-doc bg-cover p-5 shadow-xl;\n  }\n\n  .document-list-empty {\n    @apply flex w-full max-w-[730px] flex-col items-center justify-center gap-5 rounded-lg bg-dark-200 px-10 py-8;\n  }\n\n  .document-title-input {\n    @apply min-w-[78px] border-none bg-transparent px-0 text-left text-base font-semibold leading-[24px] focus-visible:ring-0 focus-visible:ring-offset-0 disabled:text-black sm:text-xl md:text-center !important;\n  }\n\n  .document-title {\n    @apply line-clamp-1 border-dark-400 text-base font-semibold leading-[24px] sm:pl-0 sm:text-xl;\n  }\n\n  .view-only-tag {\n    @apply rounded-md bg-dark-400/50 px-2 py-0.5 text-xs text-blue-100/50;\n  }\n\n  .collaborators-list {\n    @apply hidden items-center justify-end -space-x-3 overflow-hidden sm:flex;\n  }\n\n  .share-input {\n    @apply h-11 flex-1 border-none bg-dark-400 focus-visible:ring-0 focus-visible:ring-offset-0 !important;\n  }\n\n  .remove-btn {\n    @apply rounded-lg bg-transparent px-0 text-red-500 hover:bg-transparent;\n  }\n\n  .comments-container {\n    @apply mb-10 space-y-4 lg:w-fit flex w-full flex-col items-center justify-center;\n  }\n\n  .comment-composer {\n    @apply w-full max-w-[800px] border border-dark-300 bg-dark-200 shadow-sm lg:w-[350px];\n  }\n\n  .comment-thread {\n    @apply w-full max-w-[800px] border border-dark-300 bg-dark-200 shadow-sm lg:w-[350px] transition-all;\n  }\n\n  .loader {\n    @apply flex size-full h-screen items-center justify-center gap-3 text-white;\n  }\n\n  /* ======================== Auth Pages */\n  .auth-page {\n    @apply flex h-screen w-full flex-col items-center justify-center gap-10;\n  }\n\n  /* ======================== Home Page */\n  .home-container {\n    @apply relative flex min-h-screen w-full flex-col items-center gap-5 sm:gap-10;\n  }\n\n  .document-ul {\n    @apply flex w-full max-w-[730px] flex-col gap-5;\n  }\n\n  /* ======================== CollaborativeRoom */\n  .collaborative-room {\n    @apply flex size-full max-h-screen flex-1 flex-col items-center overflow-hidden;\n  }\n}\n\n/* ======================== Clerk Override */\n.cl-avatarBox {\n  width: 36px;\n  height: 36px;\n}\n\n.cl-userButtonTrigger {\n  height: fit-content !important;\n}\n\n.cl-cardBox,\n.cl-signIn-start,\n.cl-signUp-start,\n.cl-footer {\n  background: #060d18;\n  box-shadow: none;\n  padding: 20px;\n}\n\n.cl-socialButtonsBlockButton,\n.cl-socialButtonsBlockButton:hover {\n  height: 40px;\n  background-color: #3371ff;\n  color: #fff;\n}\n\n.cl-internal-2gzuzc {\n  filter: brightness(1000%);\n}\n\n.cl-logoBox {\n  height: 52px;\n}\n\n/* ======================== Liveblocks Override */\n.lb-root {\n  --lb-accent-subtle: #0b1527;\n  --lb-radius: 0px;\n  --lb-dynamic-background: #1b2840;\n}\n\n.lb-comment,\n.lb-thread-comments,\n.lb-composer,\n.lb-comment-reaction {\n  background-color: #0f1c34;\n  color: #fff;\n}\n\n.lb-button {\n  --lb-foreground-moderate: #fff;\n}\n\n.lb-button:where([data-variant=\"primary\"]) {\n  background-color: #161e30;\n  color: #b4c6ee;\n  padding: 8px;\n}\n\n.lb-button:where(\n    [data-variant=\"default\"]:not(\n        :is(\n            :enabled:hover,\n            :enabled:focus-visible,\n            [aria-expanded=\"true\"],\n            [aria-selected=\"true\"]\n          )\n      )\n  ) {\n  color: #b4c6ee;\n}\n\n.lb-button:where(\n    :enabled:hover,\n    :enabled:focus-visible,\n    [aria-expanded=\"true\"],\n    [aria-selected=\"true\"]\n  ) {\n  --lb-button-background: #161e30;\n\n  color: #b4c6ee;\n}\n\n.lb-inbox-notification-list-item:where(:not(:last-of-type)) {\n  border-bottom: none;\n}\n\n.lb-comment-body,\n.lb-dropdown-item,\n.lb-dropdown-item-icon,\n.lb-composer-editor {\n  color: #fff;\n}\n\n.lb-composer-action {\n  padding: 8px;\n}\n\n.lb-comment-content {\n  background: #0b1527;\n  margin-top: 16px;\n  padding: 12px;\n  border-radius: 4px;\n  font-size: 14px;\n}\n\n.lb-comment-date,\n.lb-lexical-mention-suggestion-user,\n.lb-composer-suggestions-list-item,\n.lb-inbox-notification-date,\n.lb-comment-author {\n  color: #b4c6ee;\n}\n\n.data-liveblocks-portal {\n  color: #b4c6ee !important;\n}\n\n.lb-root:where(:not(.lb-root .lb-root)) {\n  --lb-dynamic-background: #1b2840;\n  color: #fff;\n}\n\n.lb-composer-editor :where([data-placeholder]) {\n  color: #b4c6ee;\n  font-size: 14px;\n}\n\n.lb-lexical-floating-threads-thread:where([data-resolved]) {\n  opacity: 40%;\n}\n\n.lb-elevation {\n  background: #0f1c34;\n}\n```\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003ccode\u003etailwind.config.ts\u003c/code\u003e\u003c/summary\u003e\n\n```typescript\nimport type { Config } from 'tailwindcss';\n\nconst { fontFamily } = require('tailwindcss/defaultTheme');\n\nconst config = {\n  darkMode: ['class'],\n  content: [\n    './pages/**/*.{ts,tsx}',\n    './components/**/*.{ts,tsx}',\n    './app/**/*.{ts,tsx}',\n    './src/**/*.{ts,tsx}',\n  ],\n  prefix: '',\n  theme: {\n    container: {\n      center: true,\n      padding: '2rem',\n      screens: {\n        '2xl': '1400px',\n        xs: '360px',\n      },\n    },\n    extend: {\n      colors: {\n        blue: {\n          100: '#B4C6EE',\n          400: '#417BFF',\n          500: '#3371FF',\n        },\n        red: {\n          400: '#DD4F56',\n          500: '#DC4349',\n        },\n        dark: {\n          100: '#09111F',\n          200: '#0B1527',\n          300: '#0F1C34',\n          350: '#12213B',\n          400: '#27344D',\n          500: '#2E3D5B',\n        },\n      },\n      fontFamily: {\n        sans: ['var(--font-sans)', ...fontFamily.sans],\n      },\n      keyframes: {\n        'accordion-down': {\n          from: { height: '0' },\n          to: { height: 'var(--radix-accordion-content-height)' },\n        },\n        'accordion-up': {\n          from: { height: 'var(--radix-accordion-content-height)' },\n          to: { height: '0' },\n        },\n      },\n      backgroundImage: {\n        doc: 'url(/assets/images/doc.png)',\n        modal: 'url(/assets/images/modal.png)',\n      },\n      animation: {\n        'accordion-down': 'accordion-down 0.2s ease-out',\n        'accordion-up': 'accordion-up 0.2s ease-out',\n      },\n    },\n  },\n  plugins: [require('tailwindcss-animate')],\n} satisfies Config;\n\nexport default config;\n```\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003ccode\u003etypes/index.d.ts\u003c/code\u003e\u003c/summary\u003e\n\n```typescript\n/* eslint-disable no-unused-vars */\ndeclare type SearchParamProps = {\n  params: { [key: string]: string };\n  searchParams: { [key: string]: string | string[] | undefined };\n};\n\ndeclare type AccessType = [\"room:write\"] | [\"room:read\", \"room:presence:write\"];\n\ndeclare type RoomAccesses = Record\u003cstring, AccessType\u003e;\n\ndeclare type UserType = \"creator\" | \"editor\" | \"viewer\";\n\ndeclare type RoomMetadata = {\n  creatorId: string;\n  email: string;\n  title: string;\n};\n\ndeclare type CreateDocumentParams = {\n  userId: string;\n  email: string;\n};\n\ndeclare type User = {\n  id: string;\n  name: string;\n  email: string;\n  avatar: string;\n  color: string;\n  userType?: UserType;\n};\n\ndeclare type ShareDocumentParams = {\n  roomId: string;\n  email: string;\n  userType: UserType;\n  updatedBy: User;\n};\n\ndeclare type UserTypeSelectorParams = {\n  userType: string;\n  setUserType: React.Dispatch\u003cReact.SetStateAction\u003cUserType\u003e\u003e;\n  onClickHandler?: (value: string) =\u003e void;\n};\n\ndeclare type ShareDocumentDialogProps = {\n  roomId: string;\n  collaborators: User[];\n  creatorId: string;\n  currentUserType: UserType;\n};\n\ndeclare type HeaderProps = {\n  children: React.ReactNode;\n  className?: string;\n};\n\ndeclare type CollaboratorProps = {\n  roomId: string;\n  email: string;\n  creatorId: string;\n  collaborator: User;\n  user: User;\n};\n\ndeclare type CollaborativeRoomProps = {\n  roomId: string;\n  roomMetadata: RoomMetadata;\n  users: User[];\n  currentUserType: UserType;\n};\n\ndeclare type AddDocumentBtnProps = {\n  userId: string;\n  email: string;\n};\n\ndeclare type DeleteModalProps = { roomId: string };\n\ndeclare type ThreadWrapperProps = { thread: ThreadData\u003cBaseMetadata\u003e };\n```\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003ccode\u003elib/utils.ts\u003c/code\u003e\u003c/summary\u003e\n\n```typescript\nimport { type ClassValue, clsx } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n\nexport const parseStringify = (value: any) =\u003e JSON.parse(JSON.stringify(value));\n\nexport const getAccessType = (userType: UserType) =\u003e {\n  switch (userType) {\n    case 'creator':\n      return ['room:write'];\n    case 'editor':\n      return ['room:write'];\n    case 'viewer':\n      return ['room:read', 'room:presence:write'];\n    default:\n      return ['room:read', 'room:presence:write'];\n  }\n};\n\nexport const dateConverter = (timestamp: string): string =\u003e {\n  const timestampNum = Math.round(new Date(timestamp).getTime() / 1000);\n  const date: Date = new Date(timestampNum * 1000);\n  const now: Date = new Date();\n\n  const diff: number = now.getTime() - date.getTime();\n  const diffInSeconds: number = diff / 1000;\n  const diffInMinutes: number = diffInSeconds / 60;\n  const diffInHours: number = diffInMinutes / 60;\n  const diffInDays: number = diffInHours / 24;\n\n  switch (true) {\n    case diffInDays \u003e 7:\n      return `${Math.floor(diffInDays / 7)} weeks ago`;\n    case diffInDays \u003e= 1 \u0026\u0026 diffInDays \u003c= 7:\n      return `${Math.floor(diffInDays)} days ago`;\n    case diffInHours \u003e= 1:\n      return `${Math.floor(diffInHours)} hours ago`;\n    case diffInMinutes \u003e= 1:\n      return `${Math.floor(diffInMinutes)} minutes ago`;\n    default:\n      return 'Just now';\n  }\n};\n\n// Function to generate a random color in hex format, excluding specified colors\nexport function getRandomColor() {\n  const avoidColors = ['#000000', '#FFFFFF', '#8B4513']; // Black, White, Brown in hex format\n\n  let randomColor;\n  do {\n    // Generate random RGB values\n    const r = Math.floor(Math.random() * 256); // Random number between 0-255\n    const g = Math.floor(Math.random() * 256);\n    const b = Math.floor(Math.random() * 256);\n\n    // Convert RGB to hex format\n    randomColor = `#${r.toString(16)}${g.toString(16)}${b.toString(16)}`;\n  } while (avoidColors.includes(randomColor));\n\n  return randomColor;\n}\n\nexport const brightColors = [\n  '#2E8B57', // Darker Neon Green\n  '#FF6EB4', // Darker Neon Pink\n  '#00CDCD', // Darker Cyan\n  '#FF00FF', // Darker Neon Magenta\n  '#FF007F', // Darker Bright Pink\n  '#FFD700', // Darker Neon Yellow\n  '#00CED1', // Darker Neon Mint Green\n  '#FF1493', // Darker Neon Red\n  '#00CED1', // Darker Bright Aqua\n  '#FF7F50', // Darker Neon Coral\n  '#9ACD32', // Darker Neon Lime\n  '#FFA500', // Darker Neon Orange\n  '#32CD32', // Darker Neon Chartreuse\n  '#ADFF2F', // Darker Neon Yellow Green\n  '#DB7093', // Darker Neon Fuchsia\n  '#00FF7F', // Darker Spring Green\n  '#FFD700', // Darker Electric Lime\n  '#FF007F', // Darker Bright Magenta\n  '#FF6347', // Darker Neon Vermilion\n];\n\nexport function getUserColor(userId: string) {\n  let sum = 0;\n  for (let i = 0; i \u003c userId.length; i++) {\n    sum += userId.charCodeAt(i);\n  }\n\n  const colorIndex = sum % brightColors.length;\n  return brightColors[colorIndex];\n}\n```\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003ccode\u003ecomponents/editor/plugins/FloatingToolbar.tsx\u003c/code\u003e\u003c/summary\u003e\n\n```typescript\nimport {\n  autoUpdate,\n  flip,\n  hide,\n  limitShift,\n  offset,\n  shift,\n  size,\n  useFloating,\n} from '@floating-ui/react-dom';\nimport { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';\nimport { OPEN_FLOATING_COMPOSER_COMMAND } from '@liveblocks/react-lexical';\nimport type { LexicalEditor, LexicalNode } from 'lexical';\nimport { $getSelection, $isRangeSelection, $isTextNode } from 'lexical';\nimport Image from 'next/image';\nimport { useEffect, useLayoutEffect, useState } from 'react';\nimport * as React from 'react';\nimport { createPortal } from 'react-dom';\n\nexport default function FloatingToolbar() {\n  const [editor] = useLexicalComposerContext();\n\n  const [range, setRange] = useState\u003cRange | null\u003e(null);\n\n  useEffect(() =\u003e {\n    editor.registerUpdateListener(({ tags }) =\u003e {\n      return editor.getEditorState().read(() =\u003e {\n        // Ignore selection updates related to collaboration\n        if (tags.has('collaboration')) return;\n\n        const selection = $getSelection();\n        if (!$isRangeSelection(selection) || selection.isCollapsed()) {\n          setRange(null);\n          return;\n        }\n\n        const { anchor, focus } = selection;\n\n        const range = createDOMRange(\n          editor,\n          anchor.getNode(),\n          anchor.offset,\n          focus.getNode(),\n          focus.offset,\n        );\n\n        setRange(range);\n      });\n    });\n  }, [editor]);\n\n  if (range === null) return null;\n\n  return (\n    \u003cToolbar range={range} onRangeChange={setRange} container={document.body} /\u003e\n  );\n}\n\nfunction Toolbar({\n  range,\n  onRangeChange,\n  container,\n}: {\n  range: Range;\n  onRangeChange: (range: Range | null) =\u003e void;\n  container: HTMLElement;\n}) {\n  const [editor] = useLexicalComposerContext();\n\n  const padding = 20;\n\n  const {\n    refs: { setReference, setFloating },\n    strategy,\n    x,\n    y,\n  } = useFloating({\n    strategy: 'fixed',\n    placement: 'bottom',\n    middleware: [\n      flip({ padding, crossAxis: false }),\n      offset(10),\n      hide({ padding }),\n      shift({ padding, limiter: limitShift() }),\n      size({ padding }),\n    ],\n    whileElementsMounted: (...args) =\u003e {\n      return autoUpdate(...args, {\n        animationFrame: true,\n      });\n    },\n  });\n\n  useLayoutEffect(() =\u003e {\n    setReference({\n      getBoundingClientRect: () =\u003e range.getBoundingClientRect(),\n    });\n  }, [setReference, range]);\n\n  return createPortal(\n    \u003cdiv\n      ref={setFloating}\n      style={{\n        position: strategy,\n        top: 0,\n        left: 0,\n        transform: `translate3d(${Math.round(x)}px, ${Math.round(y)}px, 0)`,\n        minWidth: 'max-content',\n      }}\n    \u003e\n      \u003cdiv className=\"floating-toolbar\"\u003e\n        \u003cbutton\n          onClick={() =\u003e {\n            const isOpen = editor.dispatchCommand(\n              OPEN_FLOATING_COMPOSER_COMMAND,\n              undefined,\n            );\n            if (isOpen) {\n              onRangeChange(null);\n            }\n          }}\n          className=\"floating-toolbar-btn\"\n        \u003e\n          \u003cImage\n            src=\"/assets/icons/comment.svg\"\n            alt=\"comment\"\n            width={24}\n            height={24}\n          /\u003e\n        \u003c/button\u003e\n      \u003c/div\u003e\n    \u003c/div\u003e,\n    container,\n  );\n}\n\n/**\n * MIT License\n * Copyright (c) Meta Platforms, Inc. and affiliates.\n\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to deal\n * in the Software without restriction, including without limitation the rights\n * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n * copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n * \n * The above copyright notice and this permission notice shall be included in all\n * copies or substantial portions of the Software.\n * \n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n * SOFTWARE.\n */\n\nfunction getDOMTextNode(element: Node | null): Text | null {\n  let node = element;\n\n  while (node !== null) {\n    if (node.nodeType === Node.TEXT_NODE) {\n      return node as Text;\n    }\n\n    node = node.firstChild;\n  }\n\n  return null;\n}\n\nfunction getDOMIndexWithinParent(node: ChildNode): [ParentNode, number] {\n  const parent = node.parentNode;\n\n  if (parent === null) {\n    throw new Error('Should never happen');\n  }\n\n  return [parent, Array.from(parent.childNodes).indexOf(node)];\n}\n\n/**\n * Creates a selection range for the DOM.\n * @param editor - The lexical editor.\n * @param anchorNode - The anchor node of a selection.\n * @param _anchorOffset - The amount of space offset from the anchor to the focus.\n * @param focusNode - The current focus.\n * @param _focusOffset - The amount of space offset from the focus to the anchor.\n * @returns The range of selection for the DOM that was created.\n */\nexport function createDOMRange(\n  editor: LexicalEditor,\n  anchorNode: LexicalNode,\n  _anchorOffset: number,\n  focusNode: LexicalNode,\n  _focusOffset: number,\n): Range | null {\n  const anchorKey = anchorNode.getKey();\n  const focusKey = focusNode.getKey();\n  const range = document.createRange();\n  let anchorDOM: Node | Text | null = editor.getElementByKey(anchorKey);\n  let focusDOM: Node | Text | null = editor.getElementByKey(focusKey);\n  let anchorOffset = _anchorOffset;\n  let focusOffset = _focusOffset;\n\n  if ($isTextNode(anchorNode)) {\n    anchorDOM = getDOMTextNode(anchorDOM);\n  }\n\n  if ($isTextNode(focusNode)) {\n    focusDOM = getDOMTextNode(focusDOM);\n  }\n\n  if (\n    anchorNode === undefined ||\n    focusNode === undefined ||\n    anchorDOM === null ||\n    focusDOM === null\n  ) {\n    return null;\n  }\n\n  if (anchorDOM.nodeName === 'BR') {\n    [anchorDOM, anchorOffset] = getDOMIndexWithinParent(anchorDOM as ChildNode);\n  }\n\n  if (focusDOM.nodeName === 'BR') {\n    [focusDOM, focusOffset] = getDOMIndexWithinParent(focusDOM as ChildNode);\n  }\n\n  const firstChild = anchorDOM.firstChild;\n\n  if (\n    anchorDOM === focusDOM \u0026\u0026\n    firstChild !== null \u0026\u0026\n    firstChild.nodeName === 'BR' \u0026\u0026\n    anchorOffset === 0 \u0026\u0026\n    focusOffset === 0\n  ) {\n    focusOffset = 1;\n  }\n\n  try {\n    range.setStart(anchorDOM, anchorOffset);\n    range.setEnd(focusDOM, focusOffset);\n  } catch (e) {\n    return null;\n  }\n\n  if (\n    range.collapsed \u0026\u0026\n    (anchorOffset !== focusOffset || anchorKey !== focusKey)\n  ) {\n    // Range is backwards, we need to reverse it\n    range.setStart(focusDOM, focusOffset);\n    range.setEnd(anchorDOM, anchorOffset);\n  }\n\n  return range;\n}\n```\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003ccode\u003ecomponents/DeleteModal.tsx\u003c/code\u003e\u003c/summary\u003e\n\n```typescript\n\"use client\";\n\nimport Image from \"next/image\";\nimport { useState } from \"react\";\n\nimport { deleteDocument } from \"@/lib/actions/room.actions\";\n\nimport {\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\n\nimport { Button } from \"./ui/button\";\n\nexport const DeleteModal = ({ roomId }: DeleteModalProps) =\u003e {\n  const [open, setOpen] = useState(false);\n  const [loading, setLoading] = useState(false);\n\n  const deleteDocumentHandler = async () =\u003e {\n    setLoading(true);\n\n    try {\n      await deleteDocument(roomId);\n      setOpen(false);\n    } catch (error) {\n      console.log(\"Error notif:\", error);\n    }\n\n    setLoading(false);\n  };\n\n  return (\n    \u003cDialog open={open} onOpenChange={setOpen}\u003e\n      \u003cDialogTrigger asChild\u003e\n        \u003cButton className=\"min-w-9 rounded-xl bg-transparent p-2 transition-all\"\u003e\n          \u003cImage\n            src=\"/assets/icons/delete.svg\"\n            alt=\"delete\"\n            width={20}\n            height={20}\n            className=\"mt-1\"\n          /\u003e\n        \u003c/Button\u003e\n      \u003c/DialogTrigger\u003e\n      \u003cDialogContent className=\"shad-dialog\"\u003e\n        \u003cDialogHeader\u003e\n          \u003cImage\n            src=\"/assets/icons/delete-modal.svg\"\n            alt=\"delete\"\n            width={48}\n            height={48}\n            className=\"mb-4\"\n          /\u003e\n          \u003cDialogTitle\u003eDelete document\u003c/DialogTitle\u003e\n          \u003cDialogDescription\u003e\n            Are you sure you want to delete this document? This action cannot be\n            undone.\n          \u003c/DialogDescription\u003e\n        \u003c/DialogHeader\u003e\n\n        \u003cDialogFooter className=\"mt-5\"\u003e\n          \u003cDialogClose asChild className=\"w-full bg-dark-400 text-white\"\u003e\n            Cancel\n          \u003c/DialogClose\u003e\n\n          \u003cButton\n            variant=\"destructive\"\n            onClick={deleteDocumentHandler}\n            className=\"gradient-red w-full\"\n          \u003e\n            {loading ? \"Deleting...\" : \"Delete\"}\n          \u003c/Button\u003e\n        \u003c/DialogFooter\u003e\n      \u003c/DialogContent\u003e\n    \u003c/Dialog\u003e\n  );\n};\n```\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003ccode\u003ecomponents/Notifications.ts\u003c/code\u003e\u003c/summary\u003e\n\n```typescript\n\"use client\";\n\nimport {\n  useInboxNotifications,\n  useUnreadInboxNotificationsCount,\n} from \"@liveblocks/react/suspense\";\nimport {\n  InboxNotification,\n  InboxNotificationList,\n  LiveblocksUIConfig,\n} from \"@liveblocks/react-ui\";\nimport Image from \"next/image\";\nimport { ReactNode } from \"react\";\n\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\";\n\nexport const Notifications = () =\u003e {\n  const { inboxNotifications } = useInboxNotifications();\n  const { count } = useUnreadInboxNotificationsCount();\n\n  const unreadNotifications = inboxNotifications.filter(\n    (notification) =\u003e !notification.readAt // Filter unread notifications\n  );\n\n  return (\n    \u003cPopover\u003e\n      \u003cPopoverTrigger className=\"relative flex size-10 items-center justify-center rounded-lg\"\u003e\n        \u003cImage\n          src=\"/assets/icons/bell.svg\"\n          alt=\"inbox\"\n          width={24}\n          height={24}\n        /\u003e\n        {count \u003e 0 \u0026\u0026 (\n          \u003cdiv className=\"absolute right-2 top-2 z-20 size-2 rounded-full bg-blue-500\" /\u003e\n        )}\n      \u003c/PopoverTrigger\u003e\n      \u003cPopoverContent align=\"end\" className=\"shad-popover\"\u003e\n        \u003cLiveblocksUIConfig\n          overrides={{\n            INBOX_NOTIFICATION_TEXT_MENTION: (user: ReactNode) =\u003e {\n              return \u003c\u003e{user} mentioned you\u003c/\u003e;\n            },\n          }}\n        \u003e\n          \u003cInboxNotificationList\u003e\n            {unreadNotifications.length \u003c= 0 \u0026\u0026 (\n              \u003cp className=\"py-2 text-center text-dark-500\"\u003e\n                No notifications yet\n              \u003c/p\u003e\n            )}\n\n            {unreadNotifications.length \u003e 0 \u0026\u0026\n              unreadNotifications.map((inboxNotification: any) =\u003e (\n                \u003cInboxNotification\n                  key={inboxNotification.id}\n                  inboxNotification={inboxNotification}\n                  className=\"bg-dark-200 text-white\"\n                  href={`/documents/${inboxNotification.roomId}`}\n                  showActions={false}\n                  kinds={{\n                    thread: (props) =\u003e (\n                      \u003cInboxNotification.Thread\n                        {...props}\n                        showRoomName={false}\n                        showActions={false}\n                      /\u003e\n                    ),\n                    textMention: (props) =\u003e {\n                      return (\n                        \u003cInboxNotification.TextMention\n                          {...props}\n                          showRoomName={false}\n                        /\u003e\n                      );\n                    },\n                    $documentAccess: (props) =\u003e {\n                      const { title, avatar } =\n                        props.inboxNotification.activities[0].data;\n\n                      return (\n                        \u003cInboxNotification.Custom\n                          {...props}\n                          title={title}\n                          aside={\n                            \u003cInboxNotification.Icon className=\"bg-transparent\"\u003e\n                              \u003cImage\n                                src={(avatar as string) || \"\"}\n                                width={36}\n                                height={36}\n                                alt=\"avatar\"\n                                className=\"rounded-full\"\n                              /\u003e\n                            \u003c/InboxNotification.Icon\u003e\n                          }\n                        \u003e\n                          {props.children}\n                        \u003c/InboxNotification.Custom\u003e\n                      );\n                    },\n                  }}\n                /\u003e\n              ))}\n          \u003c/InboxNotificationList\u003e\n        \u003c/LiveblocksUIConfig\u003e\n      \u003c/PopoverContent\u003e\n    \u003c/Popover\u003e\n  );\n};\n```\n\n\u003c/details\u003e\n\n## \u003ca name=\"links\"\u003e🔗 Links\u003c/a\u003e\n\n- Public assets used in the project can be found [here](https://drive.google.com/file/d/1MCQaP-imgDdopwcUn4CN_D-WglDc--Ho/view?usp=sharing)\n- [Liveblocks Starter Guide](https://liveblocks.io/docs/get-started/nextjs-lexical)\n\n## \u003ca name=\"more\"\u003e🚀 More\u003c/a\u003e\n**Advance your skills with Next.js Pro Course**\n\nEnjoyed creating this project? Dive deeper into our PRO courses for a richer learning experience. They're packed with detailed explanations, cool features, and exercises to boost your skills. Give it a go!\n\n\u003ca href=\"https://www.jsmastery.pro/ultimate-next-course\" target=\"_blank\"\u003e\n\u003cimg src=\"https://i.ibb.co/804sPK6/Image-720.png\" alt=\"Project Banner\"\u003e\n\u003c/a\u003e\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fadrianhajdin%2Fcollaborative-editor","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fadrianhajdin%2Fcollaborative-editor","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fadrianhajdin%2Fcollaborative-editor/lists"}