Skip to content

Commit 67a029f

Browse files
authored
Merge pull request #36 from duylongpro99/development
feat: group users in document, notifications
2 parents 905528d + bd09c3e commit 67a029f

File tree

10 files changed

+257
-48
lines changed

10 files changed

+257
-48
lines changed

README.md

Lines changed: 84 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,98 @@
1-
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).
1+
# Write Document - Collaborative Document Editor
22

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

5-
First, run the development server:
5+
## Tech Stack
66

7-
```bash
8-
npm run dev
9-
# or
10-
yarn dev
11-
# or
12-
pnpm dev
13-
# or
14-
bun dev
7+
### Frontend
8+
- **Next.js** - React framework for production-grade applications
9+
- **TypeScript** - For type-safe code
10+
- **Radix UI** - Headless UI components for building accessible interfaces
11+
- **TipTap** - Rich text editor framework
12+
- **Clerk** - Authentication and user management
13+
- **Liveblocks** - Real-time collaboration features
14+
- **Convex** - Backend and real-time data synchronization
15+
16+
### UI/UX
17+
- **Tailwind CSS** - Utility-first CSS framework
18+
- **Radix UI Components** - Including:
19+
- Accordion
20+
- Alert Dialog
21+
- Avatar
22+
- Carousel
23+
- Dialog
24+
- Dropdown Menu
25+
- And more...
26+
27+
## Workflow
28+
29+
### Development
30+
1. Local Development
31+
```bash
32+
npm run dev
33+
34+
npm run build
35+
npm run start
36+
37+
38+
39+
## GitHub Workflow
40+
41+
The repository uses GitHub Actions for automated builds with two main workflow files:
42+
43+
### Main Build Workflow (`build.yml`)
44+
Located in `.github/workflows/build.yml`, this workflow triggers when pull requests are closed on the main branch.
45+
46+
```yaml
47+
name: Build Application
48+
on:
49+
pull_request:
50+
types: [closed]
51+
branches: [main]
1552
```
1653

17-
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
54+
The workflow uses the following secret environment variables for secure deployment:
55+
56+
NEXT_PUBLIC_CONVEX_URL
57+
CONVEX_DEPLOYMENT
58+
CLERK_SECRET_KEY
59+
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
60+
LIVE_BLOCK_SECRET_API_KEY
61+
62+
63+
Reusable Build Steps ( build-reusable.yml)
64+
Located in .github/workflows/build-reusable.yml, this contains the core build logic:
65+
66+
Environment Setup :
67+
68+
Runs on Ubuntu latest
69+
70+
Uses Node.js version 20
71+
72+
Sets up pnpm package manager (version 8)
73+
74+
Build Process :
75+
76+
Checks out the code
77+
78+
Configures Node.js environment
1879

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

21-
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.
82+
Environment Variables : All sensitive information is handled through GitHub Secrets and passed as environment variables during the build process.
2283

23-
## Learn More
84+
Usage
85+
The workflow automatically triggers when:
2486

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

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.
89+
The build process is reusable and can be called from other workflows
2990

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

32-
## Deploy on Vercel
93+
# Push changes to trigger the workflow
94+
git push origin main
3395

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

36-
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
98+
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.

convex/document.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { paginationOptsValidator } from "convex/server";
22
import { ConvexError, v } from "convex/values";
3+
import { Doc } from "./_generated/dataModel";
34
import { mutation, query } from "./_generated/server";
45

56
export const create = mutation({
@@ -129,3 +130,20 @@ export const get = query({
129130
return doc;
130131
},
131132
});
133+
134+
export const getByIds = query({
135+
args: { ids: v.array(v.id("documents")) },
136+
handler: async (ctx, { ids }) => {
137+
const documents: Array<Partial<Doc<"documents">>> = [];
138+
for (const docId of ids) {
139+
const doc = await ctx.db.get(docId);
140+
if (doc) {
141+
documents.push(doc);
142+
} else {
143+
documents.push({ _id: docId, title: "[Removed]" });
144+
}
145+
}
146+
147+
return documents;
148+
},
149+
});

src/app/document/[documentId]/(navbar)/navbar.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { SearchInput } from "@/app/(home)/SearchInput";
44
import { OrganizationSwitcher, UserButton } from "@clerk/clerk-react";
55
import Image from "next/image";
66
import Link from "next/link";
7+
import { Avatars } from "../avatars";
8+
import { Inbox } from "../inbox";
79
import { DocumentInput } from "./document-input";
810
import { MenuBar } from "./menu-bar";
911

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

2931
<SearchInput />
3032
<div className="flex gap-3 items-center pl-6">
33+
<Avatars />
34+
<Inbox />
3135
<OrganizationSwitcher
3236
afterCreateOrganizationUrl="/"
3337
afterLeaveOrganizationUrl="/"

src/app/document/[documentId]/action.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
"use server";
22

33
import { auth, clerkClient } from "@clerk/nextjs/server";
4+
import { ConvexHttpClient } from "convex/browser";
5+
import { api } from "../../../../convex/_generated/api";
6+
import { Id } from "../../../../convex/_generated/dataModel";
7+
8+
const convex = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
9+
10+
export async function getDocuments(ids: Id<"documents">[]) {
11+
return await convex.query(api.document.getByIds, { ids });
12+
}
413

514
export async function getUsers() {
615
const { sessionClaims } = await auth();
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import Image from "next/image";
2+
import React from "react";
3+
4+
const SIZE = 36;
5+
6+
type Props = {
7+
src: string;
8+
name: string;
9+
};
10+
11+
export const Avatar: React.FC<Props> = ({ src, name }) => {
12+
return (
13+
<>
14+
<div
15+
style={{ width: SIZE, height: SIZE }}
16+
className="group -ml-2 flex shrink-0 place-content-center relative border-4 border-white rounded-full bg-gray-400"
17+
>
18+
<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">
19+
{name}
20+
</div>
21+
<img src={src} alt={name} className="size-full rounded-full" />
22+
</div>
23+
</>
24+
);
25+
};
Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"use client";
22

3+
import { Separator } from "@/components/ui/separator";
34
import { useOthers, useSelf } from "@liveblocks/react/suspense";
4-
import { Avatars } from "./avatars";
5+
import { Avatar } from "./avatar";
56

67
export const AvatarsStack: React.FC = () => {
78
const users = useOthers();
@@ -10,13 +11,22 @@ export const AvatarsStack: React.FC = () => {
1011
if (!users.length) return <></>;
1112
return (
1213
<>
13-
<div className="items-center">
14+
<div className="flex items-center">
1415
{currentUser && (
1516
<div className="relative ml-2">
16-
<Avatars name="You" src={currentUser.info.avatar} />
17+
<Avatar name="You" src={currentUser.info.avatar} />
1718
</div>
1819
)}
20+
21+
<div className="flex">
22+
{users.map(({ connectionId, info }) => {
23+
return (
24+
<Avatar key={connectionId} name={info.name} src={info.avatar} />
25+
);
26+
})}
27+
</div>
1928
</div>
29+
<Separator orientation="vertical" className="h-6" />
2030
</>
2131
);
2232
};
Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,12 @@
1-
import Image from "next/image";
2-
import React from "react";
1+
"use client";
32

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

6-
type Props = {
7-
src: string;
8-
name: string;
9-
};
10-
11-
export const Avatars: React.FC<Props> = ({ src, name }) => {
6+
export const Avatars: React.FC = () => {
127
return (
13-
<>
14-
<div
15-
style={{ width: SIZE, height: SIZE }}
16-
className="group -ml-2 flex shrink-0 place-content-center relative border-4 border-white rounded-full bg-gray-400"
17-
>
18-
<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">
19-
{name}
20-
</div>
21-
<Image src={src} alt={name} className="size-full rounded-full" />
22-
</div>
23-
</>
8+
<ClientSideSuspense fallback={null}>
9+
<AvatarsStack />
10+
</ClientSideSuspense>
2411
);
2512
};
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"use client";
2+
3+
import { Button } from "@/components/ui/button";
4+
import {
5+
DropdownMenu,
6+
DropdownMenuContent,
7+
DropdownMenuTrigger,
8+
} from "@/components/ui/dropdown-menu";
9+
import { Separator } from "@/components/ui/separator";
10+
import { ClientSideSuspense } from "@liveblocks/react";
11+
import { InboxNotification, InboxNotificationList } from "@liveblocks/react-ui";
12+
import { useInboxNotifications } from "@liveblocks/react/suspense";
13+
import { BellIcon } from "lucide-react";
14+
15+
export const Inbox: React.FC = () => {
16+
return (
17+
<ClientSideSuspense
18+
fallback={
19+
<>
20+
<Button variant="ghost" className="relative" size="icon" disabled>
21+
<BellIcon className="size-5" />
22+
</Button>
23+
<Separator orientation="vertical" className="h-6" />
24+
</>
25+
}
26+
>
27+
<InboxMenu />
28+
</ClientSideSuspense>
29+
);
30+
};
31+
32+
export const InboxMenu: React.FC = () => {
33+
const { inboxNotifications } = useInboxNotifications();
34+
35+
return (
36+
<>
37+
<DropdownMenu>
38+
<DropdownMenuTrigger asChild>
39+
<Button variant="ghost" className="relative" size="icon">
40+
<BellIcon className="size-5" />
41+
{inboxNotifications.length > 0 && (
42+
<span className="absolute -top-1 -right-1 size-4 rounded-full bg-sky-500 text-xs text-white flex items-center justify-center">
43+
{inboxNotifications.length}
44+
</span>
45+
)}
46+
</Button>
47+
</DropdownMenuTrigger>
48+
<DropdownMenuContent align="end" className="w-auto">
49+
{inboxNotifications.length > 0 ? (
50+
<InboxNotificationList>
51+
{inboxNotifications.map((inboxNotification) => {
52+
return (
53+
<InboxNotification
54+
key={inboxNotification.id}
55+
inboxNotification={inboxNotification}
56+
/>
57+
);
58+
})}
59+
</InboxNotificationList>
60+
) : (
61+
<>
62+
<div className="p-2 w-[400px] text-center text-sm text-muted-foreground">
63+
No notifications
64+
</div>
65+
</>
66+
)}
67+
</DropdownMenuContent>
68+
</DropdownMenu>
69+
<Separator orientation="vertical" className="h-6" />
70+
</>
71+
);
72+
};

src/app/document/[documentId]/room.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import {
99
} from "@liveblocks/react/suspense";
1010
import { useParams } from "next/navigation";
1111
import { ReactNode, useEffect, useMemo, useState } from "react";
12-
import { getUsers } from "./action";
12+
import { Id } from "../../../../convex/_generated/dataModel";
13+
import { getDocuments, getUsers } from "./action";
1314

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

4647
return (
4748
<LiveblocksProvider
48-
authEndpoint={"/api/auth/liveblocks"}
49+
authEndpoint={async () => {
50+
const endpoint = "/api/auth/liveblocks";
51+
const room = params.documentId as string;
52+
const res = await fetch(endpoint, {
53+
method: "POST",
54+
body: JSON.stringify({ room }),
55+
});
56+
57+
return await res.json();
58+
}}
4959
throttle={16}
5060
resolveUsers={({ userIds }) =>
5161
userIds.map((id) => users.find((u) => u.id === id))
@@ -59,7 +69,15 @@ export function Room({ children }: { children: ReactNode }) {
5969
}
6070
return filterUsers.map((u) => u.id);
6171
}}
62-
resolveRoomsInfo={() => []}
72+
resolveRoomsInfo={async ({ roomIds }) => {
73+
const documents = await getDocuments(roomIds as Id<"documents">[]);
74+
return documents.map((doc) => {
75+
return {
76+
id: doc._id,
77+
name: doc.title,
78+
};
79+
});
80+
}}
6381
>
6482
<RoomProvider id={params.documentId}>
6583
<ClientSideSuspense

0 commit comments

Comments
 (0)