├── .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": "[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/{{sourceRepo}}/badge)](https://securityscorecards.dev/viewer/?uri=github.com/{{sourceRepo}})", 54 | "Sourcegraph": "[![code search for \"{{{depName}}}\"](https://sourcegraph.com/search/badge?q=repo:%5Egithub%5C.com/{{{repository}}}%24+case:yes+-file:package%28-lock%29%3F%5C.json+{{{depName}}}&label=matches)](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 |
59 | 60 | ( 64 | 65 | 66 |
67 | 76 | 84 |
85 | 87 | field.onChange(`${field.value} ${emoji}`) 88 | } 89 | /> 90 |
91 |
92 |
93 |
94 | )} 95 | /> 96 | 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 | Upload file 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 | 102 | 103 | 104 | 105 | Create Channel 106 | 107 | 108 | Channels are where your members communicate. They're best when 109 | organized around a topic — #trips, for example. 110 | 111 | 112 | 113 |
114 | 115 |
116 | ( 120 | 121 | 122 | Channel Name 123 | 124 | 125 | 131 | 132 | 133 | 134 | )} 135 | /> 136 | ( 140 | 141 | 142 | Channel Type 143 | 144 | 145 | 167 | 168 | 169 | )} 170 | /> 171 |
172 | 173 | 174 | 175 |
176 | 177 |
178 |
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 | 77 | 78 | 79 | 80 | Server customization 81 | 82 | 83 | Customize your server channel effortlessly with our versatile 84 | features. Tailor permissions, adjust settings, and personalize the 85 | channel to suit your community's unique needs. From setting 86 | specific access controls to defining channel topics, make your space 87 | truly your own. Enhance engagement and collaboration by molding the 88 | channel environment to match your vision seamlessly. 89 | 90 | 91 | 92 |
93 | 94 |
95 |
96 | ( 100 | 101 | 102 | 107 | 108 | 109 | 110 | )} 111 | /> 112 |
113 | 114 | ( 118 | 119 | 120 | Server name 121 | 122 | 123 | 129 | 130 | 131 | 132 | )} 133 | /> 134 |
135 | 136 | 137 | 138 |
139 | 140 |
141 |
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 | 52 | 53 | 54 | 55 | Delete Channel 56 | 57 | 58 | Are you sure you want to delete the channel{" "} 59 | {channel?.name}? 60 | This action cannot be undone. 61 | 62 | 63 | 64 |
65 | 68 | 71 |
72 |
73 |
74 |
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 | 46 | 47 | 48 | 49 | Delete Message 50 | 51 | 52 | Are you sure you want to delete this message? This action cannot be 53 | undone. 54 | 55 | 56 | 57 |
58 | 61 | 64 |
65 |
66 |
67 |
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 | 45 | 46 | 47 | 48 | Delete Server 49 | 50 | 51 | Are you sure you want to delete the server{" "} 52 | {server?.name}? This 53 | action cannot be undone. 54 | 55 | 56 | 57 |
58 | 61 | 64 |
65 |
66 |
67 |
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 | 99 | 100 | 101 | 102 | Edit Channel 103 | 104 | 105 | Edit {channel?.name}, you can 106 | change the channel name and type here. You cannot use the name 107 | 'general', it is reserved. 108 | 109 | 110 | 111 |
112 | 113 |
114 | ( 118 | 119 | 120 | Channel Name 121 | 122 | 123 | 129 | 130 | 131 | 132 | )} 133 | /> 134 | ( 138 | 139 | 140 | Channel Type 141 | 142 | 143 | 165 | 166 | 167 | )} 168 | /> 169 |
170 | 171 | 172 | 173 |
174 | 175 |
176 |
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 | 83 | 84 | 85 | 86 | Server customization 87 | 88 | 89 | Customize your server channel effortlessly with our versatile 90 | features. Tailor permissions, adjust settings, and personalize the 91 | channel to suit your community's unique needs. From setting 92 | specific access controls to defining channel topics, make your space 93 | truly your own. Enhance engagement and collaboration by molding the 94 | channel environment to match your vision seamlessly. 95 | 96 | 97 | 98 |
99 | 100 |
101 |
102 | ( 106 | 107 | 108 | 113 | 114 | 115 | )} 116 | /> 117 |
118 | 119 | ( 123 | 124 | 125 | Server name 126 | 127 | 128 | 134 | 135 | 136 | 137 | )} 138 | /> 139 |
140 | 141 | 142 | 143 |
144 | 145 |
146 |
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 | 79 | 80 | 81 | 82 | Server customization 83 | 84 | 85 | Customize your server channel effortlessly with our versatile 86 | features. Tailor permissions, adjust settings, and personalize the 87 | channel to suit your community's unique needs. From setting 88 | specific access controls to defining channel topics, make your space 89 | truly your own. Enhance engagement and collaboration by molding the 90 | channel environment to match your vision seamlessly. 91 | 92 | 93 | 94 |
95 | 96 |
97 |
98 | ( 102 | 103 | 104 | 109 | 110 | 111 | 112 | )} 113 | /> 114 |
115 | 116 | ( 120 | 121 | 122 | Server name 123 | 124 | 125 | 131 | 132 | 133 | 134 | )} 135 | /> 136 |
137 | 138 | 139 | 140 |
141 | 142 |
143 |
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 | 58 | 59 | 60 | 61 | Server Invite 62 | 63 | 64 | Invite your friends to your server! Share the link below. 65 | 66 | 67 |
68 | 71 | 72 |
73 | 78 | 85 |
86 | 96 |
97 |
98 |
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 | 45 | 46 | 47 | 48 | Leave Server 49 | 50 | 51 | Are you sure you want to leave{" "} 52 | {server?.name}? You 53 | will not be able to rejoin unless you are re-invited. 54 | 55 | 56 | 57 |
58 | 61 | 64 |
65 |
66 |
67 |
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 | 78 | 79 | 80 | 81 | Upload a file 82 | 83 | 84 | Upload a file to share with your friends. You can upload images, 85 | PDFs and more. 86 | 87 | 88 | 89 |
90 | 91 |
92 |
93 | ( 97 | 98 | 99 | 104 | 105 | 106 | 107 | )} 108 | /> 109 |
110 |
111 | 112 | 113 | 114 |
115 | 116 |
117 |
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 | 31 | 32 | 33 | {children} 34 | 35 | 36 | 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 |