Ecosyste.ms: Awesome
An open API service indexing awesome lists of open source software.
https://github.com/adrianhajdin/collaborative-editor
Learn how to build any collaborative application by building LiveDocs, an improved Google Docs that manages millions of collaborators in real-time.
https://github.com/adrianhajdin/collaborative-editor
clerk liveblocks nextjs nextjs14 sentry
Last synced: 3 days ago
JSON representation
Learn how to build any collaborative application by building LiveDocs, an improved Google Docs that manages millions of collaborators in real-time.
- Host: GitHub
- URL: https://github.com/adrianhajdin/collaborative-editor
- Owner: adrianhajdin
- Created: 2024-07-18T17:25:31.000Z (5 months ago)
- Default Branch: main
- Last Pushed: 2024-10-29T09:23:44.000Z (about 2 months ago)
- Last Synced: 2024-12-14T17:08:30.489Z (10 days ago)
- Topics: clerk, liveblocks, nextjs, nextjs14, sentry
- Language: TypeScript
- Homepage: https://jsm-live-docs.vercel.app
- Size: 843 KB
- Stars: 435
- Watchers: 1
- Forks: 116
- Open Issues: 13
-
Metadata Files:
- Readme: README.md
Awesome Lists containing this project
README
A Collaborative LiveDocs
Build this project step by step with our detailed tutorial on JavaScript Mastery YouTube. Join the JSM family!
## π Table of Contents
1. π€ [Introduction](#introduction)
2. βοΈ [Tech Stack](#tech-stack)
3. π [Features](#features)
4. π€Έ [Quick Start](#quick-start)
5. πΈοΈ [Snippets (Code to Copy)](#snippets)
6. π [Links](#links)
7. π [More](#more)## π¨ Tutorial
This repository contains the code corresponding to an in-depth tutorial available on our YouTube channel, JavaScript Mastery.
If 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!
Built 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.
If 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.
- Next.js
- TypeScript
- Liveblocks
- Lexical Editor
- ShadCN
- Tailwind CSSπ **Authentication**: User authentication using GitHub through NextAuth, ensuring secure sign-in/out and session management.
π **Collaborative Text Editor**: Multiple users can edit the same document simultaneously with real-time updates.
π **Documents Management**
- **Create Documents**: Users can create new documents, which are automatically saved and listed.
- **Delete Documents**: Users can delete documents they own.
- **Share Documents**: Users can share documents via email or link with view/edit permissions.
- **List Documents**: Display all documents owned or shared with the user, with search and sorting functionalities.π **Comments**: Users can add inline and general comments, with threading for discussions.
π **Active Collaborators on Text Editor**: Show active collaborators with real-time presence indicators.
π **Notifications**: Notify users of document shares, new comments, and collaborator activities.
π **Responsive**: The application is responsive across all devices.
and many more, including code architecture and reusability
Follow these steps to set up the project locally on your machine.
**Prerequisites**
Make sure you have the following installed on your machine:
- [Git](https://git-scm.com/)
- [Node.js](https://nodejs.org/en)
- [npm](https://www.npmjs.com/) (Node Package Manager)**Cloning the Repository**
```bash
git clone https://github.com/adrianhajdin/collaborative-editor.git
cd collaborative-editor
```**Installation**
Install the project dependencies using npm:
```bash
npm install
```**Set Up Environment Variables**
Create a new file named `.env` in the root of your project and add the following content:
```env
#Clerk
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up#Liveblocks
NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY=
LIVEBLOCKS_SECRET_KEY=
```Replace the placeholder values with your actual Clerk & LiveBlocks credentials. You can obtain these credentials by signing up on the [Clerk](https://clerk.com/) and [Liveblocks](liveblocks.io/) website.
**Running the Project**
```bash
npm run dev
```Open [http://localhost:3000](http://localhost:3000) in your browser to view the project.
globals.css
```css
@tailwind base;
@tailwind components;
@tailwind utilities;/* @import "@liveblocks/react-ui/styles.css"; */
/* @import "@liveblocks/react-lexical/styles.css"; *//* @import "../styles/dark-theme.css"; */
/* ========================================== TAILWIND STYLES */
@layer base {
:root {
background: #09111f;
color: #fff;
margin: 0;
}.custom-scrollbar::-webkit-scrollbar {
width: 4px;
height: 4px;
border-radius: 50px;
}.custom-scrollbar::-webkit-scrollbar-track {
background: #09090a;
}.custom-scrollbar::-webkit-scrollbar-thumb {
background: #2e3d5b;
border-radius: 50px;
}.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #7878a3;
}
}@layer utilities {
.text-28-semibold {
@apply text-[28px] font-semibold;
}
.text-10-regular {
@apply text-[10px] font-normal;
}.gradient-blue {
@apply bg-gradient-to-t from-blue-500 to-blue-400;
}
.gradient-red {
@apply bg-gradient-to-t from-red-500 to-red-400;
}.shad-dialog {
@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;
}.shad-dialog button {
@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;
}.shad-select {
@apply w-fit border-none bg-transparent text-blue-100 !important;
}.shad-select svg {
@apply ml-1 mt-1;
}.shad-select-item {
@apply cursor-pointer bg-dark-200 text-blue-100 focus:bg-dark-300 hover:bg-dark-300 focus:text-blue-100 !important;
}.shad-popover {
@apply w-[460px] border-none bg-dark-200 shadow-lg !important;
}.floating-toolbar {
@apply flex w-full min-w-max items-center justify-center gap-2 rounded-lg bg-dark-350 p-1.5 shadow-xl;
}.floating-toolbar-btn {
@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;
}.toolbar-wrapper {
@apply z-50 custom-scrollbar w-screen overflow-auto border-y border-dark-300 bg-dark-100 pl-3 pr-4 shadow-sm;
}.editor-wrapper {
@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;
}.header {
@apply min-h-[92px] min-w-full flex-nowrap bg-dark-100 flex w-full items-center justify-between gap-2 px-4;
}.document-list-container {
@apply flex flex-col items-center mb-10 w-full gap-10 px-5;
}.document-list-title {
@apply max-w-[730px] items-end flex w-full justify-between;
}.document-list-item {
@apply flex items-center justify-between gap-4 rounded-lg bg-doc bg-cover p-5 shadow-xl;
}.document-list-empty {
@apply flex w-full max-w-[730px] flex-col items-center justify-center gap-5 rounded-lg bg-dark-200 px-10 py-8;
}.document-title-input {
@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;
}.document-title {
@apply line-clamp-1 border-dark-400 text-base font-semibold leading-[24px] sm:pl-0 sm:text-xl;
}.view-only-tag {
@apply rounded-md bg-dark-400/50 px-2 py-0.5 text-xs text-blue-100/50;
}.collaborators-list {
@apply hidden items-center justify-end -space-x-3 overflow-hidden sm:flex;
}.share-input {
@apply h-11 flex-1 border-none bg-dark-400 focus-visible:ring-0 focus-visible:ring-offset-0 !important;
}.remove-btn {
@apply rounded-lg bg-transparent px-0 text-red-500 hover:bg-transparent;
}.comments-container {
@apply mb-10 space-y-4 lg:w-fit flex w-full flex-col items-center justify-center;
}.comment-composer {
@apply w-full max-w-[800px] border border-dark-300 bg-dark-200 shadow-sm lg:w-[350px];
}.comment-thread {
@apply w-full max-w-[800px] border border-dark-300 bg-dark-200 shadow-sm lg:w-[350px] transition-all;
}.loader {
@apply flex size-full h-screen items-center justify-center gap-3 text-white;
}/* ======================== Auth Pages */
.auth-page {
@apply flex h-screen w-full flex-col items-center justify-center gap-10;
}/* ======================== Home Page */
.home-container {
@apply relative flex min-h-screen w-full flex-col items-center gap-5 sm:gap-10;
}.document-ul {
@apply flex w-full max-w-[730px] flex-col gap-5;
}/* ======================== CollaborativeRoom */
.collaborative-room {
@apply flex size-full max-h-screen flex-1 flex-col items-center overflow-hidden;
}
}/* ======================== Clerk Override */
.cl-avatarBox {
width: 36px;
height: 36px;
}.cl-userButtonTrigger {
height: fit-content !important;
}.cl-cardBox,
.cl-signIn-start,
.cl-signUp-start,
.cl-footer {
background: #060d18;
box-shadow: none;
padding: 20px;
}.cl-socialButtonsBlockButton,
.cl-socialButtonsBlockButton:hover {
height: 40px;
background-color: #3371ff;
color: #fff;
}.cl-internal-2gzuzc {
filter: brightness(1000%);
}.cl-logoBox {
height: 52px;
}/* ======================== Liveblocks Override */
.lb-root {
--lb-accent-subtle: #0b1527;
--lb-radius: 0px;
--lb-dynamic-background: #1b2840;
}.lb-comment,
.lb-thread-comments,
.lb-composer,
.lb-comment-reaction {
background-color: #0f1c34;
color: #fff;
}.lb-button {
--lb-foreground-moderate: #fff;
}.lb-button:where([data-variant="primary"]) {
background-color: #161e30;
color: #b4c6ee;
padding: 8px;
}.lb-button:where(
[data-variant="default"]:not(
:is(
:enabled:hover,
:enabled:focus-visible,
[aria-expanded="true"],
[aria-selected="true"]
)
)
) {
color: #b4c6ee;
}.lb-button:where(
:enabled:hover,
:enabled:focus-visible,
[aria-expanded="true"],
[aria-selected="true"]
) {
--lb-button-background: #161e30;color: #b4c6ee;
}.lb-inbox-notification-list-item:where(:not(:last-of-type)) {
border-bottom: none;
}.lb-comment-body,
.lb-dropdown-item,
.lb-dropdown-item-icon,
.lb-composer-editor {
color: #fff;
}.lb-composer-action {
padding: 8px;
}.lb-comment-content {
background: #0b1527;
margin-top: 16px;
padding: 12px;
border-radius: 4px;
font-size: 14px;
}.lb-comment-date,
.lb-lexical-mention-suggestion-user,
.lb-composer-suggestions-list-item,
.lb-inbox-notification-date,
.lb-comment-author {
color: #b4c6ee;
}.data-liveblocks-portal {
color: #b4c6ee !important;
}.lb-root:where(:not(.lb-root .lb-root)) {
--lb-dynamic-background: #1b2840;
color: #fff;
}.lb-composer-editor :where([data-placeholder]) {
color: #b4c6ee;
font-size: 14px;
}.lb-lexical-floating-threads-thread:where([data-resolved]) {
opacity: 40%;
}.lb-elevation {
background: #0f1c34;
}
```
tailwind.config.ts
```typescript
import type { Config } from 'tailwindcss';const { fontFamily } = require('tailwindcss/defaultTheme');
const config = {
darkMode: ['class'],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
prefix: '',
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px',
xs: '360px',
},
},
extend: {
colors: {
blue: {
100: '#B4C6EE',
400: '#417BFF',
500: '#3371FF',
},
red: {
400: '#DD4F56',
500: '#DC4349',
},
dark: {
100: '#09111F',
200: '#0B1527',
300: '#0F1C34',
350: '#12213B',
400: '#27344D',
500: '#2E3D5B',
},
},
fontFamily: {
sans: ['var(--font-sans)', ...fontFamily.sans],
},
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
},
backgroundImage: {
doc: 'url(/assets/images/doc.png)',
modal: 'url(/assets/images/modal.png)',
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
plugins: [require('tailwindcss-animate')],
} satisfies Config;export default config;
```
types/index.d.ts
```typescript
/* eslint-disable no-unused-vars */
declare type SearchParamProps = {
params: { [key: string]: string };
searchParams: { [key: string]: string | string[] | undefined };
};declare type AccessType = ["room:write"] | ["room:read", "room:presence:write"];
declare type RoomAccesses = Record;
declare type UserType = "creator" | "editor" | "viewer";
declare type RoomMetadata = {
creatorId: string;
email: string;
title: string;
};declare type CreateDocumentParams = {
userId: string;
email: string;
};declare type User = {
id: string;
name: string;
email: string;
avatar: string;
color: string;
userType?: UserType;
};declare type ShareDocumentParams = {
roomId: string;
email: string;
userType: UserType;
updatedBy: User;
};declare type UserTypeSelectorParams = {
userType: string;
setUserType: React.Dispatch>;
onClickHandler?: (value: string) => void;
};declare type ShareDocumentDialogProps = {
roomId: string;
collaborators: User[];
creatorId: string;
currentUserType: UserType;
};declare type HeaderProps = {
children: React.ReactNode;
className?: string;
};declare type CollaboratorProps = {
roomId: string;
email: string;
creatorId: string;
collaborator: User;
user: User;
};declare type CollaborativeRoomProps = {
roomId: string;
roomMetadata: RoomMetadata;
users: User[];
currentUserType: UserType;
};declare type AddDocumentBtnProps = {
userId: string;
email: string;
};declare type DeleteModalProps = { roomId: string };
declare type ThreadWrapperProps = { thread: ThreadData };
```
lib/utils.ts
```typescript
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}export const parseStringify = (value: any) => JSON.parse(JSON.stringify(value));
export const getAccessType = (userType: UserType) => {
switch (userType) {
case 'creator':
return ['room:write'];
case 'editor':
return ['room:write'];
case 'viewer':
return ['room:read', 'room:presence:write'];
default:
return ['room:read', 'room:presence:write'];
}
};export const dateConverter = (timestamp: string): string => {
const timestampNum = Math.round(new Date(timestamp).getTime() / 1000);
const date: Date = new Date(timestampNum * 1000);
const now: Date = new Date();const diff: number = now.getTime() - date.getTime();
const diffInSeconds: number = diff / 1000;
const diffInMinutes: number = diffInSeconds / 60;
const diffInHours: number = diffInMinutes / 60;
const diffInDays: number = diffInHours / 24;switch (true) {
case diffInDays > 7:
return `${Math.floor(diffInDays / 7)} weeks ago`;
case diffInDays >= 1 && diffInDays <= 7:
return `${Math.floor(diffInDays)} days ago`;
case diffInHours >= 1:
return `${Math.floor(diffInHours)} hours ago`;
case diffInMinutes >= 1:
return `${Math.floor(diffInMinutes)} minutes ago`;
default:
return 'Just now';
}
};// Function to generate a random color in hex format, excluding specified colors
export function getRandomColor() {
const avoidColors = ['#000000', '#FFFFFF', '#8B4513']; // Black, White, Brown in hex formatlet randomColor;
do {
// Generate random RGB values
const r = Math.floor(Math.random() * 256); // Random number between 0-255
const g = Math.floor(Math.random() * 256);
const b = Math.floor(Math.random() * 256);// Convert RGB to hex format
randomColor = `#${r.toString(16)}${g.toString(16)}${b.toString(16)}`;
} while (avoidColors.includes(randomColor));return randomColor;
}export const brightColors = [
'#2E8B57', // Darker Neon Green
'#FF6EB4', // Darker Neon Pink
'#00CDCD', // Darker Cyan
'#FF00FF', // Darker Neon Magenta
'#FF007F', // Darker Bright Pink
'#FFD700', // Darker Neon Yellow
'#00CED1', // Darker Neon Mint Green
'#FF1493', // Darker Neon Red
'#00CED1', // Darker Bright Aqua
'#FF7F50', // Darker Neon Coral
'#9ACD32', // Darker Neon Lime
'#FFA500', // Darker Neon Orange
'#32CD32', // Darker Neon Chartreuse
'#ADFF2F', // Darker Neon Yellow Green
'#DB7093', // Darker Neon Fuchsia
'#00FF7F', // Darker Spring Green
'#FFD700', // Darker Electric Lime
'#FF007F', // Darker Bright Magenta
'#FF6347', // Darker Neon Vermilion
];export function getUserColor(userId: string) {
let sum = 0;
for (let i = 0; i < userId.length; i++) {
sum += userId.charCodeAt(i);
}const colorIndex = sum % brightColors.length;
return brightColors[colorIndex];
}
```
components/editor/plugins/FloatingToolbar.tsx
```typescript
import {
autoUpdate,
flip,
hide,
limitShift,
offset,
shift,
size,
useFloating,
} from '@floating-ui/react-dom';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { OPEN_FLOATING_COMPOSER_COMMAND } from '@liveblocks/react-lexical';
import type { LexicalEditor, LexicalNode } from 'lexical';
import { $getSelection, $isRangeSelection, $isTextNode } from 'lexical';
import Image from 'next/image';
import { useEffect, useLayoutEffect, useState } from 'react';
import * as React from 'react';
import { createPortal } from 'react-dom';export default function FloatingToolbar() {
const [editor] = useLexicalComposerContext();const [range, setRange] = useState(null);
useEffect(() => {
editor.registerUpdateListener(({ tags }) => {
return editor.getEditorState().read(() => {
// Ignore selection updates related to collaboration
if (tags.has('collaboration')) return;const selection = $getSelection();
if (!$isRangeSelection(selection) || selection.isCollapsed()) {
setRange(null);
return;
}const { anchor, focus } = selection;
const range = createDOMRange(
editor,
anchor.getNode(),
anchor.offset,
focus.getNode(),
focus.offset,
);setRange(range);
});
});
}, [editor]);if (range === null) return null;
return (
);
}function Toolbar({
range,
onRangeChange,
container,
}: {
range: Range;
onRangeChange: (range: Range | null) => void;
container: HTMLElement;
}) {
const [editor] = useLexicalComposerContext();const padding = 20;
const {
refs: { setReference, setFloating },
strategy,
x,
y,
} = useFloating({
strategy: 'fixed',
placement: 'bottom',
middleware: [
flip({ padding, crossAxis: false }),
offset(10),
hide({ padding }),
shift({ padding, limiter: limitShift() }),
size({ padding }),
],
whileElementsMounted: (...args) => {
return autoUpdate(...args, {
animationFrame: true,
});
},
});useLayoutEffect(() => {
setReference({
getBoundingClientRect: () => range.getBoundingClientRect(),
});
}, [setReference, range]);return createPortal(
,
{
const isOpen = editor.dispatchCommand(
OPEN_FLOATING_COMPOSER_COMMAND,
undefined,
);
if (isOpen) {
onRangeChange(null);
}
}}
className="floating-toolbar-btn"
>
container,
);
}/**
* MIT License
* Copyright (c) Meta Platforms, Inc. and affiliates.* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/function getDOMTextNode(element: Node | null): Text | null {
let node = element;while (node !== null) {
if (node.nodeType === Node.TEXT_NODE) {
return node as Text;
}node = node.firstChild;
}return null;
}function getDOMIndexWithinParent(node: ChildNode): [ParentNode, number] {
const parent = node.parentNode;if (parent === null) {
throw new Error('Should never happen');
}return [parent, Array.from(parent.childNodes).indexOf(node)];
}/**
* Creates a selection range for the DOM.
* @param editor - The lexical editor.
* @param anchorNode - The anchor node of a selection.
* @param _anchorOffset - The amount of space offset from the anchor to the focus.
* @param focusNode - The current focus.
* @param _focusOffset - The amount of space offset from the focus to the anchor.
* @returns The range of selection for the DOM that was created.
*/
export function createDOMRange(
editor: LexicalEditor,
anchorNode: LexicalNode,
_anchorOffset: number,
focusNode: LexicalNode,
_focusOffset: number,
): Range | null {
const anchorKey = anchorNode.getKey();
const focusKey = focusNode.getKey();
const range = document.createRange();
let anchorDOM: Node | Text | null = editor.getElementByKey(anchorKey);
let focusDOM: Node | Text | null = editor.getElementByKey(focusKey);
let anchorOffset = _anchorOffset;
let focusOffset = _focusOffset;if ($isTextNode(anchorNode)) {
anchorDOM = getDOMTextNode(anchorDOM);
}if ($isTextNode(focusNode)) {
focusDOM = getDOMTextNode(focusDOM);
}if (
anchorNode === undefined ||
focusNode === undefined ||
anchorDOM === null ||
focusDOM === null
) {
return null;
}if (anchorDOM.nodeName === 'BR') {
[anchorDOM, anchorOffset] = getDOMIndexWithinParent(anchorDOM as ChildNode);
}if (focusDOM.nodeName === 'BR') {
[focusDOM, focusOffset] = getDOMIndexWithinParent(focusDOM as ChildNode);
}const firstChild = anchorDOM.firstChild;
if (
anchorDOM === focusDOM &&
firstChild !== null &&
firstChild.nodeName === 'BR' &&
anchorOffset === 0 &&
focusOffset === 0
) {
focusOffset = 1;
}try {
range.setStart(anchorDOM, anchorOffset);
range.setEnd(focusDOM, focusOffset);
} catch (e) {
return null;
}if (
range.collapsed &&
(anchorOffset !== focusOffset || anchorKey !== focusKey)
) {
// Range is backwards, we need to reverse it
range.setStart(focusDOM, focusOffset);
range.setEnd(anchorDOM, anchorOffset);
}return range;
}
```
components/DeleteModal.tsx
```typescript
"use client";import Image from "next/image";
import { useState } from "react";import { deleteDocument } from "@/lib/actions/room.actions";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";import { Button } from "./ui/button";
export const DeleteModal = ({ roomId }: DeleteModalProps) => {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);const deleteDocumentHandler = async () => {
setLoading(true);try {
await deleteDocument(roomId);
setOpen(false);
} catch (error) {
console.log("Error notif:", error);
}setLoading(false);
};return (
Delete document
Are you sure you want to delete this document? This action cannot be
undone.
Cancel
{loading ? "Deleting..." : "Delete"}
);
};
```
components/Notifications.ts
```typescript
"use client";import {
useInboxNotifications,
useUnreadInboxNotificationsCount,
} from "@liveblocks/react/suspense";
import {
InboxNotification,
InboxNotificationList,
LiveblocksUIConfig,
} from "@liveblocks/react-ui";
import Image from "next/image";
import { ReactNode } from "react";import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";export const Notifications = () => {
const { inboxNotifications } = useInboxNotifications();
const { count } = useUnreadInboxNotificationsCount();const unreadNotifications = inboxNotifications.filter(
(notification) => !notification.readAt // Filter unread notifications
);return (
{count > 0 && (
)}
{
return <>{user} mentioned you>;
},
}}
>
{unreadNotifications.length <= 0 && (
No notifications yet
)}{unreadNotifications.length > 0 &&
unreadNotifications.map((inboxNotification: any) => (
(
),
textMention: (props) => {
return (
);
},
$documentAccess: (props) => {
const { title, avatar } =
props.inboxNotification.activities[0].data;return (
}
>
{props.children}
);
},
}}
/>
))}
);
};
```## π Links
- Public assets used in the project can be found [here](https://drive.google.com/file/d/1MCQaP-imgDdopwcUn4CN_D-WglDc--Ho/view?usp=sharing)
- [Liveblocks Starter Guide](https://liveblocks.io/docs/get-started/nextjs-lexical)## π More
**Advance your skills with Next.js Pro Course**Enjoyed 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!