Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 84 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,36 +1,98 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# Write Document - Collaborative Document Editor

## Getting Started
A real-time collaborative document editor built with modern web technologies, offering a seamless writing and collaboration experience.

First, run the development server:
## Tech Stack

```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
### Frontend
- **Next.js** - React framework for production-grade applications
- **TypeScript** - For type-safe code
- **Radix UI** - Headless UI components for building accessible interfaces
- **TipTap** - Rich text editor framework
- **Clerk** - Authentication and user management
- **Liveblocks** - Real-time collaboration features
- **Convex** - Backend and real-time data synchronization

### UI/UX
- **Tailwind CSS** - Utility-first CSS framework
- **Radix UI Components** - Including:
- Accordion
- Alert Dialog
- Avatar
- Carousel
- Dialog
- Dropdown Menu
- And more...

## Workflow

### Development
1. Local Development
```bash
npm run dev

npm run build
npm run start



## GitHub Workflow

The repository uses GitHub Actions for automated builds with two main workflow files:

### Main Build Workflow (`build.yml`)
Located in `.github/workflows/build.yml`, this workflow triggers when pull requests are closed on the main branch.

```yaml
name: Build Application
on:
pull_request:
types: [closed]
branches: [main]
```

Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
The workflow uses the following secret environment variables for secure deployment:

NEXT_PUBLIC_CONVEX_URL
CONVEX_DEPLOYMENT
CLERK_SECRET_KEY
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
LIVE_BLOCK_SECRET_API_KEY


Reusable Build Steps ( build-reusable.yml)
Located in .github/workflows/build-reusable.yml, this contains the core build logic:

Environment Setup :

Runs on Ubuntu latest

Uses Node.js version 20

Sets up pnpm package manager (version 8)

Build Process :

Checks out the code

Configures Node.js environment

You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
Installs dependencies using pnpm

This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
Environment Variables : All sensitive information is handled through GitHub Secrets and passed as environment variables during the build process.

## Learn More
Usage
The workflow automatically triggers when:

To learn more about Next.js, take a look at the following resources:
A pull request is closed on the main branch

- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
The build process is reusable and can be called from other workflows

You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
To manually trigger the workflow, you can:

## Deploy on Vercel
# Push changes to trigger the workflow
git push origin main

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.
# Or create and merge a pull request to main branch

Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
This explanation provides a clear overview of your GitHub Actions workflow setup, including the trigger conditions, environment configuration, and build process. You can add this to your README.md to help other developers understand your CI/CD pipeline.
18 changes: 18 additions & 0 deletions convex/document.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { paginationOptsValidator } from "convex/server";
import { ConvexError, v } from "convex/values";
import { Doc } from "./_generated/dataModel";
import { mutation, query } from "./_generated/server";

export const create = mutation({
Expand Down Expand Up @@ -129,3 +130,20 @@ export const get = query({
return doc;
},
});

export const getByIds = query({
args: { ids: v.array(v.id("documents")) },
handler: async (ctx, { ids }) => {
const documents: Array<Partial<Doc<"documents">>> = [];
for (const docId of ids) {
const doc = await ctx.db.get(docId);
if (doc) {
documents.push(doc);
} else {
documents.push({ _id: docId, title: "[Removed]" });
}
}

return documents;
},
});
4 changes: 4 additions & 0 deletions src/app/document/[documentId]/(navbar)/navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { SearchInput } from "@/app/(home)/SearchInput";
import { OrganizationSwitcher, UserButton } from "@clerk/clerk-react";
import Image from "next/image";
import Link from "next/link";
import { Avatars } from "../avatars";
import { Inbox } from "../inbox";
import { DocumentInput } from "./document-input";
import { MenuBar } from "./menu-bar";

Expand All @@ -28,6 +30,8 @@ export const Navbar: React.FC = () => {

<SearchInput />
<div className="flex gap-3 items-center pl-6">
<Avatars />
<Inbox />
<OrganizationSwitcher
afterCreateOrganizationUrl="/"
afterLeaveOrganizationUrl="/"
Expand Down
9 changes: 9 additions & 0 deletions src/app/document/[documentId]/action.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
"use server";

import { auth, clerkClient } from "@clerk/nextjs/server";
import { ConvexHttpClient } from "convex/browser";
import { api } from "../../../../convex/_generated/api";
import { Id } from "../../../../convex/_generated/dataModel";

const convex = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL!);

export async function getDocuments(ids: Id<"documents">[]) {
return await convex.query(api.document.getByIds, { ids });
}

export async function getUsers() {
const { sessionClaims } = await auth();
Expand Down
25 changes: 25 additions & 0 deletions src/app/document/[documentId]/avatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Image from "next/image";
import React from "react";

const SIZE = 36;

type Props = {
src: string;
name: string;
};

export const Avatar: React.FC<Props> = ({ src, name }) => {
return (
<>
<div
style={{ width: SIZE, height: SIZE }}
className="group -ml-2 flex shrink-0 place-content-center relative border-4 border-white rounded-full bg-gray-400"
>
<div className="opacity-0 group-hover:opacity-100 absolute top-full py-1 px-2 text-white text-xs rounded-lg mt-2.5 z-10 bg-black whitespace-nowrap transition-opacity">
{name}
</div>
<img src={src} alt={name} className="size-full rounded-full" />
</div>
</>
);
};
16 changes: 13 additions & 3 deletions src/app/document/[documentId]/avatars-stack.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"use client";

import { Separator } from "@/components/ui/separator";
import { useOthers, useSelf } from "@liveblocks/react/suspense";
import { Avatars } from "./avatars";
import { Avatar } from "./avatar";

export const AvatarsStack: React.FC = () => {
const users = useOthers();
Expand All @@ -10,13 +11,22 @@ export const AvatarsStack: React.FC = () => {
if (!users.length) return <></>;
return (
<>
<div className="items-center">
<div className="flex items-center">
{currentUser && (
<div className="relative ml-2">
<Avatars name="You" src={currentUser.info.avatar} />
<Avatar name="You" src={currentUser.info.avatar} />
</div>
)}

<div className="flex">
{users.map(({ connectionId, info }) => {
return (
<Avatar key={connectionId} name={info.name} src={info.avatar} />
);
})}
</div>
</div>
<Separator orientation="vertical" className="h-6" />
</>
);
};
27 changes: 7 additions & 20 deletions src/app/document/[documentId]/avatars.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,12 @@
import Image from "next/image";
import React from "react";
"use client";

const SIZE = 36;
import { ClientSideSuspense } from "@liveblocks/react";
import { AvatarsStack } from "./avatars-stack";

type Props = {
src: string;
name: string;
};

export const Avatars: React.FC<Props> = ({ src, name }) => {
export const Avatars: React.FC = () => {
return (
<>
<div
style={{ width: SIZE, height: SIZE }}
className="group -ml-2 flex shrink-0 place-content-center relative border-4 border-white rounded-full bg-gray-400"
>
<div className="opacity-0 group-hover:opacity-100 absolute top-full py-1 px-2 text-white text-xs rounded-lg mt-2.5 z-10 bg-black whitespace-nowrap transition-opacity">
{name}
</div>
<Image src={src} alt={name} className="size-full rounded-full" />
</div>
</>
<ClientSideSuspense fallback={null}>
<AvatarsStack />
</ClientSideSuspense>
);
};
72 changes: 72 additions & 0 deletions src/app/document/[documentId]/inbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"use client";

import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator";
import { ClientSideSuspense } from "@liveblocks/react";
import { InboxNotification, InboxNotificationList } from "@liveblocks/react-ui";
import { useInboxNotifications } from "@liveblocks/react/suspense";
import { BellIcon } from "lucide-react";

export const Inbox: React.FC = () => {
return (
<ClientSideSuspense
fallback={
<>
<Button variant="ghost" className="relative" size="icon" disabled>
<BellIcon className="size-5" />
</Button>
<Separator orientation="vertical" className="h-6" />
</>
}
>
<InboxMenu />
</ClientSideSuspense>
);
};

export const InboxMenu: React.FC = () => {
const { inboxNotifications } = useInboxNotifications();

return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative" size="icon">
<BellIcon className="size-5" />
{inboxNotifications.length > 0 && (
<span className="absolute -top-1 -right-1 size-4 rounded-full bg-sky-500 text-xs text-white flex items-center justify-center">
{inboxNotifications.length}
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-auto">
{inboxNotifications.length > 0 ? (
<InboxNotificationList>
{inboxNotifications.map((inboxNotification) => {
return (
<InboxNotification
key={inboxNotification.id}
inboxNotification={inboxNotification}
/>
);
})}
</InboxNotificationList>
) : (
<>
<div className="p-2 w-[400px] text-center text-sm text-muted-foreground">
No notifications
</div>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<Separator orientation="vertical" className="h-6" />
</>
);
};
24 changes: 21 additions & 3 deletions src/app/document/[documentId]/room.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import {
} from "@liveblocks/react/suspense";
import { useParams } from "next/navigation";
import { ReactNode, useEffect, useMemo, useState } from "react";
import { getUsers } from "./action";
import { Id } from "../../../../convex/_generated/dataModel";
import { getDocuments, getUsers } from "./action";

type User = {
id: string;
Expand Down Expand Up @@ -45,7 +46,16 @@ export function Room({ children }: { children: ReactNode }) {

return (
<LiveblocksProvider
authEndpoint={"/api/auth/liveblocks"}
authEndpoint={async () => {
const endpoint = "/api/auth/liveblocks";
const room = params.documentId as string;
const res = await fetch(endpoint, {
method: "POST",
body: JSON.stringify({ room }),
});

return await res.json();
}}
throttle={16}
resolveUsers={({ userIds }) =>
userIds.map((id) => users.find((u) => u.id === id))
Expand All @@ -59,7 +69,15 @@ export function Room({ children }: { children: ReactNode }) {
}
return filterUsers.map((u) => u.id);
}}
resolveRoomsInfo={() => []}
resolveRoomsInfo={async ({ roomIds }) => {
const documents = await getDocuments(roomIds as Id<"documents">[]);
return documents.map((doc) => {
return {
id: doc._id,
name: doc.title,
};
});
}}
>
<RoomProvider id={params.documentId}>
<ClientSideSuspense
Expand Down
Loading
Loading