├── .eslintrc.json
├── .github
├── codeql-config.yml
├── dependabot.yml
└── renovate.json
├── .gitignore
├── LICENSE.md
├── README.md
├── app
├── (auth)
│ ├── (routes)
│ │ ├── sign-in
│ │ │ └── [[...sign-in]]
│ │ │ │ └── page.tsx
│ │ └── sign-up
│ │ │ └── [[...sign-up]]
│ │ │ └── page.tsx
│ └── layout.tsx
├── (invite)
│ └── (routes)
│ │ └── invite
│ │ └── [inviteCode]
│ │ └── page.tsx
├── (main)
│ ├── (routes)
│ │ └── servers
│ │ │ └── [serverId]
│ │ │ ├── channels
│ │ │ └── [channelId]
│ │ │ │ └── page.tsx
│ │ │ ├── conversations
│ │ │ └── [memberId]
│ │ │ │ └── page.tsx
│ │ │ ├── layout.tsx
│ │ │ └── page.tsx
│ └── layout.tsx
├── (setup)
│ └── page.tsx
├── api
│ ├── channels
│ │ ├── [channelId]
│ │ │ └── route.ts
│ │ └── route.ts
│ ├── direct-messages
│ │ └── route.ts
│ ├── livekit
│ │ └── route.ts
│ ├── members
│ │ └── [memberId]
│ │ │ └── route.ts
│ ├── messages
│ │ └── route.ts
│ ├── servers
│ │ ├── [serverId]
│ │ │ ├── invite-code
│ │ │ │ └── route.ts
│ │ │ ├── leave
│ │ │ │ └── route.ts
│ │ │ └── route.ts
│ │ └── route.ts
│ └── uploadthing
│ │ ├── core.ts
│ │ └── route.ts
├── favicon.ico
├── globals.css
└── layout.tsx
├── components.json
├── components
├── action-tooltip.tsx
├── chat
│ ├── chat-header.tsx
│ ├── chat-input.tsx
│ ├── chat-item.tsx
│ ├── chat-messages.tsx
│ ├── chat-video-button.tsx
│ └── chat-welcome.tsx
├── emoji-picker.tsx
├── file-upload.tsx
├── media-room.tsx
├── mobile-toggle.tsx
├── modals
│ ├── create-channel-modal.tsx
│ ├── create-server-modal.tsx
│ ├── delete-channel-modal.tsx
│ ├── delete-message-modal.tsx
│ ├── delete-server-modal.tsx
│ ├── edit-channel-modal.tsx
│ ├── edit-server-modal.tsx
│ ├── initial-modal.tsx
│ ├── invite-modal.tsx
│ ├── leave-server-modal.tsx
│ ├── members-modal.tsx
│ └── message-file-modal.tsx
├── mode-toggle.tsx
├── navigation
│ ├── navigation-action.tsx
│ ├── navigation-item.tsx
│ └── navigation-sidebar.tsx
├── providers
│ ├── modal-provider.tsx
│ ├── query-provider.tsx
│ ├── socket-provider.tsx
│ └── theme-provider.tsx
├── server
│ ├── server-channel.tsx
│ ├── server-header.tsx
│ ├── server-member.tsx
│ ├── server-search.tsx
│ ├── server-section.tsx
│ └── server-sidebar.tsx
├── socket-indicator.tsx
├── ui
│ ├── avatar.tsx
│ ├── badge.tsx
│ ├── button.tsx
│ ├── command.tsx
│ ├── dialog.tsx
│ ├── dropdown-menu.tsx
│ ├── form.tsx
│ ├── input.tsx
│ ├── label.tsx
│ ├── popover.tsx
│ ├── scroll-area.tsx
│ ├── select.tsx
│ ├── separator.tsx
│ ├── sheet.tsx
│ └── tooltip.tsx
└── user-avatar.tsx
├── hooks
├── use-chat-query.ts
├── use-chat-scroll.ts
├── use-chat-socket.ts
├── use-modal-store.ts
└── use-origin.ts
├── lib
├── conversation.ts
├── current-profile-pages.ts
├── current-profile.ts
├── db.ts
├── initial-profile.ts
├── uploadthing.ts
└── utils.ts
├── middleware.ts
├── next.config.js
├── package-lock.json
├── package.json
├── pages
└── api
│ └── socket
│ ├── direct-messages
│ ├── [directMessageId].ts
│ └── index.ts
│ ├── io.ts
│ └── messages
│ ├── [messageId].ts
│ └── index.ts
├── postcss.config.js
├── prisma
└── schema.prisma
├── public
├── avataaars.png
├── avataaars2.png
├── avataaars3.png
├── avataaars4.png
├── demo-images
│ ├── dropdownNav.png
│ ├── lightMode.png
│ ├── shortcutSearch.png
│ ├── textChannels.png
│ ├── uploadFiles.png
│ ├── videoChannels.png
│ ├── videoConference.png
│ └── voiceChannels.png
├── favicon2.ico
├── intertwine.png
└── intertwine.svg
├── tailwind.config.ts
├── tsconfig.json
└── types.ts
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/.github/codeql-config.yml:
--------------------------------------------------------------------------------
1 | name: "Advanced CodeQL Analysis"
2 |
3 | on:
4 | push:
5 | branches: [main, develop]
6 | pull_request:
7 | branches: [main, develop]
8 | schedule:
9 | - cron: "0 1 * * 0"
10 |
11 | jobs:
12 | analyze:
13 | name: Analyze
14 | runs-on: ubuntu-latest
15 |
16 | strategy:
17 | fail-fast: false
18 | matrix:
19 | node: ["20", "21", "lts/*"]
20 | language: ["javascript"]
21 |
22 | steps:
23 | - name: Checkout repository
24 | uses: actions/checkout@v4
25 |
26 | - name: Setup Node.js
27 | uses: actions/setup-node@v4
28 | with:
29 | node-version: ${{ matrix.node }}
30 | cache: "npm"
31 |
32 | - name: Install Dependencies
33 | run: npm ci
34 |
35 | - name: Initialize CodeQL
36 | uses: github/codeql-action/init@v2.16.0
37 | with:
38 | languages: ${{ matrix.language }}
39 | config-file: ./.github/codeql/codeql-config.yml
40 |
41 | - name: Autobuild
42 | uses: github/codeql-action/autobuild@v2.16.0
43 |
44 | - name: Perform CodeQL Analysis
45 | uses: github/codeql-action/analyze@v2.16.0
46 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "npm"
4 | directory: "/"
5 | schedule:
6 | interval: "weekly"
7 | day: "sunday"
8 | time: "16:00"
9 | open-pull-requests-limit: 30
10 | reviewers:
11 | - "RicardoGEsteves"
12 | assignees:
13 | - "RicardoGEsteves"
14 | groups:
15 | all-dependencies:
16 | applies-to: version-updates
17 | patterns:
18 | - "*"
19 | update-types:
20 | - "minor"
21 | - "patch"
22 |
23 | - package-ecosystem: "github-actions"
24 | directory: "/"
25 | schedule:
26 | interval: "weekly"
27 | day: "sunday"
28 | time: "16:00"
29 | groups:
30 | all-actions:
31 | patterns: [ "*" ]
32 | open-pull-requests-limit: 30
33 |
--------------------------------------------------------------------------------
/.github/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": [
4 | "config:best-practices",
5 | ":rebaseStalePrs",
6 | ":enableVulnerabilityAlertsWithLabel('security')",
7 | "group:recommended",
8 | "group:monorepos",
9 | "replacements:all",
10 | "workarounds:all",
11 | ":semanticPrefixFixDepsChoreOthers",
12 | ":separateMultipleMajorReleases"
13 | ],
14 | "vulnerabilityAlerts": {
15 | "labels": [
16 | "security"
17 | ],
18 | "automerge": true,
19 | "assignees": [
20 | "@RicardoGEsteves"
21 | ],
22 | "prCreation": "immediate"
23 | },
24 | "digest": {
25 | "prBodyDefinitions": {
26 | "Change": "{{#if displayFrom}}`{{{displayFrom}}}` -> {{else}}{{#if currentValue}}`{{{currentValue}}}` -> {{/if}}{{/if}}{{#if displayTo}}`{{{displayTo}}}`{{else}}`{{{newValue}}}`{{/if}}"
27 | },
28 | "automerge": true
29 | },
30 | "prBodyDefinitions": {
31 | "Change": "[{{#if displayFrom}}`{{{displayFrom}}}` -> {{else}}{{#if currentValue}}`{{{currentValue}}}` -> {{/if}}{{/if}}{{#if displayTo}}`{{{displayTo}}}`{{else}}`{{{newValue}}}`{{/if}}]({{#if depName}}https://renovatebot.com/diffs/npm/{{replace '/' '%2f' depName}}/{{{currentVersion}}}/{{{newVersion}}}{{/if}})"
32 | },
33 | "prHeader": "RicardoGEsteves {{{groupSlug}}} update, dependency {{depName}} to v{{newVersion}}",
34 | "assignees": [
35 | "RicardoGEsteves"
36 | ],
37 | "labels": [
38 | "dependencies",
39 | "major",
40 | "minor",
41 | "patch",
42 | "{{depName}}"
43 | ],
44 | "schedule": [
45 | "every weekend"
46 | ],
47 | "prCreation": "not-pending",
48 | "configMigration": true,
49 | "platformCommit": "enabled",
50 | "packageRules": [
51 | {
52 | "prBodyDefinitions": {
53 | "OpenSSF": "[](https://securityscorecards.dev/viewer/?uri=github.com/{{sourceRepo}})",
54 | "Sourcegraph": "[](https://sourcegraph.com/search?q=repo:%5Egithub%5C.com/{{{repository}}}%24+case:yes+-file:package%28-lock%29%3F%5C.json+{{{depName}}})"
55 | },
56 | "prBodyColumns": [
57 | "Package",
58 | "Type",
59 | "Update",
60 | "Change",
61 | "Pending",
62 | "OpenSSF",
63 | "New value",
64 | "Package file",
65 | "References"
66 | ],
67 | "matchSourceUrls": [
68 | "https://github.com/{/,}**"
69 | ]
70 | },
71 | {
72 | "matchPackageNames": [
73 | "node"
74 | ],
75 | "enabled": false
76 | },
77 | {
78 | "matchPackageNames": [
79 | "npm"
80 | ],
81 | "enabled": false
82 | },
83 | {
84 | "matchPackageNames": [
85 | "pnpm"
86 | ],
87 | "enabled": false
88 | },
89 | {
90 | "matchDepTypes": [
91 | "peerDependencies"
92 | ],
93 | "enabled": false
94 | },
95 | {
96 | "description": "Automatically merge minor and patch-level updates",
97 | "matchUpdateTypes": [
98 | "minor",
99 | "patch",
100 | "pin",
101 | "digest"
102 | ],
103 | "autoApprove": true,
104 | "automerge": true,
105 | "automergeType": "pr",
106 | "automergeStrategy": "squash",
107 | "platformAutomerge": true,
108 | "assignAutomerge": true,
109 | "groupName": "all non-major dependencies",
110 | "groupSlug": "all-non-major",
111 | "addLabels": [
112 | "{{{matchUpdateTypes}}}",
113 | "{{{groupSlug}}}"
114 | ],
115 | "matchPackageNames": [
116 | "*"
117 | ]
118 | },
119 | {
120 | "description": "Dependencies major updates",
121 | "matchUpdateTypes": [
122 | "major"
123 | ],
124 | "dependencyDashboardApproval": true,
125 | "addLabels": [
126 | "{{{matchUpdateTypes}}}",
127 | "{{{depName}}}-update-{{{newVersion}}}"
128 | ],
129 | "matchPackageNames": [
130 | "*"
131 | ]
132 | }
133 | ],
134 | "group": {
135 | "branchTopic": "{{{groupSlug}}}",
136 | "commitMessageTopic": "{{{groupName}}}"
137 | },
138 | "major": {
139 | "automerge": false
140 | },
141 | "patch": {
142 | "automerge": true
143 | },
144 | "minor": {
145 | "automerge": true
146 | }
147 | }
148 |
--------------------------------------------------------------------------------
/.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 | .env
31 |
32 | # vercel
33 | .vercel
34 |
35 | # typescript
36 | *.tsbuildinfo
37 | next-env.d.ts
38 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Ricardo Esteves
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Intertwine
2 |
3 | > **(UPDATE)** - with the latest dependencies updates, you will have to do some small config changes.
4 |
5 | `This is an instant messaging and VoIP social platform which allows communication through voice calls, video calls, text messaging, and media and files. Communication can be private or take place in virtual communities called "servers".`
6 |
7 | ## Themes and Features
8 |
9 | ### Demo Images
10 |
11 | #### Themes
12 |
13 | > - Dark Mode
14 | >
15 | >
16 | > - Light Mode
17 | >
18 |
19 | #### Features
20 |
21 | > - Video Channels
22 | >
23 | > - Voice Channels
24 | >
25 | > - Text Channels
26 | >
27 | >
28 | > - Video Calls/Screen Sharing/Chat/Conference
29 | >
30 | >
31 | > - Shortcut Keys for server navigation
32 | >
33 | >
34 | > - Upload and share files
35 | >
36 | >
37 | > - Customization and other settings
38 | >
39 |
40 | #### Other Features
41 |
42 | > - Real-time updates
43 | > - Authentication
44 | > - Instant Messaging
45 | > - Media and Files
46 | > - Servers
47 | > - Channels
48 | > - Roles
49 | > - Permissions
50 | > - Emojis
51 | > - Reactions
52 | > - Mentions
53 | > - Search
54 | > - User Profiles
55 | > - User Settings
56 | > - Server Settings
57 | > - Server Invites
58 | > - Server Bans
59 | > - Server Members
60 | > - Server Roles
61 | > - Server Channels
62 | > - Server Emojis
63 | > - Server Integrations
64 | > - Server Moderation
65 | > - Server Verification
66 |
67 | ##
68 |
69 | ## Tech Stack
70 |
71 | > React, Next.js, TypeScript, TailwindCSS, Shadcn-iu, Socket.io, Clerk, Prisma, PostgreSQL, Supabase, LiveKit, Tanstack/react-query, Uploadthing/react, Axios, Zod, Zustand
72 |
73 | ##
74 |
75 | #### Install packages
76 |
77 | ```bash
78 | npm install
79 | ```
80 |
81 | ##
82 |
83 | #### Setup .env file
84 |
85 | ```bash
86 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
87 | CLERK_SECRET_KEY=
88 | NEXT_PUBLIC_CLERK_SIGN_IN_URL=
89 | NEXT_PUBLIC_CLERK_SIGN_UP_URL=
90 | NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=
91 | NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=
92 |
93 |
94 | DATABASE_URL=
95 |
96 | UPLOADTHING_SECRET=
97 | UPLOADTHING_APP_ID=
98 |
99 | LIVEKIT_API_KEY=
100 | LIVEKIT_API_SECRET=
101 | NEXT_PUBLIC_LIVEKIT_URL=
102 |
103 | NEXT_PUBLIC_SITE_URL=
104 | ```
105 |
106 | ##
107 |
108 | #### Setup Prisma
109 |
110 | ```bash
111 | npx prisma init
112 | npx prisma generate
113 | npx prisma db push
114 |
115 | npm i @prisma/client
116 | npx prisma studio
117 | ```
118 |
119 | ##
120 |
121 | #### Setup Supabase
122 |
123 | > - Create a new project on [Supabase](https://supabase.io/)
124 | >
125 | > - Add your Supabase URL to the .env file
126 | > - Add your Supabase public key to the .env file
127 |
128 | ##
129 |
130 | #### Setup Clerk
131 |
132 | > - Create a new project on [Clerk](https://clerk.dev/)
133 | > - Create a new user pool on [Clerk](https://clerk.dev/)
134 | > - Create a new user on [Clerk](https://clerk.dev/)
135 | >
136 | > - Add your Clerk public key to the .env file
137 | > - Add your Clerk secret key to the .env file
138 | > - Add your Clerk sign in URL to the .env file
139 | > - Add your Clerk sign up URL to the .env file
140 | > - Add your Clerk after sign in URL to the .env file
141 | > - Add your Clerk after sign up URL to the .env file
142 |
143 | ##
144 |
145 | #### Setup Uploadthing
146 |
147 | > - Create a new project on [Uploadthing](https://uploadthingy.com/)
148 | > - Create a new app on [Uploadthing](https://uploadthingy.com/)
149 | > - Create a new secret on [Uploadthing](https://uploadthingy.com/)
150 | >
151 | > - Add your Uploadthing secret to the .env file
152 | > - Add your Uploadthing app id to the .env file
153 |
154 | ##
155 |
156 | #### Getting Started
157 |
158 | First, run the development server:
159 |
160 | ```bash
161 | npm run dev
162 | # or
163 | yarn dev
164 | # or
165 | pnpm dev
166 | # or
167 | bun dev
168 | ```
169 |
--------------------------------------------------------------------------------
/app/(auth)/(routes)/sign-in/[[...sign-in]]/page.tsx:
--------------------------------------------------------------------------------
1 | import { SignIn } from "@clerk/nextjs";
2 |
3 | export default function Page() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/app/(auth)/(routes)/sign-up/[[...sign-up]]/page.tsx:
--------------------------------------------------------------------------------
1 | import { SignUp } from "@clerk/nextjs";
2 |
3 | export default function Page() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/app/(auth)/layout.tsx:
--------------------------------------------------------------------------------
1 | const AuthLayout = ({ children }: { children: React.ReactNode }) => {
2 | return (
3 |
{children}
4 | );
5 | };
6 |
7 | export default AuthLayout;
8 |
--------------------------------------------------------------------------------
/app/(invite)/(routes)/invite/[inviteCode]/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirectToSignIn } from "@clerk/nextjs";
2 | import { redirect } from "next/navigation";
3 |
4 | import { currentProfile } from "@/lib/current-profile";
5 | import { db } from "@/lib/db";
6 |
7 | interface InviteCodePageProps {
8 | params: {
9 | inviteCode: string;
10 | };
11 | }
12 |
13 | const InviteCodePage = async ({ params }: InviteCodePageProps) => {
14 | const profile = await currentProfile();
15 |
16 | if (!profile) {
17 | return redirectToSignIn();
18 | }
19 |
20 | if (!params.inviteCode) {
21 | return redirect("/");
22 | }
23 |
24 | const existingServer = await db.server.findFirst({
25 | where: {
26 | inviteCode: params.inviteCode,
27 | members: {
28 | some: {
29 | profileId: profile.id,
30 | },
31 | },
32 | },
33 | });
34 |
35 | if (existingServer) {
36 | return redirect(`/servers/${existingServer.id}`);
37 | }
38 |
39 | const server = await db.server.update({
40 | where: {
41 | inviteCode: params.inviteCode,
42 | },
43 | data: {
44 | members: {
45 | create: [
46 | {
47 | profileId: profile.id,
48 | },
49 | ],
50 | },
51 | },
52 | });
53 |
54 | if (server) {
55 | return redirect(`/servers/${server.id}`);
56 | }
57 |
58 | return null;
59 | };
60 |
61 | export default InviteCodePage;
62 |
--------------------------------------------------------------------------------
/app/(main)/(routes)/servers/[serverId]/channels/[channelId]/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirectToSignIn } from "@clerk/nextjs";
2 | import { redirect } from "next/navigation";
3 | import { ChannelType } from "@prisma/client";
4 |
5 | import { currentProfile } from "@/lib/current-profile";
6 | import ChatHeader from "@/components/chat/chat-header";
7 | import ChatInput from "@/components/chat/chat-input";
8 | import ChatMessages from "@/components/chat/chat-messages";
9 | import { MediaRoom } from "@/components/media-room";
10 | import { db } from "@/lib/db";
11 |
12 | interface ChannelIdPageProps {
13 | params: {
14 | serverId: string;
15 | channelId: string;
16 | };
17 | }
18 |
19 | const ChannelIdPage = async ({ params }: ChannelIdPageProps) => {
20 | const profile = await currentProfile();
21 |
22 | if (!profile) {
23 | return redirectToSignIn();
24 | }
25 |
26 | const channel = await db.channel.findUnique({
27 | where: {
28 | id: params.channelId,
29 | },
30 | });
31 |
32 | const member = await db.member.findFirst({
33 | where: {
34 | serverId: params.serverId,
35 | profileId: profile.id,
36 | },
37 | });
38 |
39 | if (!channel || !member) {
40 | redirect("/");
41 | }
42 |
43 | return (
44 |
45 |
50 | {channel.type === ChannelType.TEXT && (
51 | <>
52 |
66 |
75 | >
76 | )}
77 | {channel.type === ChannelType.AUDIO && (
78 |
79 | )}
80 | {channel.type === ChannelType.VIDEO && (
81 |
82 | )}
83 |
84 | );
85 | };
86 |
87 | export default ChannelIdPage;
88 |
--------------------------------------------------------------------------------
/app/(main)/(routes)/servers/[serverId]/conversations/[memberId]/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirectToSignIn } from "@clerk/nextjs";
2 | import { redirect } from "next/navigation";
3 |
4 | import { db } from "@/lib/db";
5 | import { getOrCreateConversation } from "@/lib/conversation";
6 | import { currentProfile } from "@/lib/current-profile";
7 | import ChatHeader from "@/components/chat/chat-header";
8 |
9 | import ChatMessages from "@/components/chat/chat-messages";
10 | import ChatInput from "@/components/chat/chat-input";
11 | import { MediaRoom } from "@/components/media-room";
12 |
13 | interface MemberIdPageProps {
14 | params: {
15 | memberId: string;
16 | serverId: string;
17 | };
18 | searchParams: {
19 | video?: boolean;
20 | };
21 | }
22 |
23 | const MemberIdPage = async ({ params, searchParams }: MemberIdPageProps) => {
24 | const profile = await currentProfile();
25 |
26 | if (!profile) {
27 | return redirectToSignIn();
28 | }
29 |
30 | const currentMember = await db.member.findFirst({
31 | where: {
32 | serverId: params.serverId,
33 | profileId: profile.id,
34 | },
35 | include: {
36 | profile: true,
37 | },
38 | });
39 |
40 | if (!currentMember) {
41 | return redirect("/");
42 | }
43 |
44 | const conversation = await getOrCreateConversation(
45 | currentMember.id,
46 | params.memberId
47 | );
48 |
49 | if (!conversation) {
50 | return redirect(`/servers/${params.serverId}`);
51 | }
52 |
53 | const { memberOne, memberTwo } = conversation;
54 |
55 | const otherMember =
56 | memberOne.profileId === profile.id ? memberTwo : memberOne;
57 |
58 | return (
59 |
60 |
66 | {searchParams.video && (
67 |
68 | )}
69 | {!searchParams.video && (
70 | <>
71 |
84 |
92 | >
93 | )}
94 |
95 | );
96 | };
97 |
98 | export default MemberIdPage;
99 |
--------------------------------------------------------------------------------
/app/(main)/(routes)/servers/[serverId]/layout.tsx:
--------------------------------------------------------------------------------
1 | import { redirectToSignIn } from "@clerk/nextjs";
2 | import { redirect } from "next/navigation";
3 |
4 | import { currentProfile } from "@/lib/current-profile";
5 | import { db } from "@/lib/db";
6 | import ServerSidebar from "@/components/server/server-sidebar";
7 |
8 | const ServerIdLayout = async ({
9 | children,
10 | params,
11 | }: {
12 | children: React.ReactNode;
13 | params: {
14 | serverId: string;
15 | };
16 | }) => {
17 | const profile = await currentProfile();
18 |
19 | if (!profile) {
20 | return redirectToSignIn();
21 | }
22 |
23 | const server = await db.server.findUnique({
24 | where: {
25 | id: params.serverId,
26 | members: {
27 | some: {
28 | profileId: profile.id,
29 | },
30 | },
31 | },
32 | });
33 |
34 | if (!server) {
35 | return redirect("/");
36 | }
37 |
38 | return (
39 |
40 |
41 |
42 |
43 |
{children}
44 |
45 | );
46 | };
47 |
48 | export default ServerIdLayout;
49 |
--------------------------------------------------------------------------------
/app/(main)/(routes)/servers/[serverId]/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirectToSignIn } from "@clerk/nextjs";
2 | import { redirect } from "next/navigation";
3 |
4 | import { currentProfile } from "@/lib/current-profile";
5 | import { db } from "@/lib/db";
6 |
7 | interface ServerIdPageProps {
8 | params: {
9 | serverId: string;
10 | };
11 | }
12 |
13 | const ServerIdPage = async ({ params }: ServerIdPageProps) => {
14 | const profile = await currentProfile();
15 |
16 | if (!profile) {
17 | return redirectToSignIn();
18 | }
19 |
20 | const server = await db.server.findUnique({
21 | where: {
22 | id: params.serverId,
23 | members: {
24 | some: {
25 | profileId: profile.id,
26 | },
27 | },
28 | },
29 | include: {
30 | channels: {
31 | where: {
32 | name: "general",
33 | },
34 | orderBy: {
35 | createdAt: "asc",
36 | },
37 | },
38 | },
39 | });
40 |
41 | const initialChannel = server?.channels[0];
42 |
43 | if (initialChannel?.name !== "general") {
44 | return null;
45 | }
46 |
47 | return redirect(`/servers/${params.serverId}/channels/${initialChannel?.id}`);
48 | };
49 |
50 | export default ServerIdPage;
51 |
--------------------------------------------------------------------------------
/app/(main)/layout.tsx:
--------------------------------------------------------------------------------
1 | import NavigationSidebar from "@/components/navigation/navigation-sidebar";
2 |
3 | const MainLayout = async ({ children }: { children: React.ReactNode }) => {
4 | return (
5 |
6 |
7 |
8 |
9 |
{children}
10 |
11 | );
12 | };
13 |
14 | export default MainLayout;
15 |
--------------------------------------------------------------------------------
/app/(setup)/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from "next/navigation";
2 |
3 | import { db } from "@/lib/db";
4 | import { initialProfile } from "@/lib/initial-profile";
5 | import InitialModal from "@/components/modals/initial-modal";
6 |
7 | const SetupPage = async () => {
8 | const profile = await initialProfile();
9 |
10 | const server = await db.server.findFirst({
11 | where: {
12 | members: {
13 | some: {
14 | profileId: profile.id,
15 | },
16 | },
17 | },
18 | });
19 |
20 | if (server) {
21 | return redirect(`/servers/${server.id}`);
22 | }
23 | return ;
24 | };
25 |
26 | export default SetupPage;
27 |
--------------------------------------------------------------------------------
/app/api/channels/[channelId]/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server";
2 | import { MemberRole } from "@prisma/client";
3 |
4 | import { currentProfile } from "@/lib/current-profile";
5 | import { db } from "@/lib/db";
6 |
7 | export async function DELETE(
8 | req: Request,
9 | { params }: { params: { channelId: string } }
10 | ) {
11 | try {
12 | const profile = await currentProfile();
13 | const { searchParams } = new URL(req.url);
14 |
15 | const serverId = searchParams.get("serverId");
16 |
17 | if (!profile) {
18 | return new NextResponse("Unauthorized", { status: 401 });
19 | }
20 |
21 | if (!serverId) {
22 | return new NextResponse("Server ID missing", { status: 400 });
23 | }
24 |
25 | if (!params.channelId) {
26 | return new NextResponse("Channel ID missing", { status: 400 });
27 | }
28 |
29 | const server = await db.server.update({
30 | where: {
31 | id: serverId,
32 | members: {
33 | some: {
34 | profileId: profile.id,
35 | role: {
36 | in: [MemberRole.ADMIN, MemberRole.MODERATOR],
37 | },
38 | },
39 | },
40 | },
41 | data: {
42 | channels: {
43 | delete: {
44 | id: params.channelId,
45 | name: {
46 | not: "general",
47 | },
48 | },
49 | },
50 | },
51 | });
52 |
53 | return NextResponse.json(server);
54 | } catch (error) {
55 | console.log("[CHANNEL_ID_DELETE]", error);
56 | return new NextResponse("Internal Error", { status: 500 });
57 | }
58 | }
59 |
60 | export async function PATCH(
61 | req: Request,
62 | { params }: { params: { channelId: string } }
63 | ) {
64 | try {
65 | const profile = await currentProfile();
66 | const { name, type } = await req.json();
67 | const { searchParams } = new URL(req.url);
68 |
69 | const serverId = searchParams.get("serverId");
70 |
71 | if (!profile) {
72 | return new NextResponse("Unauthorized", { status: 401 });
73 | }
74 |
75 | if (!serverId) {
76 | return new NextResponse("Server ID missing", { status: 400 });
77 | }
78 |
79 | if (!params.channelId) {
80 | return new NextResponse("Channel ID missing", { status: 400 });
81 | }
82 |
83 | if (name === "general") {
84 | return new NextResponse("Name cannot be 'general'", { status: 400 });
85 | }
86 |
87 | const server = await db.server.update({
88 | where: {
89 | id: serverId,
90 | members: {
91 | some: {
92 | profileId: profile.id,
93 | role: {
94 | in: [MemberRole.ADMIN, MemberRole.MODERATOR],
95 | },
96 | },
97 | },
98 | },
99 | data: {
100 | channels: {
101 | update: {
102 | where: {
103 | id: params.channelId,
104 | NOT: {
105 | name: "general",
106 | },
107 | },
108 | data: {
109 | name,
110 | type,
111 | },
112 | },
113 | },
114 | },
115 | });
116 |
117 | return NextResponse.json(server);
118 | } catch (error) {
119 | console.log("[CHANNEL_ID_PATCH]", error);
120 | return new NextResponse("Internal Error", { status: 500 });
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/app/api/channels/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server";
2 | import { MemberRole } from "@prisma/client";
3 |
4 | import { currentProfile } from "@/lib/current-profile";
5 | import { db } from "@/lib/db";
6 |
7 | export async function POST(req: Request) {
8 | try {
9 | const profile = await currentProfile();
10 | const { name, type } = await req.json();
11 | const { searchParams } = new URL(req.url);
12 |
13 | const serverId = searchParams.get("serverId");
14 |
15 | if (!profile) {
16 | return new NextResponse("Unauthorized", { status: 401 });
17 | }
18 |
19 | if (!serverId) {
20 | return new NextResponse("Server ID missing", { status: 400 });
21 | }
22 |
23 | if (name === "general") {
24 | return new NextResponse("Name cannot be 'general'", { status: 400 });
25 | }
26 |
27 | const server = await db.server.update({
28 | where: {
29 | id: serverId,
30 | members: {
31 | some: {
32 | profileId: profile.id,
33 | role: {
34 | in: [MemberRole.ADMIN, MemberRole.MODERATOR],
35 | },
36 | },
37 | },
38 | },
39 | data: {
40 | channels: {
41 | create: {
42 | profileId: profile.id,
43 | name,
44 | type,
45 | },
46 | },
47 | },
48 | });
49 |
50 | return NextResponse.json(server);
51 | } catch (error) {
52 | console.log("CHANNELS_POST", error);
53 | return new NextResponse("Internal Error", { status: 500 });
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/app/api/direct-messages/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server";
2 | import { DirectMessage } from "@prisma/client";
3 |
4 | import { currentProfile } from "@/lib/current-profile";
5 | import { db } from "@/lib/db";
6 |
7 | const MESSAGES_BATCH = 10;
8 |
9 | export async function GET(req: Request) {
10 | try {
11 | const profile = await currentProfile();
12 | const { searchParams } = new URL(req.url);
13 |
14 | const cursor = searchParams.get("cursor");
15 | const conversationId = searchParams.get("conversationId");
16 |
17 | if (!profile) {
18 | return new NextResponse("Unauthorized", { status: 401 });
19 | }
20 |
21 | if (!conversationId) {
22 | return new NextResponse("Conversation ID missing", { status: 400 });
23 | }
24 |
25 | let messages: DirectMessage[] = [];
26 |
27 | if (cursor) {
28 | messages = await db.directMessage.findMany({
29 | take: MESSAGES_BATCH,
30 | skip: 1,
31 | cursor: {
32 | id: cursor,
33 | },
34 | where: {
35 | conversationId,
36 | },
37 | include: {
38 | member: {
39 | include: {
40 | profile: true,
41 | },
42 | },
43 | },
44 | orderBy: {
45 | createdAt: "desc",
46 | },
47 | });
48 | } else {
49 | messages = await db.directMessage.findMany({
50 | take: MESSAGES_BATCH,
51 | where: {
52 | conversationId,
53 | },
54 | include: {
55 | member: {
56 | include: {
57 | profile: true,
58 | },
59 | },
60 | },
61 | orderBy: {
62 | createdAt: "desc",
63 | },
64 | });
65 | }
66 |
67 | let nextCursor = null;
68 |
69 | if (messages.length === MESSAGES_BATCH) {
70 | nextCursor = messages[MESSAGES_BATCH - 1].id;
71 | }
72 |
73 | return NextResponse.json({
74 | items: messages,
75 | nextCursor,
76 | });
77 | } catch (error) {
78 | console.log("[DIRECT_MESSAGES_GET]", error);
79 | return new NextResponse("Internal Error", { status: 500 });
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/app/api/livekit/route.ts:
--------------------------------------------------------------------------------
1 | import { AccessToken } from "livekit-server-sdk";
2 | import { NextRequest, NextResponse } from "next/server";
3 |
4 | export async function GET(req: NextRequest) {
5 | const room = req.nextUrl.searchParams.get("room");
6 | const username = req.nextUrl.searchParams.get("username");
7 | if (!room) {
8 | return NextResponse.json(
9 | { error: 'Missing "room" query parameter' },
10 | { status: 400 }
11 | );
12 | } else if (!username) {
13 | return NextResponse.json(
14 | { error: 'Missing "username" query parameter' },
15 | { status: 400 }
16 | );
17 | }
18 |
19 | const apiKey = process.env.LIVEKIT_API_KEY;
20 | const apiSecret = process.env.LIVEKIT_API_SECRET;
21 | const wsUrl = process.env.NEXT_PUBLIC_LIVEKIT_URL;
22 |
23 | if (!apiKey || !apiSecret || !wsUrl) {
24 | return NextResponse.json(
25 | { error: "Server misconfigured" },
26 | { status: 500 }
27 | );
28 | }
29 |
30 | const at = new AccessToken(apiKey, apiSecret, { identity: username });
31 |
32 | at.addGrant({ room, roomJoin: true, canPublish: true, canSubscribe: true });
33 |
34 | return NextResponse.json({ token: at.toJwt() });
35 | }
36 |
--------------------------------------------------------------------------------
/app/api/members/[memberId]/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server";
2 |
3 | import { currentProfile } from "@/lib/current-profile";
4 | import { db } from "@/lib/db";
5 |
6 | export async function DELETE(
7 | req: Request,
8 | { params }: { params: { memberId: string } }
9 | ) {
10 | try {
11 | const profile = await currentProfile();
12 | const { searchParams } = new URL(req.url);
13 |
14 | const serverId = searchParams.get("serverId");
15 |
16 | if (!profile) {
17 | return new NextResponse("Unauthorized", { status: 401 });
18 | }
19 |
20 | if (!serverId) {
21 | return new NextResponse("Server ID missing", { status: 400 });
22 | }
23 |
24 | if (!params.memberId) {
25 | return new NextResponse("Member ID missing", { status: 400 });
26 | }
27 |
28 | const server = await db.server.update({
29 | where: {
30 | id: serverId,
31 | profileId: profile.id,
32 | },
33 | data: {
34 | members: {
35 | deleteMany: {
36 | id: params.memberId,
37 | profileId: {
38 | not: profile.id,
39 | },
40 | },
41 | },
42 | },
43 | include: {
44 | members: {
45 | include: {
46 | profile: true,
47 | },
48 | orderBy: {
49 | role: "asc",
50 | },
51 | },
52 | },
53 | });
54 |
55 | return NextResponse.json(server);
56 | } catch (error) {
57 | console.log("[MEMBER_ID_DELETE]", error);
58 | return new NextResponse("Internal Error", { status: 500 });
59 | }
60 | }
61 |
62 | export async function PATCH(
63 | req: Request,
64 | { params }: { params: { memberId: string } }
65 | ) {
66 | try {
67 | const profile = await currentProfile();
68 | const { searchParams } = new URL(req.url);
69 | const { role } = await req.json();
70 |
71 | const serverId = searchParams.get("serverId");
72 |
73 | if (!profile) {
74 | return new NextResponse("Unauthorized", { status: 401 });
75 | }
76 |
77 | if (!serverId) {
78 | return new NextResponse("Server ID missing", { status: 400 });
79 | }
80 |
81 | if (!params.memberId) {
82 | return new NextResponse("Member ID missing", { status: 400 });
83 | }
84 |
85 | const server = await db.server.update({
86 | where: {
87 | id: serverId,
88 | profileId: profile.id,
89 | },
90 | data: {
91 | members: {
92 | update: {
93 | where: {
94 | id: params.memberId,
95 | profileId: {
96 | not: profile.id,
97 | },
98 | },
99 | data: {
100 | role,
101 | },
102 | },
103 | },
104 | },
105 | include: {
106 | members: {
107 | include: {
108 | profile: true,
109 | },
110 | orderBy: {
111 | role: "asc",
112 | },
113 | },
114 | },
115 | });
116 |
117 | return NextResponse.json(server);
118 | } catch (error) {
119 | console.log("[MEMBERS_ID_PATCH]", error);
120 | return new NextResponse("Internal Error", { status: 500 });
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/app/api/messages/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server";
2 | import { Message } from "@prisma/client";
3 |
4 | import { currentProfile } from "@/lib/current-profile";
5 | import { db } from "@/lib/db";
6 |
7 | const MESSAGES_BATCH = 10;
8 |
9 | export async function GET(req: Request) {
10 | try {
11 | const profile = await currentProfile();
12 | const { searchParams } = new URL(req.url);
13 |
14 | const cursor = searchParams.get("cursor");
15 | const channelId = searchParams.get("channelId");
16 |
17 | if (!profile) {
18 | return new NextResponse("Unauthorized", { status: 401 });
19 | }
20 |
21 | if (!channelId) {
22 | return new NextResponse("Channel ID missing", { status: 400 });
23 | }
24 |
25 | let messages: Message[] = [];
26 |
27 | if (cursor) {
28 | messages = await db.message.findMany({
29 | take: MESSAGES_BATCH,
30 | skip: 1,
31 | cursor: {
32 | id: cursor,
33 | },
34 | where: {
35 | channelId,
36 | },
37 | include: {
38 | member: {
39 | include: {
40 | profile: true,
41 | },
42 | },
43 | },
44 | orderBy: {
45 | createdAt: "desc",
46 | },
47 | });
48 | } else {
49 | messages = await db.message.findMany({
50 | take: MESSAGES_BATCH,
51 | where: {
52 | channelId,
53 | },
54 | include: {
55 | member: {
56 | include: {
57 | profile: true,
58 | },
59 | },
60 | },
61 | orderBy: {
62 | createdAt: "desc",
63 | },
64 | });
65 | }
66 |
67 | let nextCursor = null;
68 |
69 | if (messages.length === MESSAGES_BATCH) {
70 | nextCursor = messages[MESSAGES_BATCH - 1].id;
71 | }
72 |
73 | return NextResponse.json({
74 | items: messages,
75 | nextCursor,
76 | });
77 | } catch (error) {
78 | console.log("[MESSAGES_GET]", error);
79 | return new NextResponse("Internal Error", { status: 500 });
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/app/api/servers/[serverId]/invite-code/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server";
2 | import { v4 as uuid } from "uuid";
3 |
4 | import { db } from "@/lib/db";
5 | import { currentProfile } from "@/lib/current-profile";
6 |
7 | export async function PATCH(
8 | req: Request,
9 | { params }: { params: { serverId: string } }
10 | ) {
11 | try {
12 | const profile = await currentProfile();
13 |
14 | if (!profile) {
15 | return new NextResponse("Unauthorized", {
16 | status: 401,
17 | });
18 | }
19 |
20 | if (!params.serverId) {
21 | return new NextResponse("Not Found", {
22 | status: 404,
23 | });
24 | }
25 |
26 | const server = await db.server.update({
27 | where: {
28 | id: params.serverId,
29 | profileId: profile.id,
30 | },
31 | data: {
32 | inviteCode: uuid(),
33 | },
34 | });
35 |
36 | return NextResponse.json(server);
37 | } catch (error) {
38 | console.log("[SERVER_ID]", error);
39 | return new NextResponse("Internal Error", {
40 | status: 500,
41 | });
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/app/api/servers/[serverId]/leave/route.ts:
--------------------------------------------------------------------------------
1 | import { currentProfile } from "@/lib/current-profile";
2 | import { db } from "@/lib/db";
3 | import { NextResponse } from "next/server";
4 |
5 | export async function PATCH(
6 | req: Request,
7 | { params }: { params: { serverId: string } }
8 | ) {
9 | try {
10 | const profile = await currentProfile();
11 |
12 | if (!profile) {
13 | return new NextResponse("Unauthorized", { status: 401 });
14 | }
15 |
16 | if (!params.serverId) {
17 | return new NextResponse("Bad Request", { status: 400 });
18 | }
19 |
20 | const server = await db.server.update({
21 | where: {
22 | id: params.serverId,
23 | profileId: {
24 | not: profile.id,
25 | },
26 | members: {
27 | some: {
28 | profileId: profile.id,
29 | },
30 | },
31 | },
32 | data: {
33 | members: {
34 | deleteMany: {
35 | profileId: profile.id,
36 | },
37 | },
38 | },
39 | });
40 |
41 | return NextResponse.json(server);
42 | } catch (e) {
43 | console.log("[SERVER_ID_LEAVE]", e);
44 | return new NextResponse("Internal Server Error", { status: 500 });
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/app/api/servers/[serverId]/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server";
2 |
3 | import { currentProfile } from "@/lib/current-profile";
4 | import { db } from "@/lib/db";
5 |
6 | export async function DELETE(
7 | req: Request,
8 | { params }: { params: { serverId: string } }
9 | ) {
10 | try {
11 | const profile = await currentProfile();
12 |
13 | if (!profile) {
14 | return new NextResponse("Unauthorized", { status: 401 });
15 | }
16 |
17 | const server = await db.server.delete({
18 | where: {
19 | id: params.serverId,
20 | profileId: profile.id,
21 | },
22 | });
23 |
24 | return NextResponse.json(server);
25 | } catch (error) {
26 | console.log("[SERVER_ID_DELETE]", error);
27 | return new NextResponse("Internal Error", { status: 500 });
28 | }
29 | }
30 |
31 | export async function PATCH(
32 | req: Request,
33 | { params }: { params: { serverId: string } }
34 | ) {
35 | try {
36 | const profile = await currentProfile();
37 | const { name, imageUrl } = await req.json();
38 |
39 | if (!profile) {
40 | return new NextResponse("Unauthorized", { status: 401 });
41 | }
42 |
43 | const server = await db.server.update({
44 | where: {
45 | id: params.serverId,
46 | profileId: profile.id,
47 | },
48 | data: {
49 | name,
50 | imageUrl,
51 | },
52 | });
53 |
54 | return NextResponse.json(server);
55 | } catch (e) {
56 | console.log(["SERVER_ID_PATCH"], e);
57 | return new NextResponse("Internal Error", { status: 500 });
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/app/api/servers/route.ts:
--------------------------------------------------------------------------------
1 | import { v4 as uuid } from "uuid";
2 | import { NextResponse } from "next/server";
3 | import { MemberRole } from "@prisma/client";
4 |
5 | import { currentProfile } from "@/lib/current-profile";
6 | import { db } from "@/lib/db";
7 |
8 | export async function POST(req: Request) {
9 | try {
10 | const { name, imageUrl } = await req.json();
11 | const profile = await currentProfile();
12 |
13 | if (!profile) {
14 | return new NextResponse("Not authorized", { status: 401 });
15 | }
16 |
17 | const server = await db.server.create({
18 | data: {
19 | name,
20 | imageUrl,
21 | profileId: profile.id,
22 | inviteCode: uuid(),
23 | channels: { create: [{ name: "general", profileId: profile.id }] },
24 | members: {
25 | create: [{ profileId: profile.id, role: MemberRole.ADMIN }],
26 | },
27 | },
28 | });
29 |
30 | return NextResponse.json(server);
31 | } catch (e) {
32 | console.error("[SERVERS_POST]", e);
33 | return new NextResponse("Something went wrong", { status: 500 });
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/app/api/uploadthing/core.ts:
--------------------------------------------------------------------------------
1 | import { auth } from "@clerk/nextjs";
2 | import { createUploadthing, type FileRouter } from "uploadthing/next";
3 |
4 | const f = createUploadthing();
5 |
6 | const handleAuth = () => {
7 | const { userId } = auth();
8 | if (!userId) throw new Error("Unauthorized");
9 | return { userId: userId };
10 | };
11 |
12 | export const ourFileRouter = {
13 | serverImage: f({
14 | image: { maxFileSize: "4MB", maxFileCount: 1 },
15 | })
16 | .middleware(() => handleAuth())
17 | .onUploadComplete(() => {}),
18 | messageFile: f(["image", "pdf"])
19 | .middleware(() => handleAuth())
20 | .onUploadComplete(() => {}),
21 | } satisfies FileRouter;
22 |
23 | export type OurFileRouter = typeof ourFileRouter;
24 |
--------------------------------------------------------------------------------
/app/api/uploadthing/route.ts:
--------------------------------------------------------------------------------
1 | import { createNextRouteHandler } from "uploadthing/next";
2 |
3 | import { ourFileRouter } from "./core";
4 |
5 | // Export routes for Next App Router
6 | export const { GET, POST } = createNextRouteHandler({
7 | router: ourFileRouter,
8 | });
9 |
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RicardoGEsteves/Intertwine/beb17e6081b62f39f1218ea274a8d6c11d58b901/app/favicon.ico
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | html,
6 | body,
7 | :root {
8 | height: 100%;
9 | }
10 |
11 | @layer base {
12 | :root {
13 | --background: 0 0% 100%;
14 | --foreground: 224 71.4% 4.1%;
15 |
16 | --card: 0 0% 100%;
17 | --card-foreground: 224 71.4% 4.1%;
18 |
19 | --popover: 0 0% 100%;
20 | --popover-foreground: 224 71.4% 4.1%;
21 |
22 | --primary: 220.9 39.3% 11%;
23 | --primary-foreground: 210 20% 98%;
24 |
25 | --secondary: 220 14.3% 95.9%;
26 | --secondary-foreground: 220.9 39.3% 11%;
27 |
28 | --muted: 220 14.3% 95.9%;
29 | --muted-foreground: 220 8.9% 46.1%;
30 |
31 | --accent: 220 14.3% 95.9%;
32 | --accent-foreground: 220.9 39.3% 11%;
33 |
34 | --destructive: 0 84.2% 60.2%;
35 | --destructive-foreground: 210 20% 98%;
36 |
37 | --border: 220 13% 91%;
38 | --input: 220 13% 91%;
39 | --ring: 224 71.4% 4.1%;
40 |
41 | --radius: 0.5rem;
42 | }
43 |
44 | .dark {
45 | --background: 224 71.4% 4.1%;
46 | --foreground: 210 20% 98%;
47 |
48 | --card: 224 71.4% 4.1%;
49 | --card-foreground: 210 20% 98%;
50 |
51 | --popover: 224 71.4% 4.1%;
52 | --popover-foreground: 210 20% 98%;
53 |
54 | --primary: 210 20% 98%;
55 | --primary-foreground: 220.9 39.3% 11%;
56 |
57 | --secondary: 215 27.9% 16.9%;
58 | --secondary-foreground: 210 20% 98%;
59 |
60 | --muted: 215 27.9% 16.9%;
61 | --muted-foreground: 217.9 10.6% 64.9%;
62 |
63 | --accent: 215 27.9% 16.9%;
64 | --accent-foreground: 210 20% 98%;
65 |
66 | --destructive: 0 62.8% 30.6%;
67 | --destructive-foreground: 210 20% 98%;
68 |
69 | --border: 215 27.9% 16.9%;
70 | --input: 215 27.9% 16.9%;
71 | --ring: 216 12.2% 83.9%;
72 | }
73 | }
74 |
75 | @layer base {
76 | * {
77 | @apply border-border;
78 | }
79 | body {
80 | @apply bg-background text-foreground;
81 | }
82 | }
83 |
84 | /* @layer components {
85 | .navigation-sidebar {
86 | @apply hidden md:flex h-full w-[72px] z-30 flex-col fixed inset-y-0;
87 | }
88 | .server-sidebar {
89 | @apply hidden md:flex h-full w-60 z-20 flex-col fixed inset-y-0;
90 | }
91 | } */
92 |
93 | @import "~@uploadthing/react/styles.css";
94 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Open_Sans } from "next/font/google";
3 | import { ClerkProvider } from "@clerk/nextjs";
4 | import { ThemeProvider } from "@/components/providers/theme-provider";
5 | import { ModalProvider } from "@/components/providers/modal-provider";
6 | import { SocketProvider } from "@/components/providers/socket-provider";
7 | import { QueryProvider } from "@/components/providers/query-provider";
8 | import { cn } from "@/lib/utils";
9 |
10 | import "./globals.css";
11 |
12 | const font = Open_Sans({ subsets: ["latin"] });
13 |
14 | export const metadata: Metadata = {
15 | title: "Intertwine",
16 | description: `This is an instant messaging and VoIP social platform which allows communication through voice calls, video calls, text messaging, and media and files. Communication can be private or take place in virtual communities called "servers".`,
17 | };
18 |
19 | export default function RootLayout({
20 | children,
21 | }: {
22 | children: React.ReactNode;
23 | }) {
24 | return (
25 |
26 |
27 |
28 |
35 |
36 |
37 | {children}
38 |
39 |
40 |
41 |
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/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": "gray",
10 | "cssVariables": true
11 | },
12 | "aliases": {
13 | "components": "@/components",
14 | "utils": "@/lib/utils"
15 | }
16 | }
--------------------------------------------------------------------------------
/components/action-tooltip.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | Tooltip,
5 | TooltipContent,
6 | TooltipProvider,
7 | TooltipTrigger,
8 | } from "./ui/tooltip";
9 |
10 | interface ActionTooltipProps {
11 | label: string;
12 | children: React.ReactNode;
13 | side?: "left" | "right" | "top" | "bottom";
14 | align?: "start" | "center" | "end";
15 | }
16 |
17 | const ActionTooltip = ({
18 | label,
19 | children,
20 | side,
21 | align,
22 | }: ActionTooltipProps) => {
23 | return (
24 |
25 |
26 | {children}
27 |
28 |
29 | {label.toLowerCase()}
30 |
31 |
32 |
33 |
34 | );
35 | };
36 |
37 | export default ActionTooltip;
38 |
--------------------------------------------------------------------------------
/components/chat/chat-header.tsx:
--------------------------------------------------------------------------------
1 | import { Hash } from "lucide-react";
2 |
3 | import MobileToggle from "../mobile-toggle";
4 | import UserAvatar from "../user-avatar";
5 | import { SocketIndicator } from "../socket-indicator";
6 |
7 | import { ChatVideoButton } from "./chat-video-button";
8 |
9 | interface ChatHeaderProps {
10 | serverId: string;
11 | name: string;
12 | type: "channel" | "conversation";
13 | imageUrl?: string;
14 | }
15 |
16 | const ChatHeader = ({ serverId, name, type, imageUrl }: ChatHeaderProps) => {
17 | return (
18 |
19 |
20 | {type === "channel" &&
}
21 | {type === "conversation" && (
22 |
23 | )}
24 |
{name}
25 |
26 | {type === "conversation" && }
27 |
28 |
29 |
30 | );
31 | };
32 |
33 | export default ChatHeader;
34 |
--------------------------------------------------------------------------------
/components/chat/chat-input.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as z from "zod";
4 | import axios from "axios";
5 | import qs from "query-string";
6 | import { useForm } from "react-hook-form";
7 | import { zodResolver } from "@hookform/resolvers/zod";
8 | import { Plus } from "lucide-react";
9 | import { useRouter } from "next/navigation";
10 |
11 | import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
12 | import { Input } from "@/components/ui/input";
13 | import { useModal } from "@/hooks/use-modal-store";
14 | import { Button } from "../ui/button";
15 | import { EmojiPicker } from "@/components/emoji-picker";
16 |
17 | interface ChatInputProps {
18 | apiUrl: string;
19 | query: Record;
20 | name: string;
21 | type: "conversation" | "channel";
22 | }
23 |
24 | const formSchema = z.object({
25 | content: z.string().min(1),
26 | });
27 |
28 | const ChatInput = ({ apiUrl, query, name, type }: ChatInputProps) => {
29 | const { onOpen } = useModal();
30 | const router = useRouter();
31 |
32 | const form = useForm>({
33 | resolver: zodResolver(formSchema),
34 | defaultValues: {
35 | content: "",
36 | },
37 | });
38 |
39 | const isLoading = form.formState.isSubmitting;
40 |
41 | const onSubmit = async (values: z.infer) => {
42 | try {
43 | const url = qs.stringifyUrl({
44 | url: apiUrl,
45 | query,
46 | });
47 |
48 | await axios.post(url, values);
49 |
50 | form.reset();
51 | router.refresh();
52 | } catch (error) {
53 | console.log(error);
54 | }
55 | };
56 |
57 | return (
58 |
97 |
98 | );
99 | };
100 |
101 | export default ChatInput;
102 |
--------------------------------------------------------------------------------
/components/chat/chat-messages.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Fragment, useRef, ElementRef } from "react";
4 | import { format } from "date-fns";
5 | import { Member, Message, Profile } from "@prisma/client";
6 | import { Loader2, ServerCrash } from "lucide-react";
7 |
8 | import { useChatQuery } from "@/hooks/use-chat-query";
9 | import { useChatSocket } from "@/hooks/use-chat-socket";
10 | import { useChatScroll } from "@/hooks/use-chat-scroll";
11 |
12 | import ChatWelcome from "./chat-welcome";
13 | import ChatItem from "./chat-item";
14 |
15 | const DATE_FORMAT = "d MMM yyyy, HH:mm";
16 |
17 | type MessageWithMemberWithProfile = Message & {
18 | member: Member & {
19 | profile: Profile;
20 | };
21 | };
22 |
23 | interface ChatMessagesProps {
24 | name: string;
25 | member: Member;
26 | chatId: string;
27 | apiUrl: string;
28 | socketUrl: string;
29 | socketQuery: Record;
30 | paramKey: "channelId" | "conversationId";
31 | paramValue: string;
32 | type: "channel" | "conversation";
33 | }
34 |
35 | const ChatMessages = ({
36 | name,
37 | member,
38 | chatId,
39 | apiUrl,
40 | socketUrl,
41 | socketQuery,
42 | paramKey,
43 | paramValue,
44 | type,
45 | }: ChatMessagesProps) => {
46 | const queryKey = `chat:${chatId}`;
47 | const addKey = `chat:${chatId}:messages`;
48 | const updateKey = `chat:${chatId}:messages:update`;
49 |
50 | const chatRef = useRef>(null);
51 | const bottomRef = useRef>(null);
52 |
53 | const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status } =
54 | useChatQuery({
55 | queryKey,
56 | apiUrl,
57 | paramKey,
58 | paramValue,
59 | });
60 |
61 | useChatSocket({ queryKey, addKey, updateKey });
62 | useChatScroll({
63 | chatRef,
64 | bottomRef,
65 | loadMore: fetchNextPage,
66 | shouldLoadMore: !isFetchingNextPage && !!hasNextPage,
67 | count: data?.pages?.[0]?.items?.length ?? 0,
68 | });
69 |
70 | if (status === "pending") {
71 | return (
72 |
73 |
74 |
Loading messages...
75 |
76 | );
77 | }
78 |
79 | if (status === "error") {
80 | return (
81 |
82 |
83 |
Something went wrong!
84 |
85 | );
86 | }
87 |
88 | return (
89 |
90 | {!hasNextPage &&
}
91 | {!hasNextPage &&
}
92 | {hasNextPage && (
93 |
94 | {isFetchingNextPage ? (
95 |
96 | ) : (
97 |
103 | )}
104 |
105 | )}
106 |
107 | {data?.pages?.map((group, i) => (
108 |
109 | {group.items.map((message: MessageWithMemberWithProfile) => (
110 |
123 | ))}
124 |
125 | ))}
126 |
127 |
128 |
129 | );
130 | };
131 |
132 | export default ChatMessages;
133 |
--------------------------------------------------------------------------------
/components/chat/chat-video-button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import qs from "query-string";
4 | import { usePathname, useRouter, useSearchParams } from "next/navigation";
5 | import { Video, VideoOff } from "lucide-react";
6 |
7 | import ActionTooltip from "@/components/action-tooltip";
8 |
9 | export const ChatVideoButton = () => {
10 | const pathname = usePathname();
11 | const router = useRouter();
12 | const searchParams = useSearchParams();
13 |
14 | const isVideo = searchParams?.get("video");
15 |
16 | const onClick = () => {
17 | const url = qs.stringifyUrl(
18 | {
19 | url: pathname || "",
20 | query: {
21 | video: isVideo ? undefined : true,
22 | },
23 | },
24 | { skipNull: true }
25 | );
26 |
27 | router.push(url);
28 | };
29 |
30 | const Icon = isVideo ? VideoOff : Video;
31 | const tooltipLabel = isVideo ? "End video call" : "Start video call";
32 |
33 | return (
34 |
35 |
38 |
39 | );
40 | };
41 |
--------------------------------------------------------------------------------
/components/chat/chat-welcome.tsx:
--------------------------------------------------------------------------------
1 | import { Hash } from "lucide-react";
2 |
3 | interface ChatWelcomeProps {
4 | name: string;
5 | type: "channel" | "conversation";
6 | }
7 |
8 | const ChatWelcome = ({ name, type }: ChatWelcomeProps) => {
9 | return (
10 |
11 | {type === "channel" && (
12 |
13 |
14 |
15 | )}
16 |
17 | {type === "channel" ? "Welcome to #" : ""}
18 | {name}
19 |
20 |
21 | {type === "channel"
22 | ? `This is the start of the #${name} channel.`
23 | : `This is the start of your conversation with ${name}`}
24 |
25 |
26 | );
27 | };
28 |
29 | export default ChatWelcome;
30 |
--------------------------------------------------------------------------------
/components/emoji-picker.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Smile } from "lucide-react";
4 | import Picker from "@emoji-mart/react";
5 | import data from "@emoji-mart/data";
6 | import { useTheme } from "next-themes";
7 |
8 | import {
9 | Popover,
10 | PopoverContent,
11 | PopoverTrigger,
12 | } from "@/components/ui/popover";
13 |
14 | interface EmojiPickerProps {
15 | onChange: (value: string) => void;
16 | }
17 |
18 | export const EmojiPicker = ({ onChange }: EmojiPickerProps) => {
19 | const { resolvedTheme } = useTheme();
20 |
21 | return (
22 |
23 |
24 |
25 |
26 |
31 | onChange(emoji.native)}
35 | />
36 |
37 |
38 | );
39 | };
40 |
--------------------------------------------------------------------------------
/components/file-upload.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { FileIcon, X } from "lucide-react";
4 | import Image from "next/image";
5 |
6 | import { UploadDropzone } from "@/lib/uploadthing";
7 |
8 | // import "@uploadthing/react/styles.css";
9 |
10 | interface FileUploadProps {
11 | onChange: (url?: string) => void;
12 | value: string;
13 | endpoint: "messageFile" | "serverImage";
14 | }
15 |
16 | const FileUpload = ({ onChange, value, endpoint }: FileUploadProps) => {
17 | const fileType = value?.split(".").pop();
18 |
19 | if (value && fileType !== "pdf") {
20 | return (
21 |
22 |
23 |
29 |
30 | );
31 | }
32 |
33 | if (value && fileType === "pdf") {
34 | return (
35 |
36 |
37 |
43 | {value}
44 |
45 |
51 |
52 | );
53 | }
54 |
55 | return (
56 | {
59 | onChange(res?.[0].url);
60 | }}
61 | onUploadError={(error: Error) => console.error(error)}
62 | />
63 | );
64 | };
65 |
66 | export default FileUpload;
67 |
--------------------------------------------------------------------------------
/components/media-room.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect, useState } from "react";
4 | import { LiveKitRoom, VideoConference } from "@livekit/components-react";
5 | import "@livekit/components-styles";
6 | import { useUser } from "@clerk/nextjs";
7 | import { Loader2 } from "lucide-react";
8 |
9 | interface MediaRoomProps {
10 | chatId: string;
11 | video: boolean;
12 | audio: boolean;
13 | }
14 |
15 | export const MediaRoom = ({ chatId, video, audio }: MediaRoomProps) => {
16 | const { user } = useUser();
17 | const [token, setToken] = useState("");
18 |
19 | useEffect(() => {
20 | if (!user?.firstName || !user?.lastName) return;
21 |
22 | const name = `${user.firstName} ${user.lastName}`;
23 |
24 | (async () => {
25 | try {
26 | const resp = await fetch(
27 | `/api/livekit?room=${chatId}&username=${name}`
28 | );
29 | const data = await resp.json();
30 | setToken(data.token);
31 | } catch (e) {
32 | console.log(e);
33 | }
34 | })();
35 | }, [user?.firstName, user?.lastName, chatId]);
36 |
37 | if (token === "") {
38 | return (
39 |
40 |
41 |
Loading...
42 |
43 | );
44 | }
45 |
46 | return (
47 |
56 |
57 |
58 | );
59 | };
60 |
--------------------------------------------------------------------------------
/components/mobile-toggle.tsx:
--------------------------------------------------------------------------------
1 | import { Menu } from "lucide-react";
2 |
3 | import { Sheet, SheetContent, SheetTrigger } from "./ui/sheet";
4 | import { Button } from "./ui/button";
5 |
6 | import NavigationSidebar from "./navigation/navigation-sidebar";
7 | import ServerSidebar from "./server/server-sidebar";
8 |
9 | const MobileToggle = ({ serverId }: { serverId: string }) => {
10 | return (
11 |
12 | {/* TODO: Fix close button(x) from sheet component, its overlapping the dropdown icon */}
13 |
14 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | );
26 | };
27 |
28 | export default MobileToggle;
29 |
--------------------------------------------------------------------------------
/components/modals/create-channel-modal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import axios from "axios";
4 | import qs from "query-string";
5 | import * as z from "zod";
6 | import { zodResolver } from "@hookform/resolvers/zod";
7 | import { useForm } from "react-hook-form";
8 | import { useParams, useRouter } from "next/navigation";
9 | import { ChannelType } from "@prisma/client";
10 | import { useEffect } from "react";
11 |
12 | import {
13 | Dialog,
14 | DialogContent,
15 | DialogDescription,
16 | DialogFooter,
17 | DialogHeader,
18 | DialogTitle,
19 | } from "@/components/ui/dialog";
20 | import {
21 | Form,
22 | FormControl,
23 | FormField,
24 | FormItem,
25 | FormLabel,
26 | FormMessage,
27 | } from "@/components/ui/form";
28 | import { Button } from "@/components/ui/button";
29 | import { Input } from "@/components/ui/input";
30 | import { useModal } from "@/hooks/use-modal-store";
31 | import {
32 | Select,
33 | SelectContent,
34 | SelectItem,
35 | SelectTrigger,
36 | SelectValue,
37 | } from "@/components/ui/select";
38 |
39 | const formSchema = z.object({
40 | name: z
41 | .string()
42 | .min(3)
43 | .max(20)
44 | .refine((name) => name.toLowerCase() !== "general", {
45 | message: "You cannot use the name 'general', it is reserved.",
46 | }),
47 | type: z.nativeEnum(ChannelType),
48 | });
49 |
50 | const CreateChannelModal = () => {
51 | const { isOpen, onClose, type, data } = useModal();
52 | const router = useRouter();
53 | const params = useParams();
54 |
55 | const isModalOpen = isOpen && type === "createChannel";
56 | const { channelType } = data;
57 |
58 | const form = useForm({
59 | resolver: zodResolver(formSchema),
60 | defaultValues: {
61 | name: "",
62 | type: channelType || ChannelType.TEXT,
63 | },
64 | });
65 |
66 | useEffect(() => {
67 | if (channelType) {
68 | form.setValue("type", channelType);
69 | } else {
70 | form.setValue("type", ChannelType.TEXT);
71 | }
72 | }, [channelType, form]);
73 |
74 | const isLoading = form.formState.isSubmitting;
75 |
76 | const onSubmit = async (values: z.infer) => {
77 | try {
78 | const url = qs.stringifyUrl({
79 | url: "/api/channels",
80 | query: {
81 | serverId: params?.serverId,
82 | },
83 | });
84 |
85 | await axios.post(url, values);
86 |
87 | form.reset();
88 | router.refresh();
89 | onClose();
90 | } catch (error) {
91 | console.log(error);
92 | }
93 | };
94 |
95 | const handleModalClose = () => {
96 | onClose();
97 | form.reset();
98 | };
99 |
100 | return (
101 |
179 | );
180 | };
181 |
182 | export default CreateChannelModal;
183 |
--------------------------------------------------------------------------------
/components/modals/create-server-modal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import axios from "axios";
4 | import { z } from "zod";
5 | import { zodResolver } from "@hookform/resolvers/zod";
6 | import { useForm } from "react-hook-form";
7 | import { useRouter } from "next/navigation";
8 |
9 | import {
10 | Dialog,
11 | DialogContent,
12 | DialogDescription,
13 | DialogFooter,
14 | DialogHeader,
15 | DialogTitle,
16 | } from "@/components/ui/dialog";
17 | import {
18 | Form,
19 | FormControl,
20 | FormField,
21 | FormItem,
22 | FormLabel,
23 | FormMessage,
24 | } from "@/components/ui/form";
25 | import { Button } from "@/components/ui/button";
26 | import { Input } from "@/components/ui/input";
27 | import FileUpload from "@/components/file-upload";
28 | import { useModal } from "@/hooks/use-modal-store";
29 |
30 | const formSchema = z.object({
31 | name: z
32 | .string()
33 | .min(1, {
34 | message: "Please enter a server name.",
35 | })
36 | .max(20),
37 | imageUrl: z.string().min(1, {
38 | message: "Please enter a server image URL.",
39 | }),
40 | });
41 |
42 | const CreateServerModal = () => {
43 | const { isOpen, onClose, type } = useModal();
44 | const router = useRouter();
45 |
46 | const isModalOpen = isOpen && type === "createServer";
47 |
48 | const form = useForm({
49 | resolver: zodResolver(formSchema),
50 | defaultValues: {
51 | name: "",
52 | imageUrl: "",
53 | },
54 | });
55 |
56 | const isLoading = form.formState.isSubmitting;
57 |
58 | const onSubmit = async (values: z.infer) => {
59 | try {
60 | await axios.post("/api/servers", values);
61 |
62 | form.reset();
63 | router.refresh();
64 | onClose();
65 | } catch (error) {
66 | console.log(error);
67 | }
68 | };
69 |
70 | const handleModalClose = () => {
71 | onClose();
72 | form.reset();
73 | };
74 |
75 | return (
76 |
142 | );
143 | };
144 |
145 | export default CreateServerModal;
146 |
--------------------------------------------------------------------------------
/components/modals/delete-channel-modal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import qs from "query-string";
4 | import axios from "axios";
5 | import { useState } from "react";
6 | import { useRouter } from "next/navigation";
7 |
8 | import {
9 | Dialog,
10 | DialogContent,
11 | DialogDescription,
12 | DialogFooter,
13 | DialogHeader,
14 | DialogTitle,
15 | } from "@/components/ui/dialog";
16 | import { useModal } from "@/hooks/use-modal-store";
17 | import { Button } from "../ui/button";
18 |
19 | const DeleteChannelModal = () => {
20 | const { isOpen, onClose, type, data } = useModal();
21 | const router = useRouter();
22 |
23 | const isModalOpen = isOpen && type === "deleteChannel";
24 | const { server, channel } = data;
25 |
26 | const [isLoading, setIsLoading] = useState(false);
27 |
28 | const onClick = async () => {
29 | try {
30 | setIsLoading(true);
31 | const url = qs.stringifyUrl({
32 | url: `/api/channels/${channel?.id}`,
33 | query: {
34 | serverId: server?.id,
35 | },
36 | });
37 |
38 | await axios.delete(url);
39 |
40 | onClose();
41 | router.refresh();
42 | router.push(`/servers/${server?.id}`);
43 | } catch (error) {
44 | console.log(error);
45 | } finally {
46 | setIsLoading(false);
47 | }
48 | };
49 |
50 | return (
51 |
75 | );
76 | };
77 |
78 | export default DeleteChannelModal;
79 |
--------------------------------------------------------------------------------
/components/modals/delete-message-modal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import qs from "query-string";
4 | import axios from "axios";
5 | import { useState } from "react";
6 |
7 | import {
8 | Dialog,
9 | DialogContent,
10 | DialogDescription,
11 | DialogFooter,
12 | DialogHeader,
13 | DialogTitle,
14 | } from "@/components/ui/dialog";
15 | import { useModal } from "@/hooks/use-modal-store";
16 | import { Button } from "../ui/button";
17 |
18 | const DeleteMessageModal = () => {
19 | const { isOpen, onClose, type, data } = useModal();
20 |
21 | const isModalOpen = isOpen && type === "deleteMessage";
22 | const { apiUrl, query } = data;
23 |
24 | const [isLoading, setIsLoading] = useState(false);
25 |
26 | const onClick = async () => {
27 | try {
28 | setIsLoading(true);
29 | const url = qs.stringifyUrl({
30 | url: apiUrl || "",
31 | query,
32 | });
33 |
34 | await axios.delete(url);
35 |
36 | onClose();
37 | } catch (error) {
38 | console.log(error);
39 | } finally {
40 | setIsLoading(false);
41 | }
42 | };
43 |
44 | return (
45 |
68 | );
69 | };
70 |
71 | export default DeleteMessageModal;
72 |
--------------------------------------------------------------------------------
/components/modals/delete-server-modal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import axios from "axios";
4 | import { useState } from "react";
5 | import { useRouter } from "next/navigation";
6 | import {
7 | Dialog,
8 | DialogContent,
9 | DialogDescription,
10 | DialogFooter,
11 | DialogHeader,
12 | DialogTitle,
13 | } from "@/components/ui/dialog";
14 |
15 | import { useModal } from "@/hooks/use-modal-store";
16 | import { Button } from "../ui/button";
17 |
18 | const DeleteServerModal = () => {
19 | const { onOpen, isOpen, onClose, type, data } = useModal();
20 | const router = useRouter();
21 |
22 | const isModalOpen = isOpen && type === "deleteServer";
23 | const { server } = data;
24 |
25 | const [isLoading, setIsLoading] = useState(false);
26 |
27 | const onClick = async () => {
28 | try {
29 | setIsLoading(true);
30 |
31 | await axios.delete(`/api/servers/${server?.id}`);
32 |
33 | onClose();
34 | router.refresh();
35 | router.push("/");
36 | } catch (error) {
37 | console.log(error);
38 | } finally {
39 | setIsLoading(false);
40 | }
41 | };
42 |
43 | return (
44 |
68 | );
69 | };
70 |
71 | export default DeleteServerModal;
72 |
--------------------------------------------------------------------------------
/components/modals/edit-channel-modal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import axios from "axios";
4 | import qs from "query-string";
5 | import * as z from "zod";
6 | import { zodResolver } from "@hookform/resolvers/zod";
7 | import { useForm } from "react-hook-form";
8 | import { useRouter } from "next/navigation";
9 | import { ChannelType } from "@prisma/client";
10 | import { useEffect } from "react";
11 |
12 | import {
13 | Dialog,
14 | DialogContent,
15 | DialogDescription,
16 | DialogFooter,
17 | DialogHeader,
18 | DialogTitle,
19 | } from "@/components/ui/dialog";
20 | import {
21 | Form,
22 | FormControl,
23 | FormField,
24 | FormItem,
25 | FormLabel,
26 | FormMessage,
27 | } from "@/components/ui/form";
28 | import { Button } from "@/components/ui/button";
29 | import { Input } from "@/components/ui/input";
30 | import { useModal } from "@/hooks/use-modal-store";
31 | import {
32 | Select,
33 | SelectContent,
34 | SelectItem,
35 | SelectTrigger,
36 | SelectValue,
37 | } from "@/components/ui/select";
38 |
39 | const formSchema = z.object({
40 | name: z
41 | .string()
42 | .min(3, { message: "Channel name must be at least 3 characters long." })
43 | .max(20)
44 | .refine((name) => name.toLowerCase() !== "general", {
45 | message: "You cannot use the name 'general', it is reserved.",
46 | }),
47 | type: z.nativeEnum(ChannelType),
48 | });
49 |
50 | const EditChannelModal = () => {
51 | const { isOpen, onClose, type, data } = useModal();
52 | const router = useRouter();
53 |
54 | const isModalOpen = isOpen && type === "editChannel";
55 | const { channel, server } = data;
56 |
57 | const form = useForm({
58 | resolver: zodResolver(formSchema),
59 | defaultValues: {
60 | name: "",
61 | type: channel?.type || ChannelType.TEXT,
62 | },
63 | });
64 |
65 | useEffect(() => {
66 | if (channel) {
67 | form.setValue("name", channel.name);
68 | form.setValue("type", channel.type);
69 | }
70 | }, [form, channel]);
71 |
72 | const isLoading = form.formState.isSubmitting;
73 |
74 | const onSubmit = async (values: z.infer) => {
75 | try {
76 | const url = qs.stringifyUrl({
77 | url: `/api/channels/${channel?.id}`,
78 | query: {
79 | serverId: server?.id,
80 | },
81 | });
82 | await axios.patch(url, values);
83 |
84 | form.reset();
85 | router.refresh();
86 | onClose();
87 | } catch (error) {
88 | console.log(error);
89 | }
90 | };
91 |
92 | const handleModalClose = () => {
93 | form.reset();
94 | onClose();
95 | };
96 |
97 | return (
98 |
177 | );
178 | };
179 |
180 | export default EditChannelModal;
181 |
--------------------------------------------------------------------------------
/components/modals/edit-server-modal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import axios from "axios";
4 | import * as z from "zod";
5 | import { zodResolver } from "@hookform/resolvers/zod";
6 | import { useForm } from "react-hook-form";
7 | import { useRouter } from "next/navigation";
8 | import { useEffect } from "react";
9 |
10 | import {
11 | Dialog,
12 | DialogContent,
13 | DialogDescription,
14 | DialogFooter,
15 | DialogHeader,
16 | DialogTitle,
17 | } from "@/components/ui/dialog";
18 | import {
19 | Form,
20 | FormControl,
21 | FormField,
22 | FormItem,
23 | FormLabel,
24 | FormMessage,
25 | } from "@/components/ui/form";
26 | import { Button } from "@/components/ui/button";
27 | import { Input } from "@/components/ui/input";
28 | import FileUpload from "@/components/file-upload";
29 | import { useModal } from "@/hooks/use-modal-store";
30 |
31 | const formSchema = z.object({
32 | name: z.string().min(1, {
33 | message: "Please enter a server name.",
34 | }),
35 | imageUrl: z.string().min(1, {
36 | message: "Please enter a server image URL.",
37 | }),
38 | });
39 |
40 | const EditServerModal = () => {
41 | const { isOpen, onClose, type, data } = useModal();
42 | const router = useRouter();
43 |
44 | const isModalOpen = isOpen && type === "editServer";
45 | const { server } = data;
46 |
47 | const form = useForm({
48 | resolver: zodResolver(formSchema),
49 | defaultValues: {
50 | name: "",
51 | imageUrl: "",
52 | },
53 | });
54 |
55 | useEffect(() => {
56 | if (server) {
57 | form.setValue("name", server.name);
58 | form.setValue("imageUrl", server.imageUrl);
59 | }
60 | }, [form, server]);
61 |
62 | const isLoading = form.formState.isSubmitting;
63 |
64 | const onSubmit = async (values: z.infer) => {
65 | try {
66 | await axios.patch(`/api/servers/${server?.id}`, values);
67 |
68 | form.reset();
69 | router.refresh();
70 | onClose();
71 | } catch (error) {
72 | console.log(error);
73 | }
74 | };
75 |
76 | const handleModalClose = () => {
77 | onClose();
78 | form.reset();
79 | };
80 |
81 | return (
82 |
147 | );
148 | };
149 |
150 | export default EditServerModal;
151 |
--------------------------------------------------------------------------------
/components/modals/initial-modal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import axios from "axios";
4 | import { useEffect, useState } from "react";
5 | import { z } from "zod";
6 | import { zodResolver } from "@hookform/resolvers/zod";
7 | import { useForm } from "react-hook-form";
8 | import { useRouter } from "next/navigation";
9 |
10 | import {
11 | Dialog,
12 | DialogContent,
13 | DialogDescription,
14 | DialogFooter,
15 | DialogHeader,
16 | DialogTitle,
17 | } from "@/components/ui/dialog";
18 | import {
19 | Form,
20 | FormControl,
21 | FormField,
22 | FormItem,
23 | FormLabel,
24 | FormMessage,
25 | } from "@/components/ui/form";
26 | import { Button } from "@/components/ui/button";
27 | import { Input } from "@/components/ui/input";
28 | import FileUpload from "@/components/file-upload";
29 |
30 | const formSchema = z.object({
31 | name: z
32 | .string()
33 | .min(1, {
34 | message: "Please enter a server name.",
35 | })
36 | .max(20),
37 | imageUrl: z.string().min(1, {
38 | message: "Please enter a server image URL.",
39 | }),
40 | });
41 |
42 | const InitialModal = () => {
43 | const [isMounted, setIsMounted] = useState(false);
44 |
45 | const router = useRouter();
46 |
47 | useEffect(() => {
48 | setIsMounted(true);
49 | }, []);
50 |
51 | const form = useForm({
52 | resolver: zodResolver(formSchema),
53 | defaultValues: {
54 | name: "",
55 | imageUrl: "",
56 | },
57 | });
58 |
59 | const isLoading = form.formState.isSubmitting;
60 |
61 | const onSubmit = async (values: z.infer) => {
62 | try {
63 | await axios.post("/api/servers", values);
64 |
65 | form.reset();
66 | router.refresh();
67 | window.location.reload();
68 | } catch (error) {
69 | console.log(error);
70 | }
71 | };
72 |
73 | if (!isMounted) {
74 | return null;
75 | }
76 |
77 | return (
78 |
144 | );
145 | };
146 |
147 | export default InitialModal;
148 |
--------------------------------------------------------------------------------
/components/modals/invite-modal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import axios from "axios";
4 | import { useState } from "react";
5 | import {
6 | Dialog,
7 | DialogContent,
8 | DialogDescription,
9 | DialogHeader,
10 | DialogTitle,
11 | } from "@/components/ui/dialog";
12 | import { Check, Copy, RefreshCw } from "lucide-react";
13 |
14 | import { useModal } from "@/hooks/use-modal-store";
15 | import { Input } from "../ui/input";
16 | import { Label } from "../ui/label";
17 | import { Button } from "../ui/button";
18 | import { useOrigin } from "@/hooks/use-origin";
19 |
20 | const InviteModal = () => {
21 | const { onOpen, isOpen, onClose, type, data } = useModal();
22 | const origin = useOrigin();
23 |
24 | const isModalOpen = isOpen && type === "invite";
25 | const { server } = data;
26 |
27 | const inviteUrl = `${origin}/invite/${server?.inviteCode}`;
28 |
29 | const [copied, setCopied] = useState(false);
30 | const [isLoading, setIsLoading] = useState(false);
31 |
32 | const onCopy = () => {
33 | navigator.clipboard.writeText(inviteUrl);
34 | setCopied(true);
35 |
36 | setTimeout(() => {
37 | setCopied(false);
38 | }, 2000);
39 | };
40 |
41 | const onNewInvite = async () => {
42 | try {
43 | setIsLoading(true);
44 | const response = await axios.patch(
45 | `/api/servers/${server?.id}/invite-code`
46 | );
47 |
48 | onOpen("invite", { server: response.data });
49 | } catch (error) {
50 | console.log(error);
51 | } finally {
52 | setIsLoading(false);
53 | }
54 | };
55 |
56 | return (
57 |
99 | );
100 | };
101 |
102 | export default InviteModal;
103 |
--------------------------------------------------------------------------------
/components/modals/leave-server-modal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import axios from "axios";
4 | import { useState } from "react";
5 | import { useRouter } from "next/navigation";
6 | import {
7 | Dialog,
8 | DialogContent,
9 | DialogDescription,
10 | DialogFooter,
11 | DialogHeader,
12 | DialogTitle,
13 | } from "@/components/ui/dialog";
14 |
15 | import { useModal } from "@/hooks/use-modal-store";
16 | import { Button } from "../ui/button";
17 |
18 | const LeaveServerModal = () => {
19 | const { onOpen, isOpen, onClose, type, data } = useModal();
20 | const router = useRouter();
21 |
22 | const isModalOpen = isOpen && type === "leaveServer";
23 | const { server } = data;
24 |
25 | const [isLoading, setIsLoading] = useState(false);
26 |
27 | const onClick = async () => {
28 | try {
29 | setIsLoading(true);
30 |
31 | await axios.patch(`/api/servers/${server?.id}/leave`);
32 |
33 | onClose();
34 | router.refresh();
35 | router.push("/");
36 | } catch (error) {
37 | console.log(error);
38 | } finally {
39 | setIsLoading(false);
40 | }
41 | };
42 |
43 | return (
44 |
68 | );
69 | };
70 |
71 | export default LeaveServerModal;
72 |
--------------------------------------------------------------------------------
/components/modals/message-file-modal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import axios from "axios";
4 | import qs from "query-string";
5 | import { z } from "zod";
6 | import { zodResolver } from "@hookform/resolvers/zod";
7 | import { useForm } from "react-hook-form";
8 | import { useRouter } from "next/navigation";
9 |
10 | import {
11 | Dialog,
12 | DialogContent,
13 | DialogDescription,
14 | DialogFooter,
15 | DialogHeader,
16 | DialogTitle,
17 | } from "@/components/ui/dialog";
18 | import {
19 | Form,
20 | FormControl,
21 | FormField,
22 | FormItem,
23 | FormMessage,
24 | } from "@/components/ui/form";
25 | import { Button } from "@/components/ui/button";
26 | import FileUpload from "@/components/file-upload";
27 | import { useModal } from "@/hooks/use-modal-store";
28 |
29 | const formSchema = z.object({
30 | fileUrl: z.string().min(1, {
31 | message: "Attachment is required.",
32 | }),
33 | });
34 |
35 | const MessageFileModal = () => {
36 | const { isOpen, onClose, type, data } = useModal();
37 | const router = useRouter();
38 |
39 | const isModalOpen = isOpen && type === "messageFile";
40 | const { apiUrl, query } = data;
41 |
42 | const form = useForm({
43 | resolver: zodResolver(formSchema),
44 | defaultValues: {
45 | fileUrl: "",
46 | },
47 | });
48 |
49 | const handleClose = () => {
50 | form.reset();
51 | onClose();
52 | };
53 |
54 | const isLoading = form.formState.isSubmitting;
55 |
56 | const onSubmit = async (values: z.infer) => {
57 | try {
58 | const url = qs.stringifyUrl({
59 | url: apiUrl || "",
60 | query,
61 | });
62 |
63 | await axios.post(url, {
64 | ...values,
65 | content: values.fileUrl,
66 | });
67 |
68 | form.reset();
69 | router.refresh();
70 | handleClose();
71 | } catch (error) {
72 | console.log(error);
73 | }
74 | };
75 |
76 | return (
77 |
118 | );
119 | };
120 |
121 | export default MessageFileModal;
122 |
--------------------------------------------------------------------------------
/components/mode-toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { Moon, Sun } from "lucide-react";
5 | import { useTheme } from "next-themes";
6 |
7 | import { Button } from "@/components/ui/button";
8 | import {
9 | DropdownMenu,
10 | DropdownMenuContent,
11 | DropdownMenuItem,
12 | DropdownMenuTrigger,
13 | } from "@/components/ui/dropdown-menu";
14 |
15 | export function ModeToggle() {
16 | const { setTheme } = useTheme();
17 |
18 | return (
19 |
20 |
21 |
30 |
31 |
32 | setTheme("light")}>
33 | Light
34 |
35 | setTheme("dark")}>
36 | Dark
37 |
38 | setTheme("system")}>
39 | System
40 |
41 |
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/components/navigation/navigation-action.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Plus } from "lucide-react";
4 | import { Button } from "../ui/button";
5 |
6 | import ActionTooltip from "../action-tooltip";
7 | import { useModal } from "@/hooks/use-modal-store";
8 |
9 | const NavigationAction = () => {
10 | const { onOpen } = useModal();
11 | return (
12 | <>
13 |
14 |
22 |
23 | >
24 | );
25 | };
26 |
27 | export default NavigationAction;
28 |
--------------------------------------------------------------------------------
/components/navigation/navigation-item.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Image from "next/image";
4 | import { useParams, useRouter } from "next/navigation";
5 |
6 | import { cn } from "@/lib/utils";
7 | import ActionTooltip from "../action-tooltip";
8 | import { Button } from "../ui/button";
9 |
10 | interface NavigationItemProps {
11 | id: string;
12 | imageUrl: string;
13 | name: string;
14 | }
15 |
16 | const NavigationItem = ({ id, imageUrl, name }: NavigationItemProps) => {
17 | const params = useParams();
18 | const router = useRouter();
19 |
20 | const handleClick = () => {
21 | router.push(`/servers/${id}`);
22 | };
23 |
24 | return (
25 |
26 |
27 |
50 |
51 |
52 | );
53 | };
54 |
55 | export default NavigationItem;
56 |
--------------------------------------------------------------------------------
/components/navigation/navigation-sidebar.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from "next/navigation";
2 |
3 | import { currentProfile } from "@/lib/current-profile";
4 | import { db } from "@/lib/db";
5 | import NavigationAction from "./navigation-action";
6 | import { Separator } from "../ui/separator";
7 | import { ScrollArea } from "../ui/scroll-area";
8 | import NavigationItem from "./navigation-item";
9 | import { ModeToggle } from "../mode-toggle";
10 | import { UserButton } from "@clerk/nextjs";
11 |
12 | const NavigationSidebar = async () => {
13 | const profile = await currentProfile();
14 |
15 | if (!profile) {
16 | return redirect("/");
17 | }
18 |
19 | const servers = await db.server.findMany({
20 | where: {
21 | members: {
22 | some: {
23 | profileId: profile.id,
24 | },
25 | },
26 | },
27 | });
28 |
29 | return (
30 |
31 |
32 |
33 |
34 | {servers.map((server) => (
35 |
36 |
41 |
42 | ))}
43 |
44 |
45 |
46 |
54 |
55 |
56 | );
57 | };
58 |
59 | export default NavigationSidebar;
60 |
--------------------------------------------------------------------------------
/components/providers/modal-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect, useState } from "react";
4 |
5 | import CreateServerModal from "@/components/modals/create-server-modal";
6 | import InviteModal from "@/components/modals/invite-modal";
7 | import EditServerModal from "../modals/edit-server-modal";
8 | import MembersModal from "../modals/members-modal";
9 | import CreateChannelModal from "../modals/create-channel-modal";
10 | import LeaveServerModal from "../modals/leave-server-modal";
11 | import DeleteServerModal from "../modals/delete-server-modal";
12 | import DeleteChannelModal from "../modals/delete-channel-modal";
13 | import EditChannelModal from "../modals/edit-channel-modal";
14 | import MessageFileModal from "../modals/message-file-modal";
15 | import DeleteMessageModal from "../modals/delete-message-modal";
16 |
17 | export const ModalProvider = () => {
18 | const [isMounted, setIsMounted] = useState(false);
19 |
20 | useEffect(() => {
21 | setIsMounted(true);
22 | }, []);
23 |
24 | if (!isMounted) {
25 | return null;
26 | }
27 |
28 | return (
29 | <>
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | >
42 | );
43 | };
44 |
--------------------------------------------------------------------------------
/components/providers/query-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4 | import { useState } from "react";
5 |
6 | export const QueryProvider = ({ children }: { children: React.ReactNode }) => {
7 | const [queryClient] = useState(() => new QueryClient());
8 |
9 | return (
10 | {children}
11 | );
12 | };
13 |
--------------------------------------------------------------------------------
/components/providers/socket-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { createContext, useContext, useEffect, useState } from "react";
4 | import { io as ClientIO } from "socket.io-client";
5 |
6 | type SocketContextType = {
7 | socket: any | null;
8 | isConnected: boolean;
9 | };
10 |
11 | const SocketContext = createContext({
12 | socket: null,
13 | isConnected: false,
14 | });
15 |
16 | export const useSocket = () => {
17 | return useContext(SocketContext);
18 | };
19 |
20 | export const SocketProvider = ({ children }: { children: React.ReactNode }) => {
21 | const [socket, setSocket] = useState(null);
22 | const [isConnected, setIsConnected] = useState(false);
23 |
24 | useEffect(() => {
25 | const socketInstance = new (ClientIO as any)(
26 | process.env.NEXT_PUBLIC_SITE_URL!,
27 | {
28 | path: "/api/socket/io",
29 | // addTrailingSlash: false,
30 | }
31 | );
32 |
33 | socketInstance.on("connect", () => {
34 | setIsConnected(true);
35 | });
36 |
37 | socketInstance.on("disconnect", () => {
38 | setIsConnected(false);
39 | });
40 |
41 | setSocket(socketInstance);
42 |
43 | return () => {
44 | socketInstance.disconnect();
45 | };
46 | }, []);
47 |
48 | return (
49 |
50 | {children}
51 |
52 | );
53 | };
54 |
--------------------------------------------------------------------------------
/components/providers/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { ThemeProvider as NextThemesProvider } from "next-themes";
5 | import { type ThemeProviderProps } from "next-themes/dist/types";
6 |
7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
8 | return {children};
9 | }
10 |
--------------------------------------------------------------------------------
/components/server/server-channel.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Channel, ChannelType, MemberRole, Server } from "@prisma/client";
4 | import { Edit, Hash, Lock, Mic, Trash, Video } from "lucide-react";
5 | import { useParams, useRouter } from "next/navigation";
6 |
7 | import { cn } from "@/lib/utils";
8 | import ActionTooltip from "@/components/action-tooltip";
9 | import { ModalType, useModal } from "@/hooks/use-modal-store";
10 |
11 | interface ServerChannelProps {
12 | channel: Channel;
13 | server: Server;
14 | role?: MemberRole;
15 | }
16 |
17 | const iconMap = {
18 | [ChannelType.TEXT]: Hash,
19 | [ChannelType.AUDIO]: Mic,
20 | [ChannelType.VIDEO]: Video,
21 | };
22 |
23 | export const ServerChannel = ({
24 | channel,
25 | server,
26 | role,
27 | }: ServerChannelProps) => {
28 | const { onOpen } = useModal();
29 | const params = useParams();
30 | const router = useRouter();
31 |
32 | const Icon = iconMap[channel.type];
33 |
34 | const onClick = () => {
35 | router.push(`/servers/${params?.serverId}/channels/${channel.id}`);
36 | };
37 |
38 | const onAction = (e: React.MouseEvent, action: ModalType) => {
39 | e.stopPropagation();
40 | onOpen(action, { channel, server });
41 | };
42 |
43 | return (
44 |
82 | );
83 | };
84 |
85 | export default ServerChannel;
86 |
--------------------------------------------------------------------------------
/components/server/server-header.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { ServerWithMembersWithProfiles } from "@/types";
3 | import { MemberRole } from "@prisma/client";
4 | import {
5 | ChevronDown,
6 | LogOut,
7 | PlusCircle,
8 | Settings,
9 | Trash,
10 | UserPlus,
11 | Users,
12 | } from "lucide-react";
13 |
14 | import {
15 | DropdownMenu,
16 | DropdownMenuContent,
17 | DropdownMenuItem,
18 | DropdownMenuSeparator,
19 | DropdownMenuTrigger,
20 | } from "@/components/ui/dropdown-menu";
21 | import { Button } from "../ui/button";
22 | import { useModal } from "@/hooks/use-modal-store";
23 |
24 | interface ServerHeaderProps {
25 | server: ServerWithMembersWithProfiles;
26 | role?: MemberRole;
27 | }
28 |
29 | const ServerHeader = ({ server, role }: ServerHeaderProps) => {
30 | const { onOpen } = useModal();
31 | const isAdmin = role === MemberRole.ADMIN;
32 | const isModerator = isAdmin || role === MemberRole.MODERATOR;
33 |
34 | return (
35 |
36 |
37 |
45 |
46 |
47 | {isModerator && (
48 | onOpen("invite", { server })}
50 | className="px-3 py-2 text-sm cursor-pointer"
51 | >
52 | Invite People
53 |
54 |
55 | )}
56 | {isAdmin && (
57 | onOpen("editServer", { server })}
59 | className="px-3 py-2 text-sm cursor-pointer"
60 | >
61 | Server Settings
62 |
63 |
64 | )}
65 | {isAdmin && (
66 | onOpen("members", { server })}
68 | className="px-3 py-2 text-sm cursor-pointer"
69 | >
70 | Manage Members
71 |
72 |
73 | )}
74 | {isModerator && (
75 | onOpen("createChannel")}
77 | className="px-3 py-2 text-sm cursor-pointer"
78 | >
79 | Create Channel
80 |
81 |
82 | )}
83 | {isModerator && }
84 | {isAdmin && (
85 | onOpen("deleteServer", { server })}
87 | className="text-red-600 px-3 py-2 text-sm cursor-pointer"
88 | >
89 | Delete Server
90 |
91 |
92 | )}
93 | {!isAdmin && (
94 | onOpen("leaveServer", { server })}
96 | className="text-red-600 px-3 py-2 text-sm cursor-pointer"
97 | >
98 | Leave Server
99 |
100 |
101 | )}
102 |
103 |
104 | );
105 | };
106 |
107 | export default ServerHeader;
108 |
--------------------------------------------------------------------------------
/components/server/server-member.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Member, MemberRole, Profile, Server } from "@prisma/client";
4 | import { useParams, useRouter } from "next/navigation";
5 | import { ShieldAlert, ShieldCheck } from "lucide-react";
6 |
7 | import { cn } from "@/lib/utils";
8 | import UserAvatar from "../user-avatar";
9 |
10 | interface ServerMemberProps {
11 | member: Member & { profile: Profile };
12 | server: Server;
13 | }
14 |
15 | const roleIconMap = {
16 | [MemberRole.GUEST]: null,
17 | [MemberRole.MODERATOR]: (
18 |
19 | ),
20 | [MemberRole.ADMIN]: ,
21 | };
22 |
23 | const ServerMember = ({ member, server }: ServerMemberProps) => {
24 | const params = useParams();
25 | const router = useRouter();
26 |
27 | const icon = roleIconMap[member.role];
28 |
29 | const onClick = () => {
30 | router.push(`/servers/${params?.serverId}/conversations/${member.id}`);
31 | };
32 |
33 | return (
34 |
56 | );
57 | };
58 |
59 | export default ServerMember;
60 |
--------------------------------------------------------------------------------
/components/server/server-search.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Search } from "lucide-react";
4 | import { useEffect, useState } from "react";
5 | import { useParams, useRouter } from "next/navigation";
6 |
7 | import {
8 | CommandDialog,
9 | CommandEmpty,
10 | CommandGroup,
11 | CommandInput,
12 | CommandItem,
13 | CommandList,
14 | } from "../ui/command";
15 |
16 | interface ServerSearchProps {
17 | data: {
18 | label: string;
19 | type: "channel" | "member";
20 | data:
21 | | {
22 | icon: React.ReactNode;
23 | name: string;
24 | id: string;
25 | }[]
26 | | undefined;
27 | }[];
28 | }
29 |
30 | const ServerSearch = ({ data }: ServerSearchProps) => {
31 | const [open, setOpen] = useState(false);
32 | const router = useRouter();
33 | const params = useParams();
34 |
35 | useEffect(() => {
36 | const down = (e: KeyboardEvent) => {
37 | if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
38 | e.preventDefault();
39 | setOpen((open) => !open);
40 | }
41 | };
42 |
43 | document.addEventListener("keydown", down);
44 | return () => document.removeEventListener("keydown", down);
45 | }, []);
46 |
47 | const onClick = ({
48 | id,
49 | type,
50 | }: {
51 | id: string;
52 | type: "channel" | "member";
53 | }) => {
54 | setOpen(false);
55 |
56 | if (type === "member") {
57 | return router.push(`/servers/${params?.serverId}/conversations/${id}`);
58 | }
59 |
60 | if (type === "channel") {
61 | return router.push(`/servers/${params?.serverId}/channels/${id}`);
62 | }
63 | };
64 |
65 | return (
66 | <>
67 |
79 |
80 |
81 |
82 | No Results found
83 | {data.map(({ label, type, data }) => {
84 | if (!data?.length) return null;
85 |
86 | return (
87 |
88 | {data?.map(({ id, icon, name }) => {
89 | return (
90 | onClick({ id, type })}
93 | >
94 | {icon}
95 | {name}
96 |
97 | );
98 | })}
99 |
100 | );
101 | })}
102 |
103 |
104 | >
105 | );
106 | };
107 |
108 | export default ServerSearch;
109 |
--------------------------------------------------------------------------------
/components/server/server-section.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ChannelType, MemberRole } from "@prisma/client";
4 | import { Plus, Settings } from "lucide-react";
5 |
6 | import { ServerWithMembersWithProfiles } from "@/types";
7 | import ActionTooltip from "../action-tooltip";
8 | import { useModal } from "@/hooks/use-modal-store";
9 |
10 | interface ServerSectionProps {
11 | label: string;
12 | role?: MemberRole;
13 | sectionType: "channels" | "members";
14 | server?: ServerWithMembersWithProfiles;
15 | channelType?: ChannelType;
16 | }
17 |
18 | const ServerSection = ({
19 | label,
20 | role,
21 | sectionType,
22 | server,
23 | channelType,
24 | }: ServerSectionProps) => {
25 | const { onOpen } = useModal();
26 |
27 | return (
28 |
29 |
{label}
30 | {role !== MemberRole.GUEST && sectionType === "channels" && (
31 |
32 |
38 |
39 | )}
40 | {role === MemberRole.ADMIN && sectionType === "members" && (
41 |
42 |
48 |
49 | )}
50 |
51 | );
52 | };
53 |
54 | export default ServerSection;
55 |
--------------------------------------------------------------------------------
/components/socket-indicator.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useSocket } from "@/components/providers/socket-provider";
4 | import { Badge } from "@/components/ui/badge";
5 |
6 | export const SocketIndicator = () => {
7 | const { isConnected } = useSocket();
8 |
9 | if (!isConnected) {
10 | return (
11 | <>
12 |
16 | Fallback:
17 |
18 | Polling every 1s
19 | >
20 | );
21 | }
22 |
23 | return (
24 | <>
25 |
29 | Live:
30 |
31 | Real-time updates
32 | >
33 | );
34 | };
35 |
--------------------------------------------------------------------------------
/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 | primary: "bg-blue-500 text-white hover:bg-blue-500/90",
22 | },
23 | size: {
24 | default: "h-10 px-4 py-2",
25 | sm: "h-9 rounded-md px-3",
26 | lg: "h-11 rounded-md px-8",
27 | icon: "h-10 w-10",
28 | },
29 | },
30 | defaultVariants: {
31 | variant: "default",
32 | size: "default",
33 | },
34 | }
35 | );
36 |
37 | export interface ButtonProps
38 | extends React.ButtonHTMLAttributes,
39 | VariantProps {
40 | asChild?: boolean;
41 | }
42 |
43 | const Button = React.forwardRef(
44 | ({ className, variant, size, asChild = false, ...props }, ref) => {
45 | const Comp = asChild ? Slot : "button";
46 | return (
47 |
52 | );
53 | }
54 | );
55 | Button.displayName = "Button";
56 |
57 | export { Button, buttonVariants };
58 |
--------------------------------------------------------------------------------
/components/ui/command.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { type DialogProps } from "@radix-ui/react-dialog"
5 | import { Command as CommandPrimitive } from "cmdk"
6 | import { Search } from "lucide-react"
7 |
8 | import { cn } from "@/lib/utils"
9 | import { Dialog, DialogContent } from "@/components/ui/dialog"
10 |
11 | const Command = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef
14 | >(({ className, ...props }, ref) => (
15 |
23 | ))
24 | Command.displayName = CommandPrimitive.displayName
25 |
26 | interface CommandDialogProps extends DialogProps {}
27 |
28 | const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
29 | return (
30 |
37 | )
38 | }
39 |
40 | const CommandInput = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef
43 | >(({ className, ...props }, ref) => (
44 |
45 |
46 |
54 |
55 | ))
56 |
57 | CommandInput.displayName = CommandPrimitive.Input.displayName
58 |
59 | const CommandList = React.forwardRef<
60 | React.ElementRef,
61 | React.ComponentPropsWithoutRef
62 | >(({ className, ...props }, ref) => (
63 |
68 | ))
69 |
70 | CommandList.displayName = CommandPrimitive.List.displayName
71 |
72 | const CommandEmpty = React.forwardRef<
73 | React.ElementRef,
74 | React.ComponentPropsWithoutRef
75 | >((props, ref) => (
76 |
81 | ))
82 |
83 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName
84 |
85 | const CommandGroup = React.forwardRef<
86 | React.ElementRef,
87 | React.ComponentPropsWithoutRef
88 | >(({ className, ...props }, ref) => (
89 |
97 | ))
98 |
99 | CommandGroup.displayName = CommandPrimitive.Group.displayName
100 |
101 | const CommandSeparator = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ))
111 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName
112 |
113 | const CommandItem = React.forwardRef<
114 | React.ElementRef,
115 | React.ComponentPropsWithoutRef
116 | >(({ className, ...props }, ref) => (
117 |
125 | ))
126 |
127 | CommandItem.displayName = CommandPrimitive.Item.displayName
128 |
129 | const CommandShortcut = ({
130 | className,
131 | ...props
132 | }: React.HTMLAttributes) => {
133 | return (
134 |
141 | )
142 | }
143 | CommandShortcut.displayName = "CommandShortcut"
144 |
145 | export {
146 | Command,
147 | CommandDialog,
148 | CommandInput,
149 | CommandList,
150 | CommandEmpty,
151 | CommandGroup,
152 | CommandItem,
153 | CommandShortcut,
154 | CommandSeparator,
155 | }
156 |
--------------------------------------------------------------------------------
/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/form.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as LabelPrimitive from "@radix-ui/react-label"
3 | import { Slot } from "@radix-ui/react-slot"
4 | import {
5 | Controller,
6 | ControllerProps,
7 | FieldPath,
8 | FieldValues,
9 | FormProvider,
10 | useFormContext,
11 | } from "react-hook-form"
12 |
13 | import { cn } from "@/lib/utils"
14 | import { Label } from "@/components/ui/label"
15 |
16 | const Form = FormProvider
17 |
18 | type FormFieldContextValue<
19 | TFieldValues extends FieldValues = FieldValues,
20 | TName extends FieldPath = FieldPath
21 | > = {
22 | name: TName
23 | }
24 |
25 | const FormFieldContext = React.createContext(
26 | {} as FormFieldContextValue
27 | )
28 |
29 | const FormField = <
30 | TFieldValues extends FieldValues = FieldValues,
31 | TName extends FieldPath = FieldPath
32 | >({
33 | ...props
34 | }: ControllerProps) => {
35 | return (
36 |
37 |
38 |
39 | )
40 | }
41 |
42 | const useFormField = () => {
43 | const fieldContext = React.useContext(FormFieldContext)
44 | const itemContext = React.useContext(FormItemContext)
45 | const { getFieldState, formState } = useFormContext()
46 |
47 | const fieldState = getFieldState(fieldContext.name, formState)
48 |
49 | if (!fieldContext) {
50 | throw new Error("useFormField should be used within ")
51 | }
52 |
53 | const { id } = itemContext
54 |
55 | return {
56 | id,
57 | name: fieldContext.name,
58 | formItemId: `${id}-form-item`,
59 | formDescriptionId: `${id}-form-item-description`,
60 | formMessageId: `${id}-form-item-message`,
61 | ...fieldState,
62 | }
63 | }
64 |
65 | type FormItemContextValue = {
66 | id: string
67 | }
68 |
69 | const FormItemContext = React.createContext(
70 | {} as FormItemContextValue
71 | )
72 |
73 | const FormItem = React.forwardRef<
74 | HTMLDivElement,
75 | React.HTMLAttributes
76 | >(({ className, ...props }, ref) => {
77 | const id = React.useId()
78 |
79 | return (
80 |
81 |
82 |
83 | )
84 | })
85 | FormItem.displayName = "FormItem"
86 |
87 | const FormLabel = React.forwardRef<
88 | React.ElementRef,
89 | React.ComponentPropsWithoutRef
90 | >(({ className, ...props }, ref) => {
91 | const { error, formItemId } = useFormField()
92 |
93 | return (
94 |
100 | )
101 | })
102 | FormLabel.displayName = "FormLabel"
103 |
104 | const FormControl = React.forwardRef<
105 | React.ElementRef,
106 | React.ComponentPropsWithoutRef
107 | >(({ ...props }, ref) => {
108 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
109 |
110 | return (
111 |
122 | )
123 | })
124 | FormControl.displayName = "FormControl"
125 |
126 | const FormDescription = React.forwardRef<
127 | HTMLParagraphElement,
128 | React.HTMLAttributes
129 | >(({ className, ...props }, ref) => {
130 | const { formDescriptionId } = useFormField()
131 |
132 | return (
133 |
139 | )
140 | })
141 | FormDescription.displayName = "FormDescription"
142 |
143 | const FormMessage = React.forwardRef<
144 | HTMLParagraphElement,
145 | React.HTMLAttributes
146 | >(({ className, children, ...props }, ref) => {
147 | const { error, formMessageId } = useFormField()
148 | const body = error ? String(error?.message) : children
149 |
150 | if (!body) {
151 | return null
152 | }
153 |
154 | return (
155 |
161 | {body}
162 |
163 | )
164 | })
165 | FormMessage.displayName = "FormMessage"
166 |
167 | export {
168 | useFormField,
169 | Form,
170 | FormItem,
171 | FormLabel,
172 | FormControl,
173 | FormDescription,
174 | FormMessage,
175 | FormField,
176 | }
177 |
--------------------------------------------------------------------------------
/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/select.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as SelectPrimitive from "@radix-ui/react-select";
5 | import { Check, ChevronDown, ChevronUp } from "lucide-react";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const Select = SelectPrimitive.Root;
10 |
11 | const SelectGroup = SelectPrimitive.Group;
12 |
13 | const SelectValue = SelectPrimitive.Value;
14 |
15 | const SelectTrigger = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, children, ...props }, ref) => (
19 | span]:line-clamp-1",
23 | className
24 | )}
25 | {...props}
26 | >
27 | {children}
28 |
29 |
30 |
31 |
32 | ));
33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
34 |
35 | const SelectScrollUpButton = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 |
48 |
49 | ));
50 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
51 |
52 | const SelectScrollDownButton = React.forwardRef<
53 | React.ElementRef,
54 | React.ComponentPropsWithoutRef
55 | >(({ className, ...props }, ref) => (
56 |
64 |
65 |
66 | ));
67 | SelectScrollDownButton.displayName =
68 | SelectPrimitive.ScrollDownButton.displayName;
69 |
70 | const SelectContent = React.forwardRef<
71 | React.ElementRef,
72 | React.ComponentPropsWithoutRef
73 | >(({ className, children, position = "popper", ...props }, ref) => (
74 |
75 |
86 |
87 |
94 | {children}
95 |
96 |
97 |
98 |
99 | ));
100 | SelectContent.displayName = SelectPrimitive.Content.displayName;
101 |
102 | const SelectLabel = React.forwardRef<
103 | React.ElementRef,
104 | React.ComponentPropsWithoutRef
105 | >(({ className, ...props }, ref) => (
106 |
111 | ));
112 | SelectLabel.displayName = SelectPrimitive.Label.displayName;
113 |
114 | const SelectItem = React.forwardRef<
115 | React.ElementRef,
116 | React.ComponentPropsWithoutRef
117 | >(({ className, children, ...props }, ref) => (
118 |
126 |
127 |
128 |
129 |
130 |
131 |
132 | {children}
133 |
134 | ));
135 | SelectItem.displayName = SelectPrimitive.Item.displayName;
136 |
137 | const SelectSeparator = React.forwardRef<
138 | React.ElementRef,
139 | React.ComponentPropsWithoutRef
140 | >(({ className, ...props }, ref) => (
141 |
146 | ));
147 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
148 |
149 | export {
150 | Select,
151 | SelectGroup,
152 | SelectValue,
153 | SelectTrigger,
154 | SelectContent,
155 | SelectLabel,
156 | SelectItem,
157 | SelectSeparator,
158 | SelectScrollUpButton,
159 | SelectScrollDownButton,
160 | };
161 |
--------------------------------------------------------------------------------
/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SeparatorPrimitive from "@radix-ui/react-separator"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Separator = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(
12 | (
13 | { className, orientation = "horizontal", decorative = true, ...props },
14 | ref
15 | ) => (
16 |
27 | )
28 | )
29 | Separator.displayName = SeparatorPrimitive.Root.displayName
30 |
31 | export { Separator }
32 |
--------------------------------------------------------------------------------
/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SheetPrimitive from "@radix-ui/react-dialog"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 | import { X } from "lucide-react"
7 |
8 | import { cn } from "@/lib/utils"
9 |
10 | const Sheet = SheetPrimitive.Root
11 |
12 | const SheetTrigger = SheetPrimitive.Trigger
13 |
14 | const SheetClose = SheetPrimitive.Close
15 |
16 | const SheetPortal = SheetPrimitive.Portal
17 |
18 | const SheetOverlay = React.forwardRef<
19 | React.ElementRef,
20 | React.ComponentPropsWithoutRef
21 | >(({ className, ...props }, ref) => (
22 |
30 | ))
31 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
32 |
33 | const sheetVariants = cva(
34 | "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
35 | {
36 | variants: {
37 | side: {
38 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
39 | bottom:
40 | "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
41 | left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
42 | right:
43 | "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
44 | },
45 | },
46 | defaultVariants: {
47 | side: "right",
48 | },
49 | }
50 | )
51 |
52 | interface SheetContentProps
53 | extends React.ComponentPropsWithoutRef,
54 | VariantProps {}
55 |
56 | const SheetContent = React.forwardRef<
57 | React.ElementRef,
58 | SheetContentProps
59 | >(({ side = "right", className, children, ...props }, ref) => (
60 |
61 |
62 |
67 | {children}
68 |
69 |
70 | Close
71 |
72 |
73 |
74 | ))
75 | SheetContent.displayName = SheetPrimitive.Content.displayName
76 |
77 | const SheetHeader = ({
78 | className,
79 | ...props
80 | }: React.HTMLAttributes) => (
81 |
88 | )
89 | SheetHeader.displayName = "SheetHeader"
90 |
91 | const SheetFooter = ({
92 | className,
93 | ...props
94 | }: React.HTMLAttributes) => (
95 |
102 | )
103 | SheetFooter.displayName = "SheetFooter"
104 |
105 | const SheetTitle = React.forwardRef<
106 | React.ElementRef,
107 | React.ComponentPropsWithoutRef
108 | >(({ className, ...props }, ref) => (
109 |
114 | ))
115 | SheetTitle.displayName = SheetPrimitive.Title.displayName
116 |
117 | const SheetDescription = React.forwardRef<
118 | React.ElementRef,
119 | React.ComponentPropsWithoutRef
120 | >(({ className, ...props }, ref) => (
121 |
126 | ))
127 | SheetDescription.displayName = SheetPrimitive.Description.displayName
128 |
129 | export {
130 | Sheet,
131 | SheetPortal,
132 | SheetOverlay,
133 | SheetTrigger,
134 | SheetClose,
135 | SheetContent,
136 | SheetHeader,
137 | SheetFooter,
138 | SheetTitle,
139 | SheetDescription,
140 | }
141 |
--------------------------------------------------------------------------------
/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const TooltipProvider = TooltipPrimitive.Provider
9 |
10 | const Tooltip = TooltipPrimitive.Root
11 |
12 | const TooltipTrigger = TooltipPrimitive.Trigger
13 |
14 | const TooltipContent = React.forwardRef<
15 | React.ElementRef,
16 | React.ComponentPropsWithoutRef
17 | >(({ className, sideOffset = 4, ...props }, ref) => (
18 |
27 | ))
28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
29 |
30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
31 |
--------------------------------------------------------------------------------
/components/user-avatar.tsx:
--------------------------------------------------------------------------------
1 | import { Avatar, AvatarImage } from "@/components/ui/avatar";
2 | import { cn } from "@/lib/utils";
3 |
4 | interface UserAvatarProps {
5 | src?: string;
6 | className?: string;
7 | }
8 |
9 | const UserAvatar = ({ src, className }: UserAvatarProps) => {
10 | return (
11 |
12 |
13 |
14 | );
15 | };
16 |
17 | export default UserAvatar;
18 |
--------------------------------------------------------------------------------
/hooks/use-chat-query.ts:
--------------------------------------------------------------------------------
1 | import qs from "query-string";
2 | import { useInfiniteQuery } from "@tanstack/react-query";
3 |
4 | import { useSocket } from "@/components/providers/socket-provider";
5 |
6 | interface ChatQueryProps {
7 | queryKey: string;
8 | apiUrl: string;
9 | paramKey: "channelId" | "conversationId";
10 | paramValue: string;
11 | }
12 |
13 | export const useChatQuery = ({
14 | queryKey,
15 | apiUrl,
16 | paramKey,
17 | paramValue,
18 | }: ChatQueryProps) => {
19 | const { isConnected } = useSocket();
20 |
21 | const fetchMessages = async ({ pageParam = undefined }) => {
22 | const url = qs.stringifyUrl(
23 | {
24 | url: apiUrl,
25 | query: {
26 | cursor: pageParam,
27 | [paramKey]: paramValue,
28 | },
29 | },
30 | { skipNull: true }
31 | );
32 |
33 | const res = await fetch(url);
34 | return res.json();
35 | };
36 |
37 | const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status } =
38 | useInfiniteQuery({
39 | queryKey: [queryKey],
40 | queryFn: fetchMessages,
41 | getNextPageParam: (lastPage) => lastPage?.nextCursor,
42 | refetchInterval: isConnected ? false : 1000,
43 | initialPageParam: undefined,
44 | });
45 |
46 | return {
47 | data,
48 | fetchNextPage,
49 | hasNextPage,
50 | isFetchingNextPage,
51 | status,
52 | };
53 | };
54 |
--------------------------------------------------------------------------------
/hooks/use-chat-scroll.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | type ChatScrollProps = {
4 | chatRef: React.RefObject;
5 | bottomRef: React.RefObject;
6 | shouldLoadMore: boolean;
7 | loadMore: () => void;
8 | count: number;
9 | };
10 |
11 | export const useChatScroll = ({
12 | chatRef,
13 | bottomRef,
14 | shouldLoadMore,
15 | loadMore,
16 | count,
17 | }: ChatScrollProps) => {
18 | const [hasInitialized, setHasInitialized] = useState(false);
19 |
20 | useEffect(() => {
21 | const topDiv = chatRef?.current;
22 |
23 | const handleScroll = () => {
24 | const scrollTop = topDiv?.scrollTop;
25 |
26 | if (scrollTop === 0 && shouldLoadMore) {
27 | loadMore();
28 | }
29 | };
30 |
31 | topDiv?.addEventListener("scroll", handleScroll);
32 |
33 | return () => {
34 | topDiv?.removeEventListener("scroll", handleScroll);
35 | };
36 | }, [shouldLoadMore, loadMore, chatRef]);
37 |
38 | useEffect(() => {
39 | const bottomDiv = bottomRef?.current;
40 | const topDiv = chatRef.current;
41 | const shouldAutoScroll = () => {
42 | if (!hasInitialized && bottomDiv) {
43 | setHasInitialized(true);
44 | return true;
45 | }
46 |
47 | if (!topDiv) {
48 | return false;
49 | }
50 |
51 | const distanceFromBottom =
52 | topDiv.scrollHeight - topDiv.scrollTop - topDiv.clientHeight;
53 | return distanceFromBottom <= 100;
54 | };
55 |
56 | if (shouldAutoScroll()) {
57 | setTimeout(() => {
58 | bottomRef.current?.scrollIntoView({
59 | behavior: "smooth",
60 | });
61 | }, 100);
62 | }
63 | }, [bottomRef, chatRef, count, hasInitialized]);
64 | };
65 |
--------------------------------------------------------------------------------
/hooks/use-chat-socket.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 | import { useQueryClient } from "@tanstack/react-query";
3 | import { Member, Message, Profile } from "@prisma/client";
4 |
5 | import { useSocket } from "@/components/providers/socket-provider";
6 |
7 | type ChatSocketProps = {
8 | addKey: string;
9 | updateKey: string;
10 | queryKey: string;
11 | };
12 |
13 | type MessageWithMemberWithProfile = Message & {
14 | member: Member & {
15 | profile: Profile;
16 | };
17 | };
18 |
19 | export const useChatSocket = ({
20 | addKey,
21 | updateKey,
22 | queryKey,
23 | }: ChatSocketProps) => {
24 | const { socket } = useSocket();
25 | const queryClient = useQueryClient();
26 |
27 | useEffect(() => {
28 | if (!socket) {
29 | return;
30 | }
31 |
32 | socket.on(updateKey, (message: MessageWithMemberWithProfile) => {
33 | queryClient.setQueryData([queryKey], (oldData: any) => {
34 | if (!oldData || !oldData.pages || oldData.pages.length === 0) {
35 | return oldData;
36 | }
37 |
38 | const newData = oldData.pages.map((page: any) => {
39 | return {
40 | ...page,
41 | items: page.items.map((item: MessageWithMemberWithProfile) => {
42 | if (item.id === message.id) {
43 | return message;
44 | }
45 | return item;
46 | }),
47 | };
48 | });
49 |
50 | return {
51 | ...oldData,
52 | pages: newData,
53 | };
54 | });
55 | });
56 |
57 | socket.on(addKey, (message: MessageWithMemberWithProfile) => {
58 | queryClient.setQueryData([queryKey], (oldData: any) => {
59 | if (!oldData || !oldData.pages || oldData.pages.length === 0) {
60 | return {
61 | pages: [
62 | {
63 | items: [message],
64 | },
65 | ],
66 | };
67 | }
68 |
69 | const newData = [...oldData.pages];
70 |
71 | newData[0] = {
72 | ...newData[0],
73 | items: [message, ...newData[0].items],
74 | };
75 |
76 | return {
77 | ...oldData,
78 | pages: newData,
79 | };
80 | });
81 | });
82 |
83 | return () => {
84 | socket.off(addKey);
85 | socket.off(updateKey);
86 | };
87 | }, [queryClient, addKey, queryKey, socket, updateKey]);
88 | };
89 |
--------------------------------------------------------------------------------
/hooks/use-modal-store.ts:
--------------------------------------------------------------------------------
1 | import { Channel, ChannelType, Server } from "@prisma/client";
2 | import { create } from "zustand";
3 |
4 | export type ModalType =
5 | | "createServer"
6 | | "invite"
7 | | "editServer"
8 | | "members"
9 | | "createChannel"
10 | | "leaveServer"
11 | | "deleteServer"
12 | | "deleteChannel"
13 | | "editChannel"
14 | | "messageFile"
15 | | "deleteMessage";
16 |
17 | interface ModalData {
18 | server?: Server;
19 | channel?: Channel;
20 | channelType?: ChannelType;
21 | apiUrl?: string;
22 | query?: Record;
23 | }
24 |
25 | interface ModalStore {
26 | type: ModalType | null;
27 | data: ModalData;
28 | isOpen: boolean;
29 | onOpen: (type: ModalType, data?: ModalData) => void;
30 | onClose: () => void;
31 | }
32 |
33 | export const useModal = create((set) => ({
34 | type: null,
35 | data: {},
36 | isOpen: false,
37 | onOpen: (type, data = {}) => set({ isOpen: true, type, data }),
38 | onClose: () => set({ type: null, isOpen: false }),
39 | }));
40 |
--------------------------------------------------------------------------------
/hooks/use-origin.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect, useState } from "react";
4 |
5 | export const useOrigin = () => {
6 | const [mounted, setMounted] = useState(false);
7 |
8 | useEffect(() => {
9 | setMounted(true);
10 | }, []);
11 |
12 | const origin =
13 | typeof window !== "undefined" && window.location.origin
14 | ? window.location.origin
15 | : "";
16 |
17 | if (!mounted) return "";
18 |
19 | return origin;
20 | };
21 |
--------------------------------------------------------------------------------
/lib/conversation.ts:
--------------------------------------------------------------------------------
1 | import { db } from "@/lib/db";
2 |
3 | export const getOrCreateConversation = async (
4 | memberOneId: string,
5 | memberTwoId: string
6 | ) => {
7 | let conversation =
8 | (await findConversation(memberOneId, memberTwoId)) ||
9 | (await findConversation(memberTwoId, memberOneId));
10 |
11 | if (!conversation) {
12 | conversation = await createNewConversation(memberOneId, memberTwoId);
13 | }
14 |
15 | return conversation;
16 | };
17 |
18 | const findConversation = async (memberOneId: string, memberTwoId: string) => {
19 | try {
20 | return await db.conversation.findFirst({
21 | where: {
22 | AND: [{ memberOneId: memberOneId }, { memberTwoId: memberTwoId }],
23 | },
24 | include: {
25 | memberOne: {
26 | include: {
27 | profile: true,
28 | },
29 | },
30 | memberTwo: {
31 | include: {
32 | profile: true,
33 | },
34 | },
35 | },
36 | });
37 | } catch {
38 | return null;
39 | }
40 | };
41 |
42 | const createNewConversation = async (
43 | memberOneId: string,
44 | memberTwoId: string
45 | ) => {
46 | try {
47 | return await db.conversation.create({
48 | data: {
49 | memberOneId,
50 | memberTwoId,
51 | },
52 | include: {
53 | memberOne: {
54 | include: {
55 | profile: true,
56 | },
57 | },
58 | memberTwo: {
59 | include: {
60 | profile: true,
61 | },
62 | },
63 | },
64 | });
65 | } catch {
66 | return null;
67 | }
68 | };
69 |
--------------------------------------------------------------------------------
/lib/current-profile-pages.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest } from "next";
2 | import { getAuth } from "@clerk/nextjs/server";
3 |
4 | import { db } from "@/lib/db";
5 |
6 | export const currentProfilePages = async (req: NextApiRequest) => {
7 | const { userId } = getAuth(req);
8 |
9 | if (!userId) {
10 | return null;
11 | }
12 |
13 | const profile = await db.profile.findUnique({
14 | where: {
15 | userId,
16 | },
17 | });
18 |
19 | return profile;
20 | };
21 |
--------------------------------------------------------------------------------
/lib/current-profile.ts:
--------------------------------------------------------------------------------
1 | import { auth } from "@clerk/nextjs";
2 |
3 | import { db } from "@/lib/db";
4 |
5 | export const currentProfile = async () => {
6 | const { userId } = auth();
7 |
8 | if (!userId) {
9 | return null;
10 | }
11 |
12 | const profile = await db.profile.findUnique({
13 | where: {
14 | userId,
15 | },
16 | });
17 |
18 | return profile;
19 | };
20 |
--------------------------------------------------------------------------------
/lib/db.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client";
2 |
3 | declare global {
4 | var prisma: PrismaClient | undefined;
5 | }
6 |
7 | export const db = global.prisma || new PrismaClient();
8 |
9 | if (process.env.NODE_ENV !== "production") global.prisma = db;
10 |
--------------------------------------------------------------------------------
/lib/initial-profile.ts:
--------------------------------------------------------------------------------
1 | import { currentUser, redirectToSignIn } from "@clerk/nextjs";
2 |
3 | import { db } from "./db";
4 |
5 | export const initialProfile = async () => {
6 | const user = await currentUser();
7 |
8 | if (!user) {
9 | return redirectToSignIn();
10 | }
11 |
12 | const profile = await db.profile.findUnique({
13 | where: {
14 | userId: user.id,
15 | },
16 | });
17 |
18 | if (profile) {
19 | return profile;
20 | }
21 |
22 | const newProfile = await db.profile.create({
23 | data: {
24 | userId: user.id,
25 | name: `${user.firstName || user.username} ${user.lastName || ""}`,
26 | imageUrl: user.imageUrl,
27 | email: user.emailAddresses[0].emailAddress,
28 | },
29 | });
30 |
31 | return newProfile;
32 | };
33 |
--------------------------------------------------------------------------------
/lib/uploadthing.ts:
--------------------------------------------------------------------------------
1 | import { generateComponents } from "@uploadthing/react";
2 |
3 | import type { OurFileRouter } from "@/app/api/uploadthing/core";
4 |
5 | export const { UploadButton, UploadDropzone, Uploader } =
6 | generateComponents();
7 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/middleware.ts:
--------------------------------------------------------------------------------
1 | import { authMiddleware } from "@clerk/nextjs";
2 |
3 | export default authMiddleware({
4 | publicRoutes: ["/api/uploadthing"],
5 | });
6 |
7 | export const config = {
8 | matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"],
9 | };
10 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | images: {
4 | remotePatterns: [
5 | {
6 | protocol: "https",
7 | hostname: "utfs.io",
8 | },
9 | {
10 | protocol: "https",
11 | hostname: "uploadthing.com",
12 | },
13 | ],
14 | },
15 | };
16 |
17 | module.exports = nextConfig;
18 |
19 | // If you want to use WebSocket in Next.js, you need to add the following code to next.config.js.
20 | // To get socket.io-client to work, you need to add the following code to next.config.js.
21 |
22 | // const nextConfig = {
23 | // webpack: (config) => {
24 | // config.externals.push({
25 | // "utf-8-validate": "commonjs utf-8-validate",
26 | // bufferutil: "commonjs bufferutil"
27 | // });
28 |
29 | // return config;
30 | // },
31 | // };
32 |
33 | // if you want to use mjs in Next.js, you need to add the following code to next.config.js.
34 | // To get livekit to work, you need to add the following code to next.config.js.
35 |
36 | // const nextConfig = {
37 | // webpack: (
38 | // config,
39 | // { buildId, dev, isServer, defaultLoaders, nextRuntime, webpack }
40 | // ) => {
41 | // config.module.rules.push({
42 | // test: /\.mjs$/,
43 | // include: /node_modules/,
44 | // type: "javascript/auto",
45 | // });
46 | // return config;
47 | // },
48 | // images: {
49 | // remotePatterns: [
50 | // {
51 | // protocol: "https",
52 | // hostname: "utfs.io",
53 | // },
54 | // {
55 | // protocol: "https",
56 | // hostname: "uploadthing.com",
57 | // },
58 | // ],
59 | // },
60 | // };
61 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "intertwine",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@clerk/nextjs": "^6.9.15",
13 | "@emoji-mart/data": "^1.2.1",
14 | "@emoji-mart/react": "^1.1.1",
15 | "@hookform/resolvers": "^3.10.0",
16 | "@livekit/components-react": "^2.8.0",
17 | "@livekit/components-styles": "^1.0.12",
18 | "@prisma/client": "^6.2.1",
19 | "@radix-ui/react-avatar": "^1.0.4",
20 | "@radix-ui/react-dialog": "^1.0.5",
21 | "@radix-ui/react-dropdown-menu": "^2.1.4",
22 | "@radix-ui/react-label": "^2.0.2",
23 | "@radix-ui/react-popover": "^1.1.4",
24 | "@radix-ui/react-scroll-area": "^1.0.5",
25 | "@radix-ui/react-select": "^2.1.4",
26 | "@radix-ui/react-separator": "^1.0.3",
27 | "@radix-ui/react-slot": "^1.0.2",
28 | "@radix-ui/react-tooltip": "^1.1.6",
29 | "@tanstack/react-query": "^5.64.2",
30 | "@uploadthing/react": "^7.1.5",
31 | "axios": "^1.7.7",
32 | "class-variance-authority": "^0.7.0",
33 | "clsx": "^2.1.1",
34 | "cmdk": "^1.0.3",
35 | "date-fns": "^4.1.0",
36 | "emoji-mart": "^5.6.0",
37 | "livekit-client": "^2.6.0",
38 | "livekit-server-sdk": "^2.9.7",
39 | "lucide-react": "^0.476.0",
40 | "next": "^15.1.5",
41 | "next-themes": "^0.4.0",
42 | "query-string": "^9.1.0",
43 | "react": "^19.0.0",
44 | "react-dom": "^19.0.0",
45 | "react-hook-form": "^7.54.2",
46 | "socket.io": "^4.7.5",
47 | "socket.io-client": "^4.7.5",
48 | "tailwind-merge": "^2.6.0",
49 | "tailwindcss-animate": "^1.0.7",
50 | "uploadthing": "^7.4.4",
51 | "uuid": "^11.0.5",
52 | "zod": "^3.23.8",
53 | "zustand": "^5.0.3"
54 | },
55 | "devDependencies": {
56 | "@types/node": "22.13.5",
57 | "@types/react": "19.0.10",
58 | "@types/react-dom": "19.0.4",
59 | "@types/uuid": "10.0.0",
60 | "autoprefixer": "10.4.20",
61 | "eslint": "8.57.1",
62 | "eslint-config-next": "15.2.0",
63 | "postcss": "8.5.3",
64 | "prisma": "6.4.1",
65 | "tailwindcss": "3.4.17",
66 | "typescript": "5.7.3"
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/pages/api/socket/direct-messages/[directMessageId].ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest } from "next";
2 | import { MemberRole } from "@prisma/client";
3 |
4 | import { NextApiResponseServerIo } from "@/types";
5 | import { currentProfilePages } from "@/lib/current-profile-pages";
6 | import { db } from "@/lib/db";
7 |
8 | export default async function handler(
9 | req: NextApiRequest,
10 | res: NextApiResponseServerIo
11 | ) {
12 | if (req.method !== "DELETE" && req.method !== "PATCH") {
13 | return res.status(405).json({ error: "Method not allowed" });
14 | }
15 |
16 | try {
17 | const profile = await currentProfilePages(req);
18 | const { directMessageId, conversationId } = req.query;
19 | const { content } = req.body;
20 |
21 | if (!profile) {
22 | return res.status(401).json({ error: "Unauthorized" });
23 | }
24 |
25 | if (!conversationId) {
26 | return res.status(400).json({ error: "Conversation ID missing" });
27 | }
28 |
29 | const conversation = await db.conversation.findFirst({
30 | where: {
31 | id: conversationId as string,
32 | OR: [
33 | {
34 | memberOne: {
35 | profileId: profile.id,
36 | },
37 | },
38 | {
39 | memberTwo: {
40 | profileId: profile.id,
41 | },
42 | },
43 | ],
44 | },
45 | include: {
46 | memberOne: {
47 | include: {
48 | profile: true,
49 | },
50 | },
51 | memberTwo: {
52 | include: {
53 | profile: true,
54 | },
55 | },
56 | },
57 | });
58 |
59 | if (!conversation) {
60 | return res.status(404).json({ error: "Conversation not found" });
61 | }
62 |
63 | const member =
64 | conversation.memberOne.profileId === profile.id
65 | ? conversation.memberOne
66 | : conversation.memberTwo;
67 |
68 | if (!member) {
69 | return res.status(404).json({ error: "Member not found" });
70 | }
71 |
72 | let directMessage = await db.directMessage.findFirst({
73 | where: {
74 | id: directMessageId as string,
75 | conversationId: conversationId as string,
76 | },
77 | include: {
78 | member: {
79 | include: {
80 | profile: true,
81 | },
82 | },
83 | },
84 | });
85 |
86 | if (!directMessage || directMessage.deleted) {
87 | return res.status(404).json({ error: "Message not found" });
88 | }
89 |
90 | const isMessageOwner = directMessage.memberId === member.id;
91 | const isAdmin = member.role === MemberRole.ADMIN;
92 | const isModerator = member.role === MemberRole.MODERATOR;
93 | const canModify = isMessageOwner || isAdmin || isModerator;
94 |
95 | if (!canModify) {
96 | return res.status(401).json({ error: "Unauthorized" });
97 | }
98 |
99 | if (req.method === "DELETE") {
100 | directMessage = await db.directMessage.update({
101 | where: {
102 | id: directMessageId as string,
103 | },
104 | data: {
105 | fileUrl: null,
106 | content: "This message has been deleted.",
107 | deleted: true,
108 | },
109 | include: {
110 | member: {
111 | include: {
112 | profile: true,
113 | },
114 | },
115 | },
116 | });
117 | }
118 |
119 | if (req.method === "PATCH") {
120 | if (!isMessageOwner) {
121 | return res.status(401).json({ error: "Unauthorized" });
122 | }
123 |
124 | directMessage = await db.directMessage.update({
125 | where: {
126 | id: directMessageId as string,
127 | },
128 | data: {
129 | content,
130 | },
131 | include: {
132 | member: {
133 | include: {
134 | profile: true,
135 | },
136 | },
137 | },
138 | });
139 | }
140 |
141 | const updateKey = `chat:${conversation.id}:messages:update`;
142 |
143 | res?.socket?.server?.io?.emit(updateKey, directMessage);
144 |
145 | return res.status(200).json(directMessage);
146 | } catch (error) {
147 | console.log("[MESSAGE_ID]", error);
148 | return res.status(500).json({ error: "Internal Error" });
149 | }
150 | }
151 |
--------------------------------------------------------------------------------
/pages/api/socket/direct-messages/index.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest } from "next";
2 |
3 | import { NextApiResponseServerIo } from "@/types";
4 | import { currentProfilePages } from "@/lib/current-profile-pages";
5 | import { db } from "@/lib/db";
6 |
7 | export default async function handler(
8 | req: NextApiRequest,
9 | res: NextApiResponseServerIo
10 | ) {
11 | if (req.method !== "POST") {
12 | return res.status(405).json({ error: "Method not allowed" });
13 | }
14 |
15 | try {
16 | const profile = await currentProfilePages(req);
17 | const { content, fileUrl } = req.body;
18 | const { conversationId } = req.query;
19 |
20 | if (!profile) {
21 | return res.status(401).json({ error: "Unauthorized" });
22 | }
23 |
24 | if (!conversationId) {
25 | return res.status(400).json({ error: "Conversation ID missing" });
26 | }
27 |
28 | if (!content) {
29 | return res.status(400).json({ error: "Content missing" });
30 | }
31 |
32 | const conversation = await db.conversation.findFirst({
33 | where: {
34 | id: conversationId as string,
35 | OR: [
36 | {
37 | memberOne: {
38 | profileId: profile.id,
39 | },
40 | },
41 | {
42 | memberTwo: {
43 | profileId: profile.id,
44 | },
45 | },
46 | ],
47 | },
48 | include: {
49 | memberOne: {
50 | include: {
51 | profile: true,
52 | },
53 | },
54 | memberTwo: {
55 | include: {
56 | profile: true,
57 | },
58 | },
59 | },
60 | });
61 |
62 | if (!conversation) {
63 | return res.status(404).json({ message: "Conversation not found" });
64 | }
65 |
66 | const member =
67 | conversation.memberOne.profileId === profile.id
68 | ? conversation.memberOne
69 | : conversation.memberTwo;
70 |
71 | if (!member) {
72 | return res.status(404).json({ message: "Member not found" });
73 | }
74 |
75 | const message = await db.directMessage.create({
76 | data: {
77 | content,
78 | fileUrl,
79 | conversationId: conversationId as string,
80 | memberId: member.id,
81 | },
82 | include: {
83 | member: {
84 | include: {
85 | profile: true,
86 | },
87 | },
88 | },
89 | });
90 |
91 | const channelKey = `chat:${conversationId}:messages`;
92 |
93 | res?.socket?.server?.io?.emit(channelKey, message);
94 |
95 | return res.status(200).json(message);
96 | } catch (error) {
97 | console.log("[DIRECT_MESSAGES_POST]", error);
98 | return res.status(500).json({ message: "Internal Error" });
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/pages/api/socket/io.ts:
--------------------------------------------------------------------------------
1 | import { Server as NetServer } from "http";
2 | import { NextApiRequest } from "next";
3 | import { Server as ServerIO } from "socket.io";
4 |
5 | import { NextApiResponseServerIo } from "@/types";
6 |
7 | export const config = {
8 | api: {
9 | bodyParser: false,
10 | },
11 | };
12 |
13 | const ioHandler = (req: NextApiRequest, res: NextApiResponseServerIo) => {
14 | if (!res.socket.server.io) {
15 | const path = "/api/socket/io";
16 | const httpServer: NetServer = res.socket.server as any;
17 | const io = new ServerIO(httpServer, {
18 | path: path,
19 | // addTrailingSlash: false,
20 | });
21 | res.socket.server.io = io;
22 | }
23 |
24 | res.end();
25 | };
26 |
27 | export default ioHandler;
28 |
--------------------------------------------------------------------------------
/pages/api/socket/messages/[messageId].ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest } from "next";
2 | import { MemberRole } from "@prisma/client";
3 |
4 | import { NextApiResponseServerIo } from "@/types";
5 | import { currentProfilePages } from "@/lib/current-profile-pages";
6 | import { db } from "@/lib/db";
7 |
8 | export default async function handler(
9 | req: NextApiRequest,
10 | res: NextApiResponseServerIo
11 | ) {
12 | if (req.method !== "DELETE" && req.method !== "PATCH") {
13 | return res.status(405).json({ error: "Method not allowed" });
14 | }
15 |
16 | try {
17 | const profile = await currentProfilePages(req);
18 | const { messageId, serverId, channelId } = req.query;
19 | const { content } = req.body;
20 |
21 | if (!profile) {
22 | return res.status(401).json({ error: "Unauthorized" });
23 | }
24 |
25 | if (!serverId) {
26 | return res.status(400).json({ error: "Server ID missing" });
27 | }
28 |
29 | if (!channelId) {
30 | return res.status(400).json({ error: "Channel ID missing" });
31 | }
32 |
33 | const server = await db.server.findFirst({
34 | where: {
35 | id: serverId as string,
36 | members: {
37 | some: {
38 | profileId: profile.id,
39 | },
40 | },
41 | },
42 | include: {
43 | members: true,
44 | },
45 | });
46 |
47 | if (!server) {
48 | return res.status(404).json({ error: "Server not found" });
49 | }
50 |
51 | const channel = await db.channel.findFirst({
52 | where: {
53 | id: channelId as string,
54 | serverId: serverId as string,
55 | },
56 | });
57 |
58 | if (!channel) {
59 | return res.status(404).json({ error: "Channel not found" });
60 | }
61 |
62 | const member = server.members.find(
63 | (member) => member.profileId === profile.id
64 | );
65 |
66 | if (!member) {
67 | return res.status(404).json({ error: "Member not found" });
68 | }
69 |
70 | let message = await db.message.findFirst({
71 | where: {
72 | id: messageId as string,
73 | channelId: channelId as string,
74 | },
75 | include: {
76 | member: {
77 | include: {
78 | profile: true,
79 | },
80 | },
81 | },
82 | });
83 |
84 | if (!message || message.deleted) {
85 | return res.status(404).json({ error: "Message not found" });
86 | }
87 |
88 | const isMessageOwner = message.memberId === member.id;
89 | const isAdmin = member.role === MemberRole.ADMIN;
90 | const isModerator = member.role === MemberRole.MODERATOR;
91 | const canModify = isMessageOwner || isAdmin || isModerator;
92 |
93 | if (!canModify) {
94 | return res.status(401).json({ error: "Unauthorized" });
95 | }
96 |
97 | if (req.method === "DELETE") {
98 | message = await db.message.update({
99 | where: {
100 | id: messageId as string,
101 | },
102 | data: {
103 | fileUrl: null,
104 | content: "This message has been deleted.",
105 | deleted: true,
106 | },
107 | include: {
108 | member: {
109 | include: {
110 | profile: true,
111 | },
112 | },
113 | },
114 | });
115 | }
116 |
117 | if (req.method === "PATCH") {
118 | if (!isMessageOwner) {
119 | return res.status(401).json({ error: "Unauthorized" });
120 | }
121 |
122 | message = await db.message.update({
123 | where: {
124 | id: messageId as string,
125 | },
126 | data: {
127 | content,
128 | },
129 | include: {
130 | member: {
131 | include: {
132 | profile: true,
133 | },
134 | },
135 | },
136 | });
137 | }
138 |
139 | const updateKey = `chat:${channelId}:messages:update`;
140 |
141 | res?.socket?.server?.io?.emit(updateKey, message);
142 |
143 | return res.status(200).json(message);
144 | } catch (error) {
145 | console.log("[MESSAGE_ID]", error);
146 | return res.status(500).json({ error: "Internal Error" });
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/pages/api/socket/messages/index.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest } from "next";
2 |
3 | import { NextApiResponseServerIo } from "@/types";
4 | import { currentProfilePages } from "@/lib/current-profile-pages";
5 | import { db } from "@/lib/db";
6 |
7 | export default async function handler(
8 | req: NextApiRequest,
9 | res: NextApiResponseServerIo
10 | ) {
11 | if (req.method !== "POST") {
12 | return res.status(405).json({ error: "Method not allowed" });
13 | }
14 |
15 | try {
16 | const profile = await currentProfilePages(req);
17 | const { content, fileUrl } = req.body;
18 | const { serverId, channelId } = req.query;
19 |
20 | if (!profile) {
21 | return res.status(401).json({ error: "Unauthorized" });
22 | }
23 |
24 | if (!serverId) {
25 | return res.status(400).json({ error: "Server ID missing" });
26 | }
27 |
28 | if (!channelId) {
29 | return res.status(400).json({ error: "Channel ID missing" });
30 | }
31 |
32 | if (!content) {
33 | return res.status(400).json({ error: "Content missing" });
34 | }
35 |
36 | const server = await db.server.findFirst({
37 | where: {
38 | id: serverId as string,
39 | members: {
40 | some: {
41 | profileId: profile.id,
42 | },
43 | },
44 | },
45 | include: {
46 | members: true,
47 | },
48 | });
49 |
50 | if (!server) {
51 | return res.status(404).json({ message: "Server not found" });
52 | }
53 |
54 | const channel = await db.channel.findFirst({
55 | where: {
56 | id: channelId as string,
57 | serverId: serverId as string,
58 | },
59 | });
60 |
61 | if (!channel) {
62 | return res.status(404).json({ message: "Channel not found" });
63 | }
64 |
65 | const member = server.members.find(
66 | (member) => member.profileId === profile.id
67 | );
68 |
69 | if (!member) {
70 | return res.status(404).json({ message: "Member not found" });
71 | }
72 |
73 | const message = await db.message.create({
74 | data: {
75 | content,
76 | fileUrl,
77 | channelId: channelId as string,
78 | memberId: member.id,
79 | },
80 | include: {
81 | member: {
82 | include: {
83 | profile: true,
84 | },
85 | },
86 | },
87 | });
88 |
89 | const channelKey = `chat:${channelId}:messages`;
90 |
91 | res?.socket?.server?.io?.emit(channelKey, message);
92 |
93 | return res.status(200).json(message);
94 | } catch (error) {
95 | console.log("[MESSAGES_POST]", error);
96 | return res.status(500).json({ message: "Internal Error" });
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | generator client {
2 | provider = "prisma-client-js"
3 | }
4 |
5 | datasource db {
6 | provider = "postgresql"
7 | url = env("DATABASE_URL")
8 | relationMode = "prisma"
9 | }
10 |
11 | model Profile {
12 | id String @id @default(uuid())
13 | userId String @unique
14 | name String
15 | imageUrl String @db.Text
16 | email String @db.Text
17 |
18 | servers Server[]
19 | members Member[]
20 | channels Channel[]
21 |
22 | createdAt DateTime @default(now())
23 | updatedAt DateTime @updatedAt
24 | }
25 |
26 | model Server {
27 | id String @id @default(uuid())
28 | name String
29 | imageUrl String @db.Text
30 | inviteCode String @unique
31 |
32 | profileId String
33 | profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
34 |
35 | members Member[]
36 | channels Channel[]
37 |
38 | createdAt DateTime @default(now())
39 | updatedAt DateTime @updatedAt
40 |
41 | @@index([profileId])
42 | }
43 |
44 | enum MemberRole {
45 | ADMIN
46 | MODERATOR
47 | GUEST
48 | }
49 |
50 | model Member {
51 | id String @id @default(uuid())
52 | role MemberRole @default(GUEST)
53 |
54 | profileId String
55 | profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
56 |
57 | serverId String
58 | server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
59 |
60 | messages Message[]
61 | directMessages DirectMessage[]
62 |
63 | conversationsInitiated Conversation[] @relation("MemberOne")
64 | conversationsReceived Conversation[] @relation("MemberTwo")
65 |
66 | createdAt DateTime @default(now())
67 | updatedAt DateTime @updatedAt
68 |
69 | @@index([profileId])
70 | @@index([serverId])
71 | }
72 |
73 | enum ChannelType {
74 | TEXT
75 | AUDIO
76 | VIDEO
77 | }
78 |
79 | model Channel {
80 | id String @id @default(uuid())
81 | name String
82 | type ChannelType @default(TEXT)
83 |
84 | profileId String
85 | profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
86 |
87 | serverId String
88 | server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
89 |
90 | messages Message[]
91 |
92 | createdAt DateTime @default(now())
93 | updatedAt DateTime @updatedAt
94 |
95 | @@index([profileId])
96 | @@index([serverId])
97 | }
98 |
99 | model Message {
100 | id String @id @default(uuid())
101 | content String @db.Text
102 |
103 | fileUrl String? @db.Text
104 |
105 | memberId String
106 | member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
107 |
108 | channelId String
109 | channel Channel @relation(fields: [channelId], references: [id], onDelete: Cascade)
110 |
111 | deleted Boolean @default(false)
112 |
113 | createdAt DateTime @default(now())
114 | updatedAt DateTime @updatedAt
115 |
116 | @@index([channelId])
117 | @@index([memberId])
118 | }
119 |
120 | model Conversation {
121 | id String @id @default(uuid())
122 |
123 | memberOneId String
124 | memberOne Member @relation("MemberOne", fields: [memberOneId], references: [id], onDelete: Cascade)
125 |
126 | memberTwoId String
127 | memberTwo Member @relation("MemberTwo", fields: [memberTwoId], references: [id], onDelete: Cascade)
128 |
129 | directMessages DirectMessage[]
130 |
131 | @@index([memberTwoId])
132 |
133 | @@unique([memberOneId, memberTwoId])
134 | }
135 |
136 | model DirectMessage {
137 | id String @id @default(uuid())
138 | content String @db.Text
139 | fileUrl String? @db.Text
140 |
141 | memberId String
142 | member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
143 |
144 | conversationId String
145 | conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
146 |
147 | deleted Boolean @default(false)
148 |
149 | createdAt DateTime @default(now())
150 | updatedAt DateTime @updatedAt
151 |
152 | @@index([memberId])
153 | @@index([conversationId])
154 | }
--------------------------------------------------------------------------------
/public/avataaars.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RicardoGEsteves/Intertwine/beb17e6081b62f39f1218ea274a8d6c11d58b901/public/avataaars.png
--------------------------------------------------------------------------------
/public/avataaars2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RicardoGEsteves/Intertwine/beb17e6081b62f39f1218ea274a8d6c11d58b901/public/avataaars2.png
--------------------------------------------------------------------------------
/public/avataaars3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RicardoGEsteves/Intertwine/beb17e6081b62f39f1218ea274a8d6c11d58b901/public/avataaars3.png
--------------------------------------------------------------------------------
/public/avataaars4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RicardoGEsteves/Intertwine/beb17e6081b62f39f1218ea274a8d6c11d58b901/public/avataaars4.png
--------------------------------------------------------------------------------
/public/demo-images/dropdownNav.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RicardoGEsteves/Intertwine/beb17e6081b62f39f1218ea274a8d6c11d58b901/public/demo-images/dropdownNav.png
--------------------------------------------------------------------------------
/public/demo-images/lightMode.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RicardoGEsteves/Intertwine/beb17e6081b62f39f1218ea274a8d6c11d58b901/public/demo-images/lightMode.png
--------------------------------------------------------------------------------
/public/demo-images/shortcutSearch.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RicardoGEsteves/Intertwine/beb17e6081b62f39f1218ea274a8d6c11d58b901/public/demo-images/shortcutSearch.png
--------------------------------------------------------------------------------
/public/demo-images/textChannels.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RicardoGEsteves/Intertwine/beb17e6081b62f39f1218ea274a8d6c11d58b901/public/demo-images/textChannels.png
--------------------------------------------------------------------------------
/public/demo-images/uploadFiles.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RicardoGEsteves/Intertwine/beb17e6081b62f39f1218ea274a8d6c11d58b901/public/demo-images/uploadFiles.png
--------------------------------------------------------------------------------
/public/demo-images/videoChannels.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RicardoGEsteves/Intertwine/beb17e6081b62f39f1218ea274a8d6c11d58b901/public/demo-images/videoChannels.png
--------------------------------------------------------------------------------
/public/demo-images/videoConference.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RicardoGEsteves/Intertwine/beb17e6081b62f39f1218ea274a8d6c11d58b901/public/demo-images/videoConference.png
--------------------------------------------------------------------------------
/public/demo-images/voiceChannels.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RicardoGEsteves/Intertwine/beb17e6081b62f39f1218ea274a8d6c11d58b901/public/demo-images/voiceChannels.png
--------------------------------------------------------------------------------
/public/favicon2.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RicardoGEsteves/Intertwine/beb17e6081b62f39f1218ea274a8d6c11d58b901/public/favicon2.ico
--------------------------------------------------------------------------------
/public/intertwine.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RicardoGEsteves/Intertwine/beb17e6081b62f39f1218ea274a8d6c11d58b901/public/intertwine.png
--------------------------------------------------------------------------------
/public/intertwine.svg:
--------------------------------------------------------------------------------
1 |
14 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ["class"],
4 | content: [
5 | "./pages/**/*.{ts,tsx}",
6 | "./components/**/*.{ts,tsx}",
7 | "./app/**/*.{ts,tsx}",
8 | "./src/**/*.{ts,tsx}",
9 | ],
10 | theme: {
11 | container: {
12 | center: true,
13 | padding: "2rem",
14 | screens: {
15 | "2xl": "1400px",
16 | },
17 | },
18 | extend: {
19 | colors: {
20 | border: "hsl(var(--border))",
21 | input: "hsl(var(--input))",
22 | ring: "hsl(var(--ring))",
23 | background: "hsl(var(--background))",
24 | foreground: "hsl(var(--foreground))",
25 | primary: {
26 | DEFAULT: "hsl(var(--primary))",
27 | foreground: "hsl(var(--primary-foreground))",
28 | },
29 | secondary: {
30 | DEFAULT: "hsl(var(--secondary))",
31 | foreground: "hsl(var(--secondary-foreground))",
32 | },
33 | destructive: {
34 | DEFAULT: "hsl(var(--destructive))",
35 | foreground: "hsl(var(--destructive-foreground))",
36 | },
37 | muted: {
38 | DEFAULT: "hsl(var(--muted))",
39 | foreground: "hsl(var(--muted-foreground))",
40 | },
41 | accent: {
42 | DEFAULT: "hsl(var(--accent))",
43 | foreground: "hsl(var(--accent-foreground))",
44 | },
45 | popover: {
46 | DEFAULT: "hsl(var(--popover))",
47 | foreground: "hsl(var(--popover-foreground))",
48 | },
49 | card: {
50 | DEFAULT: "hsl(var(--card))",
51 | foreground: "hsl(var(--card-foreground))",
52 | },
53 | },
54 | borderRadius: {
55 | lg: "var(--radius)",
56 | md: "calc(var(--radius) - 2px)",
57 | sm: "calc(var(--radius) - 4px)",
58 | },
59 | keyframes: {
60 | "accordion-down": {
61 | from: { height: 0 },
62 | to: { height: "var(--radix-accordion-content-height)" },
63 | },
64 | "accordion-up": {
65 | from: { height: "var(--radix-accordion-content-height)" },
66 | to: { height: 0 },
67 | },
68 | },
69 | animation: {
70 | "accordion-down": "accordion-down 0.2s ease-out",
71 | "accordion-up": "accordion-up 0.2s ease-out",
72 | },
73 | },
74 | },
75 | plugins: [require("tailwindcss-animate")],
76 | };
77 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/types.ts:
--------------------------------------------------------------------------------
1 | import { Server as NetServer, Socket } from "net";
2 | import { NextApiResponse } from "next";
3 | import { Server as SocketIOServer } from "socket.io";
4 | import { Server, Member, Profile } from "@prisma/client";
5 |
6 | export type ServerWithMembersWithProfiles = Server & {
7 | members: (Member & { profile: Profile })[];
8 | };
9 |
10 | export type NextApiResponseServerIo = NextApiResponse & {
11 | socket: Socket & {
12 | server: NetServer & {
13 | io: SocketIOServer;
14 | };
15 | };
16 | };
17 |
--------------------------------------------------------------------------------